added features and fixes
This commit is contained in:
+322
-67
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react";
|
||||
import { useMessageStore } from "../stores/message.ts";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { useChannelStore } from "../stores/channel.ts";
|
||||
import { useServerStore } from "../stores/server.ts";
|
||||
import { useTypingStore } from "../stores/typing.ts";
|
||||
@@ -12,6 +13,11 @@ import { useReadStatesStore } from "../stores/readStates.ts";
|
||||
import { MessageSearch } from "./MessageSearch";
|
||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||
import { ThreadPanel } from "./ThreadPanel.tsx";
|
||||
import { useContextMenu } from "./ContextMenu.tsx";
|
||||
import { PinnedMessages } from "./PinnedMessages.tsx";
|
||||
import { ReactionBar } from "./ReactionBar.tsx";
|
||||
import { EmojiPicker } from "./EmojiPicker.tsx";
|
||||
import { ReplyBar } from "./ReplyBar.tsx";
|
||||
import { ForumView } from "./ForumView.tsx";
|
||||
import { CalendarView } from "./CalendarView.tsx";
|
||||
import { DocsView } from "./DocsView.tsx";
|
||||
@@ -59,23 +65,47 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// For plain text segments, render through ReactMarkdown but preserve
|
||||
// leading/trailing spaces that ReactMarkdown would otherwise trim
|
||||
const leadingSpace = seg.value.match(/^\s+/)?.[0] || "";
|
||||
const trailingSpace = seg.value.match(/\s+$/)?.[0] || "";
|
||||
const trimmed = seg.value.slice(leadingSpace.length, seg.value.length - trailingSpace.length);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={idx}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
|
||||
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
|
||||
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
|
||||
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
|
||||
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
|
||||
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
|
||||
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
|
||||
p: ({ ...props }) => <span {...props} className="inline" />,
|
||||
}}
|
||||
>
|
||||
{seg.value}
|
||||
</ReactMarkdown>
|
||||
<span key={idx}>
|
||||
{leadingSpace}
|
||||
{trimmed ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ ...props }) => <a {...props} className="text-gb-aqua hover:underline" target="_blank" rel="noreferrer" />,
|
||||
code: ({ ...props }) => <code {...props} className="bg-gb-bg-t px-1 rounded text-gb-fg-s" />,
|
||||
pre: ({ ...props }) => <pre {...props} className="bg-gb-bg-s p-2 my-1 overflow-x-auto text-gb-fg-s" />,
|
||||
blockquote: ({ ...props }) => <blockquote {...props} className="border-l-2 border-gb-orange pl-2 my-1 text-gb-fg-s" />,
|
||||
table: ({ ...props }) => <table {...props} className="border-collapse border border-gb-bg-t my-1" />,
|
||||
th: ({ ...props }) => <th {...props} className="border border-gb-bg-t px-2 py-1 bg-gb-bg-s" />,
|
||||
td: ({ ...props }) => <td {...props} className="border border-gb-bg-t px-2 py-1" />,
|
||||
p: ({ ...props }) => <span {...props} className="inline" />,
|
||||
img: ({ src, ...props }) => {
|
||||
const finalSrc = src?.startsWith("https://media") && src.includes(".giphy.com/")
|
||||
? `/api/v1/gifs/proxy?url=${encodeURIComponent(src)}`
|
||||
: src;
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
src={finalSrc}
|
||||
className="max-w-[240px] max-h-[240px] object-contain rounded my-1 block"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{trimmed}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
{trailingSpace}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -109,6 +139,110 @@ function renderEmbeds(embeds?: { url: string; title?: string; description?: stri
|
||||
);
|
||||
}
|
||||
|
||||
const MessageItem = memo(({
|
||||
message,
|
||||
memberUsernames,
|
||||
selectMode,
|
||||
isSelected,
|
||||
onAuthorClick,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
onAddReaction,
|
||||
currentUserId,
|
||||
activeReactionMessageId,
|
||||
setActiveReactionMessageId,
|
||||
members,
|
||||
parentMessage,
|
||||
onJumpToParent,
|
||||
}: {
|
||||
message: any;
|
||||
memberUsernames: Set<string>;
|
||||
selectMode: boolean;
|
||||
isSelected: boolean;
|
||||
onAuthorClick: (authorId: string) => void;
|
||||
onToggleSelect: (messageId: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, message: any) => void;
|
||||
onAddReaction: (messageId: string, emoji: string) => void;
|
||||
currentUserId?: string;
|
||||
activeReactionMessageId: string | null;
|
||||
setActiveReactionMessageId: (id: string | null) => void;
|
||||
members: any[];
|
||||
parentMessage: any | null;
|
||||
onJumpToParent: (id: string) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
id={`msg-${message.id}`}
|
||||
onClick={() => selectMode && onToggleSelect(message.id)}
|
||||
onContextMenu={(e) => onContextMenu(e, message)}
|
||||
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${isSelected ? 'bg-gb-bg-s' : ''} relative`}
|
||||
>
|
||||
{parentMessage && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onJumpToParent(parentMessage.id);
|
||||
}}
|
||||
className="cursor-pointer hover:opacity-80 mb-0.5"
|
||||
>
|
||||
<ReplyBar
|
||||
replyTo={{
|
||||
id: parentMessage.id,
|
||||
content: parentMessage.content,
|
||||
author: {
|
||||
username: parentMessage.author_username,
|
||||
}
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectMode && (
|
||||
<span className="text-gb-fg-f mr-2">
|
||||
{isSelected ? '[x]' : '[ ]'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
||||
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
|
||||
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAuthorClick(message.author_id);
|
||||
}}>
|
||||
<{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}>
|
||||
</span>{" "}
|
||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
|
||||
{/* Message reactions */}
|
||||
<ReactionBar
|
||||
messageId={message.id}
|
||||
reactions={(message.reactions || []).map((r: any) => {
|
||||
const usernames = r.users.map((uid: string) => {
|
||||
const m = members.find((member: any) => member.id === uid);
|
||||
return m ? (m.display_name || m.username) : "unknown user";
|
||||
});
|
||||
return {
|
||||
emoji: r.emoji,
|
||||
count: r.count,
|
||||
users: usernames,
|
||||
reacted: r.users.includes(currentUserId),
|
||||
};
|
||||
})}
|
||||
onRefresh={() => {}}
|
||||
/>
|
||||
|
||||
{/* Kaomoji picker popover */}
|
||||
{activeReactionMessageId === message.id && (
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => onAddReaction(message.id, emoji)}
|
||||
onClose={() => setActiveReactionMessageId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MessageItem.displayName = "MessageItem";
|
||||
|
||||
export function ChatArea() {
|
||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||
@@ -155,6 +289,85 @@ export function ChatArea() {
|
||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
||||
const markRead = useReadStatesStore((s) => s.markRead);
|
||||
|
||||
const [showPinned, setShowPinned] = useState(false);
|
||||
const [activeReactionMessageId, setActiveReactionMessageId] = useState<string | null>(null);
|
||||
const [replyToMessage, setReplyToMessage] = useState<any | null>(null);
|
||||
const pinMessage = useMessageStore((s) => s.pinMessage);
|
||||
const unpinMessage = useMessageStore((s) => s.unpinMessage);
|
||||
const fetchPinnedMessages = useMessageStore((s) => s.fetchPinnedMessages);
|
||||
const pinnedMessages = useMessageStore((s) => activeChannelId ? s.pinnedMessagesByChannel[activeChannelId] || [] : []);
|
||||
const pinnedCount = pinnedMessages.length;
|
||||
|
||||
const { showMenu, MenuPortal } = useContextMenu();
|
||||
|
||||
const handleJumpToMessage = useCallback((messageId: string) => {
|
||||
const el = document.getElementById(`msg-${messageId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("terminal-mention");
|
||||
setTimeout(() => {
|
||||
el.classList.remove("terminal-mention");
|
||||
}, 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
||||
try {
|
||||
await api.post(`/messages/${messageId}/reactions`, { emoji });
|
||||
setActiveReactionMessageId(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add reaction");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMessageContextMenu = useCallback((e: React.MouseEvent, msg: any) => {
|
||||
if (selectMode) return;
|
||||
showMenu(e, [
|
||||
{
|
||||
label: "[REPLY]",
|
||||
onClick: () => {
|
||||
setReplyToMessage(msg);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "[ADD REACTION]",
|
||||
onClick: () => {
|
||||
setActiveReactionMessageId(msg.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: msg.pinned ? "[UNPIN MESSAGE]" : "[PIN MESSAGE]",
|
||||
onClick: async () => {
|
||||
try {
|
||||
if (msg.pinned) {
|
||||
await unpinMessage(msg.channel_id, msg.id);
|
||||
} else {
|
||||
await pinMessage(msg.channel_id, msg.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pin operation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
}, [selectMode, showMenu, pinMessage, unpinMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChannelId) {
|
||||
fetchPinnedMessages(activeChannelId);
|
||||
}
|
||||
}, [activeChannelId, fetchPinnedMessages]);
|
||||
|
||||
const handleToggleSelect = useCallback((messageId: string) => {
|
||||
if (activeChannelId) {
|
||||
toggleSelectedMessage(activeChannelId, messageId);
|
||||
}
|
||||
}, [activeChannelId, toggleSelectedMessage]);
|
||||
|
||||
const handleAuthorClick = useCallback((authorId: string) => {
|
||||
setProfileUserId(authorId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeChannelId) {
|
||||
fetchMessages(activeChannelId);
|
||||
@@ -237,20 +450,22 @@ export function ChatArea() {
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
}
|
||||
const cursor = inputEl.selectionStart ?? input.length;
|
||||
const beforeCursor = input.slice(0, cursor);
|
||||
// Read directly from DOM to avoid stale closure
|
||||
const currentValue = inputEl.value;
|
||||
const cursor = inputEl.selectionStart ?? currentValue.length;
|
||||
const beforeCursor = currentValue.slice(0, cursor);
|
||||
const atIndex = beforeCursor.lastIndexOf("@");
|
||||
if (atIndex === -1) return;
|
||||
const before = input.slice(0, atIndex);
|
||||
const after = input.slice(cursor);
|
||||
const before = currentValue.slice(0, atIndex);
|
||||
const after = currentValue.slice(cursor);
|
||||
const next = `${before}@${username} ${after}`;
|
||||
setInput(next);
|
||||
setMentionQuery(null);
|
||||
requestAnimationFrame(() => {
|
||||
const pos = atIndex + username.length + 2;
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(pos, pos);
|
||||
});
|
||||
// Also set the DOM value immediately so cursor positioning works
|
||||
inputEl.value = next;
|
||||
const pos = atIndex + username.length + 2;
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(pos, pos);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
@@ -258,8 +473,9 @@ export function ChatArea() {
|
||||
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
|
||||
setError(null);
|
||||
try {
|
||||
await sendMessage(activeChannelId, input.trim());
|
||||
await sendMessage(activeChannelId, input.trim(), replyToMessage?.id);
|
||||
setInput("");
|
||||
setReplyToMessage(null);
|
||||
setMentionQuery(null);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to send";
|
||||
@@ -322,29 +538,29 @@ export function ChatArea() {
|
||||
[SELECT]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSearch((prev) => !prev)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
||||
onClick={() => {
|
||||
setShowPinned(false);
|
||||
setShowSearch((prev) => !prev);
|
||||
}}
|
||||
className={`text-xs font-mono ${showSearch ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
title="Search messages"
|
||||
>
|
||||
[SEARCH]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSearch(false);
|
||||
setShowPinned((prev) => !prev);
|
||||
}}
|
||||
className={`text-xs font-mono ${showPinned ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
title="Show pinned messages"
|
||||
>
|
||||
[PINNED{pinnedCount > 0 ? `:${pinnedCount}` : ''}]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
<MessageSearch
|
||||
channelId={activeChannelId}
|
||||
channelName={activeChannel.name}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||
<ThreadListPanel
|
||||
channelId={activeChannelId}
|
||||
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
|
||||
onClose={() => setShowThreads(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
|
||||
<span>{selectedIds.size} selected</span>
|
||||
@@ -384,7 +600,30 @@ export function ChatArea() {
|
||||
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
<MessageSearch
|
||||
channelId={activeChannelId}
|
||||
channelName={activeChannel.name}
|
||||
onClose={() => setShowSearch(false)}
|
||||
onJump={handleJumpToMessage}
|
||||
/>
|
||||
)}
|
||||
{showPinned && activeChannelId && activeChannel && (
|
||||
<PinnedMessages
|
||||
channelId={activeChannelId}
|
||||
channelName={activeChannel.name}
|
||||
onClose={() => setShowPinned(false)}
|
||||
onJump={handleJumpToMessage}
|
||||
/>
|
||||
)}
|
||||
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||
<ThreadListPanel
|
||||
channelId={activeChannelId}
|
||||
onSelect={(t: { id: string; name: string }) => setActiveThread(t)}
|
||||
onClose={() => setShowThreads(false)}
|
||||
/>
|
||||
)}
|
||||
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
|
||||
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||
@@ -392,23 +631,23 @@ export function ChatArea() {
|
||||
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
|
||||
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
|
||||
>
|
||||
{selectMode && activeChannelId && (
|
||||
<span className="text-gb-fg-f mr-2">
|
||||
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
||||
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={() => setProfileUserId(message.author_id)}>
|
||||
<{message.author_username}>
|
||||
</span>{" "}
|
||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
</div>
|
||||
message={message}
|
||||
memberUsernames={memberUsernames}
|
||||
selectMode={selectMode}
|
||||
isSelected={selectedIds.has(message.id)}
|
||||
onAuthorClick={handleAuthorClick}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onContextMenu={handleMessageContextMenu}
|
||||
onAddReaction={handleAddReaction}
|
||||
currentUserId={currentUser?.id}
|
||||
activeReactionMessageId={activeReactionMessageId}
|
||||
setActiveReactionMessageId={setActiveReactionMessageId}
|
||||
members={members}
|
||||
parentMessage={message.reply_to ? messages.find((m) => m.id === message.reply_to) : null}
|
||||
onJumpToParent={handleJumpToMessage}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
@@ -417,13 +656,28 @@ export function ChatArea() {
|
||||
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : [];
|
||||
if (chTyping.length === 0) return null;
|
||||
const names = chTyping.map(u => u.username);
|
||||
const text = names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`;
|
||||
return <div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic">{text}</div>;
|
||||
})()}
|
||||
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono italic h-5 select-none">
|
||||
{(() => {
|
||||
const chTyping = activeChannelId ? (typingUsers[activeChannelId] || []).filter(u => u.userId !== currentUser?.id) : [];
|
||||
if (chTyping.length === 0) return "\u00A0";
|
||||
const names = chTyping.map(u => u.username);
|
||||
return names.length === 1 ? `${names[0]} is typing...` : names.length === 2 ? `${names[0]} and ${names[1]} are typing...` : `${names[0]} and ${names.length - 1} others are typing...`;
|
||||
})()}
|
||||
</div>
|
||||
{replyToMessage && (
|
||||
<div className="px-3 pb-1">
|
||||
<ReplyBar
|
||||
replyTo={{
|
||||
id: replyToMessage.id,
|
||||
content: replyToMessage.content,
|
||||
author: {
|
||||
username: replyToMessage.author_username,
|
||||
}
|
||||
}}
|
||||
onCancel={() => setReplyToMessage(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="px-3 pb-1">
|
||||
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
||||
@@ -470,6 +724,7 @@ export function ChatArea() {
|
||||
{profileUserId && (
|
||||
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
||||
)}
|
||||
{MenuPortal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user