SkateSpots/src/components/crew/CordnNativePanel.tsx

1320 lines
48 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import type { ReactNode } from 'react';
import {
AlertTriangle,
Check,
ChevronDown,
Copy,
ExternalLink,
ImagePlus,
KeyRound,
Loader2,
Lock,
Plus,
QrCode,
RefreshCw,
Reply,
ScanLine,
Send,
Share2,
Trash2,
UserPlus,
Users,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCordnNative } from '@/hooks/useCordnNative';
import { shortNpub } from '@/lib/coordinator';
import { isNativePlatform } from '@/lib/platform';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils';
import type { CordnRoom } from '@/lib/cordn/room';
import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread';
import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin';
type RoomScope = 'crew' | 'community';
interface CordnNativePanelProps {
crew: CordnRoom;
/** Founder/admin — may create the group and approve join requests. */
canManage: boolean;
/** False for bot-owned rooms: the bot creates, admins still approve. */
canCreate?: boolean;
/** Founder or listed member. */
isMember: boolean;
/** Switches the room-specific copy (crew vs the app-wide community room). */
scope?: RoomScope;
/** Optional share/QR URL override (the community page uses `/#/community`). */
inviteUrl?: string;
/** Community bot pubkey — shown with a friendly label in requests/members. */
botPubkey?: string;
/** Whether the private-chat tab is the visible tab (drives seen/unread). */
active?: boolean;
/** Called when the room has inbound messages newer than the last seen cursor. */
onUnreadChange?: (hasUnread: boolean) => void;
/** Called with the number of pending join requests (founder/admin). */
onRequestsChange?: (count: number) => void;
}
type MemberRole = 'founder' | 'admin' | 'member';
interface PendingMessage {
text: string;
imageUrl?: string;
state: 'sending' | 'failed';
}
/**
* The crew's **native Cordn (MLS) chat** — real end-to-end encryption between
* members, routed through the crew's coordinator. See `lib/cordn/native/`.
*/
export function CordnNativePanel({
crew,
canManage,
canCreate = canManage,
isMember,
scope = 'crew',
inviteUrl: inviteUrlProp,
botPubkey,
active = true,
onUnreadChange,
onRequestsChange,
}: CordnNativePanelProps) {
const { t } = useTranslation();
const isCommunity = scope === 'community';
const copy = {
title: isCommunity ? t('communityUi.title') : t('cordnUi.title'),
subtitle: isCommunity ? t('communityUi.subtitle') : t('cordnUi.subtitle'),
notLoggedIn: isCommunity ? t('communityUi.notLoggedIn') : t('cordnUi.notLoggedIn'),
noGroupAdmin: isCommunity ? t('communityUi.noGroupAdmin') : t('cordnUi.noGroupAdmin'),
noGroupMember: isCommunity ? t('communityUi.noGroupMember') : t('cordnUi.noGroupMember'),
create: isCommunity ? t('communityUi.create') : t('cordnUi.create'),
setupDeviceBody: isCommunity
? t('communityUi.setupDeviceBody')
: t('cordnUi.setupDeviceBody'),
encryptedNote: isCommunity ? t('communityUi.encryptedNote') : t('cordnUi.encryptedNote'),
shareInviteText: isCommunity
? t('communityUi.shareInviteText')
: t('cordnUi.shareInviteText', { crew: crew.name }),
qrTitle: isCommunity ? t('communityUi.qrTitle') : t('cordnUi.qrTitle'),
qrHint: isCommunity ? t('communityUi.qrHint') : t('cordnUi.qrHint'),
owner: isCommunity ? t('communityUi.owner') : t('cordnUi.roleFounder'),
botName: t('communityUi.botName'),
founderSteps: isCommunity
? [
t('communityUi.onboardingFounder1'),
t('communityUi.onboardingFounder2'),
t('communityUi.onboardingFounder3'),
]
: [
t('cordnUi.onboardingFounder1'),
t('cordnUi.onboardingFounder2'),
t('cordnUi.onboardingFounder3'),
],
memberSteps: isCommunity
? [
t('communityUi.onboardingMember1'),
t('communityUi.onboardingMember2'),
t('communityUi.onboardingMember3'),
]
: [
t('cordnUi.onboardingMember1'),
t('cordnUi.onboardingMember2'),
t('cordnUi.onboardingMember3'),
],
};
const { user } = useCurrentUser();
const native = useCordnNative({ crew, enabled: !!user && isMember, active });
// Stable callbacks (the hook's return object is recreated each render).
const sendNative = native.send;
const checkInviteNative = native.checkInvite;
const [draft, setDraft] = useState('');
const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null);
const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [copiedDevice, setCopiedDevice] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [awaiting, setAwaiting] = useState(() =>
user?.pubkey ? readPendingJoin(user.pubkey, native.groupId) : false,
);
const [pending, setPending] = useState<PendingMessage | null>(null);
const [sentIds, setSentIds] = useState<Set<string>>(() => new Set());
const [inviteOpen, setInviteOpen] = useState(false);
const [deviceLinkOpen, setDeviceLinkOpen] = useState(false);
const [deviceLinkCode, setDeviceLinkCode] = useState<string | null>(null);
const [deviceLinkPending, setDeviceLinkPending] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const checkingRef = useRef(false);
const loadedRequestsRef = useRef(false);
const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile();
const inviteUrl = useMemo(() => {
if (inviteUrlProp) return inviteUrlProp;
// In the Android/iOS shell the page origin is `https://localhost`, which is
// useless in a QR code. Share the public site there; keep the live origin on
// the web (including previews).
const origin = isNativePlatform() ? 'https://skate-spots.org' : window.location.origin;
return `${origin}/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`;
}, [crew.pubkey, crew.identifier, inviteUrlProp]);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [native.messages.length, pending?.state]);
useEffect(() => {
if (!user?.pubkey) return;
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
}, [user?.pubkey, native.groupId]);
useEffect(() => {
if (native.group && awaiting) {
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
setAwaiting(false);
setDeviceLinkOpen(false);
setDeviceLinkCode(null);
setDeviceLinkPending(false);
}
}, [native.group, awaiting, user?.pubkey, native.groupId]);
// Founders should see waiting requests without hunting for the check button.
// Quiet reads keep the tab badge fresh without toggling the composer's busy
// state. Amber is excluded: a background ContextVM request would pop the
// signer; those users use the explicit Check requests button.
const loadRequestsQuietNative = native.loadRequestsQuiet;
const interactive = native.interactiveHintVisible;
const groupGid = native.group?.gid;
useEffect(() => {
// Hidden tabs only do this for nsec/bunker. Extension/Amber users either
// open the tab once (extension) or use the manual Check requests button.
if (!canManage || !groupGid || !native.ready || interactive || (!native.passiveSigner && !active) || loadedRequestsRef.current) return;
loadedRequestsRef.current = true;
void loadRequestsQuietNative();
}, [canManage, groupGid, native.ready, interactive, native.passiveSigner, active, loadRequestsQuietNative]);
useEffect(() => {
if (!canManage || !groupGid || !native.ready || interactive || !native.passiveSigner) return;
const id = window.setInterval(() => void loadRequestsQuietNative(), 60_000);
return () => window.clearInterval(id);
}, [canManage, groupGid, native.ready, interactive, native.passiveSigner, loadRequestsQuietNative]);
useEffect(() => {
onRequestsChange?.(canManage ? native.requests.length : 0);
}, [canManage, native.requests.length, onRequestsChange]);
// "New since last opened" marker. While the tab is visible we advance the
// cursor; while it is hidden we only compare inbound timestamps. The first
// observation seeds the cursor so a room's old history is not "unread".
const messageCursor = useMemo(() => {
const latest = native.messages
.filter((m) => m.direction === 'inbound' && m.sender !== user?.pubkey)
.reduce((max, m) => Math.max(max, m.createdAt), 0);
return { latest };
}, [native.messages, user?.pubkey]);
useEffect(() => {
if (!user?.pubkey || !groupGid || !native.ready) {
onUnreadChange?.(false);
return;
}
const gid = groupGid;
const latest = messageCursor.latest;
if (active) {
writeCordnSeen(user.pubkey, gid, latest);
onUnreadChange?.(false);
return;
}
const seen = readCordnSeen(user.pubkey, gid);
if (seen === null) {
writeCordnSeen(user.pubkey, gid, latest);
onUnreadChange?.(false);
} else {
onUnreadChange?.(latest > seen);
}
}, [active, groupGid, native.ready, user?.pubkey, messageCursor.latest, onUnreadChange]);
// Opening the private tab pulls anything missed while it was hidden. Silent
// signers also poll in the hook; this makes tab activation immediate.
const refreshNative = native.refresh;
useEffect(() => {
if (!active || !native.ready || !groupGid || interactive) return;
void refreshNative();
}, [active, native.ready, groupGid, interactive, refreshNative]);
const markSent = useCallback((id: string) => {
if (!id) return;
setSentIds((prev) => new Set(prev).add(id));
window.setTimeout(() => {
setSentIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}, 3000);
}, []);
const withReply = useCallback(
(text: string) =>
replyTo
? `> ${replyTo.content.replace(/\n/g, '\n> ')}\n\n${text}`
: text,
[replyTo],
);
const copyMessage = useCallback(async (text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setNotice(t('common.copied'));
window.setTimeout(() => setNotice(null), 1500);
} catch {
// clipboard blocked
}
}, [t]);
const deliver = useCallback(
async (text: string, imageUrl?: string) => {
setNotice(null);
setPending({ text, imageUrl, state: 'sending' });
const sent = await sendNative(text, imageUrl);
if (sent) {
markSent(sent.id);
setPending(null);
} else {
setPending((current) => (current ? { ...current, state: 'failed' } : current));
}
},
[sendNative, markSent],
);
const handleSend = useCallback(async () => {
const body = draft.trim();
if (!body || native.busy) return;
setDraft('');
const content = withReply(body);
const sent = await native.send(content);
if (sent) {
markSent(sent.id);
setReplyTo(null);
setPending(null);
} else {
setPending({ text: content, state: 'failed' });
}
}, [draft, native, withReply, markSent]);
const retrySend = useCallback(async () => {
if (!pending || pending.state !== 'failed') return;
await deliver(pending.text, pending.imageUrl);
}, [pending, deliver]);
const onPickImage = useCallback(
async (file: File | undefined) => {
if (!file) return;
try {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (!url) throw new Error('upload returned no url');
const caption = draft.trim();
setDraft('');
await deliver(withReply(caption), url);
} catch {
setNotice(t('cordnUi.uploadFailed'));
}
},
[uploadFile, draft, deliver, withReply, t],
);
const copyRef = useCallback(async () => {
if (!native.groupRef) return;
try {
await navigator.clipboard.writeText(native.groupRef);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard unavailable — the ref is still visible to select.
}
}, [native.groupRef]);
const copyInvite = useCallback(async () => {
try {
await navigator.clipboard.writeText(inviteUrl);
setCopiedLink(true);
window.setTimeout(() => setCopiedLink(false), 1500);
} catch {
// clipboard blocked — the link is visible to select.
}
}, [inviteUrl]);
const openDeviceLink = useCallback(async () => {
setDeviceLinkOpen(true);
setDeviceLinkCode(null);
setDeviceLinkPending(true);
setNotice(null);
const code = await native.createDeviceLink();
if (!code) {
setDeviceLinkPending(false);
setNotice(t('cordnUi.deviceLinkFailed'));
return;
}
setDeviceLinkCode(code);
if (user?.pubkey) writePendingJoin(user.pubkey, native.groupId);
setAwaiting(true);
}, [native, user?.pubkey, t]);
const copyDeviceLink = useCallback(async () => {
if (!deviceLinkCode) return;
try {
await navigator.clipboard.writeText(deviceLinkCode);
setCopiedDevice(true);
window.setTimeout(() => setCopiedDevice(false), 1500);
} catch {
// clipboard blocked — the code is visible
}
}, [deviceLinkCode]);
const addScannedDevice = useCallback(async (raw: string) => {
setNotice(null);
const ok = await native.addDevice(raw);
setNotice(ok ? t('cordnUi.deviceLinked') : t('cordnUi.deviceLinkFailed'));
}, [native, t]);
const checkNow = useCallback(async () => {
if (checkingRef.current) return false;
checkingRef.current = true;
try {
return await checkInviteNative();
} finally {
checkingRef.current = false;
}
}, [checkInviteNative]);
const handleRequestJoin = useCallback(async () => {
setNotice(null);
const requested = await native.requestJoin();
if (!requested || !user?.pubkey) return;
writePendingJoin(user.pubkey, native.groupId);
setAwaiting(true);
}, [native, user?.pubkey]);
const handleCheckInvite = useCallback(async () => {
if (native.busy) return;
setNotice(null);
const joined = await checkNow();
if (joined) {
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
setAwaiting(false);
return;
}
if (!awaiting) setNotice(t('cordnUi.noInvite'));
}, [checkNow, awaiting, t, native.busy, user?.pubkey, native.groupId]);
// Keep the latest checker in a ref: the poll must not restart just because a
// parent re-render gave the hook callbacks new identities.
const checkNowRef = useRef(checkNow);
useEffect(() => {
checkNowRef.current = checkNow;
}, [checkNow]);
// Poll the coordinator while a join request is pending. Backs off from 10s to
// 30s so a founder who approves later is picked up without a manual tap.
// Interactive signers (Amber) are excluded — polling would pop their signer.
useEffect(() => {
if (!awaiting || native.group || !native.ready || native.interactiveHintVisible || (!active && !native.passiveSigner)) return;
let cancelled = false;
let timer: number | undefined;
let delay = 10_000;
const tick = async () => {
if (cancelled) return;
const joined = await checkNowRef.current();
if (cancelled) return;
if (joined) {
setAwaiting(false);
setNotice(null);
return;
}
delay = Math.min(30_000, Math.round(delay * 1.5));
timer = window.setTimeout(() => void tick(), delay);
};
timer = window.setTimeout(() => void tick(), delay);
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [awaiting, native.group, native.ready, native.interactiveHintVisible, active, native.passiveSigner]);
// "Join our crew chat" invite. The chat needs the person to be a crew member
// first, so the link just drops them on this crew's private-chat tab with the
// "Ask to join" flow ready. Uses the OS share sheet where available.
const shareInvite = useCallback(async () => {
try {
if (navigator.share) {
await navigator.share({
title: crew.name,
text: copy.shareInviteText,
url: inviteUrl,
});
return;
}
await navigator.clipboard.writeText(inviteUrl);
setNotice(t('cordnUi.inviteCopied'));
window.setTimeout(() => setNotice(null), 2500);
} catch {
// share cancelled or clipboard blocked — nothing to do
}
}, [crew.name, copy.shareInviteText, inviteUrl, t]);
if (!user) {
return <Notice icon={<Lock className="h-5 w-5" />} text={copy.notLoggedIn} />;
}
if (!isMember) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.membersOnly')} />;
}
if (!native.coordinator) {
return <Notice icon={<KeyRound className="h-5 w-5" />} text={t('cordnUi.noCoordinator')} />;
}
const status = native.ready ? t('cordnUi.ready') : t('cordnUi.connecting');
const steps = canCreate ? copy.founderSteps : copy.memberSteps;
const roleOf = (pubkey: string): MemberRole =>
pubkey === crew.pubkey ? 'founder' : crew.admins.includes(pubkey) ? 'admin' : 'member';
return (
<Card className="border-white/10 bg-black/40">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Lock className="h-4 w-4 text-teal-400" /> {copy.title}
</CardTitle>
<div className="flex flex-wrap items-center justify-end gap-2">
{native.group && (
<Badge variant="secondary" className="text-[10px]">
{t('cordnUi.memberCount', { count: native.group.members.length })}
</Badge>
)}
{native.error ? (
<>
<Badge
variant="outline"
className="gap-1 border-red-500/40 text-[10px] text-red-300"
>
<AlertTriangle className="h-3 w-3" /> {t('cordnUi.unreachable')}
</Badge>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={native.retry}
aria-label={t('cordnUi.retry')}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Badge
variant="outline"
className={cn(
'text-[10px]',
native.ready ? 'border-teal-500/40 text-teal-300' : 'text-gray-400',
)}
>
{status}
</Badge>
{!native.ready && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={native.retry}
aria-label={t('cordnUi.retry')}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
{native.group && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
onClick={() => setInviteOpen(true)}
>
<Share2 className="h-3 w-3" /> {t('cordnUi.shareInvite')}
</Button>
)}
</div>
</div>
<p className="pt-1 text-xs text-gray-500">{copy.subtitle}</p>
</CardHeader>
<CardContent className="space-y-3">
{native.error && (
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
<div className="flex flex-wrap items-center justify-between gap-2">
<p>{t('cordnUi.errorUnreachable')}</p>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 border-red-500/30 text-red-200 hover:text-red-100"
onClick={native.retry}
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.retry')}
</Button>
</div>
<details className="mt-1">
<summary className="cursor-pointer text-[10px] text-red-300/70">
{t('cordnUi.errorDetails')}
</summary>
<p className="mt-1 break-words font-mono text-[10px] text-red-300/70">
{native.error}
</p>
</details>
</div>
)}
{!native.group ? (
<div className="space-y-4 py-4 text-center">
<p className="text-sm text-gray-400">
{canCreate
? copy.noGroupAdmin
: native.keyPackageReady
? copy.noGroupMember
: copy.setupDeviceBody}
</p>
<ol className="mx-auto max-w-sm space-y-2 text-left">
{steps.map((step, index) => (
<li key={step} className="flex items-start gap-2 text-xs text-gray-400">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-teal-500/15 text-[10px] font-bold text-teal-300">
{index + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
{copy.encryptedNote}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
{canCreate ? (
<Button
size="sm"
disabled={!native.ready || native.busy}
onClick={() => void native.createChat()}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
{native.busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
{copy.create}
</Button>
) : (
<>
<Button
size="sm"
disabled={!native.ready || native.busy || awaiting}
onClick={() => void handleRequestJoin()}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
<UserPlus className="h-3.5 w-3.5" />{' '}
{native.keyPackageReady ? t('cordnUi.requestJoin') : t('cordnUi.setupDevice')}
</Button>
<Button
size="sm"
variant="outline"
disabled={!native.ready || native.busy}
onClick={() => void handleCheckInvite()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.checkInvite')}
</Button>
</>
)}
<Button
size="sm"
variant="outline"
disabled={!native.ready || native.busy}
onClick={() => void openDeviceLink()}
className="gap-1.5"
>
<QrCode className="h-3.5 w-3.5" /> {t('cordnUi.linkDevice')}
</Button>
</div>
{awaiting && (
<p className="flex items-center justify-center gap-1.5 text-xs text-teal-300">
{deviceLinkPending ? (
<>
<QrCode className="h-3.5 w-3.5" /> {t('cordnUi.waitingDevice')}
</>
) : native.interactiveHintVisible ? (
<>
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.waitingApprovalManual')}
</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('cordnUi.waitingApproval')}
</>
)}
</p>
)}
{notice && <p className="text-center text-xs text-teal-300">{notice}</p>}
<AdvancedPanel
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady}
copied={copied}
onCopyRef={() => void copyRef()}
/>
</div>
) : (
<>
<details className="group rounded-md border border-white/10 bg-black/20 px-2.5 py-2">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-gray-400">
<Users className="h-3.5 w-3.5 text-teal-400" />
{t('cordnUi.members')}
<span className="text-gray-600">· {native.group.members.length}</span>
<ChevronDown className="ml-auto h-3 w-3 transition-transform group-open:rotate-180" />
</summary>
<div className="mt-2 space-y-2">
{native.group.members.map((member) => (
<MemberRow
key={`${member.pubkey}-${member.leafIndex}`}
pubkey={member.pubkey}
role={roleOf(member.pubkey)}
isSelf={member.pubkey === user.pubkey}
founderLabel={copy.owner}
botPubkey={botPubkey}
botName={copy.botName}
canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
onRemove={() => {
if (!window.confirm(t('cordnUi.removeMemberConfirm', { name: shortNpub(member.pubkey) }))) return;
void native.removeMember(member.leafIndex).then((ok) => {
if (!ok) setNotice(t('cordnUi.removeMemberFailed'));
});
}}
/>
))}
</div>
<div className="mt-2 flex flex-wrap justify-end gap-2">
{!isCommunity && user.pubkey === crew.pubkey && (
<Button asChild size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Users className="h-3 w-3" /> {t('socialUi.editCrew.title')}
</Link>
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
onClick={() => setScannerOpen(true)}
>
<ScanLine className="h-3 w-3" /> {t('cordnUi.addDevice')}
</Button>
</div>
</details>
<div
ref={scrollRef}
className="max-h-80 space-y-2 overflow-y-auto rounded-md border border-white/10 bg-black/30 p-3"
>
{native.messages.length === 0 && !pending ? (
<p className="py-6 text-center text-xs text-gray-500">
{t('cordnUi.placeholder')}
</p>
) : (
native.messages.map((m) => (
<div
key={m.id}
className={cn('flex flex-col gap-0.5', m.direction === 'outbound' && 'items-end')}
>
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-gray-500">
<span>
{m.sender === user.pubkey ? t('cordnUi.you') : shortNpub(m.sender)}
</span>
<span>
{formatDistanceToNow(new Date(m.createdAt * 1000), { addSuffix: true })}
</span>
{m.direction === 'outbound' && sentIds.has(m.id) && (
<span className="flex items-center gap-0.5 text-teal-400">
<Check className="h-3 w-3" /> {t('cordnUi.sent')}
</span>
)}
<span className="ml-auto flex items-center gap-0.5">
<button
type="button"
onClick={() => void copyMessage(m.content)}
className="rounded p-0.5 text-gray-500 hover:bg-white/10 hover:text-gray-200"
aria-label={t('common.copy')}
title={t('common.copy')}
>
<Copy className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => setReplyTo({ id: m.id, content: m.content })}
className="rounded p-0.5 text-gray-500 hover:bg-white/10 hover:text-gray-200"
aria-label={t('cordnUi.reply')}
title={t('cordnUi.reply')}
>
<Reply className="h-3 w-3" />
</button>
</span>
</div>
<div
className={cn(
'max-w-[85%] space-y-1 overflow-hidden rounded-2xl px-3 py-1.5 text-sm',
m.direction === 'outbound'
? 'bg-teal-500/20 text-teal-100'
: 'bg-white/10 text-gray-100',
)}
>
{m.imageUrl && (
<ImageWithFallback
src={m.imageUrl}
alt=""
loading="lazy"
className="max-h-60 w-full rounded-lg object-cover"
/>
)}
{m.content && (
<div className="whitespace-pre-wrap break-words">{m.content}</div>
)}
</div>
</div>
))
)}
{pending && (
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-gray-500">
<span>{t('cordnUi.you')}</span>
<span className={cn(pending.state === 'failed' && 'text-red-300')}>
{pending.state === 'sending'
? t('cordnUi.sending')
: t('cordnUi.sendFailed')}
</span>
</div>
<div className="max-w-[85%] space-y-1 overflow-hidden rounded-2xl bg-teal-500/20 px-3 py-1.5 text-sm text-teal-100 opacity-80">
{pending.imageUrl && (
<img
src={pending.imageUrl}
alt=""
className="max-h-60 w-full rounded-lg object-cover"
/>
)}
{pending.text && (
<div className="whitespace-pre-wrap break-words">{pending.text}</div>
)}
</div>
{pending.state === 'failed' && (
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
onClick={() => void retrySend()}
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.retry')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs text-gray-400"
onClick={() => setPending(null)}
>
<X className="h-3 w-3" /> {t('cordnUi.dismiss')}
</Button>
</div>
)}
</div>
)}
</div>
{replyTo && (
<div className="flex items-start gap-2 rounded-md border-l-2 border-teal-400 bg-teal-500/10 px-2 py-1 text-xs text-gray-300">
<Reply className="mt-0.5 h-3 w-3 shrink-0 text-teal-300" />
<span className="min-w-0 flex-1 truncate">
{t('cordnUi.reply')}: {replyTo.content || t('cordnUi.attachImage')}
</span>
<button
type="button"
onClick={() => setReplyTo(null)}
className="shrink-0 rounded p-0.5 text-gray-500 hover:text-gray-200"
aria-label={t('common.cancel')}
>
<X className="h-3 w-3" />
</button>
</div>
)}
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
void onPickImage(e.target.files?.[0]);
e.target.value = '';
}}
/>
<Button
size="icon"
variant="outline"
disabled={uploading || native.busy}
onClick={() => fileRef.current?.click()}
aria-label={t('cordnUi.attachImage')}
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
</Button>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder={t('cordnUi.placeholder')}
className="flex-1"
/>
<Button
size="icon"
disabled={native.busy || !draft.trim()}
onClick={() => void handleSend()}
aria-label={t('cordnUi.send')}
>
{native.busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={() => void native.refresh()}
aria-label={t('cordnUi.refresh')}
>
<RefreshCw className={cn('h-4 w-4', native.busy && 'animate-spin')} />
</Button>
</div>
{notice && <p className="text-xs text-teal-300">{notice}</p>}
{native.interactiveHintVisible && (
<p className="text-[11px] text-gray-500">{t('cordnUi.interactiveHint')}</p>
)}
<AdvancedPanel
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady || !!native.group}
copied={copied}
onCopyRef={() => void copyRef()}
/>
{canManage && (
<div className="space-y-2 rounded-md border border-white/10 bg-black/20 p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-300">
{t('cordnUi.requests')}
</span>
<Button
size="sm"
variant="ghost"
disabled={native.busy}
onClick={() => void native.loadRequests()}
className="h-7 gap-1 text-xs"
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.checkRequests')}
</Button>
</div>
{native.requests.length === 0 ? (
<p className="px-1 text-[11px] text-gray-500">
{native.group.members.length <= 1
? t('cordnUi.inviteHint')
: t('cordnUi.noRequests')}
</p>
) : (
native.requests.map((req) => (
<RequestRow
key={`${req.pk}-${req.kp_ref}`}
pubkey={req.pk}
disabled={native.busy}
onApprove={() => void native.approve(req.pk)}
botPubkey={botPubkey}
botName={copy.botName}
/>
))
)}
</div>
)}
</>
)}
</CardContent>
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogContent className="max-w-sm border-white/10 bg-zinc-950">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<QrCode className="h-4 w-4 text-teal-400" /> {copy.qrTitle}
</DialogTitle>
<DialogDescription>{copy.qrHint}</DialogDescription>
</DialogHeader>
<div className="flex justify-center rounded-xl bg-white p-3">
<QRCodeCanvas value={inviteUrl} size={220} level="M" />
</div>
<code className="block break-all rounded bg-black/50 px-2 py-1.5 text-[10px] text-gray-400">
{inviteUrl}
</code>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => void shareInvite()} className="flex-1 gap-1.5">
<Share2 className="h-3.5 w-3.5" /> {t('cordnUi.shareInvite')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void copyInvite()}
className="flex-1 gap-1.5"
>
{copiedLink ? (
<Check className="h-3.5 w-3.5 text-teal-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedLink ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deviceLinkOpen}
onOpenChange={(open) => {
setDeviceLinkOpen(open);
if (!open) setDeviceLinkCode(null);
}}
>
<DialogContent className="max-w-sm border-white/10 bg-zinc-950">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<QrCode className="h-4 w-4 text-teal-400" /> {t('cordnUi.linkDeviceTitle')}
</DialogTitle>
<DialogDescription>{t('cordnUi.linkDeviceHint')}</DialogDescription>
</DialogHeader>
{deviceLinkCode ? (
<>
<div className="flex justify-center rounded-xl bg-white p-3">
<QRCodeCanvas value={deviceLinkCode} size={240} level="L" />
</div>
<code className="block max-h-20 break-all overflow-y-auto rounded bg-black/50 px-2 py-1.5 text-[10px] text-gray-400">
{deviceLinkCode}
</code>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => void copyDeviceLink()} className="flex-1 gap-1.5">
{copiedDevice ? (
<Check className="h-3.5 w-3.5 text-teal-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedDevice ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setDeviceLinkOpen(false);
setDeviceLinkCode(null);
}}
>
{t('common.close')}
</Button>
</div>
</>
) : (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-gray-400">
<Loader2 className="h-4 w-4 animate-spin" /> {t('common.loading')}
</div>
)}
<p className="text-[11px] text-gray-500">{t('cordnUi.linkDeviceNote')}</p>
</DialogContent>
</Dialog>
<QRScannerModal
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
onResult={(result) => void addScannedDevice(result.value)}
/>
</Card>
);
}
function AdvancedPanel({
groupRef,
coordinatorPubkey,
keyPackageReady,
copied,
onCopyRef,
}: {
groupRef: string | null;
coordinatorPubkey: string;
keyPackageReady: boolean;
copied: boolean;
onCopyRef: () => void;
}) {
const { t } = useTranslation();
const [copiedCordn, setCopiedCordn] = useState(false);
const cordnUrl = groupRef ? `https://cordn.net/chat/${groupRef}` : null;
const copyCordn = async () => {
if (!cordnUrl) return;
try {
await navigator.clipboard.writeText(cordnUrl);
setCopiedCordn(true);
window.setTimeout(() => setCopiedCordn(false), 1500);
} catch {
// clipboard blocked — the link is visible to select.
}
};
return (
<details className="group rounded-md border border-white/10 bg-black/20 px-2.5 py-2 text-left">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-[11px] text-gray-500">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-180" />
{t('cordnUi.advanced')}
</summary>
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span>{t('cordnUi.coordinator')}</span>
<code className="truncate rounded bg-black/40 px-1.5 py-0.5 text-gray-400">
{shortNpub(coordinatorPubkey)}
</code>
</div>
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span>{t('cordnUi.keyPackage')}</span>
<span className={keyPackageReady ? 'text-teal-300' : 'text-amber-300'}>
{keyPackageReady ? t('cordnUi.keyPackageReady') : t('cordnUi.keyPackageMissing')}
</span>
</div>
{groupRef && (
<>
<div className="flex items-center gap-2 text-[11px] text-gray-500">
<span className="shrink-0">{t('cordnUi.groupRef')}</span>
<code className="min-w-0 flex-1 truncate rounded bg-black/40 px-1.5 py-0.5 text-gray-400">
{groupRef}
</code>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={onCopyRef}
>
{copied ? (
<Check className="h-3 w-3 text-teal-400" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<a
href={cordnUrl ?? '#'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-teal-300 transition-colors hover:text-teal-200"
>
<ExternalLink className="h-3 w-3" />{' '}
{t('socialUi.crewCoordinator.openCordnGroup')}
</a>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-[11px] text-gray-400"
onClick={() => void copyCordn()}
>
{copiedCordn ? (
<Check className="h-3 w-3 text-teal-400" />
) : (
<Copy className="h-3 w-3" />
)}
{copiedCordn ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
</>
)}
</div>
</details>
);
}
function PersonLabel({
pubkey,
isSelf,
botPubkey,
botName,
}: {
pubkey: string;
isSelf?: boolean;
botPubkey?: string;
botName?: string;
}) {
const { t } = useTranslation();
const { data } = useAuthor(pubkey);
const isBot = !!botPubkey && pubkey === botPubkey;
const name = isBot
? (botName ?? shortNpub(pubkey))
: data?.metadata?.display_name || data?.metadata?.name || shortNpub(pubkey);
const picture = isBot ? undefined : data?.metadata?.picture;
const initials = name.replace(/^@/, '').slice(0, 2).toUpperCase();
return (
<div className="flex min-w-0 items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={picture} alt={name} />
<AvatarFallback className="bg-teal-900/40 text-[9px] font-bold text-teal-300">
{initials}
</AvatarFallback>
</Avatar>
<span className="truncate text-xs text-gray-300">
{name}
{isSelf && <span className="text-gray-500"> ({t('cordnUi.you')})</span>}
</span>
</div>
);
}
function MemberRow({
pubkey,
role,
isSelf,
founderLabel,
botPubkey,
botName,
canRemove,
onRemove,
}: {
pubkey: string;
role: MemberRole;
isSelf: boolean;
founderLabel: string;
botPubkey?: string;
botName?: string;
canRemove?: boolean;
onRemove?: () => void;
}) {
const { t } = useTranslation();
const label =
role === 'founder'
? founderLabel
: role === 'admin'
? t('cordnUi.roleAdmin')
: t('cordnUi.roleMember');
return (
<div className="flex items-center justify-between gap-2">
<PersonLabel pubkey={pubkey} isSelf={isSelf} botPubkey={botPubkey} botName={botName} />
<div className="flex shrink-0 items-center gap-1">
<Badge
variant="outline"
className={cn(
'shrink-0 text-[9px]',
role === 'founder' && 'border-teal-500/40 text-teal-300',
role === 'admin' && 'border-amber-500/40 text-amber-300',
role === 'member' && 'text-gray-400',
)}
>
{label}
</Badge>
{canRemove && onRemove && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
onClick={onRemove}
aria-label={t('common.remove')}
title={t('common.remove')}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
}
function RequestRow({
pubkey,
disabled,
onApprove,
botPubkey,
botName,
}: {
pubkey: string;
disabled: boolean;
onApprove: () => void;
botPubkey?: string;
botName?: string;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-2">
<PersonLabel pubkey={pubkey} botPubkey={botPubkey} botName={botName} />
<Button
size="sm"
disabled={disabled}
onClick={onApprove}
className="h-7 shrink-0 gap-1 text-xs"
>
<UserPlus className="h-3 w-3" /> {t('cordnUi.approve')}
</Button>
</div>
);
}
function Notice({ icon, text }: { icon: ReactNode; text: string }) {
return (
<Card className="border-dashed border-white/10 bg-black/40">
<CardContent className="flex items-center gap-3 py-8 text-sm text-gray-400">
<span className="text-teal-400">{icon}</span>
{text}
</CardContent>
</Card>
);
}