All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
/**
|
|
* 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<HTMLDivElement>(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<React.CSSProperties>(() => {
|
|
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<string | null>(null);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (!allKeys || !privateKeyPem) {
|
|
setChannelKey(null);
|
|
return;
|
|
}
|
|
(async () => {
|
|
const merged: Record<string, string> = {};
|
|
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<Map<string, string>>(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<PinnedMessage | null>(null);
|
|
|
|
if (!isOpen || !anchorRect) return null;
|
|
|
|
return createPortal(
|
|
<>
|
|
<div className={styles.overlay}>
|
|
<div ref={ref} className={styles.popover} style={positionStyle}>
|
|
<div className={styles.header}>
|
|
<span className={styles.headerIcon}>
|
|
<PushPin size={20} weight="fill" />
|
|
</span>
|
|
<h2 className={styles.title}>Pinned Messages</h2>
|
|
</div>
|
|
<div className={styles.body}>
|
|
{pinned.map((msg) => (
|
|
<PinnedMessageRow
|
|
key={msg.id}
|
|
message={msg}
|
|
onJumpTo={() => handleJumpTo(msg.id)}
|
|
onUnpin={() => setUnpinTarget(msg)}
|
|
showHoverActions
|
|
canUnpin
|
|
/>
|
|
))}
|
|
{pinnedRaw === undefined && (
|
|
<div className={styles.loading}>Loading pinned messages…</div>
|
|
)}
|
|
<ReachedEndNotice />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<PinConfirmationModal
|
|
isOpen={!!unpinTarget}
|
|
onClose={() => setUnpinTarget(null)}
|
|
channelId={channelId}
|
|
messageId={unpinTarget?.id ?? null}
|
|
message={unpinTarget}
|
|
variant="unpin"
|
|
/>
|
|
</>,
|
|
document.body,
|
|
);
|
|
}
|