diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 6223bb47d..faf743c6a 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -25,8 +25,9 @@ pub struct BlobDescriptor { /// NIP-71 poster frame URL. `None` for non-video blobs or if extraction failed. #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, - /// Original filename, for the generic file-card label. Captured client-side - /// (the relay is content-addressed and never learns it). `None` for media. + /// Original filename captured client-side (the relay is content-addressed + /// and never learns it). Generic files use it for file-card labels; custom + /// emoji upload uses it to suggest a shortcode. #[serde(skip_serializing_if = "Option::is_none")] pub filename: Option, } @@ -324,15 +325,10 @@ async fn process_picked_path( } } - // Generic files (non-image, non-video) carry their original filename so the - // client can render a file card with a real label. Media is identified by - // its preview, so no filename is attached. - if !mime.starts_with("image/") && !mime.starts_with("video/") { - descriptor.filename = path - .file_name() - .and_then(|n| n.to_str()) - .map(sanitize_filename); - } + descriptor.filename = path + .file_name() + .and_then(|n| n.to_str()) + .map(sanitize_filename); Ok(descriptor) } @@ -430,11 +426,7 @@ pub async fn upload_media_bytes( } } - // Attach the original filename for generic files (drag/paste supply it from - // the JS File object). Media identifies itself by its preview, so skip it. - if !mime.starts_with("image/") && !mime.starts_with("video/") { - descriptor.filename = filename.as_deref().map(sanitize_filename); - } + descriptor.filename = filename.as_deref().map(sanitize_filename); Ok(descriptor) } diff --git a/desktop/src/features/custom-emoji/emojiMartCategory.ts b/desktop/src/features/custom-emoji/emojiMartCategory.ts index 14483166f..c1184e881 100644 --- a/desktop/src/features/custom-emoji/emojiMartCategory.ts +++ b/desktop/src/features/custom-emoji/emojiMartCategory.ts @@ -15,7 +15,7 @@ export function buildCustomEmojiCategory(customEmoji: CustomEmoji[]) { name: "Custom", emojis: customEmoji.map((e) => ({ id: e.shortcode, - name: e.shortcode, + name: `:${e.shortcode}:`, keywords: [e.shortcode], skins: [{ src: rewriteRelayUrl(e.url) }], })), diff --git a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx index fae6a19f7..80f44e143 100644 --- a/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx +++ b/desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx @@ -8,7 +8,10 @@ import { useRemoveCustomEmojiMutation, useSetCustomEmojiMutation, } from "@/features/custom-emoji/hooks"; -import { normalizeShortcode } from "@/shared/api/customEmoji"; +import { + normalizeShortcode, + suggestShortcodeFromFilename, +} from "@/shared/api/customEmoji"; import { pickAndUploadMedia } from "@/shared/api/tauri"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Button } from "@/shared/ui/button"; @@ -31,6 +34,10 @@ export function CustomEmojiSettingsCard() { const removeEmoji = useRemoveCustomEmojiMutation(); const [name, setName] = React.useState(""); + const [pendingUpload, setPendingUpload] = React.useState<{ + url: string; + filename: string | null; + } | null>(null); const [isUploading, setIsUploading] = React.useState(false); const normalized = normalizeShortcode(name); @@ -38,29 +45,63 @@ export function CustomEmojiSettingsCard() { // "Replace" only applies to MY set — that's the set the upload will rewrite. const ownDuplicate = normalized !== null && own.some((e) => e.shortcode === normalized); - const canSubmit = normalized !== null && !isUploading && !setEmoji.isPending; + const canSubmit = + pendingUpload !== null && + normalized !== null && + !isUploading && + !setEmoji.isPending; - const handleAdd = React.useCallback(async () => { - if (normalized === null) return; + const handleUpload = React.useCallback(async () => { setIsUploading(true); try { const blobs = await pickAndUploadMedia(); - const url = blobs[0]?.url; - if (!url) { - // User cancelled the picker, or nothing uploaded. + const blob = blobs[0]; + if (!blob?.url) { + return; + } + if (!blob.type.startsWith("image/")) { + toast.error("Choose an image file for custom emoji."); return; } - const stored = await setEmoji.mutateAsync({ shortcode: normalized, url }); + setPendingUpload({ url: blob.url, filename: blob.filename ?? null }); + const suggested = blob.filename + ? suggestShortcodeFromFilename(blob.filename) + : null; + if (suggested && name.trim().length === 0) { + setName(suggested); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to upload emoji image.", + ); + } finally { + setIsUploading(false); + } + }, [name]); + + const handleAdd = React.useCallback(async () => { + if (normalized === null || pendingUpload === null) return; + try { + const stored = await setEmoji.mutateAsync({ + shortcode: normalized, + url: pendingUpload.url, + }); setName(""); + setPendingUpload(null); toast.success(`Added :${stored}:`); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to add emoji.", ); - } finally { - setIsUploading(false); } - }, [normalized, setEmoji]); + }, [normalized, pendingUpload, setEmoji]); + + const handleReset = React.useCallback(() => { + setName(""); + setPendingUpload(null); + }, []); const handleRemove = React.useCallback( async (shortcode: string) => { @@ -91,49 +132,117 @@ export function CustomEmojiSettingsCard() {
{ event.preventDefault(); if (canSubmit) void handleAdd(); }} > -
- -
- : +
+
+

1. Upload an image

+

+ Square images work best. GIF, PNG, JPEG, and WebP files are + supported. +

+
+
+
+ {pendingUpload ? ( + Selected custom emoji preview + ) : ( + + )} +
+
+

+ {pendingUpload?.filename ?? "No image selected"} +

