Skip to content

fix/dm replies and history#156

Merged
ValwareIRC merged 6 commits into
mainfrom
fix/dm-replies-and-history
Mar 10, 2026
Merged

fix/dm replies and history#156
ValwareIRC merged 6 commits into
mainfrom
fix/dm-replies-and-history

Conversation

@matheusfillipe
Copy link
Copy Markdown
Contributor

@matheusfillipe matheusfillipe commented Mar 10, 2026

  • Fix Replies in dms
  • redesign of reply buble
  • fix /me and multiline replies in DMs
  • Improved reactions dialog
CopyQ uFDWXp image

Summary by CodeRabbit

  • New Features

    • Reaction popover anchored to message UI for wide layouts; new popover component and responsive reaction flow.
    • Public capability check added for server feature detection.
  • Bug Fixes

    • Better text truncation in message layouts.
    • Improved /me behavior and private-message echo handling.
    • Preserved reaction modal behavior and username click handling in replies.
  • Refactor

    • Centralized reply resolution for consistent reply tracking.
  • Tests

    • Added multiline DM deduplication and reply linkage tests.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 10, 2026

📝 Walkthrough

Walkthrough

Updates across UI, hooks, store, client, and tests: layout fixes in message components, reworked reply UI, reaction popover and anchoring, private-message and /me send behavior adjusted with capability checks, centralized reply resolution in store, IRC client capability API, and new DM multiline tests.

Changes

Cohort / File(s) Summary
Message UI
src/components/message/MessageItem.tsx, src/components/message/MessageReply.tsx
Added min-w-0 classes to flex containers in MessageItem; rewrote MessageReply into a two-panel layout with decorative bar, RiReplyFill icon, clickable username (stopPropagation + onUsernameClick), and truncated content area.
Reaction UI & Chat Area
src/components/layout/ChatArea.tsx, src/components/ui/ReactionModal.tsx, src/components/ui/ReactionPopover.tsx
Added ReactionPopover component and wiring in ChatArea (anchor rect capture); ReactionModal text change (Cancel → Close) and removal of auto-close on emoji select; ChatArea uses popover on wide views and modal on narrow.
Message Sending & Reactions Hooks
src/hooks/useMessageSending.ts, src/hooks/useReactions.ts
useMessageSending: improved /me target resolution (channel vs private), added selectedPrivateChat dependency, and conditional local echo based on server echo-message capability. useReactions: changed channel vs private resolution to id-based lookup, consults live store message reactions, and removed auto-close of reaction modal after action.
IRC Client
src/lib/ircClient.ts
Added public hasCapability(serverId: string, cap: string): boolean and extended MULTILINE_MESSAGE event payload to include target for recipient tracking.
Store / Reply Resolution
src/store/index.ts
Introduced resolveReplyMessage helper and refactored multiple IRC event handlers to use it (CHANMSG, MULTILINE_MESSAGE, USERMSG, DM paths), unifying draft/reply metadata extraction and replyMessage population.
Tests
tests/store/multilineDedup.test.ts
Added "DM multiline messages" test suite (DM storage, deduplication, and reply linkage) with makeDmMessage helper to validate DM batch handling and reply resolution.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • ValwareIRC

Poem

🐰 I hopped through flex and reply display,
Anchored reactions found their way.
/me now knows where to send,
Replies link true from end to end.
Tests and popovers cheer the day! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix/dm replies and history' directly and clearly summarizes the main changes across the PR, which focus on fixing replies in direct messages, redesigning the reply bubble, and fixing /me and multiline replies in DMs.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/dm-replies-and-history

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

Pages Preview
Preview URL: https://fix-dm-replies-and-history.obsidianirc.pages.dev

Automated deployment preview for the PR in the Cloudflare Pages.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/store/multilineDedup.test.ts (1)

256-261: Tighten reply-link assertion for stronger regression protection.

At Line 260, you assert sender identity; consider also asserting the referenced message identity (id/msgid) so the test fails if reply linkage points to the wrong message from the same user.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/store/multilineDedup.test.ts` around lines 256 - 261, The test
currently checks replyMessage is present and the userId is "alice" but doesn't
verify which message is being referenced; update the assertion in the same test
after accessing stored?.replyMessage (from useStore.getState(), state.messages,
dmChannelKey, stored) to also assert the referenced message identity (e.g.,
stored?.replyMessage?.id or stored?.replyMessage?.msgid) equals the expected
original message id (replace with the actual original message id used in the
test, e.g., "orig-msg") so the test fails if the reply links to the wrong
message even when the sender matches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/store/index.ts`:
- Around line 3675-3679: Guard the use of target before calling
target.toLowerCase(): in the block that checks if
currentUser?.username.toLowerCase() === sender.toLowerCase(), ensure target is
non-null/undefined (and a string) before using target.toLowerCase() — e.g.,
return or skip the DM echo handling when target is falsy, or normalize target
with a safe default (use optional chaining/default '') before passing it into
the server.privateChats?.find comparator; update the lookup that assigns
privateChat (and any other places using target.toLowerCase()) to use the
guarded/normalized value to avoid runtime exceptions.
- Around line 130-139: The reply resolver in resolveReplyMessage only matches
messages by message.msgid, so replies referencing IDs in a message's
multilineMessageIds won't resolve; update the lookup logic (either in
resolveReplyMessage or inside findChannelMessageById) to consider both
message.msgid and any entries in message.multilineMessageIds when comparing to
replyId (i.e., treat a message as a match if replyId === message.msgid OR
replyId is included in message.multilineMessageIds), ensuring the function still
returns the found Message or null as before.

