This commit is contained in:
@@ -41,9 +41,43 @@ function extractUrls(text: string): string[] {
|
||||
return Array.from(new Set(cleaned));
|
||||
}
|
||||
|
||||
/** True when a message body is entirely made up of one or more GIF
|
||||
* URLs plus whitespace — i.e. the user posted a GIF from the
|
||||
* picker and there's nothing worth showing as text. The render
|
||||
* path hides the <MessageContent> block in that case so only the
|
||||
* embedded preview appears. */
|
||||
function isGifOnlyContent(text: string): boolean {
|
||||
const urls = extractUrls(text);
|
||||
if (urls.length === 0) return false;
|
||||
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
|
||||
let remainder = text;
|
||||
for (const u of urls) {
|
||||
remainder = remainder.split(u).join('');
|
||||
}
|
||||
return remainder.trim().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord-style relative timestamp:
|
||||
* - Same calendar day → `Today at 7:08 PM`
|
||||
* - Previous calendar day → `Yesterday at 7:08 PM`
|
||||
* - Anything else → `4/11/2026, 7:08 PM`
|
||||
*/
|
||||
function formatTime(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const now = new Date();
|
||||
const time = date.toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
if (isToday) return `Today at ${time}`;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `Yesterday at ${time}`;
|
||||
}
|
||||
return `${date.toLocaleDateString()}, ${time}`;
|
||||
}
|
||||
|
||||
function formatFullTime(ts: number): string {
|
||||
@@ -96,12 +130,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
} | 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.
|
||||
// optional attachment metadata of the image that was clicked so
|
||||
// the lightbox info card can render filename / size / dimensions.
|
||||
// Null means the lightbox is closed. For inline GIFs posted via
|
||||
// a URL (no encrypted attachment), the metadata is absent — the
|
||||
// lightbox gracefully hides the star / detail chrome in that
|
||||
// case.
|
||||
const [lightboxItem, setLightboxItem] = useState<{
|
||||
src: string;
|
||||
attachment: AttachmentMetadata;
|
||||
attachment?: AttachmentMetadata;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Right-click context menu state. When set, the MessageActionBar for
|
||||
@@ -437,7 +476,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{msg.content && (
|
||||
{msg.content && !isGifOnlyContent(msg.content) && (
|
||||
<div className={styles.text}>
|
||||
<MessageContent
|
||||
content={msg.content}
|
||||
@@ -453,7 +492,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
extractUrls(msg.content)
|
||||
.slice(0, 3)
|
||||
.map((url, idx) => (
|
||||
<LinkEmbed key={`embed-${idx}-${url}`} url={url} />
|
||||
<LinkEmbed
|
||||
key={`embed-${idx}-${url}`}
|
||||
url={url}
|
||||
onOpenGif={(gifUrl) =>
|
||||
setLightboxItem({
|
||||
src: gifUrl,
|
||||
filename: gifUrl.split('/').pop() || 'gif',
|
||||
mimeType: 'image/gif',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{msg.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
@@ -578,17 +627,13 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
alt={`:${r.emoji}:`}
|
||||
title={`:${r.emoji}:`}
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: 'contain',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
className={styles.reactionEmoji}
|
||||
/>
|
||||
) : (
|
||||
<TwemojiImg
|
||||
emoji={resolveReactionKeyToUnicode(r.emoji)}
|
||||
size={16}
|
||||
className={styles.reactionEmoji}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.reactionCount}>{r.count}</span>
|
||||
@@ -623,11 +668,15 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
<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}
|
||||
filename={
|
||||
lightboxItem?.attachment?.filename ?? lightboxItem?.filename
|
||||
}
|
||||
mimeType={
|
||||
lightboxItem?.attachment?.mimeType ?? lightboxItem?.mimeType
|
||||
}
|
||||
size={lightboxItem?.attachment?.size}
|
||||
width={lightboxItem?.attachment?.width}
|
||||
height={lightboxItem?.attachment?.height}
|
||||
attachment={lightboxItem?.attachment}
|
||||
onClose={() => setLightboxItem(null)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user