feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
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.
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user