/** * ChannelHeaderPinsPopover — desktop popover anchored to the * ChannelHeader pin button. Fetches pinned messages from Convex, * decrypts them using the channel key, and renders a scrollable * list. Dismisses on outside click or Escape. */ import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { PushPin } from '@phosphor-icons/react'; import { useQuery } from 'convex/react'; import { api } from '../../../../../convex/_generated/api'; import { usePlatform } from '../../platform'; import { PinnedMessageRow, ReachedEndNotice, type PinnedMessage } from './PinnedMessageRow'; import { PinConfirmationModal } from './PinConfirmationModal'; import type { AttachmentMetadata } from './EncryptedAttachment'; import styles from './ChannelHeaderPinsPopover.module.css'; interface ChannelHeaderPinsPopoverProps { isOpen: boolean; channelId: string; anchorRect: DOMRect | null; onClose: () => void; } // Ciphertext format matches Messages.tsx: content + 32-hex-char GCM tag. const TAG_LENGTH = 32; export function ChannelHeaderPinsPopover({ isOpen, channelId, anchorRect, onClose, }: ChannelHeaderPinsPopoverProps) { const { crypto } = usePlatform(); const ref = useRef(null); // Outside click + Escape dismiss. useEffect(() => { if (!isOpen) return; const handleClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { onClose(); } }; const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('mousedown', handleClick); document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('mousedown', handleClick); document.removeEventListener('keydown', handleEscape); }; }, [isOpen, onClose]); // Position the popover below and right-aligned to the pin button, // clamped to the viewport. const positionStyle = useMemo(() => { if (!anchorRect) return { top: 0, left: 0 }; const POPOVER_WIDTH = 480; const POPOVER_MAX_HEIGHT = Math.min(window.innerHeight - 120, 720); const GAP = 8; const MARGIN = 12; const rawLeft = anchorRect.right - POPOVER_WIDTH; const left = Math.max( MARGIN, Math.min(rawLeft, window.innerWidth - POPOVER_WIDTH - MARGIN), ); const rawTop = anchorRect.bottom + GAP; const top = Math.max( MARGIN, Math.min(rawTop, window.innerHeight - POPOVER_MAX_HEIGHT - MARGIN), ); return { top, left }; }, [anchorRect]); // Channel key decryption — mirrors Messages.tsx. const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; const privateKeyPem = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('privateKey') : null; const allKeys = useQuery( api.channelKeys.getKeysForUser, userId && isOpen ? ({ userId: userId as any } as any) : 'skip', ); // Each encrypted_key_bundle decrypts to a JSON object mapping // channelId → keyHex. Decrypt once per bundle and merge. const [channelKey, setChannelKey] = useState(null); useEffect(() => { let cancelled = false; if (!allKeys || !privateKeyPem) { setChannelKey(null); return; } (async () => { const merged: Record = {}; for (const item of allKeys) { try { const bundleJson = await crypto.privateDecrypt( privateKeyPem, (item as any).encrypted_key_bundle, ); Object.assign(merged, JSON.parse(bundleJson)); } catch (err) { console.error('Failed to decrypt key bundle:', err); } } if (cancelled) return; setChannelKey(merged[channelId] ?? null); })(); return () => { cancelled = true; }; }, [allKeys, privateKeyPem, channelId, crypto]); // Fetch pinned messages. const pinnedRaw = useQuery( api.messages.listPinned, isOpen ? ({ channelId: channelId as any, userId: (userId as any) ?? undefined, } as any) : 'skip', ); // Decrypt pinned message content. const [decryptedMap, setDecryptedMap] = useState>(new Map()); useEffect(() => { if (!channelKey || !pinnedRaw) return; let cancelled = false; (async () => { const next = new Map(decryptedMap); let changed = false; for (const msg of pinnedRaw as any[]) { const id = msg.id as string; if (next.has(id)) continue; if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) { next.set(id, '[Invalid Encrypted Message]'); changed = true; continue; } const tag = msg.ciphertext.slice(-TAG_LENGTH); const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH); try { const plaintext = await crypto.decryptData( contentHex, channelKey, msg.nonce, tag, ); if (cancelled) return; next.set(id, plaintext); changed = true; } catch { next.set(id, '[Unable to decrypt]'); changed = true; } } if (changed && !cancelled) setDecryptedMap(next); })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [pinnedRaw, channelKey]); const pinned: PinnedMessage[] = useMemo(() => { if (!pinnedRaw) return []; return (pinnedRaw as any[]) .slice() .sort((a, b) => { const at = a.created_at ? new Date(a.created_at).getTime() : 0; const bt = b.created_at ? new Date(b.created_at).getTime() : 0; return bt - at; }) .map((msg) => { const id = msg.id as string; const rawContent = decryptedMap.get(id) ?? ''; let text = rawContent; const attachments: AttachmentMetadata[] = []; try { const parsed = JSON.parse(rawContent); if (parsed && typeof parsed === 'object') { if (Array.isArray(parsed)) { for (const item of parsed) { if (item?.type === 'attachment' && item.url && item.key && item.iv) { attachments.push(item as AttachmentMetadata); } } text = ''; } else if (parsed.type === 'attachment' && parsed.url && parsed.key && parsed.iv) { attachments.push(parsed as AttachmentMetadata); text = ''; } else if (parsed.text !== undefined) { text = String(parsed.text); } } } catch { // plain text — leave as-is } return { id, authorName: msg.displayName || msg.username || 'User', authorAvatarUrl: msg.avatarUrl ?? null, content: text, timestamp: msg.created_at ? new Date(msg.created_at).getTime() : Date.now(), attachments, } as PinnedMessage; }); }, [pinnedRaw, decryptedMap]); const handleJumpTo = (messageId: string) => { window.dispatchEvent( new CustomEvent('brycord:scroll-to-message', { detail: { channelId, messageId }, }), ); onClose(); }; const [unpinTarget, setUnpinTarget] = useState(null); if (!isOpen || !anchorRect) return null; return createPortal( <>

Pinned Messages

{pinned.map((msg) => ( handleJumpTo(msg.id)} onUnpin={() => setUnpinTarget(msg)} showHoverActions canUnpin /> ))} {pinnedRaw === undefined && (
Loading pinned messages…
)}
setUnpinTarget(null)} channelId={channelId} messageId={unpinTarget?.id ?? null} message={unpinTarget} variant="unpin" /> , document.body, ); }