Skip to content

Commit bc53008

Browse files
wesbillmanPinky
andauthored
Improve emoji naming and custom emoji UX (#878)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
1 parent 581c7e9 commit bc53008

20 files changed

Lines changed: 348 additions & 109 deletions

desktop/src-tauri/src/commands/media.rs

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ pub struct BlobDescriptor {
2525
/// NIP-71 poster frame URL. `None` for non-video blobs or if extraction failed.
2626
#[serde(skip_serializing_if = "Option::is_none")]
2727
pub image: Option<String>,
28-
/// Original filename, for the generic file-card label. Captured client-side
29-
/// (the relay is content-addressed and never learns it). `None` for media.
28+
/// Original filename captured client-side (the relay is content-addressed
29+
/// and never learns it). Generic files use it for file-card labels; custom
30+
/// emoji upload uses it to suggest a shortcode.
3031
#[serde(skip_serializing_if = "Option::is_none")]
3132
pub filename: Option<String>,
3233
}
@@ -324,15 +325,10 @@ async fn process_picked_path(
324325
}
325326
}
326327

327-
// Generic files (non-image, non-video) carry their original filename so the
328-
// client can render a file card with a real label. Media is identified by
329-
// its preview, so no filename is attached.
330-
if !mime.starts_with("image/") && !mime.starts_with("video/") {
331-
descriptor.filename = path
332-
.file_name()
333-
.and_then(|n| n.to_str())
334-
.map(sanitize_filename);
335-
}
328+
descriptor.filename = path
329+
.file_name()
330+
.and_then(|n| n.to_str())
331+
.map(sanitize_filename);
336332

337333
Ok(descriptor)
338334
}
@@ -430,11 +426,7 @@ pub async fn upload_media_bytes(
430426
}
431427
}
432428

433-
// Attach the original filename for generic files (drag/paste supply it from
434-
// the JS File object). Media identifies itself by its preview, so skip it.
435-
if !mime.starts_with("image/") && !mime.starts_with("video/") {
436-
descriptor.filename = filename.as_deref().map(sanitize_filename);
437-
}
429+
descriptor.filename = filename.as_deref().map(sanitize_filename);
438430

439431
Ok(descriptor)
440432
}

