SkateSpots/src/components/crew/CordnNativePanel.tsx

1794 lines
68 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import type { ReactNode } from 'react';
import {
AlertTriangle,
Check,
CheckCheck,
ChevronDown,
Copy,
ImagePlus,
KeyRound,
Loader2,
Lock,
Plus,
QrCode,
RefreshCw,
Reply,
ScanLine,
Search,
Send,
Share2,
ShieldMinus,
ShieldPlus,
SmilePlus,
Trash2,
UserPlus,
Users,
Volume2,
VolumeX,
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
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 { useNostrPublish } from '@/hooks/useNostrPublish';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCordnNative } from '@/hooks/useCordnNative';
import { useDmMute } from '@/hooks/useDmMute';
import { useCordnRoomRead } from '@/hooks/useCordnRoomRead';
import { enqueueCordnOutbox, markCordnOutboxAttempt, readCordnOutbox, removeCordnOutbox } from '@/lib/cordnOutbox';
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 { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
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';
outboxId?: string;
attempts?: number;
}
/**
* 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 { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const [roleBusy, setRoleBusy] = useState<string | null>(null);
const isCommunity = scope === 'community';
const waitingApprovalKey = isCommunity ? 'communityUi.waitingApproval' : 'cordnUi.waitingApproval';
const waitingApprovalManualKey = isCommunity
? 'communityUi.waitingApprovalManual'
: 'cordnUi.waitingApprovalManual';
const noInviteKey = isCommunity ? 'communityUi.noInvite' : 'cordnUi.noInvite';
const sourceCrew = crew as SkateCrew;
const canChangeRoles = !isCommunity && user?.pubkey === crew.pubkey && !!sourceCrew.event;
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 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 [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState('');
const [online, setOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine);
const [replyTo, setReplyTo] = useState<{ id: string; content: string } | null>(null);
const [reactingTo, setReactingTo] = useState<string | null>(null);
const readSentRef = useRef<Set<string>>(new Set());
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 { isMuted, toggle: toggleMute } = useDmMute();
const { reads, markActive, markSeen } = useCordnRoomRead(user?.pubkey);
const muteKey = native.groupId ? `cordn:${native.groupId}` : undefined;
const muted = isMuted(muteKey);
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]);
// Read receipts: when the tab is visible, acknowledge the latest inbound
// message once. Control receipts are filtered out of `native.messages`.
useEffect(() => {
if (!active || !native.ready || !user?.pubkey || native.interactiveHintVisible) return;
const lastInbound = [...native.messages]
.reverse()
.find((m) => m.direction === 'inbound' && m.sender !== user.pubkey);
if (!lastInbound || readSentRef.current.has(lastInbound.id)) return;
readSentRef.current.add(lastInbound.id);
void native.markRead(lastInbound.id);
}, [active, native, native.messages, native.markRead, native.ready, user?.pubkey]);
useEffect(() => {
if (!user?.pubkey) return;
setAwaiting(readPendingJoin(user.pubkey, native.groupId));
}, [user?.pubkey, native.groupId]);
const latestInboundAt = useMemo(
() =>
native.messages.reduce(
(max, message) =>
message.direction === 'inbound' && message.sender !== user?.pubkey
? Math.max(max, message.createdAt)
: max,
0,
),
[native.messages, user?.pubkey],
);
useEffect(() => {
if (native.groupId && latestInboundAt > 0) markActive(native.groupId, latestInboundAt);
}, [native.groupId, latestInboundAt, markActive]);
useEffect(() => {
if (active && native.groupId && latestInboundAt > 0) markSeen(native.groupId, latestInboundAt);
}, [active, native.groupId, latestInboundAt, markSeen]);
useEffect(() => {
const onOnline = () => setOnline(true);
const onOffline = () => setOnline(false);
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);
// A different room must not inherit the previous room's draft/pending bubble.
useEffect(() => {
setPending(null);
setReplyTo(null);
setReactingTo(null);
setSearch('');
setSearchOpen(false);
}, [native.groupId]);
// Restore the oldest failed send after a reload/room switch. It is retried
// automatically a few times when the app is online, then stays queued for a
// deliberate tap.
useEffect(() => {
if (!user?.pubkey || !native.groupId) return;
const [next] = readCordnOutbox(user.pubkey, native.groupId);
if (!next) return;
setPending((current) =>
current ?? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
},
);
}, [user?.pubkey, native.groupId]);
const displayMessages = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return native.messages;
return native.messages.filter(
(message) =>
message.content.toLowerCase().includes(query) ||
(message.imageUrl ?? '').toLowerCase().includes(query),
);
}, [native.messages, search]);
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. The panel records the latest inbound
// timestamp; a hidden tab compares it with the local seen cursor. The first
// observation seeds the cursor so old history never reads as unread.
useEffect(() => {
if (!user?.pubkey || !groupGid || !native.ready) {
onUnreadChange?.(false);
return;
}
if (active) {
onUnreadChange?.(false);
return;
}
const state = reads[groupGid];
if (!state) {
markSeen(groupGid, latestInboundAt);
onUnreadChange?.(false);
return;
}
markActive(groupGid, latestInboundAt);
onUnreadChange?.(latestInboundAt > state.seenAt);
}, [
active,
groupGid,
native.ready,
user?.pubkey,
reads,
latestInboundAt,
markActive,
markSeen,
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, outboxId?: string): Promise<boolean> => {
setNotice(null);
const currentAttempt = outboxId
? readCordnOutbox(user?.pubkey, native.groupId).find((item) => item.id === outboxId)?.attempts
: undefined;
setPending({
text,
imageUrl,
state: 'sending',
outboxId,
attempts: currentAttempt,
});
const sent = await sendNative(text, imageUrl);
if (sent) {
markSent(sent.id);
if (outboxId) removeCordnOutbox(user?.pubkey, native.groupId, outboxId);
const [next] = readCordnOutbox(user?.pubkey, native.groupId);
setPending(next ? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
} : null);
return true;
}
let nextOutboxId = outboxId;
let attempts = currentAttempt ?? 0;
if (nextOutboxId) {
markCordnOutboxAttempt(user?.pubkey, native.groupId, nextOutboxId);
attempts += 1;
} else {
const item = enqueueCordnOutbox(user?.pubkey, native.groupId, text, imageUrl);
nextOutboxId = item?.id;
attempts = item?.attempts ?? 1;
}
setPending({
text,
imageUrl,
state: 'failed',
outboxId: nextOutboxId,
attempts,
});
return false;
},
[sendNative, markSent, user?.pubkey, native.groupId],
);
const handleSend = useCallback(async () => {
const body = draft.trim();
if (!body || native.busy) return;
setDraft('');
const content = withReply(body);
const ok = await deliver(content);
if (ok) setReplyTo(null);
}, [draft, native.busy, withReply, deliver]);
const retrySend = useCallback(async () => {
if (!pending || pending.state !== 'failed') return;
await deliver(pending.text, pending.imageUrl, pending.outboxId);
}, [pending, deliver]);
const discardPending = useCallback(() => {
if (pending?.outboxId) removeCordnOutbox(user?.pubkey, native.groupId, pending.outboxId);
const [next] = readCordnOutbox(user?.pubkey, native.groupId);
setPending(next ? {
text: next.text,
imageUrl: next.imageUrl,
state: 'failed',
outboxId: next.id,
attempts: next.attempts,
} : null);
}, [pending, user?.pubkey, native.groupId]);
// A failed send is retried a few times when the app is online, then left for
// the explicit Retry button. This avoids both data loss and infinite retry.
useEffect(() => {
if (
!pending ||
pending.state !== 'failed' ||
!online ||
!native.ready ||
(pending.attempts ?? 0) >= 3
) return;
const timer = window.setTimeout(() => void retrySend(), 12_000);
return () => window.clearTimeout(timer);
}, [pending, online, native.ready, retrySend]);
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 handleApprove = useCallback(async (request: { pk: string; kp_ref: string; at: number }) => {
const ok = await native.approve(
request.kp_ref || request.pk,
request.pk,
{ pk: request.pk, at: request.at },
);
if (ok) {
setNotice(t('cordnUi.requestHandled'));
window.setTimeout(() => setNotice(null), 2500);
}
}, [native, t]);
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(noInviteKey));
}, [checkNow, awaiting, t, noInviteKey, 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';
const updateAdminRole = async (memberPubkey: string, makeAdmin: boolean) => {
if (!canChangeRoles || !sourceCrew.event) return;
setRoleBusy(memberPubkey);
try {
const tags = sourceCrew.event.tags.filter(
([name, value]) => !(name === 'p' && value === memberPubkey),
);
tags.push(makeAdmin ? ['p', memberPubkey, '', 'admin'] : ['p', memberPubkey]);
await publishEvent({
kind: SKATE_CREW_KIND,
content: sourceCrew.event.content,
tags,
});
await queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
await queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
setNotice(t(makeAdmin ? 'cordnUi.adminAdded' : 'cordnUi.adminRemoved'));
} catch {
setNotice(t('cordnUi.roleUpdateFailed'));
} finally {
setRoleBusy(null);
}
};
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 && (
<>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-gray-400 hover:text-teal-300"
onClick={() => {
setSearchOpen((open) => !open);
if (searchOpen) setSearch('');
}}
aria-label={t('common.search')}
title={t('common.search')}
>
<Search className="h-3.5 w-3.5" />
</Button>
{muteKey && (
<Button
size="icon"
variant="ghost"
className={cn('h-7 w-7 hover:text-amber-300', muted ? 'text-amber-400' : 'text-gray-400')}
onClick={() => toggleMute(muteKey)}
aria-pressed={muted}
aria-label={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
title={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
>
{muted ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
</Button>
)}
</>
)}
{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>
{native.group && native.lastCheckedAt && (
<p className="pt-0.5 text-[10px] text-gray-600">
{t('cordnUi.lastChecked')} {formatDistanceToNow(new Date(native.lastCheckedAt), { addSuffix: true })}
</p>
)}
{!isCommunity && (
<details className="group mt-1 rounded-md border border-white/10 bg-black/20 px-2.5 py-1.5">
<summary className="flex cursor-pointer list-none items-center gap-1 text-[11px] text-gray-400">
<ChevronDown className="h-3 w-3 transition-transform group-open:rotate-180" />
{t('cordnUi.compareTitle')}
</summary>
<div className="mt-1.5 space-y-1 text-[11px] leading-relaxed text-gray-500">
<p>{t('cordnUi.compareChat')}</p>
<p>{t('cordnUi.comparePrivate')}</p>
</div>
</details>
)}
</CardHeader>
<CardContent className="space-y-3">
{isCommunity && native.group && botPubkey && !native.group.members.some((member) => member.pubkey === botPubkey) && (
<div role="status" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{t('communityUi.botMissing')}
</div>
)}
{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) => {
// Members see the setup move visibly: device key prepared →
// request sent → waiting for an admin. Founders start at step 1.
const stepDone = !canCreate && index === 0 && (awaiting || native.keyPackageReady);
const stepWaiting = !canCreate && index === 1 && awaiting;
return (
<li
key={step}
className={cn(
'flex items-start gap-2 text-xs',
stepDone || stepWaiting ? 'text-teal-200' : 'text-gray-400',
)}
>
<span className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
stepDone ? 'bg-teal-500/20 text-teal-300' : 'bg-teal-500/15 text-teal-300',
)}>
{stepDone ? <Check className="h-2.5 w-2.5" /> : stepWaiting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : index + 1}
</span>
<span>{step}</span>
</li>
);
})}
</ol>
<p className="mx-auto max-w-sm text-[11px] text-gray-500">
{copy.encryptedNote}
</p>
{/* Amber needs one foreground approval for the device key and one
for the join request. Show the two translated step labels so the
second prompt is expected, not a mystery. */}
{native.interactiveHintVisible && !canCreate && (
<p className="mx-auto flex max-w-sm items-center justify-center gap-1.5 text-[11px] text-amber-300/80">
<KeyRound className="h-3 w-3 shrink-0" />
<span>Amber</span>
<span aria-hidden="true">·</span>
<span>{t('cordnUi.setupDevice')}</span>
<span aria-hidden="true">→</span>
<span>{t('cordnUi.requestJoin')}</span>
</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(waitingApprovalManualKey)}
</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t(waitingApprovalKey)}
</>
)}
</p>
)}
{awaiting && !deviceLinkPending && (
<button
type="button"
disabled={!native.ready || native.busy}
onClick={() => void handleRequestJoin()}
className="mx-auto block text-[11px] text-teal-300 underline underline-offset-2 hover:text-teal-200 disabled:opacity-50"
>
{t('cordnUi.requestAgain')}
</button>
)}
{isCommunity && (
<p className="mx-auto max-w-sm text-center text-[11px] text-gray-500">
{t('communityUi.secondDeviceHint')}
</p>
)}
{notice && <p className="text-center text-xs text-teal-300">{notice}</p>}
<AdvancedPanel
groupRef={native.groupRef}
coordinatorPubkey={native.coordinator.serverPubkey}
keyPackageReady={native.keyPackageReady}
lastCheckedAt={native.lastCheckedAt}
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}
canChangeRole={canChangeRoles && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
roleBusy={roleBusy === member.pubkey}
onToggleAdmin={() => void updateAdminRole(member.pubkey, roleOf(member.pubkey) !== 'admin')}
canRemove={canManage && member.pubkey !== user.pubkey && member.pubkey !== crew.pubkey}
onRemove={() => {
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>
{searchOpen && (
<div className="flex items-center gap-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('common.search')}
aria-label={t('common.search')}
className="h-8 border-white/10 bg-black/40 text-xs"
autoFocus
/>
{search && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-gray-400"
onClick={() => setSearch('')}
aria-label={t('common.clear')}
title={t('common.clear')}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
)}
<div
ref={scrollRef}
className="max-h-80 space-y-2 overflow-y-auto rounded-md border border-white/10 bg-black/30 p-3"
>
{displayMessages.length === 0 && !pending ? (
<p className="py-6 text-center text-xs text-gray-500">
{search.trim() ? t('common.noResults') : t('cordnUi.placeholder')}
</p>
) : (
displayMessages.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>
)}
{m.direction === 'outbound' && (native.readBy[m.id]?.length ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-teal-400">
<CheckCheck className="h-3 w-3" /> {t('cordnUi.readBy', { count: native.readBy[m.id].length })}
</span>
)}
<span className="ml-auto flex items-center gap-0.5">
<button
type="button"
onClick={() => setReactingTo(reactingTo === m.id ? null : m.id)}
className="rounded p-0.5 text-gray-500 hover:bg-white/10 hover:text-gray-200"
aria-label={t('cordnUi.addReaction')}
title={t('cordnUi.addReaction')}
>
<SmilePlus className="h-3 w-3" />
</button>
<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>
{m.direction === 'outbound' && (
<button
type="button"
onClick={() => {
if (!window.confirm(t('cordnUi.deleteMessageConfirm'))) return;
void native.deleteMessage(m.id);
}}
className="rounded p-0.5 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
aria-label={t('cordnUi.deleteMessage')}
title={t('cordnUi.deleteMessage')}
>
<Trash2 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>
{reactingTo === m.id && (
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-black/80 px-2 py-1">
{['👍', '❤️', '🔥', '😂'].map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
void native.toggleReaction(m.id, emoji);
setReactingTo(null);
}}
className="rounded px-1 text-sm hover:bg-white/10"
>
{emoji}
</button>
))}
</div>
)}
{Object.values(native.reactions[m.id] ?? {}).some((pks) => pks.length > 0) && (
<div className={cn('flex flex-wrap gap-1', m.direction === 'outbound' && 'justify-end')}>
{Object.entries(native.reactions[m.id] ?? {})
.filter(([, pks]) => pks.length > 0)
.map(([emoji, pks]) => (
<button
key={emoji}
type="button"
onClick={() => void native.toggleReaction(m.id, emoji)}
className={cn(
'rounded-full border px-1.5 py-0.5 text-[10px]',
user?.pubkey && pks.includes(user.pubkey)
? 'border-teal-500/40 bg-teal-500/10 text-teal-200'
: 'border-white/10 bg-white/5 text-gray-300',
)}
>
{emoji} {pks.length}
</button>
))}
</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')}
{pending.state === 'failed' && pending.outboxId && (
<span className="ml-1 text-gray-500">· {t('cordnUi.queued')}</span>
)}
</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={discardPending}
>
<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}
lastCheckedAt={native.lastCheckedAt}
copied={copied}
onCopyRef={() => void copyRef()}
/>
{canManage && (
<div
id={isCommunity ? 'community-requests' : undefined}
className="scroll-mt-24 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 handleApprove(req)}
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,
lastCheckedAt,
copied,
onCopyRef,
}: {
groupRef: string | null;
coordinatorPubkey: string;
keyPackageReady: boolean;
lastCheckedAt: number | null;
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>
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span>{t('cordnUi.lastChecked')}</span>
<span className="text-gray-400">
{lastCheckedAt
? formatDistanceToNow(new Date(lastCheckedAt), { addSuffix: true })
: '—'}
</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">
<span className="w-full text-[10px] text-gray-600">
{t('cordnUi.cordnWebCaveat')}
</span>
<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.copyCordnLink')}
</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,
canChangeRole,
roleBusy,
onToggleAdmin,
}: {
pubkey: string;
role: MemberRole;
isSelf: boolean;
founderLabel: string;
botPubkey?: string;
botName?: string;
canRemove?: boolean;
onRemove?: () => void;
canChangeRole?: boolean;
roleBusy?: boolean;
onToggleAdmin?: () => 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>
{canChangeRole && onToggleAdmin && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-gray-500 hover:bg-amber-500/10 hover:text-amber-300"
onClick={onToggleAdmin}
disabled={roleBusy}
aria-label={role === 'admin' ? t('cordnUi.removeAdmin') : t('cordnUi.makeAdmin')}
title={role === 'admin' ? t('cordnUi.removeAdmin') : t('cordnUi.makeAdmin')}
>
{roleBusy ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : role === 'admin' ? (
<ShieldMinus className="h-3 w-3" />
) : (
<ShieldPlus className="h-3 w-3" />
)}
</Button>
)}
{canRemove && onRemove && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-gray-500 hover:bg-red-500/10 hover:text-red-300"
aria-label={t('common.remove')}
title={t('common.remove')}
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('common.remove')}</AlertDialogTitle>
<AlertDialogDescription>
{t('cordnUi.removeMemberConfirm', { name: shortNpub(pubkey) })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={onRemove}>{t('common.remove')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</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} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
disabled={disabled}
className="h-7 shrink-0 gap-1 text-xs"
>
<UserPlus className="h-3 w-3" /> {t('cordnUi.approve')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('cordnUi.approveTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('cordnUi.approveDesc', { name: shortNpub(pubkey) })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={onApprove}>{t('cordnUi.approve')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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>
);
}