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
32 changes: 23 additions & 9 deletions src/components/layout/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -536,6 +537,9 @@ export const ChatArea: React.FC<{
selectedServerId,
currentUser,
});
const [reactionAnchorRect, setReactionAnchorRect] = useState<DOMRect | null>(
null,
);

// Memoize preview styles — selectedColor/selectedFormatting don't change while typing,
// so recomputing this on every keystroke is unnecessary object churn.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1436,6 +1440,7 @@ export const ChatArea: React.FC<{
]);

const handleReactClick = (message: MessageType, buttonElement: Element) => {
setReactionAnchorRect(buttonElement.getBoundingClientRect());
openReactionModal(message);
};

Expand Down Expand Up @@ -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)"
Expand All @@ -1838,7 +1843,7 @@ export const ChatArea: React.FC<{
: "Type a message..."
}
enterKeyHint={
isMobile && globalSettings.enableMultilineInput
isNativeMobile && globalSettings.enableMultilineInput
? "enter"
: "send"
}
Expand All @@ -1860,7 +1865,7 @@ export const ChatArea: React.FC<{
}}
onAtClick={handleAtButtonClick}
onSendClick={handleSendMessage}
showSendButton={isMobile}
showSendButton={isNativeMobile}
hideEmoji={isNativeMobile}
hasText={messageText.trim().length > 0}
/>
Expand Down Expand Up @@ -2043,11 +2048,20 @@ export const ChatArea: React.FC<{
}}
/>

<ReactionModal
isOpen={reactionModal.isOpen}
onClose={closeReactionModal}
onSelectEmoji={selectReaction}
/>
{isNarrowView ? (
<ReactionModal
isOpen={reactionModal.isOpen}
onClose={closeReactionModal}
onSelectEmoji={selectReaction}
/>
) : (
<ReactionPopover
isOpen={reactionModal.isOpen}
anchorRect={reactionAnchorRect}
onClose={closeReactionModal}
onSelectEmoji={selectReaction}
/>
)}

<ModerationModal
isOpen={moderationModal.isOpen}
Expand Down
4 changes: 2 additions & 2 deletions src/components/message/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,7 @@ export const MessageItem = (props: MessageItemProps) => {
/>

<div
className={`flex-1 relative ${isCurrentUser ? "text-white" : ""}`}
className={`flex-1 min-w-0 relative ${isCurrentUser ? "text-white" : ""}`}
>
{showHeader && (
<MessageHeader
Expand All @@ -944,7 +944,7 @@ export const MessageItem = (props: MessageItemProps) => {
/>
)}

<div className="relative">
<div className="relative min-w-0">
{message.replyMessage && (
<MessageReply
replyMessage={message.replyMessage}
Expand Down
25 changes: 17 additions & 8 deletions src/components/message/MessageReply.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type React from "react";
import { RiReplyFill } from "react-icons/ri";
import { stripIrcFormatting } from "../../lib/messageFormatter";
import type { MessageType } from "../../types";

Expand Down Expand Up @@ -29,17 +30,25 @@ export const MessageReply: React.FC<MessageReplyProps> = ({

return (
<div
className={`bg-${theme}-dark-200 rounded text-sm text-${theme}-text-muted mb-2 pl-1 pr-2 select-none line-clamp-2 ${onReplyClick ? "cursor-pointer hover:bg-opacity-80" : ""}`}
className={`flex mb-2 min-w-0 w-full rounded-md overflow-hidden bg-black/[0.22] border border-white/[0.04] transition-colors ${onReplyClick ? "cursor-pointer hover:bg-black/[0.32]" : ""}`}
onClick={onReplyClick}
title={onReplyClick ? "Click to jump to message" : ""}
>
┌ Replying to{" "}
<strong>
<span className="cursor-pointer" onClick={handleUsernameClick}>
{replyUsername}
</span>
</strong>
: {plainContent}
<div className="w-0.5 flex-shrink-0 bg-indigo-400/70 rounded-l" />
<div className="flex-1 min-w-0 py-1.5 px-2.5 overflow-hidden">
<div className="flex items-center gap-1.5 mb-0.5">
<RiReplyFill className="text-[11px] flex-shrink-0 text-indigo-400/70" />
<span
className="text-xs font-semibold text-indigo-300 hover:underline cursor-pointer truncate"
onClick={handleUsernameClick}
>
{replyUsername}
</span>
</div>
<div className={`text-xs text-${theme}-text-muted opacity-80 truncate`}>
{plainContent}
</div>
Comment on lines +48 to +50
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.

</div>
</div>
);
};
3 changes: 1 addition & 2 deletions src/components/ui/ReactionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const ReactionModal: React.FC<ReactionModalProps> = ({

const handleEmojiSelect = (emojiData: EmojiClickData) => {
onSelectEmoji(emojiData.emoji);
onClose();
};

const handleBackdropClick = (e: React.MouseEvent) => {
Expand All @@ -41,7 +40,7 @@ const ReactionModal: React.FC<ReactionModalProps> = ({
onClick={onClose}
className="text-sm text-discord-text-muted hover:text-white w-full text-center py-1"
>
Cancel
Close
</button>
</div>
</div>
Expand Down
81 changes: 81 additions & 0 deletions src/components/ui/ReactionPopover.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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(
<div ref={popoverRef} style={computeStyle(anchorRect)}>
<div className="bg-discord-dark-400 rounded-lg shadow-lg border border-discord-dark-300 overflow-hidden">
<AppEmojiPicker
onEmojiClick={(d: EmojiClickData) => onSelectEmoji(d.emoji)}
/>
</div>
</div>,
document.body,
);
}
2 changes: 1 addition & 1 deletion src/components/ui/ReplyBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
<FaTimes className="text-xs" />
<FaTimes className="text-base" />
</button>
</div>
);
Expand Down
46 changes: 41 additions & 5 deletions src/hooks/useMessageSending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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) {
Expand All @@ -173,7 +198,15 @@ export function useMessageSending({
ircClient.sendRaw(selectedServerId, fullCommand);
}
},
[selectedServerId, selectedChannel, currentUser, setAway, clearAway],
[
selectedServerId,
selectedChannel,
selectedPrivateChat,
currentUser,
localReplyTo,
setAway,
clearAway,
],
);

/**
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 14 additions & 10 deletions src/hooks/useReactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
Expand All @@ -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,
);

Expand Down Expand Up @@ -152,8 +158,6 @@ export function useReactions({
});
}
}

closeReactionModal();
},
[
reactionModal.message,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/ircClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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 || [],
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading