feat: suppress URL text for image-only messages, add sourceUrl to context menu

This commit is contained in:
Jannis Braun
2026-03-25 04:59:53 +01:00
parent 5c45a55ef7
commit 001d45da2b
+38 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { MessageWithUser } from '@backspace/shared'; import type { MessageWithUser, Embed } from '@backspace/shared';
import { MarkdownRenderer } from './MarkdownRenderer'; import { MarkdownRenderer } from './MarkdownRenderer';
import { MentionBadge } from './MentionBadge'; import { MentionBadge } from './MentionBadge';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
@@ -61,6 +61,23 @@ function isGifOnlyMessage(content: string | null): boolean {
return GIF_URL_REGEX.test(trimmed); return GIF_URL_REGEX.test(trimmed);
} }
/**
* Returns the original posted URL when a message is a single URL that resolved
* to an image embed, or null if the message should render normally.
*/
function getImageEmbedSourceUrl(content: string | null, embeds: Embed[]): string | null {
if (!content) return null;
const trimmed = content.trim();
// Must be a single URL with no surrounding text
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return null;
if (/\s/.test(trimmed)) return null;
// Must have at least one image embed whose URL matches the posted content
const hasMatchingImageEmbed = embeds.some(
(e) => e.embedType === 'image' && e.url === trimmed
);
return hasMatchingImageEmbed ? trimmed : null;
}
export function Message({ message, isCompact, isFirstInGroup, previousMessageId }: MessageProps) { export function Message({ message, isCompact, isFirstInGroup, previousMessageId }: MessageProps) {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? ''); const [editContent, setEditContent] = useState(message.content ?? '');
@@ -130,6 +147,11 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
}, []); }, []);
const isGifOnly = isGifOnlyMessage(message.content); const isGifOnly = isGifOnlyMessage(message.content);
const imageEmbedSourceUrl = getImageEmbedSourceUrl(message.content, message.embeds || []);
// sourceUrl: the original URL for context menu Copy/Open Link actions
const sourceUrl = isGifOnly
? (message.content?.trim() ?? null)
: imageEmbedSourceUrl;
// Close reaction picker on outside click // Close reaction picker on outside click
useEffect(() => { useEffect(() => {
@@ -183,6 +205,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
selectedText, selectedText,
previousMessageId, previousMessageId,
imageUrl, imageUrl,
sourceUrl,
isAuthor, isAuthor,
isDm: isDmMessage, isDm: isDmMessage,
canAddReactions, canAddReactions,
@@ -354,6 +377,20 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
loading="lazy" loading="lazy"
/> />
</div> </div>
) : imageEmbedSourceUrl ? (
<>
{/* URL text suppressed — embeds render the image */}
{!isEditing && message.embeds && message.embeds.length > 0 && (
<div className="flex flex-col gap-2 mt-1">
{message.embeds.map((embed) => (
<EmbedRenderer key={embed.id} embed={embed} />
))}
</div>
)}
{message.editedAt && (
<span className="text-[10px] text-txt-tertiary select-none font-medium">(edited)</span>
)}
</>
) : ( ) : (
<> <>
{message.content && ( {message.content && (