diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index d9deb8d9..f067df9a 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -50,6 +50,7 @@ import InviteUserModal from "../ui/InviteUserModal"; import LoadingSpinner from "../ui/LoadingSpinner"; import ModerationModal, { type ModerationAction } from "../ui/ModerationModal"; import ReactionModal from "../ui/ReactionModal"; +import { ReactionPopover } from "../ui/ReactionPopover"; import { ReplyBadge } from "../ui/ReplyBadge"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; import { TextArea } from "../ui/TextInput"; @@ -536,6 +537,9 @@ export const ChatArea: React.FC<{ selectedServerId, currentUser, }); + const [reactionAnchorRect, setReactionAnchorRect] = useState( + null, + ); // Memoize preview styles — selectedColor/selectedFormatting don't change while typing, // so recomputing this on every keystroke is unnecessary object churn. @@ -942,7 +946,7 @@ export const ChatArea: React.FC<{ // Handle Enter key behavior based on settings if (e.key === "Enter") { - if (isMobile && globalSettings.enableMultilineInput) { + if (isNativeMobile && globalSettings.enableMultilineInput) { return; } @@ -1436,6 +1440,7 @@ export const ChatArea: React.FC<{ ]); const handleReactClick = (message: MessageType, buttonElement: Element) => { + setReactionAnchorRect(buttonElement.getBoundingClientRect()); openReactionModal(message); }; @@ -1818,7 +1823,7 @@ export const ChatArea: React.FC<{ selectedChannel ? `Message #${selectedChannel.name.replace(/^#/, "")}${ globalSettings.enableMultilineInput && - !isMobile && + !isNativeMobile && !isCompactInput ? globalSettings.multilineOnShiftEnter ? " (Shift+Enter for new line)" @@ -1838,7 +1843,7 @@ export const ChatArea: React.FC<{ : "Type a message..." } enterKeyHint={ - isMobile && globalSettings.enableMultilineInput + isNativeMobile && globalSettings.enableMultilineInput ? "enter" : "send" } @@ -1860,7 +1865,7 @@ export const ChatArea: React.FC<{ }} onAtClick={handleAtButtonClick} onSendClick={handleSendMessage} - showSendButton={isMobile} + showSendButton={isNativeMobile} hideEmoji={isNativeMobile} hasText={messageText.trim().length > 0} /> @@ -2043,11 +2048,20 @@ export const ChatArea: React.FC<{ }} /> - + {isNarrowView ? ( + + ) : ( + + )} { />
{showHeader && ( { /> )} -
+
{message.replyMessage && ( = ({ return (
- ┌ Replying to{" "} - - - {replyUsername} - - - : {plainContent} +
+
+
+ + + {replyUsername} + +
+
+ {plainContent} +
+
); }; diff --git a/src/components/ui/ReactionModal.tsx b/src/components/ui/ReactionModal.tsx index e10a85de..aa0619cb 100644 --- a/src/components/ui/ReactionModal.tsx +++ b/src/components/ui/ReactionModal.tsx @@ -18,7 +18,6 @@ const ReactionModal: React.FC = ({ const handleEmojiSelect = (emojiData: EmojiClickData) => { onSelectEmoji(emojiData.emoji); - onClose(); }; const handleBackdropClick = (e: React.MouseEvent) => { @@ -41,7 +40,7 @@ const ReactionModal: React.FC = ({ onClick={onClose} className="text-sm text-discord-text-muted hover:text-white w-full text-center py-1" > - Cancel + Close
diff --git a/src/components/ui/ReactionPopover.tsx b/src/components/ui/ReactionPopover.tsx new file mode 100644 index 00000000..b1b92222 --- /dev/null +++ b/src/components/ui/ReactionPopover.tsx @@ -0,0 +1,81 @@ +import type { EmojiClickData } from "emoji-picker-react"; +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { AppEmojiPicker } from "./AppEmojiPicker"; + +interface Props { + isOpen: boolean; + anchorRect: DOMRect | null; + onClose: () => void; + onSelectEmoji: (emoji: string) => void; +} + +const PICKER_W = 352; +const PICKER_H = 450; +const GAP = 8; +const MARGIN = 12; + +function computeStyle(anchorRect: DOMRect): React.CSSProperties { + const vw = window.innerWidth; + const vh = window.innerHeight; + + const top = + anchorRect.bottom + GAP + PICKER_H <= vh - MARGIN + ? anchorRect.bottom + GAP + : Math.max(MARGIN, anchorRect.top - GAP - PICKER_H); + + const left = Math.min( + Math.max(MARGIN, anchorRect.left), + vw - PICKER_W - MARGIN, + ); + + return { position: "fixed", top, left, zIndex: 50, width: PICKER_W }; +} + +export function ReactionPopover({ + isOpen, + anchorRect, + onClose, + onSelectEmoji, +}: Props) { + const popoverRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handleMouseDown = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); + + if (!isOpen || !anchorRect) return null; + + return createPortal( +
+
+ onSelectEmoji(d.emoji)} + /> +
+
, + document.body, + ); +} diff --git a/src/components/ui/ReplyBadge.tsx b/src/components/ui/ReplyBadge.tsx index 3eb40d9d..375aee4d 100644 --- a/src/components/ui/ReplyBadge.tsx +++ b/src/components/ui/ReplyBadge.tsx @@ -26,7 +26,7 @@ export function ReplyBadge({ className="ml-auto flex-shrink-0 p-1 rounded hover:bg-discord-dark-300 text-discord-text-muted hover:text-discord-text-normal transition-colors" onClick={onClose} > - +
); diff --git a/src/hooks/useMessageSending.ts b/src/hooks/useMessageSending.ts index d733fb32..9bad7ff5 100644 --- a/src/hooks/useMessageSending.ts +++ b/src/hooks/useMessageSending.ts @@ -154,10 +154,35 @@ export function useMessageSending({ ); } else if (commandName === "me") { const actionMessage = cleanedText.substring(4).trim(); + const target = + selectedChannel?.name ?? selectedPrivateChat?.username ?? ""; + if (!target) return; ircClient.sendRaw( selectedServerId, - `PRIVMSG ${selectedChannel?.name || ""} :\u0001ACTION ${actionMessage}\u0001`, + `PRIVMSG ${target} :\u0001ACTION ${actionMessage}\u0001`, ); + // Non-echo fallback: servers without echo-message won't reflect our + // ACTION back, so add it locally the same way regular DMs are handled. + if ( + selectedPrivateChat && + currentUser && + !ircClient.hasCapability(selectedServerId, "echo-message") + ) { + const { addMessage } = useStore.getState(); + const outgoingMessage: Message = { + id: uuidv4(), + content: `\u0001ACTION ${actionMessage}\u0001`, + timestamp: new Date(), + userId: currentUser.username || currentUser.id, + channelId: selectedPrivateChat.id, + serverId: selectedServerId, + type: "message" as const, + reactions: [], + replyMessage: localReplyTo, + mentioned: [], + }; + addMessage(outgoingMessage); + } } else if (commandName === "away") { const message = args.join(" "); if (message) { @@ -173,7 +198,15 @@ export function useMessageSending({ ircClient.sendRaw(selectedServerId, fullCommand); } }, - [selectedServerId, selectedChannel, currentUser, setAway, clearAway], + [ + selectedServerId, + selectedChannel, + selectedPrivateChat, + currentUser, + localReplyTo, + setAway, + clearAway, + ], ); /** @@ -431,9 +464,12 @@ export function useMessageSending({ sendRegularMessage(cleanedText, target); } - // For private messages, manually add our own message to the chat - // since the server doesn't echo private messages back to us - if (selectedPrivateChat && currentUser) { + // Only needed for servers that won't echo our outgoing DM back. + if ( + selectedPrivateChat && + currentUser && + !ircClient.hasCapability(selectedServerId, "echo-message") + ) { const outgoingMessage: Message = { id: uuidv4(), content: cleanedText, diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts index 8eda12dc..eac20bbf 100644 --- a/src/hooks/useReactions.ts +++ b/src/hooks/useReactions.ts @@ -70,13 +70,11 @@ export function useReactions({ const server = servers.find((s) => s.id === message.serverId); if (!server) return { server: undefined, target: undefined }; - if (message.channelId) { - const channel = server.channels.find((c) => c.id === message.channelId); - return { server, target: channel?.name }; - } - // Private message + const channel = server.channels.find((c) => c.id === message.channelId); + if (channel) return { server, target: channel.name }; + const privateChat = server.privateChats?.find( - (pc) => pc.username === message.userId.split("-")[0], + (pc) => pc.id === message.channelId, ); return { server, target: privateChat?.username }; }, @@ -96,8 +94,16 @@ export function useReactions({ const { server, target } = findServerAndTarget(reactionModal.message); if (server && target) { - // Check if user has already reacted with this emoji - const existingReaction = reactionModal.message.reactions.find( + // Check live store state — reactionModal.message is stale after optimistic updates + const storeMsg = (() => { + const key = `${reactionModal.message.serverId}-${reactionModal.message.channelId}`; + return useStore + .getState() + .messages[key]?.find((m) => m.id === reactionModal.message?.id); + })(); + const liveReactions = + storeMsg?.reactions ?? reactionModal.message.reactions; + const existingReaction = liveReactions.find( (r) => r.emoji === emoji && r.userId === currentUser?.username, ); @@ -152,8 +158,6 @@ export function useReactions({ }); } } - - closeReactionModal(); }, [ reactionModal.message, diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 432db1a8..f4d66e31 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -107,6 +107,7 @@ export interface EventMap { BATCH_END: BaseIRCEvent & { batchId: string }; MULTILINE_MESSAGE: BaseMessageEvent & { channelName?: string; + target: string; // raw BATCH recipient (channel name or username) lines: string[]; messageIds: string[]; // All message IDs that make up this multiline message }; @@ -1913,6 +1914,7 @@ export class IRCClient { (batch.batchMsgId ? { msgid: batch.batchMsgId } : undefined), // Preserve all BATCH opener tags (includes +draft/reply) sender, channelName: target.startsWith("#") ? target : undefined, + target, message: combinedMessage, lines: batch.messages, messageIds: batch.messageIds || [], @@ -2830,6 +2832,10 @@ export class IRCClient { return this.currentUsers.get(serverId) || null; } + hasCapability(serverId: string, cap: string): boolean { + return this.servers.get(serverId)?.capabilities?.includes(cap) ?? false; + } + getBatchType(serverId: string, batchId: string): string | undefined { return this.activeBatches.get(serverId)?.get(batchId)?.type; } diff --git a/src/store/index.ts b/src/store/index.ts index ac0ee711..9e712dfa 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -124,7 +124,22 @@ export const findChannelMessageById = ( messageId: string, ): Message | undefined => { const messages = getChannelMessages(serverId, channelId); - return messages.find((message) => message.msgid === messageId); + return messages.find( + (message) => + message.msgid === messageId || + message.multilineMessageIds?.includes(messageId), + ); +}; + +const resolveReplyMessage = ( + mtags: Record | undefined, + serverId: string, + channelId: string, +): Message | null => { + const replyId = mtags?.["+draft/reply"]?.trim() || null; + return replyId + ? (findChannelMessageById(serverId, channelId, replyId) ?? null) + : null; }; // ============================================================================ @@ -3311,16 +3326,8 @@ ircClient.on("CHANMSG", (response) => { const channel = server.channels.find( (c) => c.name.toLowerCase() === channelName.toLowerCase(), ); - const replyTo = null; - if (channel) { - const replyId = mtags?.["+draft/reply"] - ? mtags["+draft/reply"].trim() - : null; - - const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) || null - : null; + const replyMessage = resolveReplyMessage(mtags, server.id, channel.id); // Check for mentions and get current state const currentState = useStore.getState(); @@ -3531,7 +3538,7 @@ ircClient.on("CHANMSG", (response) => { // Handle multiline messages ircClient.on("MULTILINE_MESSAGE", (response) => { - const { mtags, channelName, sender, message, messageIds, timestamp } = + const { mtags, channelName, target, sender, message, messageIds, timestamp } = response; // Check for duplicate messages based on messageIds or batch msgid @@ -3576,13 +3583,7 @@ ircClient.on("MULTILINE_MESSAGE", (response) => { : null; if (channel) { - const replyId = mtags?.["+draft/reply"] - ? mtags["+draft/reply"].trim() - : null; - - const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) || null - : null; + const replyMessage = resolveReplyMessage(mtags, server.id, channel.id); const newMessage = { id: uuidv4(), @@ -3673,17 +3674,50 @@ ircClient.on("MULTILINE_MESSAGE", (response) => { }; }); } else if (!channelName) { - // Handle multiline private messages - // Similar logic to USERMSG but for multiline content const currentUser = ircClient.getCurrentUser(response.serverId); - if ( - currentUser && - sender.toLowerCase() === currentUser.username.toLowerCase() - ) { - return; // Don't create private chats with ourselves + + if (currentUser?.username.toLowerCase() === sender.toLowerCase()) { + // Own message echo — store under DM keyed by `target`, same as USERMSG echo handler. + if (!target) return; + const privateChat = server.privateChats?.find( + (pc) => pc.username.toLowerCase() === target.toLowerCase(), + ); + if (privateChat) { + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + multilineMessageIds: messageIds, + 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, + }; + const idsToTrack = + messageIds?.length > 0 + ? messageIds + : mtags?.msgid + ? [mtags.msgid] + : []; + if (idsToTrack.length > 0) { + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + ...idsToTrack, + ]), + })); + } + useStore.getState().addMessage(newMessage); + } + return; } - // Create or find private chat (IRC nicks are case-insensitive) + // Incoming DM from another user let privateChat = server.privateChats.find( (chat) => chat.username.toLowerCase() === sender.toLowerCase(), ); @@ -3697,7 +3731,7 @@ ircClient.on("MULTILINE_MESSAGE", (response) => { lastActivity: new Date(), isPinned: false, order: undefined, - isOnline: false, // Will be updated by MONITOR + isOnline: false, isAway: false, }; privateChat = newPrivateChat; @@ -3713,25 +3747,33 @@ ircClient.on("MULTILINE_MESSAGE", (response) => { const newMessage = { id: uuidv4(), msgid: mtags?.msgid, - multilineMessageIds: messageIds, // Store all message IDs for redaction - content: message, // Use the properly combined message from IRC client + multilineMessageIds: messageIds, + content: message, timestamp, userId: sender, channelId: privateChat.id, serverId: server.id, type: "message" as const, reactions: [], - replyMessage: null, + replyMessage: resolveReplyMessage(mtags, server.id, privateChat.id), mentioned: [], tags: mtags, }; + const idsToTrack = + messageIds?.length > 0 ? messageIds : mtags?.msgid ? [mtags.msgid] : []; + if (idsToTrack.length > 0) { + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + ...idsToTrack, + ]), + })); + } + useStore.getState().addMessage(newMessage); - // Play notification sound if appropriate (but not for historical messages) - // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) const isHistoricalMessage = mtags?.batch !== undefined; - if (!isHistoricalMessage) { const state = useStore.getState(); const serverCurrentUser = ircClient.getCurrentUser(response.serverId); @@ -3842,13 +3884,7 @@ ircClient.on("USERMSG", (response) => { ); if (channel) { - const replyId = mtags?.["+draft/reply"] - ? mtags["+draft/reply"].trim() - : null; - - const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) || null - : null; + const replyMessage = resolveReplyMessage(mtags, server.id, channel.id); const newMessage = { id: uuidv4(), @@ -3901,9 +3937,31 @@ ircClient.on("USERMSG", (response) => { } } - // Don't create private chats with ourselves when the server echoes back our own messages const currentUser = ircClient.getCurrentUser(response.serverId); if (currentUser?.username === sender) { + // 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); + } + } return; } @@ -3939,12 +3997,12 @@ ircClient.on("USERMSG", (response) => { content: message, timestamp, userId: sender, - channelId: privateChat.id, // Use private chat ID as channel ID + channelId: privateChat.id, serverId: server.id, type: "message" as const, reactions: [], - replyMessage: null, - mentioned: [], // PMs don't have mentions in the traditional sense + replyMessage: resolveReplyMessage(mtags, server.id, privateChat.id), + mentioned: [], tags: mtags, }; @@ -6737,9 +6795,11 @@ ircClient.on("TAGMSG", (response) => { if (isChannel) { channel = server.channels.find((c) => c.name === channelName); } else { - // Private chat + // channelName may be our own nick (incoming reaction echo), so also try sender channel = server.privateChats?.find( - (pc) => pc.username.toLowerCase() === channelName.toLowerCase(), + (pc) => + pc.username.toLowerCase() === channelName.toLowerCase() || + pc.username.toLowerCase() === sender.toLowerCase(), ); } @@ -6797,9 +6857,11 @@ ircClient.on("TAGMSG", (response) => { if (isChannel) { channel = server.channels.find((c) => c.name === channelName); } else { - // Private chat + // channelName may be our own nick (incoming unreact echo), so also try sender channel = server.privateChats?.find( - (pc) => pc.username.toLowerCase() === channelName.toLowerCase(), + (pc) => + pc.username.toLowerCase() === channelName.toLowerCase() || + pc.username.toLowerCase() === sender.toLowerCase(), ); } diff --git a/tests/store/multilineDedup.test.ts b/tests/store/multilineDedup.test.ts index 263e29be..fd231648 100644 --- a/tests/store/multilineDedup.test.ts +++ b/tests/store/multilineDedup.test.ts @@ -150,6 +150,117 @@ describe("Multiline message deduplication", () => { }); }); + describe("DM multiline messages", () => { + const dmChannelKey = "server-1-dm-alice"; + + function makeDmMessage(overrides: Partial = {}): Message { + return makeMessage({ + channelId: "dm-alice", + ...overrides, + }); + } + + test("DM incoming multiline is stored and tracked in processedMessageIds", () => { + const { addMessage } = useStore.getState(); + const batchMsgId = "dm-batch-id-1"; + const messageIds = ["dm-msg-1", "dm-msg-2"]; + + const idsToTrack = messageIds.length > 0 ? messageIds : [batchMsgId]; + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + ...idsToTrack, + ]), + })); + + addMessage( + makeDmMessage({ + id: "stored-dm-1", + msgid: batchMsgId, + content: "line 1\nline 2", + userId: "alice", + }), + ); + + const state = useStore.getState(); + expect(state.processedMessageIds.has("dm-msg-1")).toBe(true); + expect(state.processedMessageIds.has("dm-msg-2")).toBe(true); + const messages = state.messages[dmChannelKey]; + expect(messages).toHaveLength(1); + }); + + test("DM multiline dedup: second identical batch is skipped", () => { + const { addMessage } = useStore.getState(); + const batchMsgId = "dm-batch-id-2"; + const messageIds = ["dm-msg-3", "dm-msg-4"]; + + // First event: dedup check passes, track ids, add message + let state = useStore.getState(); + const shouldSkip1 = messageIds.some((id) => + state.processedMessageIds.has(id), + ); + expect(shouldSkip1).toBe(false); + + useStore.setState((s) => ({ + processedMessageIds: new Set([...s.processedMessageIds, ...messageIds]), + })); + addMessage( + makeDmMessage({ + id: "dm-stored-1", + msgid: batchMsgId, + userId: "alice", + }), + ); + + // Second event: duplicate detected, handler returns early + state = useStore.getState(); + const shouldSkip2 = messageIds.some((id) => + state.processedMessageIds.has(id), + ); + expect(shouldSkip2).toBe(true); + + // Only one message stored + expect(state.messages[dmChannelKey]).toHaveLength(1); + }); + + test("DM multiline reply: replyMessage is set when pre-seeded message exists", () => { + const { addMessage } = useStore.getState(); + + // Seed the message being replied to + const replyTarget = makeDmMessage({ + id: "original-msg", + msgid: "original-msgid", + content: "original content", + userId: "alice", + timestamp: new Date("2026-01-01T00:00:00.000Z"), + }); + addMessage(replyTarget); + + // Add a reply that references it + const replyMsg = makeDmMessage({ + id: "reply-msg", + msgid: "reply-msgid", + content: "this is my reply", + userId: "bob", + timestamp: new Date("2026-01-01T00:01:00.000Z"), + replyMessage: makeDmMessage({ + id: "original-msg", + msgid: "original-msgid", + content: "original content", + userId: "alice", + timestamp: new Date("2026-01-01T00:00:00.000Z"), + }), + }); + addMessage(replyMsg); + + const state = useStore.getState(); + const messages = state.messages[dmChannelKey]; + const stored = messages?.find((m) => m.id === "reply-msg"); + expect(stored?.replyMessage).not.toBeNull(); + expect(stored?.replyMessage?.userId).toBe("alice"); + }); + }); + describe("full multiline message flow", () => { test("two identical multiline messages with same batch msgid should result in one stored message", () => { const { addMessage } = useStore.getState();