added features and fixes

This commit is contained in:
2026-07-02 15:34:00 +00:00
parent eb5b7d55a4
commit 89f8381e2d
30 changed files with 1718 additions and 206 deletions
+8
View File
@@ -99,6 +99,14 @@ export function ChannelList() {
} else {
setGroups([]);
}
const handleRefresh = () => {
refreshGroups();
};
window.addEventListener('refresh-server-groups', handleRefresh);
return () => {
window.removeEventListener('refresh-server-groups', handleRefresh);
};
}, [activeServerId, fetchChannels]);
useEffect(() => {
+322 -67
View File
@@ -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);
}}>
&lt;{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}&gt;
</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)}>
&lt;{message.author_username}&gt;
</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>
);
}
+34 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useEffect, useRef, useState, useCallback, memo } from "react";
import { useParams } from "react-router-dom";
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
import { useAuthStore } from "../stores/auth.ts";
@@ -23,6 +23,19 @@ function renderDMContent(content: string) {
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" />,
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"
/>
);
},
}}
>
{content}
@@ -30,6 +43,17 @@ function renderDMContent(content: string) {
);
}
const DMMessageItem = memo(({ msg }: { msg: ConversationMessage }) => {
return (
<div className="break-words">
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
</div>
);
});
DMMessageItem.displayName = "DMMessageItem";
export function DMChat() {
const { conversationId } = useParams<{ conversationId: string }>();
const activeId = useConversationStore((s) => s.activeConversationId);
@@ -116,21 +140,18 @@ export function DMChat() {
{!id && <p className="text-gb-fg-f">[select a conversation]</p>}
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
{messages.map((msg: ConversationMessage) => (
<div key={msg.id} className="break-words">
<span className="text-gb-fg-f">[{formatTime(msg.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{msg.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
</div>
<DMMessageItem key={msg.id} msg={msg} />
))}
<div ref={bottomRef} />
</div>
{(() => {
const convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : [];
if (convTyping.length === 0) return null;
const names = convTyping.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 convTyping = id ? (typingUsers[id] || []).filter(u => u.userId !== currentUser?.id) : [];
if (convTyping.length === 0) return "\u00A0";
const names = convTyping.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>
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
+222 -26
View File
@@ -1,10 +1,127 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
const EMOJI_LIST = [
'👍', '❤️', '😂', '😮', '😢', '😡', '🎉', '🔥',
'👀', '💯', '✨', '🙏', '💀', '🤡', '🤔', '😎',
'🫡', '🫠', '🤯', '🥳', '😴', '🤮', '👻', '🎃',
'🚀', '⭐', '🌈', '🍕', '🍺', '☕', '🎮', '🎵',
interface KaomojiItem {
emoji: string;
tags: string[];
}
interface Category {
name: string;
tabLabel: string;
items: KaomojiItem[];
}
const KAOMOJI_CATEGORIES: Category[] = [
{
name: 'Bear (Kuma)',
tabLabel: 'Bear',
items: [
{ emoji: 'ʕ•ᴥ•ʔ', tags: ['bear', 'kuma', 'animal', 'happy', 'neutral', 'cute'] },
{ emoji: 'ʕっ•ᴥ•ʔっ', tags: ['bear', 'kuma', 'hug', 'love', 'happy', 'cute'] },
{ emoji: 'ʕ•ᴥ•ʔノ♡', tags: ['bear', 'kuma', 'love', 'wave', 'happy', 'cute'] },
{ emoji: 'ʕ º ᴥ º ʔ', tags: ['bear', 'kuma', 'surprised', 'shocked', 'gasp'] },
{ emoji: 'ʕ◕ᴥ◕ʔ', tags: ['bear', 'kuma', 'cute', 'happy', 'smile'] },
{ emoji: 'ʕ ; ᴥ ; ʔ', tags: ['bear', 'kuma', 'sad', 'crying', 'tear'] },
{ emoji: 'ʕಠᴥಠʔ', tags: ['bear', 'kuma', 'angry', 'mad', 'glare'] },
{ emoji: 'ʕ*ノᴥノʔ', tags: ['bear', 'kuma', 'shy', 'blush', 'cute'] },
{ emoji: 'ʕ⊙ᴥ⊙ʔ', tags: ['bear', 'kuma', 'shocked', 'surprised', 'gasp'] },
{ emoji: 'ʕ/•ᴥ•ʔ/', tags: ['bear', 'kuma', 'happy', 'celebrate', 'dance'] },
{ emoji: 'ʕ·ᴥ·ʔ', tags: ['bear', 'kuma', 'small', 'cute', 'neutral'] },
{ emoji: '⊂( ̄(エ) ̄)⊃', tags: ['bear', 'kuma', 'big', 'hug', 'animal'] },
{ emoji: '(● ̄(エ) ̄●)', tags: ['bear', 'kuma', 'chubby', 'cute', 'neutral'] },
{ emoji: 'ʕ •ᴥ•ʔゝ☆', tags: ['bear', 'kuma', 'magic', 'star', 'happy'] },
{ emoji: 'ʕ•ᴥ•ʔ/', tags: ['bear', 'kuma', 'wave', 'hello', 'happy'] },
{ emoji: 'ʕっ•ᴥ•ʔ', tags: ['bear', 'kuma', 'reach', 'cute'] },
{ emoji: 'ʕ ⊃・ ェ ・ ʔ⊃', tags: ['bear', 'kuma', 'hug', 'reach'] },
{ emoji: 'ʕ ꈍ ᴥ ꈍ ʔ', tags: ['bear', 'kuma', 'shy', 'happy', 'smug'] },
{ emoji: 'ʕ ·(ェ)· ʔ', tags: ['bear', 'kuma', 'classic', 'neutral'] },
]
},
{
name: 'Happy / Laughing',
tabLabel: 'Joy',
items: [
{ emoji: '(^_^)', tags: ['happy', 'smile', 'classic', 'joy'] },
{ emoji: '(^o^)', tags: ['happy', 'laugh', 'excited', 'joy'] },
{ emoji: '(*^ω^*)', tags: ['happy', 'cute', 'smile', 'blush'] },
{ emoji: '(≧▽≦)', tags: ['happy', 'laugh', 'excited', 'joy', 'grin'] },
{ emoji: '(✿◠‿◠)', tags: ['happy', 'flower', 'cute', 'smile', 'peace'] },
{ emoji: '(•‿•)', tags: ['happy', 'smile', 'cute'] },
{ emoji: '(o^^o)', tags: ['happy', 'cute', 'blush', 'smile'] },
{ emoji: '( ‘‿’ )', tags: ['happy', 'smile', 'cute'] },
{ emoji: '(〇‿〇)', tags: ['happy', 'celebrate', 'excited', 'dance'] },
{ emoji: '(◕‿◕)', tags: ['happy', 'smile', 'cute'] },
{ emoji: '(>ω<)', tags: ['happy', 'excited', 'cute', 'joy'] },
{ emoji: '(★ω★)', tags: ['happy', 'excited', 'star', 'sparkle', 'eyes'] },
{ emoji: '(*^.^*)', tags: ['happy', 'smile', 'cute', 'shy'] },
]
},
{
name: 'Sad / Crying',
tabLabel: 'Sad',
items: [
{ emoji: '(T_T)', tags: ['sad', 'crying', 'tear', 'classic'] },
{ emoji: '(ಥ﹏ಥ)', tags: ['sad', 'crying', 'tear', 'cute', 'pity'] },
{ emoji: '(;_;)', tags: ['sad', 'crying', 'tear'] },
{ emoji: '(._.)', tags: ['sad', 'depressed', 'down', 'neutral'] },
{ emoji: '(._. )', tags: ['sad', 'depressed', 'down', 'shy'] },
{ emoji: '(╥﹏╥)', tags: ['sad', 'crying', 'tear', 'heavy'] },
{ emoji: '((>_<))', tags: ['sad', 'worried', 'frustrated', 'nervous'] },
{ emoji: '(#>_<)', tags: ['sad', 'frustrated', 'mad', 'angry'] },
{ emoji: '(;;)', tags: ['sad', 'crying', 'tear'] },
]
},
{
name: 'Angry / Glaring',
tabLabel: 'Mad',
items: [
{ emoji: '(ಠ_ಠ)', tags: ['angry', 'mad', 'glare', 'disapproval', 'look'] },
{ emoji: '(╯°□°)╯︵ ┻━┻', tags: ['angry', 'mad', 'table', 'flip', 'rage'] },
{ emoji: '┬─┬ノ( º _ ºノ)', tags: ['neutral', 'table', 'put', 'back', 'calm'] },
{ emoji: '(#`д´)ノ', tags: ['angry', 'mad', 'yell', 'shake'] },
{ emoji: '(¬_¬)', tags: ['suspicious', 'glare', 'annoyed', 'side-eye'] },
{ emoji: '( ̄︿ ̄)', tags: ['annoyed', 'disappointed', 'unhappy'] },
{ emoji: '(╬ಠ益ಠ)', tags: ['angry', 'mad', 'rage', 'furious'] },
{ emoji: '(凸ಠ益ಠ)凸', tags: ['angry', 'mad', 'middle', 'finger', 'rage'] },
]
},
{
name: 'Shy / Cute',
tabLabel: 'Shy',
items: [
{ emoji: '(*n_n*)', tags: ['shy', 'blush', 'cute', 'smile'] },
{ emoji: '(*ノ∀`*)', tags: ['shy', 'blush', 'embarrassed', 'cute'] },
{ emoji: '(ノωヽ)', tags: ['shy', 'blush', 'embarrassed', 'hide', 'cute'] },
{ emoji: '( ꈍ ꈍ )', tags: ['cute', 'shy', 'blush', 'happy'] },
{ emoji: '( ⁄•⁄ω⁄•⁄ )', tags: ['cute', 'shy', 'blush', 'super', 'embarrassed'] },
{ emoji: '(❤ω❤)', tags: ['love', 'cute', 'excited', 'heart'] },
]
},
{
name: 'Cats & Animals',
tabLabel: 'Pets',
items: [
{ emoji: '(=^·^=)', tags: ['cat', 'animal', 'cute', 'happy'] },
{ emoji: '(=^·ェ·^=)', tags: ['cat', 'animal', 'cute'] },
{ emoji: '(=^ ◡ ^=)', tags: ['cat', 'animal', 'happy', 'smile'] },
{ emoji: '(=^·^=)ノ', tags: ['cat', 'animal', 'wave', 'hello'] },
{ emoji: '(◕ᴥ◕)', tags: ['animal', 'cute', 'seal'] },
{ emoji: '(=^ ᆺ ^=)', tags: ['cat', 'animal', 'cute'] },
]
},
{
name: 'Action & Funny',
tabLabel: 'Misc',
items: [
{ emoji: '¯\\_(ツ)_/¯', tags: ['funny', 'shrug', 'meh', 'whatever'] },
{ emoji: '( ͡° ͜ʖ ͡°)', tags: ['funny', 'lenny', 'smug', 'face'] },
{ emoji: '(ง •̀_•́)ง', tags: ['action', 'fight', 'determined', 'fist'] },
{ emoji: '(-)(._.)', tags: ['action', 'pat', 'comfort', 'friend'] },
{ emoji: '(/°▽°)/', tags: ['action', 'happy', 'throw', 'hands'] },
{ emoji: '(°ロ°)', tags: ['surprised', 'shocked', 'gasp'] },
{ emoji: 'ヽ(°〇°)ノ', tags: ['surprised', 'shocked', 'amaze'] },
]
}
];
interface EmojiPickerProps {
@@ -14,41 +131,120 @@ interface EmojiPickerProps {
export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState('All');
const filtered = search
? EMOJI_LIST.filter(e => e.includes(search))
: EMOJI_LIST;
// Search logic (checks emoji or tags matching)
const searchResults = useMemo(() => {
if (!search) return [];
const query = search.toLowerCase().trim();
const results: KaomojiItem[] = [];
const seen = new Set<string>();
for (const cat of KAOMOJI_CATEGORIES) {
for (const item of cat.items) {
if (seen.has(item.emoji)) continue;
const matchesEmoji = item.emoji.includes(query);
const matchesTags = item.tags.some(tag => tag.includes(query));
if (matchesEmoji || matchesTags) {
results.push(item);
seen.add(item.emoji);
}
}
}
return results;
}, [search]);
// Tab filter logic
const displayedItems = useMemo(() => {
if (activeTab === 'All') {
const all: KaomojiItem[] = [];
const seen = new Set<string>();
for (const cat of KAOMOJI_CATEGORIES) {
for (const item of cat.items) {
if (!seen.has(item.emoji)) {
all.push(item);
seen.add(item.emoji);
}
}
}
return all;
}
const cat = KAOMOJI_CATEGORIES.find(c => c.tabLabel === activeTab);
return cat ? cat.items : [];
}, [activeTab]);
return (
<div className="absolute bottom-full left-0 mb-1 bg-gb-bg border border-gb-bg-t p-2 min-w-[200px] z-50">
<div className="absolute bottom-full left-0 mb-1 bg-gb-bg border border-gb-bg-t p-2 w-[340px] max-h-[340px] overflow-y-auto z-50 shadow-lg font-mono">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gb-fg-f font-mono">EMOJI</span>
<button onClick={onClose} className="text-xs text-gb-red font-mono">[x]</button>
<span className="text-xs text-gb-fg-f">KAOMOJI PICKER</span>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange font-mono">[x]</button>
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="w-full px-2 py-1 mb-2 text-xs font-mono bg-gb-bg-s text-gb-fg border-none outline-none"
placeholder="search (happy, bear, sad, table...)"
className="w-full px-2 py-1 mb-2 text-xs bg-gb-bg-s text-gb-fg border border-gb-bg-t outline-none focus:border-gb-orange"
autoFocus
/>
<div className="grid grid-cols-8 gap-1">
{filtered.map((emoji) => (
{/* Tabs list (only shown if not searching) */}
{!search && (
<div className="flex flex-wrap gap-1 mb-2 border-b border-gb-bg-t pb-1.5 text-[10px]">
<button
key={emoji}
onClick={() => {
onSelect(emoji);
onClose();
}}
className="w-6 h-6 flex items-center justify-center text-sm hover:bg-gb-bg-s transition-colors"
onClick={() => setActiveTab('All')}
className={`px-1.5 py-0.5 border ${activeTab === 'All' ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg'}`}
>
{emoji}
[All]
</button>
))}
{KAOMOJI_CATEGORIES.map(cat => (
<button
key={cat.tabLabel}
onClick={() => setActiveTab(cat.tabLabel)}
className={`px-1.5 py-0.5 border ${activeTab === cat.tabLabel ? 'border-gb-orange text-gb-orange bg-gb-bg-s' : 'border-transparent text-gb-fg-f hover:text-gb-fg'}`}
>
[{cat.tabLabel}]
</button>
))}
</div>
)}
{/* Emoticons grid */}
<div className="flex flex-wrap gap-1.5 max-h-56 overflow-y-auto">
{search ? (
searchResults.map((item) => (
<button
key={item.emoji}
onClick={() => {
onSelect(item.emoji);
onClose();
}}
className="px-2 py-1 text-xs bg-gb-bg-s text-gb-fg border border-gb-bg-t hover:border-gb-orange hover:text-gb-orange transition-colors"
title={item.tags.join(', ')}
>
{item.emoji}
</button>
))
) : (
displayedItems.map((item) => (
<button
key={item.emoji}
onClick={() => {
onSelect(item.emoji);
onClose();
}}
className="px-2 py-1 text-xs bg-gb-bg-s text-gb-fg border border-gb-bg-t hover:border-gb-orange hover:text-gb-orange transition-colors"
title={item.tags.join(', ')}
>
{item.emoji}
</button>
))
)}
</div>
{filtered.length === 0 && (
<div className="text-xs text-gb-fg-f font-mono text-center py-2">[no results]</div>
{search && searchResults.length === 0 && (
<div className="text-xs text-gb-fg-f text-center py-4">[no matches found]</div>
)}
</div>
);
+6 -6
View File
@@ -97,7 +97,7 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
);
return (
<div className="border border-gb-bg-t bg-gb-bg rounded font-mono text-sm flex flex-col max-h-80 w-full">
<div className="border border-gb-bg-t bg-gb-bg rounded font-mono text-sm flex flex-col h-72 w-full">
{/* Header with search */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-gb-bg-t">
<span className="text-gb-fg-f select-none">{'>'} search:</span>
@@ -120,7 +120,7 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
</div>
{/* Results grid */}
<div className="overflow-y-auto p-2">
<div className="flex-1 overflow-y-auto p-2">
{loading && (
<p className="text-gb-fg-f text-center py-2">[loading...]</p>
)}
@@ -128,18 +128,18 @@ export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) {
<p className="text-gb-fg-f text-center py-2">[no results]</p>
)}
{!loading && results.length > 0 && (
<div className="grid grid-cols-3 gap-1">
<div className="columns-3 gap-1 space-y-1">
{results.map((gif) => (
<button
key={gif.id}
type="button"
onClick={() => handleClick(gif)}
className="cursor-pointer border border-transparent hover:border-gb-orange rounded overflow-hidden focus:outline-none focus:border-gb-orange"
className="cursor-pointer border border-transparent hover:border-gb-orange rounded overflow-hidden focus:outline-none focus:border-gb-orange w-full break-inside-avoid block"
>
<img
src={gif.images.fixed_height.url}
src={`/api/v1/gifs/proxy?url=${encodeURIComponent(gif.images.fixed_height.url)}`}
alt={gif.title || 'GIF'}
className="w-full h-auto object-cover"
className="w-full h-auto block"
loading="lazy"
/>
</button>
+32 -10
View File
@@ -8,6 +8,7 @@ import { MemberContextMenu } from "./MemberContextMenu.tsx";
import { useConversationStore } from "../stores/conversation.ts";
import { useLayoutStore } from "../stores/layout.ts";
import { useNavigate } from "react-router-dom";
import { UserProfileModal } from "./UserProfileModal.tsx";
function statusIcon(status: UserStatus): string {
switch (status) {
@@ -48,7 +49,12 @@ function usernameColor(status: UserStatus): string {
}
}
function MemberRow({ member }: { member: Member }) {
interface MemberRowProps {
member: Member;
onProfileClick: (userId: string) => void;
}
function MemberRow({ member, onProfileClick }: MemberRowProps) {
const presence = usePresenceStore((s) => s.presences[member.id]);
const status = presence?.status ?? member.status;
const [menuOpen, setMenuOpen] = useState(false);
@@ -67,16 +73,27 @@ function MemberRow({ member }: { member: Member }) {
return (
<div className="relative">
<button
onClick={() => setMenuOpen((v) => !v)}
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t"
<div
onClick={() => onProfileClick(member.id)}
className="w-full flex items-center gap-2 px-2 py-1 text-left hover:bg-gb-bg-t cursor-pointer group"
>
<span className={statusColor(status)}>{statusIcon(status)}</span>
<span className={`truncate ${usernameColor(status)}`}>
{member.display_name || member.username}
<span className={`truncate ${usernameColor(status)} flex-1`}>
{member.nickname || member.display_name || member.username}
</span>
<span className="ml-auto text-gb-fg-f text-xs">[...]</span>
</button>
{!isSelf && (
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="text-gb-fg-f hover:text-gb-orange font-mono text-xs px-1 hover:bg-gb-bg-s rounded shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
title="Member options"
>
[...]
</button>
)}
</div>
{menuOpen && !isSelf && activeServerId && (
<MemberContextMenu
memberId={member.id}
@@ -128,6 +145,8 @@ export function MemberList() {
const fetchMembers = useMemberStore((s) => s.fetchMembers);
const presences = usePresenceStore((s) => s.presences);
const [profileUserId, setProfileUserId] = useState<string | null>(null);
const members = activeServerId
? membersByServer[activeServerId] || []
: [];
@@ -164,7 +183,7 @@ export function MemberList() {
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{online.map((m) => (
<MemberRow key={m.id} member={m} />
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))}
</div>
)}
@@ -173,11 +192,14 @@ export function MemberList() {
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{offline.map((m) => (
<MemberRow key={m.id} member={m} />
<MemberRow key={m.id} member={m} onProfileClick={setProfileUserId} />
))}
</div>
)}
</div>
{profileUserId && (
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)}
</div>
);
}
+13 -7
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useRoleStore, type Role } from '../stores/role.ts';
import type { User } from '../stores/auth.ts';
import { usePermissions } from '../lib/usePermissions.ts';
interface MemberRoleAssignProps {
serverId: string;
@@ -14,6 +15,9 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
const setMemberRoles = useRoleStore((s) => s.setMemberRoles);
const getMemberRoles = useRoleStore((s) => s.getMemberRoles);
const { isOwner, canManageRoles } = usePermissions(serverId);
const canEdit = isOwner || canManageRoles;
const [open, setOpen] = useState(false);
const [memberRoles, setMemberRolesState] = useState<Role[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -73,13 +77,15 @@ export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
{r.name}
</span>
))}
<button
type="button"
onClick={() => setOpen(true)}
className="terminal-button text-xs"
>
[ROLES]
</button>
{canEdit && (
<button
type="button"
onClick={() => setOpen(true)}
className="terminal-button text-xs"
>
[ROLES]
</button>
)}
</div>
{/* Modal */}
+4 -1
View File
@@ -27,7 +27,10 @@ export function MentionDropdown({ query, members, onSelect }: MentionDropdownPro
<button
key={m.id}
type="button"
onClick={() => onSelect(m.username)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(m.username);
}}
className="w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors"
>
<span className="text-gb-green"></span>
+1 -1
View File
@@ -56,7 +56,7 @@ export function MessageSearch({ channelId, channelName, onClose, onJump }: Messa
}, [results, selectedIndex, onClose, onJump]);
return (
<div className="absolute inset-x-0 top-10 z-20 bg-gb-bg border border-gb-bg-t shadow-lg mx-3">
<div className="absolute inset-x-0 top-0 z-20 bg-gb-bg border-b border-gb-bg-t shadow-lg">
<div className="px-3 py-2 border-b border-gb-bg-t flex items-center justify-between">
<span className="text-xs text-gb-orange font-mono">SEARCH #{channelName.toUpperCase()}</span>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
+89
View File
@@ -0,0 +1,89 @@
import { useEffect } from "react";
import { useMessageStore } from "../stores/message.ts";
interface PinnedMessagesProps {
channelId: string;
channelName: string;
onClose: () => void;
onJump?: (messageId: string) => void;
}
export function PinnedMessages({ channelId, channelName, onClose, onJump }: PinnedMessagesProps) {
const pinnedMessages = useMessageStore((s) => s.pinnedMessagesByChannel[channelId] || []);
const fetchPinnedMessages = useMessageStore((s) => s.fetchPinnedMessages);
const unpinMessage = useMessageStore((s) => s.unpinMessage);
useEffect(() => {
fetchPinnedMessages(channelId);
}, [channelId, fetchPinnedMessages]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return (
<div className="absolute inset-x-0 top-0 z-20 bg-gb-bg border-b border-gb-bg-t shadow-lg">
<div className="px-3 py-2 border-b border-gb-bg-t flex items-center justify-between">
<span className="text-xs text-gb-orange font-mono">PINNED IN #{channelName.toUpperCase()} ({pinnedMessages.length}/5)</span>
<button onClick={onClose} className="text-xs text-gb-red hover:text-gb-orange">[x]</button>
</div>
<div className="max-h-64 overflow-y-auto font-mono text-xs">
{pinnedMessages.length === 0 ? (
<div className="px-3 py-4 text-center text-gb-fg-f">[no pinned messages in this channel]</div>
) : (
pinnedMessages.map((msg) => (
<div
key={msg.id}
className="w-full text-left px-3 py-2 border-b border-gb-bg-t last:border-b-0 hover:bg-gb-bg-s/20 flex flex-col gap-1"
>
<div className="flex items-center justify-between text-gb-fg-f">
<div className="flex items-center gap-2">
<span className="text-gb-aqua font-bold">&lt;{msg.author_username}&gt;</span>
<span className="text-[10px] text-gb-fg-f">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-2">
{onJump && (
<button
onClick={() => {
onJump(msg.id);
onClose();
}}
className="text-gb-aqua hover:text-gb-orange"
title="Jump to message"
>
[JUMP]
</button>
)}
<button
onClick={async () => {
if (confirm("Unpin this message?")) {
try {
await unpinMessage(channelId, msg.id);
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to unpin");
}
}
}}
className="text-gb-red hover:text-gb-orange"
title="Unpin message"
>
[UNPIN]
</button>
</div>
</div>
<div className="text-gb-fg break-words whitespace-pre-wrap">{msg.content}</div>
</div>
))
)}
</div>
</div>
);
}
+3 -2
View File
@@ -257,8 +257,9 @@ function RoleForm({ serverId, role, onClose }: RoleFormProps) {
);
}
export function RoleManager() {
const { serverId } = useParams<{ serverId: string }>();
export function RoleManager({ serverId: propServerId }: { serverId?: string } = {}) {
const { serverId: paramServerId } = useParams<{ serverId: string }>();
const serverId = propServerId || paramServerId;
const roles = useRoleStore((s) => s.roles);
const loading = useRoleStore((s) => s.loading);
const error = useRoleStore((s) => s.error);
+227 -15
View File
@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react';
import { api } from '../lib/api.ts';
import { useServerStore } from '../stores/server.ts';
import { usePermissions } from '../lib/usePermissions.ts';
import { RoleManager } from './RoleManager.tsx';
interface ServerGroup {
id: string;
server_id: string;
name: string;
position: number;
}
interface ServerSettingsModalProps {
serverId: string;
@@ -47,13 +56,15 @@ const ACTION_LABELS: Record<string, string> = {
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) {
const [tab, setTab] = useState<'audit' | 'availability'>('audit');
const [tab, setTab] = useState<'audit' | 'availability' | 'roles' | 'groups'>('audit');
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const servers = useServerStore((s) => s.servers);
const server = servers.find((s) => s.id === serverId);
const { isOwner, canManageRoles, canManageChannels, canManageServer } = usePermissions(serverId);
// Availability state
const [slots, setSlots] = useState<AvailabilitySlot[]>([]);
const [availLoading, setAvailLoading] = useState(false);
@@ -62,11 +73,23 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
const [editStart, setEditStart] = useState('09:00');
const [editEnd, setEditEnd] = useState('17:00');
// Groups state
const [groups, setGroups] = useState<ServerGroup[]>([]);
const [groupName, setGroupName] = useState('');
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editingGroupName, setEditingGroupName] = useState('');
const [groupsLoading, setGroupsLoading] = useState(false);
const handleClose = () => {
window.dispatchEvent(new CustomEvent('refresh-server-groups'));
onClose();
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown);
@@ -74,7 +97,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
}, [onClose]);
useEffect(() => {
if (tab === 'audit') {
if (tab === 'audit' && (isOwner || canManageServer)) {
setLoading(true);
setError(null);
api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`)
@@ -87,8 +110,15 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
.then((data) => setSlots(Array.isArray(data) ? data : []))
.catch(() => {})
.finally(() => setAvailLoading(false));
} else if (tab === 'groups' && (isOwner || canManageChannels)) {
setGroupsLoading(true);
setError(null);
api.get<ServerGroup[]>(`/servers/${serverId}/groups`)
.then((data) => setGroups(Array.isArray(data) ? data : []))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load groups'))
.finally(() => setGroupsLoading(false));
}
}, [tab, serverId]);
}, [tab, serverId, isOwner, canManageServer, canManageChannels]);
const formatTime = (iso: string) => {
const d = new Date(iso);
@@ -116,39 +146,116 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
const slotsForDay = (day: string) => slots.filter((s) => s.day_of_week === day);
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
if (!groupName.trim()) return;
setError(null);
try {
await api.post(`/servers/${serverId}/groups`, { name: groupName.trim() });
setGroupName('');
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
}
};
const handleRenameGroup = async (groupId: string, name: string) => {
if (!name.trim()) return;
setError(null);
try {
await api.patch(`/servers/${serverId}/groups/${groupId}`, { name: name.trim() });
setEditingGroupId(null);
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename group');
}
};
const handleDeleteGroup = async (groupId: string) => {
if (!confirm('Are you sure you want to delete this group? Channels inside will be moved to ungrouped.')) return;
setError(null);
try {
await api.delete(`/servers/${serverId}/groups/${groupId}`);
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete group');
}
};
const handleMoveGroup = async (groupId: string, direction: 'up' | 'down') => {
const idx = groups.findIndex((g) => g.id === groupId);
if (idx === -1) return;
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= groups.length) return;
setError(null);
const g1 = groups[idx];
const g2 = groups[targetIdx];
try {
await api.patch(`/servers/${serverId}/groups/${g1.id}`, { position: g2.position });
await api.patch(`/servers/${serverId}/groups/${g2.id}`, { position: g1.position });
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reorder group');
}
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
onClick={handleClose}
>
<div
className="bg-gb-bg border border-gb-bg-t w-[700px] max-h-[80vh] flex flex-col font-mono"
className="bg-gb-bg border border-gb-bg-t w-[750px] max-h-[85vh] flex flex-col font-mono"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
<span className="text-sm text-gb-orange">SERVER SETTINGS: {server?.name ?? serverId}</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
<button onClick={handleClose} className="text-xs text-gb-red">[x]</button>
</div>
<div className="flex border-b border-gb-bg-t">
<button
onClick={() => setTab('audit')}
className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[AUDIT LOG]
</button>
{(isOwner || canManageServer) && (
<button
onClick={() => setTab('audit')}
className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[AUDIT LOG]
</button>
)}
<button
onClick={() => setTab('availability')}
className={`px-4 py-2 text-xs ${tab === 'availability' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[AVAILABILITY]
</button>
{(isOwner || canManageRoles) && (
<button
onClick={() => setTab('roles')}
className={`px-4 py-2 text-xs ${tab === 'roles' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[ROLES]
</button>
)}
{(isOwner || canManageChannels) && (
<button
onClick={() => setTab('groups')}
className={`px-4 py-2 text-xs ${tab === 'groups' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[GROUPS]
</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
{error && <p className="text-gb-red text-xs mb-3">ERR: {error}</p>}
{tab === 'audit' && (
<>
{loading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
{error && <p className="text-gb-red text-xs">ERR: {error}</p>}
{!loading && !error && entries.length === 0 && (
{!loading && entries.length === 0 && (
<p className="text-gb-fg-f text-xs">[no audit log entries]</p>
)}
<div className="space-y-2">
@@ -173,6 +280,7 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
</div>
</>
)}
{tab === 'availability' && (
<div className="space-y-4">
{availLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
@@ -232,6 +340,110 @@ export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalPr
</div>
</div>
)}
{tab === 'roles' && (
<div className="h-[60vh] overflow-y-auto">
<RoleManager serverId={serverId} />
</div>
)}
{tab === 'groups' && (
<div className="space-y-4">
<p className="text-xs text-gb-fg-s">manage channel groups for layout organization</p>
{/* Create group form */}
<form onSubmit={handleCreateGroup} className="flex gap-2 items-center">
<input
type="text"
placeholder="group name..."
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="terminal-input text-xs px-2 py-1 flex-1"
/>
<button type="submit" className="px-3 py-1 bg-gb-orange text-gb-bg text-xs">[CREATE GROUP]</button>
</form>
{groupsLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
{!groupsLoading && groups.length === 0 && (
<p className="text-gb-fg-f text-xs">[no groups created yet]</p>
)}
<div className="space-y-2 mt-2">
{groups.map((g, index) => {
const isEditing = editingGroupId === g.id;
return (
<div key={g.id} className="flex items-center justify-between border border-gb-bg-t p-2 bg-gb-bg-s text-xs">
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<input
autoFocus
value={editingGroupName}
onChange={(e) => setEditingGroupName(e.target.value)}
className="bg-gb-bg-t text-gb-fg px-1 py-0.5 text-xs outline-none border border-gb-orange flex-1"
/>
<button
type="button"
onClick={() => handleRenameGroup(g.id, editingGroupName)}
className="text-gb-green hover:underline font-bold"
>
[SAVE]
</button>
<button
type="button"
onClick={() => setEditingGroupId(null)}
className="text-gb-fg-f hover:underline"
>
[CANCEL]
</button>
</div>
) : (
<>
<span className="text-gb-fg-s font-bold uppercase">{g.name}</span>
<div className="flex items-center gap-2">
<button
type="button"
disabled={index === 0}
onClick={() => handleMoveGroup(g.id, 'up')}
className="text-gb-fg-f hover:text-gb-orange disabled:opacity-30"
title="Move Up"
>
</button>
<button
type="button"
disabled={index === groups.length - 1}
onClick={() => handleMoveGroup(g.id, 'down')}
className="text-gb-fg-f hover:text-gb-orange disabled:opacity-30"
title="Move Down"
>
</button>
<button
type="button"
onClick={() => {
setEditingGroupId(g.id);
setEditingGroupName(g.name);
}}
className="text-gb-aqua hover:underline"
>
[RENAME]
</button>
<button
type="button"
onClick={() => handleDeleteGroup(g.id)}
className="text-gb-red hover:underline"
>
[DELETE]
</button>
</div>
</>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
+90 -1
View File
@@ -1,5 +1,9 @@
import { useEffect, useState } from 'react';
import { useAuthStore, type PublicProfile } from '../stores/auth.ts';
import { useServerStore } from '../stores/server.ts';
import { useMemberStore } from '../stores/member.ts';
import { usePermissions } from '../lib/usePermissions.ts';
import { MemberRoleAssign } from './MemberRoleAssign.tsx';
interface UserProfileModalProps {
userId: string;
@@ -9,9 +13,44 @@ interface UserProfileModalProps {
export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
const getPublicProfile = useAuthStore((s) => s.getPublicProfile);
const blockUser = useAuthStore((s) => s.blockUser);
const currentUser = useAuthStore((s) => s.user);
const activeServerId = useServerStore((s) => s.activeServerId);
const [profile, setProfile] = useState<PublicProfile | null>(null);
const [error, setError] = useState<string | null>(null);
const isSelf = currentUser?.id === userId;
const { isOwner, canManageServer, canChangeNickname, canManageNicknames } = usePermissions(activeServerId);
const canEditNickname = isOwner || canManageServer || (isSelf ? canChangeNickname : canManageNicknames);
const membersByServer = useMemberStore((s) => s.membersByServer);
const serverMembers = activeServerId ? membersByServer[activeServerId] || [] : [];
const memberData = serverMembers.find((m) => m.id === userId);
const currentNickname = memberData?.nickname || '';
const setMemberNickname = useMemberStore((s) => s.setMemberNickname);
const [nickname, setNickname] = useState(currentNickname);
const [isEditingNickname, setIsEditingNickname] = useState(false);
const [savingNickname, setSavingNickname] = useState(false);
useEffect(() => {
setNickname(currentNickname);
}, [currentNickname]);
const handleSaveNickname = async () => {
if (!activeServerId) return;
setSavingNickname(true);
setError(null);
try {
await setMemberNickname(activeServerId, userId, nickname.trim());
setIsEditingNickname(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save nickname');
} finally {
setSavingNickname(false);
}
};
useEffect(() => {
getPublicProfile(userId).then(setProfile).catch(setError);
}, [userId, getPublicProfile]);
@@ -40,10 +79,54 @@ export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
<div className="mt-12">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${statusColor}`} />
<span className="text-gb-fg font-bold">{profile.display_name || profile.username}</span>
<span className="text-gb-fg font-bold">{currentNickname || profile.display_name || profile.username}</span>
</div>
<div className="text-gb-fg-s text-xs">@{profile.username}</div>
{profile.pronouns && <div className="text-gb-fg-t text-[10px]">{profile.pronouns}</div>}
{activeServerId && (
<div className="text-[10px] font-mono mt-1 flex items-center gap-1.5">
<span className="text-gb-fg-t">NICKNAME:</span>
{isEditingNickname ? (
<div className="flex items-center gap-1">
<input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="no nickname"
className="bg-gb-bg-t text-gb-fg px-1 py-0.5 text-[10px] outline-none border border-gb-orange max-w-[120px]"
/>
<button
onClick={handleSaveNickname}
disabled={savingNickname}
className="text-gb-green hover:underline font-bold"
>
[SAVE]
</button>
<button
onClick={() => {
setNickname(currentNickname);
setIsEditingNickname(false);
}}
className="text-gb-fg-f hover:underline"
>
[X]
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="text-gb-fg-s">{currentNickname || '[NONE]'}</span>
{canEditNickname && (
<button
onClick={() => setIsEditingNickname(true)}
className="text-gb-aqua hover:underline"
>
[EDIT]
</button>
)}
</div>
)}
</div>
)}
</div>
</div>
{profile.tagline && <div className="text-gb-fg text-sm mb-2 italic">"{profile.tagline}"</div>}
@@ -67,6 +150,12 @@ export function UserProfileModal({ userId, onClose }: UserProfileModalProps) {
))}
</div>
)}
{activeServerId && (
<div className="mb-4 mt-2">
<div className="text-xs text-gb-fg-t font-mono mb-1">{'>'} ROLES:</div>
<MemberRoleAssign serverId={activeServerId} member={profile as any} />
</div>
)}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[CLOSE]</button>
<button
+7
View File
@@ -15,6 +15,9 @@ const PERMS = {
CONNECT_VOICE: 256,
SPEAK_VOICE: 512,
SHARE_SCREEN: 1024,
CHANGE_NICKNAME: 8192,
MANAGE_NICKNAMES: 16384,
MANAGE_ROLES: 32768,
} as const;
export { PERMS };
@@ -45,6 +48,10 @@ export function usePermissions(serverId: string | null) {
canBan: has(PERMS.BAN_MEMBERS),
canMute: has(PERMS.MANAGE_MESSAGES),
canManageChannels: has(PERMS.MANAGE_CHANNELS),
canManageRoles: has(PERMS.MANAGE_ROLES),
canManageServer: has(PERMS.MANAGE_SERVER),
canChangeNickname: has(PERMS.CHANGE_NICKNAME),
canManageNicknames: has(PERMS.MANAGE_NICKNAMES),
isOwner,
};
}
+15
View File
@@ -5,6 +5,7 @@ export interface Member {
id: string;
username: string;
display_name: string;
nickname?: string;
avatar: string;
status: "online" | "idle" | "dnd" | "offline";
status_text: string;
@@ -15,6 +16,7 @@ interface MemberState {
isLoading: boolean;
fetchMembers: (serverId: string) => Promise<void>;
updateMemberStatus: (userId: string, status: Member["status"]) => void;
setMemberNickname: (serverId: string, userId: string, nickname: string) => Promise<void>;
}
export const useMemberStore = create<MemberState>((set) => ({
@@ -46,4 +48,17 @@ export const useMemberStore = create<MemberState>((set) => ({
}
return { membersByServer: next };
}),
setMemberNickname: async (serverId, userId, nickname) => {
await api.patch(`/servers/${serverId}/members/${userId}`, { nickname });
set((state) => {
const list = state.membersByServer[serverId] || [];
const updated = list.map((m) =>
m.id === userId ? { ...m, nickname: nickname || undefined } : m
);
return {
membersByServer: { ...state.membersByServer, [serverId]: updated },
};
});
},
}));
+116 -3
View File
@@ -10,6 +10,12 @@ export interface MessageEmbed {
site_name?: string;
}
export interface Reaction {
emoji: string;
count: number;
users: string[];
}
export interface Message {
id: string;
channel_id: string;
@@ -19,6 +25,8 @@ export interface Message {
content: string;
reply_to?: string | null;
embeds?: MessageEmbed[];
pinned: boolean;
reactions?: Reaction[];
created_at: string;
edited_at: string | null;
}
@@ -29,6 +37,7 @@ export interface SearchResultMessage extends Message {
export interface MessageState {
messagesByChannel: Record<string, Message[]>;
pinnedMessagesByChannel: Record<string, Message[]>;
searchResultsByChannel: Record<string, SearchResultMessage[]>;
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
isLoading: boolean;
@@ -39,9 +48,14 @@ export interface MessageState {
fetchOlderMessages: (channelId: string) => Promise<void>;
sendMessage: (channelId: string, content: string, replyTo?: string) => Promise<Message>;
searchMessages: (channelId: string, query: string) => Promise<SearchResultMessage[]>;
pinMessage: (channelId: string, messageId: string) => Promise<void>;
unpinMessage: (channelId: string, messageId: string) => Promise<void>;
fetchPinnedMessages: (channelId: string) => Promise<Message[]>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (channelId: string, messageId: string) => void;
addReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void;
removeReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void;
bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>;
toggleSelectedMessage: (channelId: string, messageId: string) => void;
clearSelectedMessages: (channelId: string) => void;
@@ -49,6 +63,7 @@ export interface MessageState {
export const useMessageStore = create<MessageState>((set, get) => ({
messagesByChannel: {},
pinnedMessagesByChannel: {},
searchResultsByChannel: {},
selectedMessageIds: {},
isLoading: false,
@@ -139,6 +154,28 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return message;
},
pinMessage: async (channelId, messageId) => {
const updated = await api.put<Message>(`/channels/${channelId}/messages/${messageId}/pin`, {});
get().updateMessage(updated);
},
unpinMessage: async (channelId, messageId) => {
const updated = await api.delete<Message>(`/channels/${channelId}/messages/${messageId}/pin`);
get().updateMessage(updated);
},
fetchPinnedMessages: async (channelId) => {
const pinned = await api.get<Message[]>(`/channels/${channelId}/messages/pinned`);
const list = Array.isArray(pinned) ? pinned : [];
set((state) => ({
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[channelId]: list,
},
}));
return list;
},
addMessage: (message) =>
set((state) => {
const list = state.messagesByChannel[message.channel_id] || [];
@@ -156,12 +193,35 @@ export const useMessageStore = create<MessageState>((set, get) => ({
updateMessage: (message) =>
set((state) => {
const list = state.messagesByChannel[message.channel_id] || [];
const updatedList = list.map((m) =>
m.id === message.id ? message : m,
);
const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || [];
let updatedPinned = [...pinnedList];
if (message.pinned) {
if (!pinnedList.some((m) => m.id === message.id)) {
updatedPinned = [message, ...pinnedList].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} else {
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m));
}
} else {
updatedPinned = pinnedList.filter((m) => m.id !== message.id);
}
if (updatedPinned.length > 5) {
updatedPinned = updatedPinned.slice(0, 5);
}
return {
messagesByChannel: {
...state.messagesByChannel,
[message.channel_id]: list.map((m) =>
m.id === message.id ? message : m,
),
[message.channel_id]: updatedList,
},
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[message.channel_id]: updatedPinned,
},
};
}),
@@ -169,11 +229,64 @@ export const useMessageStore = create<MessageState>((set, get) => ({
removeMessage: (channelId, messageId) =>
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const pinnedList = state.pinnedMessagesByChannel[channelId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: list.filter((m) => m.id !== messageId),
},
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[channelId]: pinnedList.filter((m) => m.id !== messageId),
},
};
}),
addReaction: (channelId, messageId, emoji, userId) =>
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const updatedList = list.map((m) => {
if (m.id !== messageId) return m;
const reactions = m.reactions ? [...m.reactions] : [];
const existing = reactions.find((r) => r.emoji === emoji);
if (existing) {
if (!existing.users.includes(userId)) {
existing.users = [...existing.users, userId];
existing.count = existing.users.length;
}
} else {
reactions.push({ emoji, count: 1, users: [userId] });
}
return { ...m, reactions };
});
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: updatedList,
},
};
}),
removeReaction: (channelId, messageId, emoji, userId) =>
set((state) => {
const list = state.messagesByChannel[channelId] || [];
const updatedList = list.map((m) => {
if (m.id !== messageId) return m;
if (!m.reactions) return m;
const reactions = m.reactions
.map((r) => {
if (r.emoji !== emoji) return r;
const users = r.users.filter((id) => id !== userId);
return { ...r, users, count: users.length };
})
.filter((r) => r.count > 0);
return { ...m, reactions };
});
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: updatedList,
},
};
}),
+1 -1
View File
@@ -40,7 +40,7 @@ export const useTypingStore = create<TypingState>((set, get) => ({
[channel_id]: (state.typingUsers[channel_id] || []).filter(u => u.userId !== user_id),
},
}));
}, 3000);
}, 6000);
set(state => ({
typingUsers: {
+25
View File
@@ -115,6 +115,8 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
const addMessage = useMessageStore.getState().addMessage;
const updateMessage = useMessageStore.getState().updateMessage;
const removeMessage = useMessageStore.getState().removeMessage;
const addReaction = useMessageStore.getState().addReaction;
const removeReaction = useMessageStore.getState().removeReaction;
const addChannel = useChannelStore.getState().addChannel;
const updateChannel = useChannelStore.getState().updateChannel;
const removeChannel = useChannelStore.getState().removeChannel;
@@ -163,6 +165,29 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (ids) removeMessage(ids.channel_id, ids.message_id);
break;
}
case 'REACTION_ADD': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const reaction = isRecord(payload.reaction) ? payload.reaction : null;
if (channelId && messageId && reaction) {
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
const userId = typeof reaction.user_id === 'string' ? reaction.user_id : '';
if (emoji && userId) {
addReaction(channelId, messageId, emoji, userId);
}
}
break;
}
case 'REACTION_REMOVE': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const messageId = typeof payload.message_id === 'string' ? payload.message_id : '';
const emoji = typeof payload.emoji === 'string' ? payload.emoji : '';
const userId = typeof payload.user_id === 'string' ? payload.user_id : '';
if (channelId && messageId && emoji && userId) {
removeReaction(channelId, messageId, emoji, userId);
}
break;
}
case 'CHANNEL_CREATE': {
if (isRecord(payload)) addChannel(payload as unknown as Channel);
break;