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

- 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:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View File

@@ -0,0 +1,735 @@
import { useMutation, useQuery } from 'convex/react';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { useIsMobile } from '../../hooks/useIsMobile';
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
import { ImageLightbox } from './ImageLightbox';
import { LinkEmbed } from './LinkEmbed';
import type { DecryptedMessage } from './Messages';
import { MessageActionBar } from './MessageActionBar';
import { MessageContent } from './MessageContent';
import { MobileMessageActionsSheet } from './MobileMessageActionsSheet';
import {
MemberProfilePopout,
type MemberProfilePopoutMember,
} from '../member/MemberProfilePopout';
import { PinConfirmationModal } from './PinConfirmationModal';
import { ReactionsModal } from './ReactionsModal';
import { Tooltip } from '@discord-clone/ui';
import { reactionKeyToName } from '../../utils/emojiLookup';
import type { PinnedMessage } from './PinnedMessageRow';
import { TwemojiImg } from './TwemojiImg';
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
import styles from './MessageGroup.module.css';
interface MessageGroupProps {
messages: DecryptedMessage[];
channelId: string;
onReply?: (eventId: string, username: string) => void;
}
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
function extractUrls(text: string): string[] {
const matches = text.match(URL_REGEX) ?? [];
// Strip trailing punctuation that's almost never part of the URL but
// commonly butts up against one in prose ("see https://foo.com.").
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
return Array.from(new Set(cleaned));
}
function formatTime(ts: number): string {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function formatFullTime(ts: number): string {
const date = new Date(ts);
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps) {
const first = messages[0];
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const channelMembers = useQuery(api.members.getChannelMembers, {
channelId: channelId as any,
});
const mentionMembers = Array.isArray(channelMembers)
? channelMembers.map((m: any) => ({
displayName: m.displayName || m.username || '',
username: m.username || '',
userId: m.id,
}))
: [];
const removeMessage = useMutation(api.messages.remove);
const addReaction = useMutation(api.reactions.add);
const removeReaction = useMutation(api.reactions.remove);
// Custom emoji catalog — powers `:shortcode:` rendering inside
// message bodies and custom-emoji reaction chips. One query per
// group keeps the subscription count reasonable.
const customEmojiDocs = (useQuery(api.customEmojis.list, {}) ?? []) as Array<{
_id: string;
name: string;
src: string;
}>;
const customEmojiList = customEmojiDocs.map((e) => ({ name: e.name, url: e.src }));
const customEmojiByName = new Map<string, string>();
for (const e of customEmojiDocs) customEmojiByName.set(e.name.toLowerCase(), e.src);
// Reaction picker state — a single picker shared across the group.
// Records which message was clicked so the chosen emoji is bound
// to the right target.
const [reactPicker, setReactPicker] = useState<{
messageId: string;
pos: { top: number; left: number };
} | null>(null);
// Image lightbox state — tracks the decrypted blob URL + the
// full attachment metadata of the image that was clicked so the
// lightbox info card can render filename / size / dimensions.
// Null means the lightbox is closed.
const [lightboxItem, setLightboxItem] = useState<{
src: string;
attachment: AttachmentMetadata;
} | null>(null);
// Right-click context menu state. When set, the MessageActionBar for
// the target message opens its More dropdown at the click coordinates
// and the parent row gets `actionBarForceVisible` so the hover bar
// stays pinned regardless of cursor position.
const [contextMenu, setContextMenu] = useState<{
messageId: string;
x: number;
y: number;
} | null>(null);
// Tracks which message currently has its local More dropdown open
// so we can pin that row's hover bar visible even when the pointer
// leaves the row. Flips back to null when the dropdown closes.
const [localMenuOpenFor, setLocalMenuOpenFor] = useState<string | null>(
null,
);
const [mobileSheetForMsg, setMobileSheetForMsg] =
useState<DecryptedMessage | null>(null);
// Author profile popout — opens when the user clicks the avatar
// or username in the message header, same component MemberListContainer
// uses so profile cards look identical regardless of entry point.
const [authorPopout, setAuthorPopout] = useState<{
anchorRect: DOMRect;
member: MemberProfilePopoutMember;
} | null>(null);
// Full reactions breakdown modal — opens when the user clicks a
// reaction chip's tooltip or the chip itself. Tracks the target
// message id so the modal can read its latest reaction rows.
const [reactionsModalMsgId, setReactionsModalMsgId] = useState<
string | null
>(null);
const longPressTimerRef = useRef<number | null>(null);
const isMobile = useIsMobile();
const openAuthorPopout = (msg: DecryptedMessage, rect: DOMRect) => {
setAuthorPopout({
anchorRect: rect,
member: {
userId: msg.senderId,
displayName: msg.authorName,
avatarUrl: msg.authorAvatarUrl,
},
});
};
const startLongPress = (msg: DecryptedMessage) => {
if (!isMobile) return;
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
}
longPressTimerRef.current = window.setTimeout(() => {
setMobileSheetForMsg(msg);
}, 450);
};
const cancelLongPress = () => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
};
const handleCopyText = (content: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(content);
}
};
const handleCopyLink = (messageId: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const url = `${window.location.origin}${window.location.pathname}#msg-${messageId}`;
void navigator.clipboard.writeText(url);
}
};
// Pin / unpin both flow through the PinConfirmationModal so the
// user gets the same confirmation step the new UI uses. The
// variant flips based on whether the target message is already
// pinned.
const [pinTarget, setPinTarget] = useState<{
message: PinnedMessage;
variant: 'pin' | 'unpin';
} | null>(null);
const handlePin = (messageId: string) => {
const msg = messages.find((m) => m.id === messageId);
if (!msg) return;
setPinTarget({
message: {
id: msg.id,
authorName: msg.authorName,
authorAvatarUrl: msg.authorAvatarUrl,
content: msg.content,
timestamp: msg.timestamp,
attachments: msg.attachments,
},
variant: msg.pinned ? 'unpin' : 'pin',
});
};
const openReactPicker = (messageId: string, anchorEl: Element | null) => {
const rect = anchorEl?.getBoundingClientRect();
if (!rect) return;
setReactPicker({
messageId,
pos: {
top: rect.bottom + 8,
left: Math.max(8, rect.right - 480),
},
});
};
const handlePickReaction = async (value: EmojiPickerValue) => {
if (!reactPicker || !myUserId) return;
try {
// For unicode we store the raw surrogates as the reaction key.
// For custom server emojis we store the shortcode — the render
// path below rehydrates it via `customEmojiByName` and falls
// back through `resolveReactionKeyToUnicode` for legacy /
// unknown keys.
const emojiKey =
value.kind === 'custom' ? value.shortcode : value.surrogates;
await addReaction({
messageId: reactPicker.messageId as any,
userId: myUserId as any,
emoji: emojiKey,
});
} catch (err) {
console.error('Failed to add reaction:', err);
}
setReactPicker(null);
};
const handleDelete = async (messageId: string) => {
if (!myUserId) return;
try {
await removeMessage({ id: messageId as any, userId: myUserId as any });
} catch (err) {
console.error('Failed to delete message:', err);
}
};
const handleToggleReaction = async (messageId: string, emoji: string, me: boolean) => {
if (!myUserId) return;
try {
if (me) {
await removeReaction({
messageId: messageId as any,
userId: myUserId as any,
emoji,
});
} else {
await addReaction({
messageId: messageId as any,
userId: myUserId as any,
emoji,
});
}
} catch (err) {
console.error('Failed to toggle reaction:', err);
}
};
return (
<div className={styles.group}>
<div className={styles.contentColumn}>
{messages.map((msg, i) => {
const isFirst = i === 0;
const isOwn = msg.senderId === myUserId;
const menuOpenForThis = contextMenu?.messageId === msg.id;
return (
<div
key={msg.id}
data-message-id={msg.id}
className={`${styles.messageContent} ${isFirst ? styles.firstMessage : ''} messageHoverable ${menuOpenForThis || localMenuOpenFor === msg.id ? 'actionBarForceVisible' : ''}`}
onContextMenu={(e) => {
if (isMobile) return;
e.preventDefault();
setContextMenu({ messageId: msg.id, x: e.clientX, y: e.clientY });
}}
onTouchStart={() => startLongPress(msg)}
onTouchEnd={cancelLongPress}
onTouchMove={cancelLongPress}
onTouchCancel={cancelLongPress}
>
<MessageActionBar
isOwnMessage={isOwn}
onMenuOpenChange={(open) =>
setLocalMenuOpenFor(open ? msg.id : null)
}
onReply={() => onReply?.(msg.id, first.authorName)}
onDelete={() => handleDelete(msg.id)}
onReact={(e) => openReactPicker(msg.id, e?.currentTarget ?? null)}
onQuickReact={async (emoji) => {
if (!myUserId) return;
try {
await addReaction({
messageId: msg.id as any,
userId: myUserId as any,
emoji,
});
} catch (err) {
console.error('Failed to add quick reaction:', err);
}
}}
onPin={() => handlePin(msg.id)}
onCopyText={() => handleCopyText(msg.content)}
onCopyLink={() => handleCopyLink(msg.id)}
externalMenuAt={
menuOpenForThis ? { x: contextMenu.x, y: contextMenu.y } : null
}
onExternalMenuClose={() => setContextMenu(null)}
/>
{isFirst && msg.replyToId && (
<div
className={styles.replyContext}
role="button"
tabIndex={0}
onClick={() => {
if (!msg.replyToId) return;
window.dispatchEvent(
new CustomEvent('brycord:scroll-to-message', {
detail: {
channelId,
messageId: msg.replyToId,
},
}),
);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!msg.replyToId) return;
window.dispatchEvent(
new CustomEvent('brycord:scroll-to-message', {
detail: {
channelId,
messageId: msg.replyToId,
},
}),
);
}
}}
style={{ cursor: 'pointer' }}
>
<div className={styles.replySpine} />
<span className={styles.replyAuthor}>
{msg.replyToAuthorName ?? 'Unknown'}
</span>
<span className={styles.replyText}>
{msg.replyToContent ?? <em>(missing context)</em>}
</span>
</div>
)}
{isFirst && (
<>
<div
className={styles.avatarSlot}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}
}}
style={{ cursor: 'pointer' }}
>
<Avatar
src={first.authorAvatarUrl}
size={40}
fallback={first.authorName}
/>
</div>
<div className={styles.header}>
<span
className={styles.username}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}
}}
style={{
cursor: 'pointer',
color: first.authorRoleColor ?? undefined,
}}
>
{first.authorName}
</span>
<span
className={styles.timestamp}
title={formatFullTime(first.timestamp)}
>
{formatTime(first.timestamp)}
</span>
</div>
</>
)}
{!isFirst && (
<span
className={styles.gutterTimestamp}
title={formatFullTime(msg.timestamp)}
>
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}
</span>
)}
{msg.content && (
<div className={styles.text}>
<MessageContent
content={msg.content}
members={mentionMembers}
customEmojis={customEmojiList}
/>
{msg.editedTimestamp && (
<span className={styles.editedTag}> (edited)</span>
)}
</div>
)}
{msg.content &&
extractUrls(msg.content)
.slice(0, 3)
.map((url, idx) => (
<LinkEmbed key={`embed-${idx}-${url}`} url={url} />
))}
{msg.attachments.length > 0 && (
<div className={styles.attachments}>
{msg.attachments.map((att, j) => {
const kind = att.mimeType.split('/')[0];
const cls =
kind === 'image' || kind === 'video'
? styles.imageAttachment
: styles.fileAttachment;
return (
<EncryptedAttachment
key={`${msg.id}-${j}`}
metadata={att}
className={cls}
onImageClick={(url, attachment) => {
setLightboxItem({ src: url, attachment });
}}
/>
);
})}
</div>
)}
{msg.reactions.length > 0 && (
<div className={styles.reactions}>
{msg.reactions.map((r) => {
// Custom emoji wins over unicode lookup. The reaction
// key is a plain shortcode, so check the custom map
// first; if no match, fall through to the existing
// shortcode→unicode path.
const customUrl = /^[a-zA-Z0-9_]+$/.test(r.emoji)
? customEmojiByName.get(r.emoji.toLowerCase())
: undefined;
// Tooltip body: emoji glyph + ":name: reacted by
// A, B, C, and N others" + hint line. Names come
// straight from the enriched payload so we don't
// refetch on hover. Marked interactive so the user
// can move onto the panel and click it to open the
// full reactions modal.
const emojiLabel = `:${reactionKeyToName(r.emoji)}:`;
const displayNames = r.users
.slice(0, 3)
.map((u) => u.displayName || u.username);
const extras = Math.max(0, r.count - displayNames.length);
const namesText =
displayNames.length === 0
? ''
: extras === 0
? displayNames.length === 1
? displayNames[0]
: displayNames.length === 2
? `${displayNames[0]} and ${displayNames[1]}`
: `${displayNames.slice(0, -1).join(', ')}, and ${displayNames[displayNames.length - 1]}`
: `${displayNames.join(', ')}, and ${extras} ${extras === 1 ? 'other' : 'others'}`;
const tooltipContent = (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
textAlign: 'center',
maxWidth: 240,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{customUrl ? (
<img
src={customUrl}
alt={emojiLabel}
draggable={false}
style={{
width: 32,
height: 32,
objectFit: 'contain',
}}
/>
) : (
<TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)}
size={32}
/>
)}
</div>
<span style={{ fontWeight: 600 }}>
{emojiLabel} reacted by
</span>
<span style={{ fontWeight: 500 }}>{namesText}</span>
<span
style={{
fontSize: '0.6875rem',
color: 'var(--text-tertiary)',
marginTop: 2,
}}
>
Click to view all reactions
</span>
</div>
);
return (
<Tooltip
key={r.emoji}
content={tooltipContent}
placement="top"
delay={200}
interactive
onContentClick={() => setReactionsModalMsgId(msg.id)}
>
<button
type="button"
className={`${styles.reactionChip} ${r.me ? styles.reactionMe : ''}`}
onClick={() => setReactionsModalMsgId(msg.id)}
>
{customUrl ? (
<img
src={customUrl}
alt={`:${r.emoji}:`}
title={`:${r.emoji}:`}
draggable={false}
style={{
width: 16,
height: 16,
objectFit: 'contain',
verticalAlign: 'middle',
}}
/>
) : (
<TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)}
size={16}
/>
)}
<span className={styles.reactionCount}>{r.count}</span>
</button>
</Tooltip>
);
})}
</div>
)}
</div>
);
})}
</div>
{reactPicker &&
createPortal(
<div
style={{
position: 'fixed',
top: reactPicker.pos.top,
left: reactPicker.pos.left,
zIndex: 15000,
}}
onClick={(e) => e.stopPropagation()}
>
<EmojiPicker
onSelect={handlePickReaction}
onClose={() => setReactPicker(null)}
/>
</div>,
document.body,
)}
<ImageLightbox
isOpen={!!lightboxItem}
src={lightboxItem?.src ?? ''}
filename={lightboxItem?.attachment.filename}
mimeType={lightboxItem?.attachment.mimeType}
size={lightboxItem?.attachment.size}
width={lightboxItem?.attachment.width}
height={lightboxItem?.attachment.height}
attachment={lightboxItem?.attachment}
onClose={() => setLightboxItem(null)}
/>
<PinConfirmationModal
isOpen={!!pinTarget}
onClose={() => setPinTarget(null)}
channelId={channelId}
messageId={pinTarget?.message.id ?? null}
message={pinTarget?.message ?? null}
variant={pinTarget?.variant ?? 'pin'}
/>
{authorPopout && (
<MemberProfilePopout
anchorRect={authorPopout.anchorRect}
member={authorPopout.member}
onClose={() => setAuthorPopout(null)}
/>
)}
{(() => {
// Look up the target message by id from the current group
// so the modal always renders against the latest reaction
// rows (the group array is re-created on every parent
// re-render by Messages.tsx's memoized decrypt step).
const targetMsg = reactionsModalMsgId
? messages.find((m) => m.id === reactionsModalMsgId) ?? null
: null;
return (
<ReactionsModal
isOpen={!!targetMsg}
onClose={() => setReactionsModalMsgId(null)}
reactions={targetMsg?.reactions ?? []}
myUserId={myUserId}
customEmojiByName={customEmojiByName}
onRemoveOwnReaction={async (emoji) => {
if (!targetMsg || !myUserId) return;
try {
await removeReaction({
messageId: targetMsg.id as any,
userId: myUserId as any,
emoji,
});
} catch (err) {
console.error('Failed to remove reaction:', err);
}
}}
/>
);
})()}
{mobileSheetForMsg && (
<MobileMessageActionsSheet
isOpen={!!mobileSheetForMsg}
onClose={() => setMobileSheetForMsg(null)}
isOwnMessage={mobileSheetForMsg.senderId === myUserId}
canDelete={mobileSheetForMsg.senderId === myUserId}
hasContent={!!mobileSheetForMsg.content}
onQuickReact={async (emoji) => {
if (!myUserId) return;
try {
await addReaction({
messageId: mobileSheetForMsg.id as any,
userId: myUserId as any,
emoji,
});
} catch (err) {
console.error('Failed to add quick reaction:', err);
}
}}
onAddReaction={() => {
// No anchor on mobile — just open a centered picker
// by passing `null` as the anchor.
setReactPicker({
messageId: mobileSheetForMsg.id,
anchor: null as any,
});
}}
onReply={() => {
onReply?.(mobileSheetForMsg.id, first.authorName);
}}
onForward={() => {
/* forward not implemented yet */
}}
onEdit={
mobileSheetForMsg.senderId === myUserId
? () => {
/* edit not implemented via sheet yet */
}
: undefined
}
onPin={() => handlePin(mobileSheetForMsg.id)}
isPinned={mobileSheetForMsg.pinned}
onCopyText={() => handleCopyText(mobileSheetForMsg.content)}
onDelete={
mobileSheetForMsg.senderId === myUserId
? () => handleDelete(mobileSheetForMsg.id)
: undefined
}
/>
)}
</div>
);
}