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.
179 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|