1320 lines
48 KiB
TypeScript
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>
|
|
);
|
|
}
|