Files
DiscordClone/packages/shared/src/components/channel/PinnedMessageRow.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

179 lines
5.0 KiB
TypeScript

/**
* PinnedMessageRow — shared card used by the desktop pins popover,
* the mobile Pins drawer, and the pin confirmation modal. Matches
* the new UI's behaviour:
*
* - `showHoverActions` (desktop) → card is non-clickable; a "Jump"
* button + close X appear on the right side of the meta row and
* fire `onJumpTo` / `onUnpin` respectively.
* - `showHoverActions === false` (mobile / preview) → whole card
* is tappable to `onJumpTo`; no hover buttons. The confirmation
* modal renders with `onJumpTo={undefined}` so the card is a
* read-only preview.
*/
import { Flag, X } from '@phosphor-icons/react';
import { Avatar } from '@discord-clone/ui';
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
import styles from './PinnedMessageRow.module.css';
export interface PinnedMessage {
id: string;
authorName: string;
authorAvatarUrl: string | null;
content: string;
timestamp: number;
attachments?: AttachmentMetadata[];
}
interface PinnedMessageRowProps {
message: PinnedMessage;
/** Handler for navigating to the pinned message. */
onJumpTo?: () => void;
/** Handler for unpinning the message. Only rendered when the
* caller also enables `showHoverActions`. */
onUnpin?: () => void;
/**
* Desktop layout flag. When true, the card is not clickable and
* the meta row shows a Jump button + close X. When false (mobile
* / static preview), the whole card is the button.
*/
showHoverActions?: boolean;
/** Whether the viewer has permission to unpin. Hides the X when
* they don't, even if `showHoverActions` is on. */
canUnpin?: boolean;
}
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()}, ${time}`;
}
export function PinnedMessageRow({
message,
onJumpTo,
onUnpin,
showHoverActions = false,
canUnpin = true,
}: PinnedMessageRowProps) {
const isClickable = !showHoverActions && !!onJumpTo;
const handleCardClick = () => {
if (isClickable && onJumpTo) onJumpTo();
};
const handleJumpClick = (e: React.MouseEvent) => {
e.stopPropagation();
onJumpTo?.();
};
const handleUnpinClick = (e: React.MouseEvent) => {
e.stopPropagation();
onUnpin?.();
};
return (
<div
className={`${styles.card} ${isClickable ? '' : styles.cardStatic}`}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={isClickable ? handleCardClick : undefined}
onKeyDown={
isClickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCardClick();
}
}
: undefined
}
>
<div className={styles.avatar}>
<Avatar
src={message.authorAvatarUrl || undefined}
fallback={message.authorName}
size={36}
/>
</div>
<div className={styles.body}>
<div className={styles.meta}>
<span className={styles.author}>{message.authorName}</span>
<span className={styles.timestamp}>
{formatTimestamp(message.timestamp)}
</span>
{showHoverActions && (
<div className={styles.hoverActions}>
{onJumpTo && (
<button
type="button"
className={styles.jumpButton}
onClick={handleJumpClick}
>
Jump
</button>
)}
{canUnpin && onUnpin && (
<button
type="button"
className={styles.closeButton}
onClick={handleUnpinClick}
aria-label="Unpin message"
title="Unpin message"
>
<X size={14} weight="bold" />
</button>
)}
</div>
)}
</div>
{message.content ? (
<div className={styles.content}>{message.content}</div>
) : !message.attachments || message.attachments.length === 0 ? (
<div className={`${styles.content} ${styles.undecryptable}`}>
(no preview)
</div>
) : null}
{message.attachments && message.attachments.length > 0 && (
<div
className={styles.attachments}
onClick={(e) => e.stopPropagation()}
>
{message.attachments.map((att, i) => (
<EncryptedAttachment
key={`${message.id}-att-${i}`}
metadata={att}
/>
))}
</div>
)}
</div>
</div>
);
}
/** "You've reached the end" terminator used below the list and as
* the empty state. */
export function ReachedEndNotice() {
return (
<div className={styles.reachedEnd}>
<Flag size={32} weight="regular" className={styles.reachedEndIcon} />
<div className={styles.reachedEndTitle}>You've reached the end</div>
<div className={styles.reachedEndBody}>
Members with the "Pin Messages" permission can pin messages for
everyone to see.
</div>
</div>
);
}