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: 7 additions & 1 deletion src/components/message/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
73 changes: 72 additions & 1 deletion src/hooks/useMessageSending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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}`,
);
});
},
Expand All @@ -433,6 +502,8 @@ export function useMessageSending({
selectedFormatting,
localReplyTo,
currentUser,
selectedChannel,
selectedPrivateChat,
],
);

Expand Down
1 change: 1 addition & 0 deletions src/lib/irc/IRCClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/lib/labeledResponse.ts
Original file line number Diff line number Diff line change
@@ -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-<base36 ms>-<counter>` 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} `;
}
82 changes: 82 additions & 0 deletions src/store/handlers/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,33 @@ export function registerMessageHandlers(store: StoreApi<AppState>): 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 &&
Expand Down Expand Up @@ -466,6 +493,37 @@ export function registerMessageHandlers(store: StoreApi<AppState>): 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(),
Expand Down Expand Up @@ -746,6 +804,30 @@ export function registerMessageHandlers(store: StoreApi<AppState>): 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(),
Expand Down
62 changes: 62 additions & 0 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message>,
) => boolean;
failPendingMessage: (
serverId: string,
bufferId: string,
label: string,
) => void;
addGlobalNotification: (notification: {
type: "fail" | "warn" | "note";
command: string;
Expand Down Expand Up @@ -1707,6 +1721,54 @@ const useStore = create<AppState>((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: [
Expand Down
11 changes: 11 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +242 to +251
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

too many comments jesus

status?: "pending" | "failed";
}

// Alias for backwards compatibility
Expand Down
1 change: 1 addition & 0 deletions tests/components/ChatArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}));
Expand Down
Loading