---

Nitpick comments:
In `@tests/store/multilineDedup.test.ts`:
- Around line 256-261: The test currently checks replyMessage is present and the
userId is "alice" but doesn't verify which message is being referenced; update
the assertion in the same test after accessing stored?.replyMessage (from
useStore.getState(), state.messages, dmChannelKey, stored) to also assert the
referenced message identity (e.g., stored?.replyMessage?.id or
stored?.replyMessage?.msgid) equals the expected original message id (replace
with the actual original message id used in the test, e.g., "orig-msg") so the
test fails if the reply links to the wrong message even when the sender matches.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f6a5690b-b1b6-4abc-b2bf-232a19b3e6aa

📥 Commits

Reviewing files that changed from the base of the PR and between fbc9093 and 4f44d00.

📒 Files selected for processing (6)
  • src/components/message/MessageItem.tsx
  • src/components/message/MessageReply.tsx
  • src/hooks/useMessageSending.ts
  • src/lib/ircClient.ts
  • src/store/index.ts
  • tests/store/multilineDedup.test.ts

Comment thread src/hooks/useMessageSending.ts
Comment thread src/store/index.ts
Comment thread src/store/index.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/store/index.ts (1)

3715-3739: ⚠️ Potential issue | 🟠 Major

Use openPrivateChat() for multiline-first DMs.

Unlike the single-line DM path on Line 3978, this branch hand-rolls a PrivateChat. That skips the side effects in openPrivateChat()MONITOR, WHO, and CHATHISTORY LATEST—so a DM whose first message is multiline comes up without presence hydration or history backfill.

