diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx index d7891b28..9ce2da73 100644 --- a/src/components/message/MessageItem.tsx +++ b/src/components/message/MessageItem.tsx @@ -648,7 +648,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 576fcf70..38dc26a0 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; @@ -412,6 +439,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, @@ -421,7 +490,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}`, ); }); }, @@ -433,6 +502,8 @@ export function useMessageSending({ selectedFormatting, localReplyTo, currentUser, + selectedChannel, + selectedPrivateChat, ], ); diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index b643ffff..376646a8 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -528,6 +528,7 @@ export class IRCClient implements IRCClientContext { "invite-notify", "monitor", "extended-monitor", + "labeled-response", "draft/read-marker", "obsidianirc/cmdslist", // 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 193c13f9..46ee56d7 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 && @@ -466,6 +493,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(), @@ -746,6 +804,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 2902669a..ef90e1b2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -737,6 +737,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; @@ -1707,6 +1721,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 49842f98..8e781454 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -239,6 +239,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", }, }));