diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 530d2b336..1604dcc30 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -38,7 +38,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state + ["src/shared/api/relayClientSession.ts", 740], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/commands/agents.rs", 845], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 25232f2f3..e1d85f919 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useMentions } from "@/features/messages/lib/useMentions"; +import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { type BlobDescriptor, pickAndUploadMedia, @@ -63,6 +64,7 @@ export function MessageComposer({ const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); + const notifyTyping = useTypingBroadcast(channelId); const [uploadState, setUploadState] = React.useState<{ status: "idle" | "uploading" | "error"; @@ -371,11 +373,15 @@ export function MessageComposer({ updateDraftSelection(event.target); mentions.updateMentionQuery(nextContent, cursorPos); channelLinks.updateChannelQuery(nextContent, cursorPos); + if (nextContent.trim().length > 0) { + notifyTyping(); + } }, [ updateDraftSelection, mentions.updateMentionQuery, channelLinks.updateChannelQuery, + notifyTyping, ], ); diff --git a/desktop/src/features/messages/useTypingBroadcast.ts b/desktop/src/features/messages/useTypingBroadcast.ts new file mode 100644 index 000000000..80175318a --- /dev/null +++ b/desktop/src/features/messages/useTypingBroadcast.ts @@ -0,0 +1,39 @@ +import { useCallback, useRef } from "react"; + +import { relayClient } from "@/shared/api/relayClient"; + +const TYPING_SEND_INTERVAL_MS = 3_000; + +/** + * Publishes kind:20002 typing indicators for the current user, + * throttled to at most once every 3 seconds per channel. + */ +export function useTypingBroadcast(channelId: string | null | undefined) { + const lastSentRef = useRef(0); + const lastChannelRef = useRef(channelId); + const channelIdRef = useRef(channelId); + channelIdRef.current = channelId; + + const notifyTyping = useCallback(() => { + const id = channelIdRef.current; + if (!id) { + return; + } + + // Reset throttle when channel changes. + if (lastChannelRef.current !== id) { + lastChannelRef.current = id; + lastSentRef.current = 0; + } + + const now = Date.now(); + if (now - lastSentRef.current < TYPING_SEND_INTERVAL_MS) { + return; + } + + lastSentRef.current = now; + relayClient.sendTypingIndicator(id).catch(() => {}); + }, []); + + return notifyTyping; +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index d27cf2d83..446483582 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -125,6 +125,21 @@ export class RelayClient { ); } + async sendTypingIndicator(channelId: string) { + // Bail when disconnected — not worth triggering a reconnect for ephemeral typing events. + if (this.wsId === null) { + return; + } + const event = await signRelayEvent({ + kind: KIND_TYPING_INDICATOR, + content: "", + tags: [["h", channelId]], + }); + + // Fire-and-forget: no need to wait for relay acknowledgement. + void this.sendRaw(["EVENT", event]).catch(() => {}); + } + async subscribeToChannel( channelId: string, onEvent: (event: RelayEvent) => void,