Files
DiscordClone/packages/shared/src/components/channel/ChannelHeaderPinsPopover.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- 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.
2026-04-14 09:02:14 -05:00

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,
);
}