Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 8 additions & 16 deletions desktop/src-tauri/src/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/custom-emoji/emojiMartCategory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }],
})),
Expand Down
181 changes: 145 additions & 36 deletions desktop/src/features/custom-emoji/ui/CustomEmojiSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,36 +34,74 @@ 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);
const nameInvalid = name.trim().length > 0 && normalized === null;
// "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) => {
Expand Down Expand Up @@ -91,49 +132,117 @@ export function CustomEmojiSettingsCard() {
</div>

<form
className="flex items-end gap-2"
className="max-w-2xl space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (canSubmit) void handleAdd();
}}
>
<div className="min-w-0 flex-1 space-y-1.5">
<label className="text-sm font-medium" htmlFor="custom-emoji-name">
Name
</label>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">:</span>
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold">1. Upload an image</h4>
<p className="text-sm text-muted-foreground">
Square images work best. GIF, PNG, JPEG, and WebP files are
supported.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-md border bg-background">
{pendingUpload ? (
<img
alt="Selected custom emoji preview"
src={rewriteRelayUrl(pendingUpload.url)}
className="h-14 w-14 object-contain"
draggable={false}
/>
) : (
<ImagePlus className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-sm text-muted-foreground">
{pendingUpload?.filename ?? "No image selected"}
</p>
<Button
type="button"
data-testid="custom-emoji-upload"
onClick={() => void handleUpload()}
disabled={isUploading || setEmoji.isPending}
variant="outline"
>
{isUploading
? "Uploading…"
: pendingUpload
? "Choose different image"
: "Upload image"}
</Button>
</div>
</div>
</div>

<div className="space-y-3 border-t pt-4">
<div>
<h4 className="text-sm font-semibold">2. Give it a name</h4>
<p className="text-sm text-muted-foreground">
This is what you’ll type to add this emoji to messages and
reactions.
</p>
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
:
</span>
<Input
id="custom-emoji-name"
data-testid="custom-emoji-name-input"
autoCapitalize="none"
autoCorrect="off"
className="px-6"
placeholder="party-parrot"
spellCheck={false}
value={name}
onChange={(event) => setName(event.target.value)}
/>
<span className="text-muted-foreground">:</span>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
:
</span>
</div>
{nameInvalid ? (
<p className="text-sm text-destructive">
Use only letters, numbers, hyphen, or underscore.
</p>
) : pendingUpload === null ? (
<p className="text-sm text-muted-foreground">
Choose an image first; Sprout will suggest a name from the
filename.
</p>
) : ownDuplicate ? (
<p className="text-sm text-muted-foreground">
You already have :{normalized}: — saving will replace its image.
</p>
) : null}
</div>

<div className="flex justify-end gap-2 border-t pt-4">
<Button
type="button"
variant="outline"
onClick={handleReset}
disabled={
setEmoji.isPending || (name.length === 0 && !pendingUpload)
}
>
Clear
</Button>
<Button
type="submit"
data-testid="custom-emoji-add"
disabled={!canSubmit}
>
{setEmoji.isPending ? "Saving…" : "Save emoji"}
</Button>
</div>
<Button
type="submit"
data-testid="custom-emoji-add"
disabled={!canSubmit}
>
<ImagePlus className="mr-2 h-4 w-4" />
{isUploading ? "Uploading…" : "Upload image"}
</Button>
</form>
{nameInvalid ? (
<p className="text-sm text-destructive">
Use only letters, numbers, hyphen, or underscore.
</p>
) : ownDuplicate ? (
<p className="text-sm text-muted-foreground">
You already have :{normalized}: — uploading will replace its image.
</p>
) : null}

<div className="space-y-3" data-testid="custom-emoji-mine">
<h3 className="text-sm font-medium">
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/custom-emoji/ui/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({
}
}}
perLine={8}
previewPosition="none"
previewPosition="bottom"
set="native"
skinTonePosition="search"
theme="auto"
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/messages/lib/customEmojiNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> round-trips cleanly.
return `<img data-custom-emoji data-shortcode="${esc(shortcode)}" src="${esc(src)}" alt=":${esc(shortcode)}:" />`;
return `<img data-custom-emoji data-shortcode="${esc(shortcode)}" src="${esc(src)}" alt=":${esc(shortcode)}:" title=":${esc(shortcode)}:" />`;
};
}

Expand Down Expand Up @@ -190,6 +190,7 @@ export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
mergeAttributes(HTMLAttributes, {
src,
alt: `:${shortcode}:`,
title: `:${shortcode}:`,
"data-custom-emoji": "",
"data-shortcode": shortcode,
draggable: "false",
Expand Down
8 changes: 5 additions & 3 deletions desktop/src/features/messages/lib/formatTimelineMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/messages/lib/imetaMediaMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`]
: []),
]);
}

Expand Down
Loading