SkateSpots/src/components/crew/CrewCoordinatorCard.tsx

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>
);
}