From 7c2526a71b7170278e9e2c41bc0e3bf4bfc19952 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sun, 3 May 2026 05:30:17 +0100 Subject: [PATCH] feat: labeled-response (optimistic outgoing messages) Implements the IRCv3 labeled-response capability so we can show a sent message immediately in its destination buffer with a "pending" indicator, then let the server's echo authoritatively replace it with the real msgid / content / nick once it arrives. Mostly visible on slow connections, where the placeholder gives instant feedback without losing the safety net of waiting for the server to confirm. Wire-protocol bits: - labeled-response added to ourCaps so the cap is auto-requested when the server advertises it (depends on `batch`, also in caps). - lib/labeledResponse.ts: makeLabel() + withLabel(prefix, label), returning a tag-prefix string that composes cleanly with the existing `+reply=` / `+draft/reply=` prefix path. - useMessageSending sends `@;label= PRIVMSG ...` when both `labeled-response` and `echo-message` are negotiated. Without echo-message there's no echo to acknowledge the placeholder so we keep the legacy code path; whispers also keep their existing flow because they don't have a 1:1 echo match. State: - Message gains optional `pendingLabel` and `status` ("pending" | "failed"); regular received messages have neither field, so nothing changes for them. - Two new store actions: confirmPendingMessage(serverId, bufferId, label, updates) replaces the placeholder in place when the echo arrives, returning false on miss so callers can fall back to addMessage; failPendingMessage flips the status when the PENDING_TIMEOUT_MS (30s) timer fires. - CHANMSG, USERMSG (regular and multiline own-echo branches) now consult confirmPendingMessage before falling through to addMessage when mtags.label is present and the sender is the current user. msgid is recorded in processedMessageIds so any duplicate-echo guards keep working. UI: - MessageItem applies opacity-60 + italic for status === "pending" and opacity-60 + line-through + red text for "failed". The rest of the row layout is unchanged so the placeholder simply "settles" into place when the echo lands. Tests: - ChatArea test mock now stubs hasCapability(); useMessageSending calls it to gate the label path. --- src/components/message/MessageItem.tsx | 8 ++- src/hooks/useMessageSending.ts | 73 ++++++++++++++++++++++- src/lib/irc/IRCClient.ts | 1 + src/lib/labeledResponse.ts | 36 +++++++++++ src/store/handlers/messages.ts | 82 ++++++++++++++++++++++++++ src/store/index.ts | 62 +++++++++++++++++++ src/types/index.ts | 11 ++++ tests/components/ChatArea.test.tsx | 1 + 8 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 src/lib/labeledResponse.ts diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx index e29eebaa..7b84562e 100644 --- a/src/components/message/MessageItem.tsx +++ b/src/components/message/MessageItem.tsx @@ -647,7 +647,13 @@ export const MessageItem = memo((props: MessageItemProps) => { data-message-id={message.id} className={`px-4 hover:bg-discord-message-hover group relative transition-colors duration-150 ${ showHeader ? "mt-4" : "py-0.5" - }${isHighlighted ? " bg-primary/10 ring-1 ring-primary/30 rounded" : ""}`} + }${isHighlighted ? " bg-primary/10 ring-1 ring-primary/30 rounded" : ""}${ + message.status === "pending" + ? " opacity-60 italic" + : message.status === "failed" + ? " opacity-60 line-through text-discord-red" + : "" + }`} onMouseEnter={handleMessageMouseEnter} onMouseLeave={handleMessageMouseLeave} onTouchStart={longPress.onTouchStart} diff --git a/src/hooks/useMessageSending.ts b/src/hooks/useMessageSending.ts index 9763e1f4..9dc82066 100644 --- a/src/hooks/useMessageSending.ts +++ b/src/hooks/useMessageSending.ts @@ -5,6 +5,7 @@ import { useCallback } from "react"; import { v4 as uuidv4 } from "uuid"; import ircClient from "../lib/ircClient"; +import { makeLabel, withLabel } from "../lib/labeledResponse"; import { type FormattingType, formatMessageForIrc, @@ -13,6 +14,32 @@ import { createBatchId, splitLongMessage } from "../lib/messageProtocol"; import useStore, { serverSupportsMultiline } from "../store"; import type { Channel, Message, PrivateChat, User } from "../types"; +/** + * labeled-response is only useful when the server will also echo our + * own messages back: without echo-message, no echo arrives, no + * acknowledgment, and the placeholder would hang forever. + */ +function shouldUseLabeledResponse(serverId: string): boolean { + return ( + ircClient.hasCapability(serverId, "labeled-response") && + ircClient.hasCapability(serverId, "echo-message") && + ircClient.hasCapability(serverId, "batch") + ); +} + +/** How long to wait before flipping a pending message to "failed". */ +const PENDING_TIMEOUT_MS = 30_000; + +function arm_pending_timeout( + serverId: string, + bufferId: string, + label: string, +) { + setTimeout(() => { + useStore.getState().failPendingMessage(serverId, bufferId, label); + }, PENDING_TIMEOUT_MS); +} + interface UseMessageSendingOptions { selectedServerId: string | null; selectedChannelId: string | null; @@ -409,6 +436,48 @@ export function useMessageSending({ const whisperContext = getWhisperContext(localReplyTo, currentUser); + // labeled-response: when the cap is acked, we generate one label + // for the whole send (even when split across multiple PRIVMSGs by + // splitLongMessage; the spec lets us reuse the same label across + // them as long as we don't reuse before the response completes). + const useLabel = + !whisperContext && shouldUseLabeledResponse(selectedServerId); + const label = useLabel ? makeLabel() : null; + + // Buffer id for the pending placeholder. Channels are matched + // by id from the store; PMs use the PrivateChat record's id. + const bufferId = selectedChannel + ? selectedChannel.id + : selectedPrivateChat + ? selectedPrivateChat.id + : null; + + // Insert pending placeholder (only when we have a label *and* a + // store buffer to put it in -- whisper has neither). + if (label && bufferId && currentUser && selectedServerId) { + const placeholder: Message = { + id: uuidv4(), + content: cleanedText, + timestamp: new Date(), + userId: currentUser.username || currentUser.id, + channelId: bufferId, + serverId: selectedServerId, + type: "message", + reactions: [], + replyMessage: localReplyTo, + mentioned: [], + pendingLabel: label, + status: "pending", + }; + useStore.getState().addMessage(placeholder); + arm_pending_timeout(selectedServerId, bufferId, label); + } + + const replyPrefix = localReplyTo?.msgid + ? `@+reply=${localReplyTo.msgid};+draft/reply=${localReplyTo.msgid} ` + : ""; + const tagPrefix = withLabel(replyPrefix, label); + sendViaWhisperOrRegular( selectedServerId, whisperContext, @@ -418,7 +487,7 @@ export function useMessageSending({ splitLines.forEach((line: string) => { ircClient.sendRaw( selectedServerId, - `${localReplyTo?.msgid ? `@+reply=${localReplyTo.msgid};+draft/reply=${localReplyTo.msgid} ` : ""}PRIVMSG ${target} :${line}`, + `${tagPrefix}PRIVMSG ${target} :${line}`, ); }); }, @@ -430,6 +499,8 @@ export function useMessageSending({ selectedFormatting, localReplyTo, currentUser, + selectedChannel, + selectedPrivateChat, ], ); diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index 5b1d500e..c6be9b4e 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -470,6 +470,7 @@ export class IRCClient implements IRCClientContext { "invite-notify", "monitor", "extended-monitor", + "labeled-response", // Note: unrealircd.org/link-security is informational only, don't request it ]; diff --git a/src/lib/labeledResponse.ts b/src/lib/labeledResponse.ts new file mode 100644 index 00000000..7019488a --- /dev/null +++ b/src/lib/labeledResponse.ts @@ -0,0 +1,36 @@ +// labeled-response: tag generator + helper utilities. +// +// Spec: the label tag value is opaque, MUST NOT exceed 64 bytes, and +// SHOULD NOT be reused before a complete response is received. We +// produce values like `lr--` which are well under +// 64 bytes and monotonically unique within a process. + +let counter = 0; + +export function makeLabel(): string { + counter = (counter + 1) % 0xffffff; + return `lr-${Date.now().toString(36)}-${counter.toString(36)}`; +} + +/** + * Combine an existing IRC tag prefix (which may already include things + * like `+reply=...`) with a `label=...` tag, preserving the leading `@` + * and the trailing space the wire format expects. + * + * - `existingPrefix` is either "" or "@k1=v1;k2=v2 " (with trailing space). + * - Returns "" if `label` is null/undefined; otherwise the combined + * prefix with one trailing space. + */ +export function withLabel( + existingPrefix: string, + label: string | null | undefined, +): string { + if (!label) return existingPrefix; + if (!existingPrefix) return `@label=${label} `; + // existingPrefix already starts with '@' and ends with ' '; insert + // before the trailing space. + const trimmed = existingPrefix.endsWith(" ") + ? existingPrefix.slice(0, -1) + : existingPrefix; + return `${trimmed};label=${label} `; +} diff --git a/src/store/handlers/messages.ts b/src/store/handlers/messages.ts index 68f3644e..623cf2c2 100644 --- a/src/store/handlers/messages.ts +++ b/src/store/handlers/messages.ts @@ -120,6 +120,33 @@ export function registerMessageHandlers(store: StoreApi): void { tags: mtags, }; + // labeled-response: if this is the echo of a message we just + // sent, the server MUST repeat the same label tag back. Find + // the local pending placeholder by label and update it in + // place with the server's authoritative msgid + content. + if (mtags?.label && isOwnMessage) { + const matched = store + .getState() + .confirmPendingMessage(server.id, channel.id, mtags.label, { + msgid: mtags.msgid, + content: message, + userId: response.sender, + timestamp, + tags: mtags, + }); + if (matched) { + // Track the msgid so subsequent duplicate-echo guards work. + if (mtags.msgid) { + store.setState((state) => ({ + processedMessageIds: new Set(state.processedMessageIds).add( + mtags.msgid as string, + ), + })); + } + return; + } + } + // Update channel unread count and mention flag if not the active channel const isActiveChannel = getCurrentSelection(currentState).selectedChannelId === channel.id && @@ -464,6 +491,37 @@ export function registerMessageHandlers(store: StoreApi): void { (pc) => pc.username.toLowerCase() === target.toLowerCase(), ); if (privateChat) { + // labeled-response: confirm the pending placeholder when + // a matching label tag is on the BATCH-wrapped echo. + if (mtags?.label) { + const matched = store + .getState() + .confirmPendingMessage(server.id, privateChat.id, mtags.label, { + msgid: mtags.msgid, + multilineMessageIds: messageIds, + content: message, + userId: sender, + timestamp, + tags: mtags, + }); + if (matched) { + const idsToTrack = + messageIds?.length > 0 + ? messageIds + : mtags?.msgid + ? [mtags.msgid] + : []; + if (idsToTrack.length > 0) { + store.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + ...idsToTrack, + ]), + })); + } + return; + } + } const privateChatKey = `${server.id}-${privateChat.id}`; const newMessage = { id: uuidv4(), @@ -744,6 +802,30 @@ export function registerMessageHandlers(store: StoreApi): void { (pc) => pc.username.toLowerCase() === target.toLowerCase(), ); if (privateChat) { + // labeled-response: replace the pending placeholder we + // inserted on send instead of appending a new entry. + if (mtags?.label) { + const matched = store + .getState() + .confirmPendingMessage(server.id, privateChat.id, mtags.label, { + msgid: mtags.msgid, + content: message, + userId: sender, + timestamp, + tags: mtags, + }); + if (matched) { + if (mtags.msgid) { + store.setState((state) => ({ + processedMessageIds: new Set(state.processedMessageIds).add( + mtags.msgid as string, + ), + })); + } + return; + } + } + const privateChatKey = `${server.id}-${privateChat.id}`; const newMessage = { id: uuidv4(), diff --git a/src/store/index.ts b/src/store/index.ts index 7ef1cff3..b4acdb14 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -632,6 +632,20 @@ export interface AppState { setName: (serverId: string, realname: string) => void; changeNick: (serverId: string, newNick: string) => void; addMessage: (message: Message) => void; + // labeled-response: replace a local pending placeholder (matched by + // pendingLabel) with the server's echo. Returns true if a match was + // found. Caller falls back to addMessage on miss. + confirmPendingMessage: ( + serverId: string, + bufferId: string, + label: string, + updates: Partial, + ) => boolean; + failPendingMessage: ( + serverId: string, + bufferId: string, + label: string, + ) => void; addGlobalNotification: (notification: { type: "fail" | "warn" | "note"; command: string; @@ -1512,6 +1526,54 @@ const useStore = create((set, get) => ({ }); }, + confirmPendingMessage: (serverId, bufferId, label, updates) => { + const channelKey = `${serverId}-${bufferId}`; + let matched = false; + set((state) => { + const list = state.messages[channelKey]; + if (!list) return state; + const next = list.map((msg) => { + if (msg.pendingLabel !== label) return msg; + matched = true; + return { + ...msg, + ...updates, + pendingLabel: undefined, + status: undefined, + }; + }); + if (!matched) return state; + return { + messages: { + ...state.messages, + [channelKey]: next, + }, + }; + }); + return matched; + }, + + failPendingMessage: (serverId, bufferId, label) => { + const channelKey = `${serverId}-${bufferId}`; + set((state) => { + const list = state.messages[channelKey]; + if (!list) return state; + let touched = false; + const next = list.map((msg) => { + if (msg.pendingLabel !== label) return msg; + touched = true; + return { ...msg, status: "failed" as const }; + }); + if (!touched) return state; + return { + messages: { + ...state.messages, + [channelKey]: next, + }, + }; + }); + }, + addGlobalNotification: (notification) => { set((state) => ({ globalNotifications: [ diff --git a/src/types/index.ts b/src/types/index.ts index d5694eff..0fd90a1e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -168,6 +168,17 @@ export interface Message { jsonLogData?: JsonValue; // True when the message was replayed from chathistory (not a live event) fromHistory?: boolean; + // labeled-response: when the user sends a message with the + // labeled-response cap acked, we insert a local placeholder + // immediately and wait for the server's echo to match it back. + // `pendingLabel` is the label tag value we attached on send, set + // only on the local placeholder until the echo arrives. + pendingLabel?: string; + // labeled-response: lifecycle state of an outgoing message. + // - "pending": placeholder awaiting server echo + // - "failed": no echo / FAIL arrived in the timeout window + // undefined => normal received message (no pending lifecycle). + status?: "pending" | "failed"; } // Alias for backwards compatibility diff --git a/tests/components/ChatArea.test.tsx b/tests/components/ChatArea.test.tsx index ce4160cf..40c856c4 100644 --- a/tests/components/ChatArea.test.tsx +++ b/tests/components/ChatArea.test.tsx @@ -14,6 +14,7 @@ vi.mock("../../src/lib/ircClient", () => ({ on: vi.fn(), getCurrentUser: vi.fn(() => ({ id: "test-user", username: "tester" })), getNick: vi.fn(() => "tester"), + hasCapability: vi.fn(() => false), version: "1.0.0", }, }));