376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Lock, ShieldCheck, Server, ExternalLink, Settings, Copy, MessagesSquare, Users, QrCode } from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
|
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
|
|
import { CORDN_RELAY_URL, coordinatorAddUrl, CORDN_HOME, SKATESPOTS_COORDINATOR } from '@/lib/coordinator';
|
|
import { cn } from '@/lib/utils';
|
|
import type { CrewCoordinator, SkateCrew } from '@/lib/types';
|
|
import { nip19 } from 'nostr-tools';
|
|
|
|
/**
|
|
* The crew's encrypted-chat card (sidebar).
|
|
*
|
|
* A crew has **two** chats and the card must not conflate them:
|
|
* - the **Chat** tab — built-in NIP-17 group chat, always on, no setup; and
|
|
* - the **Private chat** tab — real MLS (forward secrecy, member removal),
|
|
* created once by an admin and routed through a coordinator.
|
|
*
|
|
* It leads with an honest status for each and a one-tap entry into the private
|
|
* chat; the coordinator details and any external clients (Staircase / Cordn web)
|
|
* are secondary. ⚠️ The status must match the tab: an earlier version badged
|
|
* "Private chat · On" while the MLS tab said "not created yet".
|
|
*/
|
|
export function CrewCoordinatorCard({
|
|
crew,
|
|
isFounder,
|
|
canManage,
|
|
nativeGroupExists,
|
|
}: {
|
|
crew: SkateCrew;
|
|
isFounder: boolean;
|
|
/** Founder/admin — may create the MLS group. */
|
|
canManage: boolean;
|
|
/** Whether this device already holds the crew's MLS group (local check). */
|
|
nativeGroupExists: boolean | undefined;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { coordinator } = crew;
|
|
|
|
// Which relay the messages actually travel over — a `wss://` coordinator URL
|
|
// IS the relay; an `https://` one is addressed by pubkey over the shared
|
|
// ContextVM relay (public). The copy below must not overclaim either way.
|
|
const endpoint = resolveCordnEndpoint(crew);
|
|
const overPublicRelay = endpoint?.relayUrl === CORDN_RELAY_URL;
|
|
|
|
const privateChatTab = `/crew/${crew.pubkey}/${crew.identifier}?tab=mls`;
|
|
|
|
const mlsLabel =
|
|
nativeGroupExists === true
|
|
? t('socialUi.crewCoordinator.on')
|
|
: nativeGroupExists === undefined
|
|
? t('socialUi.crewCoordinator.checking')
|
|
: canManage
|
|
? t('socialUi.crewCoordinator.setUpChat')
|
|
: t('socialUi.crewCoordinator.joinChat');
|
|
const mlsCta =
|
|
nativeGroupExists === true
|
|
? t('socialUi.crewCoordinator.openChat')
|
|
: canManage
|
|
? t('socialUi.crewCoordinator.setUpChat')
|
|
: t('socialUi.crewCoordinator.joinChat');
|
|
|
|
return (
|
|
<Card className="border-2 border-teal-500/25 bg-gradient-to-br from-teal-500/[0.06] to-black/90">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
|
<Lock className="h-5 w-5 text-teal-400" /> {t('socialUi.crewCoordinator.encryptedChat')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-3">
|
|
{/* Chat (NIP-17) — always on */}
|
|
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/40 p-3">
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-teal-500/15">
|
|
<MessagesSquare className="h-4 w-4 text-teal-400" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="flex items-center gap-2 text-sm font-bold text-white">
|
|
{t('socialUi.crewCoordinator.chatWord')}
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-teal-300">
|
|
<ShieldCheck className="h-3 w-3" /> {t('socialUi.crewCoordinator.on')}
|
|
</span>
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] leading-relaxed text-gray-500">
|
|
{t('socialUi.crewCoordinator.chatRowDesc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Private chat (MLS) — created once, then joined */}
|
|
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/40 p-3">
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-teal-500/15">
|
|
<Lock className="h-4 w-4 text-teal-400" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="flex items-center gap-2 text-sm font-bold text-white">
|
|
{t('socialUi.crewCoordinator.privateChatName')}
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-wide',
|
|
nativeGroupExists === true ? 'bg-teal-500/20 text-teal-300' : 'bg-white/10 text-gray-400',
|
|
)}
|
|
>
|
|
{mlsLabel}
|
|
</span>
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] leading-relaxed text-gray-500">
|
|
{t('socialUi.crewCoordinator.privateChatDesc')}
|
|
</p>
|
|
</div>
|
|
<Link
|
|
to={privateChatTab}
|
|
className="shrink-0 rounded-lg border border-teal-500/30 bg-teal-500/10 px-2.5 py-1.5 text-xs font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
|
|
>
|
|
{mlsCta}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Coordinator details (only when one is configured) */}
|
|
{coordinator && (
|
|
<div className="space-y-3 border-t border-white/5 pt-3">
|
|
<CoordinatorEndpoint coordinator={coordinator} />
|
|
{coordinator.operator && <CoordinatorOperator pubkey={coordinator.operator} />}
|
|
|
|
{endpoint && (
|
|
<p className="text-[11px] leading-relaxed text-gray-500">
|
|
{t('socialUi.crewCoordinator.deliveredOver')}{' '}
|
|
<span className="font-mono text-gray-400">{urlDomain(endpoint.relayUrl) || endpoint.relayUrl}</span>
|
|
{overPublicRelay ? ` ${t('socialUi.crewCoordinator.sharedRelay')}` : ` ${t('socialUi.crewCoordinator.ownRelay')}`}
|
|
</p>
|
|
)}
|
|
|
|
<div className="rounded-xl border border-emerald-500/15 bg-emerald-500/[0.04] p-3">
|
|
<p className="flex items-center gap-1.5 text-xs font-bold text-emerald-300">
|
|
<Lock className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.chatLive')}
|
|
</p>
|
|
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
|
|
{t('socialUi.crewCoordinator.chatRowDesc')}{' '}
|
|
{overPublicRelay
|
|
? t('socialUi.crewCoordinator.publicWarning')
|
|
: t('socialUi.crewCoordinator.ownRelaySafe')}
|
|
</p>
|
|
</div>
|
|
|
|
{isFounder && (
|
|
<Link
|
|
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
|
|
className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-400 hover:text-emerald-300"
|
|
>
|
|
<Settings className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.manage')}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* External clients — secondary, clearly labelled */}
|
|
{(coordinator || crew.cordnGroup) && (
|
|
<div className="space-y-2 border-t border-white/5 pt-3">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">
|
|
{t('socialUi.crewCoordinator.openInAnotherApp')}
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StaircaseLink
|
|
coordinator={
|
|
coordinator
|
|
? {
|
|
pubkey: coordinator.pubkey,
|
|
relays: /^wss?:\/\//i.test(coordinator.url) ? [coordinator.url] : [],
|
|
}
|
|
: SKATESPOTS_COORDINATOR
|
|
}
|
|
/>
|
|
{crew.cordnGroup && <CordnGroupLink group={crew.cordnGroup} />}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Advanced (founders, no coordinator configured) */}
|
|
{!coordinator && isFounder && (
|
|
<p className="text-[11px] leading-relaxed text-gray-600">
|
|
{t('socialUi.crewCoordinator.advancedA')}{' '}
|
|
<a
|
|
href="https://github.com/Cordn-msg/cordn"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-gray-400 underline underline-offset-2 hover:text-teal-300"
|
|
>
|
|
{t('socialUi.crewCoordinator.cordnCoordinator')}
|
|
</a>
|
|
?{' '}
|
|
<Link
|
|
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
|
|
className="inline-flex items-center gap-1 font-semibold text-gray-400 hover:text-teal-300"
|
|
>
|
|
<Settings className="h-3 w-3" /> {t('socialUi.crewCoordinator.routeThrough')}
|
|
</Link>
|
|
</p>
|
|
)}
|
|
|
|
<p className="pt-1 text-[11px] leading-relaxed text-gray-600">
|
|
<Users className="mr-1 inline h-3 w-3" />
|
|
{t('socialUi.crewCoordinator.privateChatDescFull')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/** Real-MLS entry points for a coordinator: open the Cordn **web** client
|
|
* (cordn.net add-link), or copy the nprofile to add it in the **Staircase**
|
|
* native app. Falls back to the Cordn home when there's no pubkey. */
|
|
function StaircaseLink({ coordinator }: { coordinator: { pubkey?: string; relays?: string[] } }) {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const [showQr, setShowQr] = useState(false);
|
|
const nprofile = coordinator.pubkey
|
|
? nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] })
|
|
: null;
|
|
const webUrl = coordinator.pubkey
|
|
? coordinatorAddUrl({ pubkey: coordinator.pubkey, relays: coordinator.relays ?? [] })
|
|
: CORDN_HOME;
|
|
|
|
const copyNprofile = async () => {
|
|
if (!nprofile) return;
|
|
try {
|
|
await navigator.clipboard.writeText(nprofile);
|
|
toast({ title: t('common.copied') });
|
|
} catch {
|
|
toast({ title: t('common.tryAgain'), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
const btn =
|
|
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors';
|
|
return (
|
|
<>
|
|
<a
|
|
href={webUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`${btn} border-teal-500/30 bg-teal-500/10 text-teal-300 hover:bg-teal-500/20`}
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openInCordnWeb')}
|
|
</a>
|
|
{nprofile && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyNprofile()}
|
|
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowQr((v) => !v)}
|
|
aria-expanded={showQr}
|
|
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
|
|
>
|
|
<QrCode className="h-3.5 w-3.5" /> QR
|
|
</button>
|
|
</>
|
|
)}
|
|
{showQr && nprofile && (
|
|
<div className="mt-2 flex w-full flex-col items-center gap-2 rounded-xl border border-white/10 bg-black/40 p-3">
|
|
<QRCodeCanvas value={nprofile} size={168} level="M" />
|
|
<p className="text-center text-[11px] text-gray-500">
|
|
{t('socialUi.coordinator.scanToJoin')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Link a crew to its Cordn group (created in Staircase / Cordn web) for real MLS chat. */
|
|
function CordnGroupLink({ group }: { group: string }) {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const url = /^cordn1/i.test(group)
|
|
? `https://cordn.net/chat/${group}`
|
|
: /^https?:\/\//i.test(group)
|
|
? group
|
|
: null;
|
|
const copy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(group);
|
|
toast({ title: t('common.copied') });
|
|
} catch {
|
|
toast({ title: t('common.tryAgain'), variant: 'destructive' });
|
|
}
|
|
};
|
|
const btn =
|
|
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors';
|
|
return (
|
|
<>
|
|
{url && (
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`${btn} border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20`}
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.openCordnGroup')}
|
|
</a>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => void copy()}
|
|
className={`${btn} border-white/15 bg-white/5 text-gray-300 hover:bg-white/10`}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.copyForStaircase')}
|
|
</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator }) {
|
|
const { t } = useTranslation();
|
|
const safe = sanitizeUrl(coordinator.url);
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-xl border border-emerald-500/20 bg-black/40 p-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/15">
|
|
<Server className="h-5 w-5 text-emerald-400" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-emerald-400/80">
|
|
{t('socialUi.crewCoordinator.coordinator')}
|
|
</p>
|
|
<p className="truncate text-sm font-bold text-white">{urlDomain(coordinator.url)}</p>
|
|
</div>
|
|
{safe && (
|
|
<a
|
|
href={safe}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-emerald-500/40 hover:text-emerald-300"
|
|
title={t('socialUi.crewCoordinator.open', { domain: urlDomain(coordinator.url) })}
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CoordinatorOperator({ pubkey }: { pubkey: string }) {
|
|
const { t } = useTranslation();
|
|
const author = useAuthor(pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
|
const pic = author.data?.metadata?.picture;
|
|
const npub = nip19.npubEncode(pubkey);
|
|
|
|
return (
|
|
<Link
|
|
to={`/${npub}`}
|
|
className="flex items-center gap-2.5 rounded-xl bg-black/40 p-2.5 transition-colors hover:bg-black/60"
|
|
>
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={pic} alt={name} />
|
|
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{t('socialUi.crewCoordinator.runBy')}</p>
|
|
<p className="truncate text-sm font-bold text-gray-200">{name}</p>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|