Suggested fix
       let privateChat = server.privateChats.find(
         (chat) => chat.username.toLowerCase() === sender.toLowerCase(),
       );
       if (!privateChat) {
-        const newPrivateChat = {
-          id: generateDeterministicId(server.id, sender),
-          username: sender,
-          serverId: server.id,
-          unreadCount: 0,
-          isMentioned: false,
-          lastActivity: new Date(),
-          isPinned: false,
-          order: undefined,
-          isOnline: false,
-          isAway: false,
-        };
-        privateChat = newPrivateChat;
-        useStore.setState((state) => ({
-          servers: state.servers.map((s) =>
-            s.id === server.id
-              ? { ...s, privateChats: [...s.privateChats, newPrivateChat] }
-              : s,
-          ),
-        }));
+        useStore.getState().openPrivateChat(server.id, sender);
+        privateChat = useStore
+          .getState()
+          .servers.find((s) => s.id === server.id)
+          ?.privateChats?.find(
+            (pc) => pc.username.toLowerCase() === sender.toLowerCase(),
+          );
+      }
+
+      if (!privateChat) {
+        return;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/store/index.ts` around lines 3715 - 3739, This branch creates a
PrivateChat object manually (using generateDeterministicId and directly updating
servers[].privateChats) which skips important side effects; instead call the
existing openPrivateChat(sender, server.id) (or the function signature used
elsewhere) when no privateChat is found so MONITOR, WHO and CHATHISTORY LATEST
are executed and presence/history are hydrated; remove the ad-hoc
newPrivateChat/state update and rely on openPrivateChat to create and insert the
chat, then assign privateChat from the result before proceeding.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/message/MessageReply.tsx`:
- Around line 48-50: The dynamic Tailwind class `text-${theme}-text-muted` in
the MessageReply component will be purged; replace it with a deterministic
mapping and conditional lookup: add a theme-to-class map (e.g., themeTextMuted)
and use that map when building the className for the div that renders
plainContent so you pass a static class string (fallback to a safe default like
"text-discord-text-muted"); update the className expression in the MessageReply
JSX (the div containing plainContent) to use the mapped value instead of the
template literal.

In `@src/store/index.ts`:
- Around line 3937-3959: The DM-echo branch that creates newMessage and calls
useStore.getState().addMessage(...) does not add the echoed message's
mtags?.msgid to the dedupe set, so add the echo's msgid into processedMessageIds
(or call the existing function that marks a msgid processed) immediately after
creating/adding newMessage (before returning) to ensure mtags?.msgid is recorded
and the USERMSG dedupe gate (processedMessageIds) is respected for future
replays/duplicate echoes.
- Around line 130-138: resolveReplyMessage currently only looks up replyId
directly via findChannelMessageById, so replies that target per-line multiline
aliases don't resolve; change resolveReplyMessage to, after a failed direct
lookup, consult the multiline alias mapping (the multilineMessageIds structure)
for the server/channel to map the replyId back to the root message id and then
call findChannelMessageById with that root id; specifically, in
resolveReplyMessage use the existing replyId, call
findChannelMessageById(serverId, channelId, replyId) first, and if it returns
null, query multilineMessageIds for the given serverId/channelId to get the
canonical msgid for that alias and retry findChannelMessageById with that
canonical id so multiline-line replies resolve correctly.

---

Outside diff comments:
In `@src/store/index.ts`:
- Around line 3715-3739: This branch creates a PrivateChat object manually
(using generateDeterministicId and directly updating servers[].privateChats)
which skips important side effects; instead call the existing
openPrivateChat(sender, server.id) (or the function signature used elsewhere)
when no privateChat is found so MONITOR, WHO and CHATHISTORY LATEST are executed
and presence/history are hydrated; remove the ad-hoc newPrivateChat/state update
and rely on openPrivateChat to create and insert the chat, then assign
privateChat from the result before proceeding.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ae7943a-5c42-4fec-8bf3-390d0b5bbe89

📥 Commits

Reviewing files that changed from the base of the PR and between fbc9093 and f24578b.

📒 Files selected for processing (7)
  • src/components/message/MessageItem.tsx
  • src/components/message/MessageReply.tsx
  • src/hooks/useMessageSending.ts
  • src/hooks/useReactions.ts
  • src/lib/ircClient.ts
  • src/store/index.ts
  • tests/store/multilineDedup.test.ts

Comment on lines +48 to +50
<div className={`text-xs text-${theme}-text-muted opacity-80 truncate`}>
{plainContent}
</div>
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.

⚠️ Potential issue | 🟡 Minor

Dynamic Tailwind class won't be purged correctly.

The template literal text-${theme}-text-muted generates dynamic class names at runtime (e.g., text-discord-text-muted). Tailwind's purge/content scanner cannot detect these classes during the build, so they may not be included in the production CSS unless explicitly safelisted.

🔧 Suggested fix: Use conditional classes or a lookup
-        <div className={`text-xs text-${theme}-text-muted opacity-80 truncate`}>
+        <div className="text-xs text-discord-text-muted opacity-80 truncate">

If multiple themes are needed, consider using a class map:

const themeTextMuted: Record<string, string> = {
  discord: "text-discord-text-muted",
  // add other themes as needed
};
// ...
<div className={`text-xs ${themeTextMuted[theme] || "text-discord-text-muted"} opacity-80 truncate`}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className={`text-xs text-${theme}-text-muted opacity-80 truncate`}>
{plainContent}
</div>
const themeTextMuted: Record<string, string> = {
discord: "text-discord-text-muted",
// add other themes as needed
};
// In the render:
<div className={`text-xs ${themeTextMuted[theme] || "text-discord-text-muted"} opacity-80 truncate`}>
{plainContent}
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/message/MessageReply.tsx` around lines 48 - 50, The dynamic
Tailwind class `text-${theme}-text-muted` in the MessageReply component will be
purged; replace it with a deterministic mapping and conditional lookup: add a
theme-to-class map (e.g., themeTextMuted) and use that map when building the
className for the div that renders plainContent so you pass a static class
string (fallback to a safe default like "text-discord-text-muted"); update the
className expression in the MessageReply JSX (the div containing plainContent)
to use the mapped value instead of the template literal.

Comment thread src/store/index.ts
Comment on lines +130 to +138
const resolveReplyMessage = (
mtags: Record<string, string> | undefined,
serverId: string,
channelId: string,
): Message | null => {
const replyId = mtags?.["+draft/reply"]?.trim() || null;
return replyId
? (findChannelMessageById(serverId, channelId, replyId) ?? null)
: null;
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.

⚠️ Potential issue | 🟠 Major

Resolve replies against multiline aliases too.

Line 136 now funnels every reply through a lookup that only matches message.msgid. Multiline messages in this file also keep per-line ids in multilineMessageIds, and Line 3296 already treats those ids as valid aliases for later events. If a reply points at one of those ids, the bubble will still resolve to null.

Suggested fix
 const resolveReplyMessage = (
   mtags: Record<string, string> | undefined,
   serverId: string,
   channelId: string,
 ): Message | null => {
   const replyId = mtags?.["+draft/reply"]?.trim() || null;
-  return replyId
-    ? (findChannelMessageById(serverId, channelId, replyId) ?? null)
-    : null;
+  if (!replyId) {
+    return null;
+  }
+
+  const messages = getChannelMessages(serverId, channelId);
+  return (
+    messages.find(
+      (message) =>
+        message.msgid === replyId ||
+        message.multilineMessageIds?.includes(replyId),
+    ) ?? null
+  );
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/store/index.ts` around lines 130 - 138, resolveReplyMessage currently
only looks up replyId directly via findChannelMessageById, so replies that
target per-line multiline aliases don't resolve; change resolveReplyMessage to,
after a failed direct lookup, consult the multiline alias mapping (the
multilineMessageIds structure) for the server/channel to map the replyId back to
the root message id and then call findChannelMessageById with that root id;
specifically, in resolveReplyMessage use the existing replyId, call
findChannelMessageById(serverId, channelId, replyId) first, and if it returns
null, query multilineMessageIds for the given serverId/channelId to get the
canonical msgid for that alias and retry findChannelMessageById with that
canonical id so multiline-line replies resolve correctly.

Comment thread src/store/index.ts
Comment on lines +3937 to +3959
// Own message echo — store under the DM keyed by `target`, not `sender`.
if (server && target) {
const privateChat = server.privateChats?.find(
(pc) => pc.username.toLowerCase() === target.toLowerCase(),
);
if (privateChat) {
const newMessage = {
id: uuidv4(),
msgid: mtags?.msgid,
content: message,
timestamp,
userId: sender,
channelId: privateChat.id,
serverId: server.id,
type: "message" as const,
reactions: [],
replyMessage: resolveReplyMessage(mtags, server.id, privateChat.id),
mentioned: [],
tags: mtags,
};
useStore.getState().addMessage(newMessage);
}
}
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.

⚠️ Potential issue | 🟡 Minor

Record the echoed DM msgid before returning.

Line 3957 adds the echoed message, but this branch never updates processedMessageIds. That makes outbound DM echoes the only USERMSG path that bypasses the msgid dedupe gate on Lines 3800-3806, so the same message can still be accepted again during history replay or a duplicate echo.

Suggested fix
       if (privateChat) {
         const newMessage = {
           id: uuidv4(),
           msgid: mtags?.msgid,
           content: message,
           timestamp,
           userId: sender,
           channelId: privateChat.id,
           serverId: server.id,
           type: "message" as const,
           reactions: [],
           replyMessage: resolveReplyMessage(mtags, server.id, privateChat.id),
           mentioned: [],
           tags: mtags,
         };
+        if (mtags?.msgid) {
+          useStore.setState((state) => ({
+            processedMessageIds: new Set([
+              ...state.processedMessageIds,
+              mtags.msgid,
+            ]),
+          }));
+        }
         useStore.getState().addMessage(newMessage);
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Own message echo — store under the DM keyed by `target`, not `sender`.
if (server && target) {
const privateChat = server.privateChats?.find(
(pc) => pc.username.toLowerCase() === target.toLowerCase(),
);
if (privateChat) {
const newMessage = {
id: uuidv4(),
msgid: mtags?.msgid,
content: message,
timestamp,
userId: sender,
channelId: privateChat.id,
serverId: server.id,
type: "message" as const,
reactions: [],
replyMessage: resolveReplyMessage(mtags, server.id, privateChat.id),
mentioned: [],
tags: mtags,
};
useStore.getState().addMessage(newMessage);
}
}
// Own message echo — store under the DM keyed by `target`, not `sender`.
if (server && target) {
const privateChat = server.privateChats?.find(
(pc) => pc.username.toLowerCase() === target.toLowerCase(),
);
if (privateChat) {
const newMessage = {
id: uuidv4(),
msgid: mtags?.msgid,
content: message,
timestamp,
userId: sender,
channelId: privateChat.id,
serverId: server.id,
type: "message" as const,
reactions: [],
replyMessage: resolveReplyMessage(mtags, server.id, privateChat.id),
mentioned: [],
tags: mtags,
};
if (mtags?.msgid) {
useStore.setState((state) => ({
processedMessageIds: new Set([
...state.processedMessageIds,
mtags.msgid,
]),
}));
}
useStore.getState().addMessage(newMessage);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/store/index.ts` around lines 3937 - 3959, The DM-echo branch that creates
newMessage and calls useStore.getState().addMessage(...) does not add the echoed
message's mtags?.msgid to the dedupe set, so add the echo's msgid into
processedMessageIds (or call the existing function that marks a msgid processed)
immediately after creating/adding newMessage (before returning) to ensure
mtags?.msgid is recorded and the USERMSG dedupe gate (processedMessageIds) is
respected for future replays/duplicate echoes.

@ValwareIRC ValwareIRC merged commit 1b60283 into main Mar 10, 2026
4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Mar 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants