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
8 changes: 4 additions & 4 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,17 @@ const overrides = new Map([
["src/features/channels/useUnreadChannels.ts", 717], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks + markChannelRead latestByChannelRef fallback chain (matches markChannelUnread)
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 525], // message query/mutation hooks + optimistic updates + custom-emoji reaction URL resolution (reactionEmojiUrl from cached relay set) + outgoing tag split (splitOutgoingTags) routing custom-emoji tags through the dedicated emoji_tags send arg so they skip the imeta-only gate
["src/features/messages/hooks.ts", 530], // message query/mutation hooks + optimistic updates + custom-emoji reaction URL resolution (reactionEmojiUrl from cached relay set) + outgoing tag split (splitOutgoingTags) routing custom-emoji tags through the dedicated emoji_tags send arg so they skip the imeta-only gate; edit mutation splits the merged tag set the same way so edited custom emoji ride the dedicated emoji_tags arg
["src/features/messages/lib/useRichTextEditor.ts", 620], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body) + custom-emoji atom-node wiring (useComposerCustomEmoji extension + src re-resolve effect; replacePlainTextRange inserts the selectable/copyable CustomEmojiNode for `:shortcode:` so it behaves like a built-in emoji glyph). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up).
["src/features/messages/ui/MessageComposer.tsx", 860], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them + custom emoji: useCustomEmoji feeds picker/autocomplete and mergeOutgoingTags attaches NIP-30 emoji tags on send; insertEmoji inserts a known `:shortcode:` as the selectable CustomEmojiNode atom (else plain native unicode)
["src/features/messages/ui/MessageComposer.tsx", 870], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them + custom emoji: useCustomEmoji feeds picker/autocomplete and mergeOutgoingTags attaches NIP-30 emoji tags on send AND on edit-save (so edited custom emoji stay resolvable); insertEmoji inserts a known `:shortcode:` as the selectable CustomEmojiNode atom (else plain native unicode)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 830], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + SidebarDndContext wrapper + sectionIds memo for DnD section reorder + controlled create-channel dialog for ⌘⇧N shortcut
["src/features/sidebar/ui/CustomChannelSection.tsx", 615], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + per-section mark-all-read + DnD wrappers (SortableSectionShell, DraggableChannelRow, DroppableSectionBody, DroppableUngroupedBody) + draggable prop on ChannelGroupSection
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 910], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload + relay-mesh client preflight (start_local_agent_with_preflight + StartTarget split: async ensure outside store lock, then sync spawn under lock)
["src-tauri/src/commands/messages.rs", 525], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + edit_message media_tags param (Slack-style attachment-editable edits) + add_reaction custom-emoji branch (build_custom_emoji_reaction when emoji_url is set)
["src-tauri/src/commands/messages.rs", 530], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + edit_message media_tags + emoji_tags params (Slack-style attachment edits + edited custom emoji stay resolvable) + add_reaction custom-emoji branch (build_custom_emoji_reaction when emoji_url is set)
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 1390], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents + relay_mesh_model_id detector (mesh preset-env match) + tests
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
Expand All @@ -71,7 +71,7 @@ const overrides = new Map([
["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src-tauri/src/events.rs", 825], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test + emoji_tags() NIP-30 builder (mirrors the imeta_tags injection guard, first elem must be "emoji")
["src-tauri/src/events.rs", 830], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test + emoji_tags() NIP-30 builder (mirrors the imeta_tags injection guard, first elem must be "emoji"); build_message_edit also threads emoji_tags so edited custom emoji stay resolvable
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block
["src-tauri/src/huddle/stt.rs", 580], // STT pipeline + PTT edge-detection flush + PTT gating (is_speech AND ptt_active) + barge-in for VAD mode + rubato resampler + earshot VAD + sherpa-onnx transcription
Expand Down
5 changes: 4 additions & 1 deletion desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ pub async fn edit_message(
event_id: String,
content: String,
media_tags: Vec<Vec<String>>,
emoji_tags: Option<Vec<Vec<String>>>,
state: State<'_, AppState>,
) -> Result<(), String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
Expand All @@ -413,7 +414,9 @@ pub async fn edit_message(
if trimmed.is_empty() && media_tags.is_empty() {
return Err("edit must have content or attachments".into());
}
let builder = events::build_message_edit(channel_uuid, target_eid, trimmed, &media_tags)?;
let emoji = emoji_tags.unwrap_or_default();
let builder =
events::build_message_edit(channel_uuid, target_eid, trimmed, &media_tags, &emoji)?;
submit_event(builder, &state).await?;
Ok(())
}
Expand Down
6 changes: 5 additions & 1 deletion desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,19 +302,23 @@ pub fn build_forum_comment(

/// Kind 40003 — edit a message. Carries the full new content AND a fresh
/// imeta tag set; the receiver overlays the imeta tags onto the original
/// event so the rendered message reflects exactly the edited state.
/// event so the rendered message reflects exactly the edited state. NIP-30
/// custom-emoji tags ride along the same way so an edited body's `:shortcode:`s
/// stay resolvable (the send path attaches these too).
pub fn build_message_edit(
channel_id: Uuid,
target_event_id: EventId,
content: &str,
media_tags: &[Vec<String>],
custom_emoji_tags: &[Vec<String>],
) -> Result<EventBuilder, String> {
check_content(content)?;
let mut tags = vec![
tag(vec!["h", &channel_id.to_string()])?,
tag(vec!["e", &target_event_id.to_hex()])?,
];
imeta_tags(media_tags, &mut tags)?;
emoji_tags(custom_emoji_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags))
}

Expand Down
67 changes: 67 additions & 0 deletions desktop/src/features/custom-emoji/ui/EmojiPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import * as React from "react";

import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";

/**
* The one emoji picker for the whole app. Every place that lets a user choose
* an emoji — composing a message, reacting to a regular or system message,
* setting a status — renders this, so the config and custom-emoji wiring can't
* drift across call sites (they used to, and that's why custom emoji were
* missing from some pickers).
*
* It always wires the workspace custom-emoji palette in via `useCustomEmoji()`,
* so custom emoji show up everywhere for free. Selection is normalized to a
* single string: a standard emoji emits its `native` glyph; a custom emoji has
* no `native`, so it emits its `:shortcode:` (the emoji-mart `id` is the
* shortcode). Consumers store/send that string and let the existing renderers
* (reactions' `emojiUrl`, the remark shortcode plugin) resolve it to an image.
*
* Only the raw picker lives here — not the Popover/trigger. Those differ per
* site (ghost button vs status swatch, popover vs dialog content) and forcing
* them into one wrapper would be less clear, not more. The thing that drifted
* was the picker config + custom wiring + select handling; that's what this
* centralizes.
*/
type EmojiPickerProps = {
/** Autofocus the search field when the picker mounts (e.g. reaction popovers). */
autoFocus?: boolean;
/** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */
onSelect: (emoji: string) => void;
};

export const EmojiPicker = React.memo(function EmojiPicker({
autoFocus = false,
onSelect,
}: EmojiPickerProps) {
const customEmoji = useCustomEmoji();
const custom = React.useMemo(
() => buildCustomEmojiCategory(customEmoji),
[customEmoji],
);

return (
<Picker
autoFocus={autoFocus}
custom={custom}
data={data}
maxFrequentRows={2}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
// Standard emoji carry a `native` glyph. Custom emoji don't — emit
// their `:shortcode:` (emoji-mart `id` == shortcode) instead. Ignore a
// malformed selection that has neither.
const value = emoji.native ?? (emoji.id ? `:${emoji.id}:` : "");
if (value) {
onSelect(value);
}
}}
perLine={8}
previewPosition="none"
set="native"
skinTonePosition="search"
theme="auto"
/>
);
});
8 changes: 7 additions & 1 deletion desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,13 @@ export function useEditMessageMutation(channel: Channel | null) {
throw new Error("No channel selected.");
}

await editMessage(channel.id, eventId, content, mediaTags);
// `mediaTags` arrives as the merged outgoing set (imeta + NIP-30 emoji).
// Split so each rides its own validated Tauri arg — emoji tags must NOT
// go through the imeta-only `mediaTags` channel (the Rust `imeta_tags`
// guard rejects any non-imeta prefix), mirroring the send path.
const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags);

await editMessage(channel.id, eventId, content, imetaTags, emojiTags);
},
onSuccess: (_data, { eventId, content, mediaTags }) => {
if (!channel) {
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/features/messages/lib/applyEditTagOverlay.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
export type Tag = string[];

/**
* Merge an event's tags with an edit's tags: imeta from the edit (full new
* attachment set), all other tag kinds from the original. Pass-through when
* `editTags` is `undefined`.
* Merge an event's tags with an edit's tags: imeta + NIP-30 emoji tags from the
* edit (full new attachment + custom-emoji set), all other tag kinds from the
* original. Pass-through when `editTags` is `undefined`.
*/
export function applyEditTagOverlay(
originalTags: Tag[],
Expand Down
23 changes: 20 additions & 3 deletions desktop/src/features/messages/lib/applyEditTagOverlay.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
/**
* Merge the original event's tags with an edit's tags so that:
* - `imeta` tags come exclusively from the edit (full new attachment set);
* - `emoji` (NIP-30 custom-emoji) tags come from the edit *when the edit
* supplies any* — the edited body may add or remove custom emoji, so a
* supplied set rebuilds the shortcode→url map. But when the edit supplies
* NO emoji tags, the original's emoji tags are PRESERVED. A tag-less edit
* can come from an older build (before edits carried emoji tags) or another
* client that doesn't know this path; dropping the original's emoji tags
* there would strip the only shortcode→url mapping and re-break a
* `:shortcode:` that the original rendered fine. Preserving on empty is
* strictly safe: an orphaned emoji tag whose shortcode is no longer in the
* body resolves nothing, so it can't cause a stale render.
* - all other tag kinds (`h`, `e`, `p` mentions, etc.) come exclusively
* from the original — the edit can't rewrite channel membership,
* thread refs, or mention targets.
Expand All @@ -20,7 +30,14 @@
*/
export function applyEditTagOverlay(originalTags, editTags) {
if (!editTags) return originalTags;
const nonImetaOriginal = originalTags.filter((t) => t[0] !== "imeta");
const imetaFromEdit = editTags.filter((t) => t[0] === "imeta");
return [...nonImetaOriginal, ...imetaFromEdit];
const editEmoji = editTags.filter((t) => t[0] === "emoji");
// imeta is always fully replaced by the edit. emoji is replaced only when
// the edit actually supplies emoji tags; otherwise the original's are kept.
const droppedFromOriginal =
editEmoji.length > 0
? (t) => t[0] !== "imeta" && t[0] !== "emoji"
: (t) => t[0] !== "imeta";
const baseFromOriginal = originalTags.filter(droppedFromOriginal);
const overlaidFromEdit = editTags.filter((t) => t[0] === "imeta");
return [...baseFromOriginal, ...overlaidFromEdit, ...editEmoji];
}
Loading