+ +
+
+
+ +
+
+

2. Give it a name

+

+ This is what you’ll type to add this emoji to messages and + reactions. +

+
+
+ + : + setName(event.target.value)} /> - : + + : +
+ {nameInvalid ? ( +

+ Use only letters, numbers, hyphen, or underscore. +

+ ) : pendingUpload === null ? ( +

+ Choose an image first; Sprout will suggest a name from the + filename. +

+ ) : ownDuplicate ? ( +

+ You already have :{normalized}: — saving will replace its image. +

+ ) : null} +
+ +
+ +
- - {nameInvalid ? ( -

- Use only letters, numbers, hyphen, or underscore. -

- ) : ownDuplicate ? ( -

- You already have :{normalized}: — uploading will replace its image. -

- ) : null}

diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index f588a358c..423416baf 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -58,7 +58,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({ } }} perLine={8} - previewPosition="none" + previewPosition="bottom" set="native" skinTonePosition="search" theme="auto" diff --git a/desktop/src/features/messages/lib/customEmojiNode.ts b/desktop/src/features/messages/lib/customEmojiNode.ts index d8beec86f..157ddb69b 100644 --- a/desktop/src/features/messages/lib/customEmojiNode.ts +++ b/desktop/src/features/messages/lib/customEmojiNode.ts @@ -129,7 +129,7 @@ export function registerCustomEmojiMarkdownIt( // proxy at PM-render time, so here we emit the raw `src`; `parseHTML` // re-derives the node from `data-shortcode` and the palette supplies the // live url. We still set `src` so a fully-formed round-trips cleanly. - return `:${esc(shortcode)}:`; + return `:${esc(shortcode)}:`; }; } @@ -190,6 +190,7 @@ export const CustomEmojiNode = Node.create({ mergeAttributes(HTMLAttributes, { src, alt: `:${shortcode}:`, + title: `:${shortcode}:`, "data-custom-emoji": "", "data-shortcode": shortcode, draggable: "false", diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index fbd0e8127..45880cac2 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -262,9 +262,11 @@ export function formatTimelineMessages( const profile = profiles?.[actorPubkey]; const displayName = - profile?.displayName?.trim() || - profile?.nip05Handle?.trim() || - `${actorPubkey.slice(0, 8)}…`; + currentPubkeyLower && actorPubkey === currentPubkeyLower + ? "You" + : profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + `${actorPubkey.slice(0, 8)}…`; existing.users.push({ pubkey: actorPubkey, displayName, diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index c84748f94..a8141536e 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -81,6 +81,22 @@ test("formatImetaMediaLine: image mime → ![image] line", () => { ); }); +test("buildImetaTags omits image filenames from imeta", () => { + assert.deepEqual( + buildImetaTags([ + { + url: "https://b/a.png", + type: "image/png", + sha256: "abc", + size: 10, + uploaded: 1, + filename: "Party Parrot.png", + }, + ]), + [["imeta", "url https://b/a.png", "m image/png", "x abc", "size 10"]], + ); +}); + test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suffix)", () => { assert.equal( formatImetaMediaLine({ url: "https://cdn/blob/xyz", type: "video/mp4" }), diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 0429feb9f..861a01781 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -97,7 +97,11 @@ export function buildImetaTags( ...(d.thumb ? [`thumb ${d.thumb}`] : []), ...(d.duration != null ? [`duration ${d.duration}`] : []), ...(d.image ? [`image ${d.image}`] : []), - ...(d.filename ? [`filename ${d.filename}`] : []), + ...(!d.type.startsWith("image/") && + !d.type.startsWith("video/") && + d.filename + ? [`filename ${d.filename}`] + : []), ]); } diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index dfca40034..81c02cd21 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -2,11 +2,9 @@ import * as React from "react"; import type { TimelineReaction } from "@/features/messages/types"; import { cn } from "@/shared/lib/cn"; +import { emojiDisplayName } from "@/shared/lib/emojiName"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; - -const MAX_VISIBLE_REACTORS = 10; /** * Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set, @@ -22,10 +20,12 @@ function EmojiGlyph({ reaction: TimelineReaction; className?: string; }) { + const displayName = emojiDisplayName(reaction.emoji); if (reaction.emojiUrl) { return ( {reaction.emoji} ); } - return {reaction.emoji}; + return ( + + {reaction.emoji} + + ); +} + +function formatReactionUsers(reaction: TimelineReaction): string { + const names = reaction.users.map((user) => user.displayName).filter(Boolean); + if (reaction.reactedByCurrentUser) { + const others = names.filter((name) => name !== "You"); + names.splice(0, names.length, "You (click to remove)", ...others); + } + if (names.length === 0) return `${reaction.count} people`; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; } function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) { - const visible = reaction.users.slice(0, MAX_VISIBLE_REACTORS); - const overflow = reaction.users.length - MAX_VISIBLE_REACTORS; + const displayName = emojiDisplayName(reaction.emoji); + const userText = formatReactionUsers(reaction); return ( -
-
- - - {reaction.count} {reaction.count === 1 ? "reaction" : "reactions"} - +
+
+
-
- {visible.map((user) => ( -
- - {user.displayName} -
- ))} +
+ {userText} reacted with +
+
+ {displayName}
- {overflow > 0 && ( - +{overflow} more - )} - {reaction.reactedByCurrentUser && ( - - Click to remove your reaction - - )}
); } @@ -175,11 +180,14 @@ function ReactionPill({ onSelect(reaction.emoji); }; + const displayName = emojiDisplayName(reaction.emoji); + if (reaction.users.length === 0) { return (