desktop/src/features/custom-emoji/emojiMartCategory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function buildCustomEmojiCategory(customEmoji: CustomEmoji[]) {
1515
name: "Custom",
1616
emojis: customEmoji.map((e) => ({
1717
id: e.shortcode,
18-
name: e.shortcode,
18+
name: `:${e.shortcode}:`,
1919
keywords: [e.shortcode],
2020
skins: [{ src: rewriteRelayUrl(e.url) }],
2121
})),

desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx

Lines changed: 145 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
useRemoveCustomEmojiMutation,
99
useSetCustomEmojiMutation,
1010
} from "@/features/custom-emoji/hooks";
11-
import { normalizeShortcode } from "@/shared/api/customEmoji";
11+
import {
12+
normalizeShortcode,
13+
suggestShortcodeFromFilename,
14+
} from "@/shared/api/customEmoji";
1215
import { pickAndUploadMedia } from "@/shared/api/tauri";
1316
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
1417
import { Button } from "@/shared/ui/button";
@@ -31,36 +34,74 @@ export function CustomEmojiSettingsCard() {
3134
const removeEmoji = useRemoveCustomEmojiMutation();
3235

3336
const [name, setName] = React.useState("");
37+
const [pendingUpload, setPendingUpload] = React.useState<{
38+
url: string;
39+
filename: string | null;
40+
} | null>(null);
3441
const [isUploading, setIsUploading] = React.useState(false);
3542

3643
const normalized = normalizeShortcode(name);
3744
const nameInvalid = name.trim().length > 0 && normalized === null;
3845
// "Replace" only applies to MY set — that's the set the upload will rewrite.
3946
const ownDuplicate =
4047
normalized !== null && own.some((e) => e.shortcode === normalized);
41-
const canSubmit = normalized !== null && !isUploading && !setEmoji.isPending;
48+
const canSubmit =
49+
pendingUpload !== null &&
50+
normalized !== null &&
51+
!isUploading &&
52+
!setEmoji.isPending;
4253

43-
const handleAdd = React.useCallback(async () => {
44-
if (normalized === null) return;
54+
const handleUpload = React.useCallback(async () => {
4555
setIsUploading(true);
4656
try {
4757
const blobs = await pickAndUploadMedia();
48-
const url = blobs[0]?.url;
49-
if (!url) {
50-
// User cancelled the picker, or nothing uploaded.
58+
const blob = blobs[0];
59+
if (!blob?.url) {
60+
return;
61+
}
62+
if (!blob.type.startsWith("image/")) {
63+
toast.error("Choose an image file for custom emoji.");
5164
return;
5265
}
53-
const stored = await setEmoji.mutateAsync({ shortcode: normalized, url });
66+
setPendingUpload({ url: blob.url, filename: blob.filename ?? null });
67+
const suggested = blob.filename
68+
? suggestShortcodeFromFilename(blob.filename)
69+
: null;
70+
if (suggested && name.trim().length === 0) {
71+
setName(suggested);
72+
}
73+
} catch (error) {
74+
toast.error(
75+
error instanceof Error
76+
? error.message
77+
: "Failed to upload emoji image.",
78+
);
79+
} finally {
80+
setIsUploading(false);
81+
}
82+
}, [name]);
83+
84+
const handleAdd = React.useCallback(async () => {
85+
if (normalized === null || pendingUpload === null) return;
86+
try {
87+
const stored = await setEmoji.mutateAsync({
88+
shortcode: normalized,
89+
url: pendingUpload.url,
90+
});
5491
setName("");
92+
setPendingUpload(null);
5593
toast.success(`Added :${stored}:`);
5694
} catch (error) {
5795
toast.error(
5896
error instanceof Error ? error.message : "Failed to add emoji.",
5997
);
60-
} finally {
61-
setIsUploading(false);
6298
}
63-
}, [normalized, setEmoji]);
99+
}, [normalized, pendingUpload, setEmoji]);
100+
101+
const handleReset = React.useCallback(() => {
102+
setName("");
103+
setPendingUpload(null);
104+
}, []);
64105

65106
const handleRemove = React.useCallback(
66107
async (shortcode: string) => {
@@ -91,49 +132,117 @@ export function CustomEmojiSettingsCard() {
91132
</div>
92133

93134
<form
94-
className="flex items-end gap-2"
135+
className="max-w-2xl space-y-4"
95136
onSubmit={(event) => {
96137
event.preventDefault();
97138
if (canSubmit) void handleAdd();
98139
}}
99140
>
100-
<div className="min-w-0 flex-1 space-y-1.5">
101-
<label className="text-sm font-medium" htmlFor="custom-emoji-name">
102-
Name
103-
</label>
104-
<div className="flex items-center gap-1">
105-
<span className="text-muted-foreground">:</span>
141+
<div className="space-y-3">
142+
<div>
143+
<h4 className="text-sm font-semibold">1. Upload an image</h4>
144+
<p className="text-sm text-muted-foreground">
145+
Square images work best. GIF, PNG, JPEG, and WebP files are
146+
supported.
147+
</p>
148+
</div>
149+
<div className="flex flex-wrap items-center gap-4">
150+
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-md border bg-background">
151+
{pendingUpload ? (
152+
<img
153+
alt="Selected custom emoji preview"
154+
src={rewriteRelayUrl(pendingUpload.url)}
155+
className="h-14 w-14 object-contain"
156+
draggable={false}
157+
/>
158+
) : (
159+
<ImagePlus className="h-6 w-6 text-muted-foreground" />
160+
)}
161+
</div>
162+
<div className="min-w-0 flex-1 space-y-2">
163+
<p className="truncate text-sm text-muted-foreground">
164+
{pendingUpload?.filename ?? "No image selected"}
165+
</p>
166+
<Button
167+
type="button"
168+
data-testid="custom-emoji-upload"
169+
onClick={() => void handleUpload()}
170+
disabled={isUploading || setEmoji.isPending}
171+
variant="outline"
172+
>
173+
{isUploading
174+
? "Uploading…"
175+
: pendingUpload
176+
? "Choose different image"
177+
: "Upload image"}
178+
</Button>
179+
</div>
180+
</div>
181+
</div>
182+
183+
<div className="space-y-3 border-t pt-4">
184+
<div>
185+
<h4 className="text-sm font-semibold">2. Give it a name</h4>
186+
<p className="text-sm text-muted-foreground">
187+
This is what you’ll type to add this emoji to messages and
188+
reactions.
189+
</p>
190+
</div>
191+
<div className="relative">
192+
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
193+
:
194+
</span>
106195
<Input
107196
id="custom-emoji-name"
108197
data-testid="custom-emoji-name-input"
109198
autoCapitalize="none"
110199
autoCorrect="off"
200+
className="px-6"
111201
placeholder="party-parrot"
112202
spellCheck={false}
113203
value={name}
114204
onChange={(event) => setName(event.target.value)}
115205
/>
116-
<span className="text-muted-foreground">:</span>
206+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
207+
:
208+
</span>
117209
</div>
210+
{nameInvalid ? (
211+
<p className="text-sm text-destructive">
212+
Use only letters, numbers, hyphen, or underscore.
213+
</p>
214+
) : pendingUpload === null ? (
215+
<p className="text-sm text-muted-foreground">
216+
Choose an image first; Sprout will suggest a name from the
217+
filename.
218+
</p>
219+
) : ownDuplicate ? (
220+
<p className="text-sm text-muted-foreground">
221+
You already have :{normalized}: — saving will replace its image.
222+
</p>
223+
) : null}
224+
</div>
225+
226+
<div className="flex justify-end gap-2 border-t pt-4">
227+
<Button
228+
type="button"
229+
variant="outline"
230+
onClick={handleReset}
231+
disabled={
232+
setEmoji.isPending || (name.length === 0 && !pendingUpload)
233+
}
234+
>
235+
Clear
236+
</Button>
237+
<Button
238+
type="submit"
239+
data-testid="custom-emoji-add"
240+
disabled={!canSubmit}
241+
>
242+
{setEmoji.isPending ? "Saving…" : "Save emoji"}
243+
</Button>
118244
</div>
119-
<Button
120-
type="submit"
121-
data-testid="custom-emoji-add"
122-
disabled={!canSubmit}
123-
>
124-
<ImagePlus className="mr-2 h-4 w-4" />
125-
{isUploading ? "Uploading…" : "Upload image"}
126-
</Button>
127245
</form>
128-
{nameInvalid ? (
129-
<p className="text-sm text-destructive">
130-
Use only letters, numbers, hyphen, or underscore.
131-
</p>
132-
) : ownDuplicate ? (
133-
<p className="text-sm text-muted-foreground">
134-
You already have :{normalized}: — uploading will replace its image.
135-
</p>
136-
) : null}
137246

138247
<div className="space-y-3" data-testid="custom-emoji-mine">
139248
<h3 className="text-sm font-medium">

desktop/src/features/custom-emoji/ui/EmojiPicker.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({
5858
}
5959
}}
6060
perLine={8}
61-
previewPosition="none"
61+
previewPosition="bottom"
6262
set="native"
6363
skinTonePosition="search"
6464
theme="auto"

desktop/src/features/messages/lib/customEmojiNode.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export function registerCustomEmojiMarkdownIt(
129129
// proxy at PM-render time, so here we emit the raw `src`; `parseHTML`
130130
// re-derives the node from `data-shortcode` and the palette supplies the
131131
// live url. We still set `src` so a fully-formed <img> round-trips cleanly.
132-
return `<img data-custom-emoji data-shortcode="${esc(shortcode)}" src="${esc(src)}" alt=":${esc(shortcode)}:" />`;
132+
return `<img data-custom-emoji data-shortcode="${esc(shortcode)}" src="${esc(src)}" alt=":${esc(shortcode)}:" title=":${esc(shortcode)}:" />`;
133133
};
134134
}
135135

@@ -190,6 +190,7 @@ export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
190190
mergeAttributes(HTMLAttributes, {
191191
src,
192192
alt: `:${shortcode}:`,
193+
title: `:${shortcode}:`,
193194
"data-custom-emoji": "",
194195
"data-shortcode": shortcode,
195196
draggable: "false",

desktop/src/features/messages/lib/formatTimelineMessages.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,11 @@ export function formatTimelineMessages(
262262

263263
const profile = profiles?.[actorPubkey];
264264
const displayName =
265-
profile?.displayName?.trim() ||
266-
profile?.nip05Handle?.trim() ||
267-
`${actorPubkey.slice(0, 8)}…`;
265+
currentPubkeyLower && actorPubkey === currentPubkeyLower
266+
? "You"
267+
: profile?.displayName?.trim() ||
268+
profile?.nip05Handle?.trim() ||
269+
`${actorPubkey.slice(0, 8)}…`;
268270
existing.users.push({
269271
pubkey: actorPubkey,
270272
displayName,

desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ test("formatImetaMediaLine: image mime → ![image] line", () => {
8181
);
8282
});
8383

84+
test("buildImetaTags omits image filenames from imeta", () => {
85+
assert.deepEqual(
86+
buildImetaTags([
87+
{
88+
url: "https://b/a.png",
89+
type: "image/png",
90+
sha256: "abc",
91+
size: 10,
92+
uploaded: 1,
93+
filename: "Party Parrot.png",
94+
},
95+
]),
96+
[["imeta", "url https://b/a.png", "m image/png", "x abc", "size 10"]],
97+
);
98+
});
99+
84100
test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suffix)", () => {
85101
assert.equal(
86102
formatImetaMediaLine({ url: "https://cdn/blob/xyz", type: "video/mp4" }),

desktop/src/features/messages/lib/imetaMediaMarkdown.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ export function buildImetaTags(
9797
...(d.thumb ? [`thumb ${d.thumb}`] : []),
9898
...(d.duration != null ? [`duration ${d.duration}`] : []),
9999
...(d.image ? [`image ${d.image}`] : []),
100-
...(d.filename ? [`filename ${d.filename}`] : []),
100+
...(!d.type.startsWith("image/") &&
101+
!d.type.startsWith("video/") &&
102+
d.filename
103+
? [`filename ${d.filename}`]
104+
: []),
101105
]);
102106
}
103107

0 commit comments

Comments
 (0)