sync: phase 1 backend + frontend from server
This commit is contained in:
+121
-21
@@ -5,6 +5,9 @@ import { useServerStore } from "../stores/server.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { GiphyPicker, type Gif } from "./GiphyPicker";
|
||||
import { MentionDropdown } from "./MentionDropdown";
|
||||
import { MessageSearch } from "./MessageSearch";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -15,39 +18,85 @@ function formatTime(iso: string): string {
|
||||
|
||||
|
||||
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||
// Split on @username, preserving mentions.
|
||||
const parts: (string | { mention: string })[] = [];
|
||||
// Split on @username, preserving mentions; render plain text segments as markdown.
|
||||
const segments: { type: "text" | "mention"; value: string }[] = [];
|
||||
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = mentionRe.exec(content)) !== null) {
|
||||
if (match.index > last) {
|
||||
parts.push(content.slice(last, match.index));
|
||||
segments.push({ type: "text", value: content.slice(last, match.index) });
|
||||
}
|
||||
const username = match[1];
|
||||
if (memberUsernames.has(username)) {
|
||||
parts.push({ mention: username });
|
||||
segments.push({ type: "mention", value: username });
|
||||
} else {
|
||||
parts.push(match[0]);
|
||||
segments.push({ type: "text", value: match[0] });
|
||||
}
|
||||
last = mentionRe.lastIndex;
|
||||
}
|
||||
if (last < content.length) {
|
||||
parts.push(content.slice(last));
|
||||
segments.push({ type: "text", value: content.slice(last) });
|
||||
}
|
||||
|
||||
return parts.map((part, idx) => {
|
||||
if (typeof part === "string") {
|
||||
return <span key={idx}>{part}</span>;
|
||||
return segments.map((seg, idx) => {
|
||||
if (seg.type === "mention") {
|
||||
return (
|
||||
<span key={idx} className="text-gb-aqua">
|
||||
@{seg.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={idx} className="text-gb-aqua">
|
||||
@{part.mention}
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEmbeds(embeds?: { url: string; title?: string; description?: string; image_url?: string; site_name?: string }[]) {
|
||||
if (!embeds || embeds.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
{embeds.map((embed) => (
|
||||
<a
|
||||
key={embed.url}
|
||||
href={embed.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block bg-gb-bg-s border border-gb-bg-t p-2 hover:border-gb-fg-t transition-colors"
|
||||
>
|
||||
{embed.site_name && <div className="text-gb-fg-f text-[10px] uppercase">{embed.site_name}</div>}
|
||||
{embed.title && <div className="text-gb-fg text-xs font-bold truncate">{embed.title}</div>}
|
||||
{embed.description && <div className="text-gb-fg-s text-xs line-clamp-2">{embed.description}</div>}
|
||||
{embed.image_url && (
|
||||
<img
|
||||
src={embed.image_url}
|
||||
alt=""
|
||||
className="mt-1 max-h-24 object-cover border border-gb-bg-t"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatArea() {
|
||||
const activeChannelId = useChannelStore((s) => s.activeChannelId);
|
||||
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||
@@ -62,7 +111,9 @@ export function ChatArea() {
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -87,6 +138,20 @@ export function ChatArea() {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slowmodeRemaining <= 0) return;
|
||||
const t = setInterval(() => {
|
||||
setSlowmodeRemaining((r) => {
|
||||
if (r <= 1) {
|
||||
clearInterval(t);
|
||||
return 0;
|
||||
}
|
||||
return r - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [slowmodeRemaining]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursor = e.target.selectionStart ?? value.length;
|
||||
@@ -132,14 +197,25 @@ export function ChatArea() {
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!activeChannelId || !input.trim()) return;
|
||||
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
|
||||
setError(null);
|
||||
try {
|
||||
await sendMessage(activeChannelId, input.trim());
|
||||
setInput("");
|
||||
setMentionQuery(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send");
|
||||
const msg = err instanceof Error ? err.message : "Failed to send";
|
||||
// Parse slowmode error from API.
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed.error === "slowmode" && typeof parsed.retry_after === "number") {
|
||||
setSlowmodeRemaining(parsed.retry_after);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// not json
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,13 +232,31 @@ export function ChatArea() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gb-bg">
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
|
||||
{activeChannel
|
||||
? `# ${activeChannel.name}`
|
||||
: activeChannelId
|
||||
? `#${activeChannelId}`
|
||||
: "[NO CHANNEL SELECTED]"}
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
||||
<span>
|
||||
{activeChannel
|
||||
? `# ${activeChannel.name}`
|
||||
: activeChannelId
|
||||
? `#${activeChannelId}`
|
||||
: "[NO CHANNEL SELECTED]"}
|
||||
</span>
|
||||
{activeChannelId && (
|
||||
<button
|
||||
onClick={() => setShowSearch((prev) => !prev)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
||||
title="Search messages"
|
||||
>
|
||||
[SEARCH]
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
<MessageSearch
|
||||
channelId={activeChannelId}
|
||||
channelName={activeChannel.name}
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||
{!isLoading && messages.length === 0 && (
|
||||
@@ -179,6 +273,7 @@ export function ChatArea() {
|
||||
<span className="text-gb-fg">
|
||||
{renderContent(message.content, memberUsernames)}
|
||||
</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
@@ -196,6 +291,11 @@ export function ChatArea() {
|
||||
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
{slowmodeRemaining > 0 && (
|
||||
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
|
||||
SLOWMODE: wait {slowmodeRemaining}s
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
|
||||
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
|
||||
<div className="flex-1 min-w-0 relative">
|
||||
@@ -206,7 +306,7 @@ export function ChatArea() {
|
||||
onChange={handleInputChange}
|
||||
placeholder="type a message..."
|
||||
className="terminal-input w-full"
|
||||
disabled={!activeChannelId}
|
||||
disabled={!activeChannelId || slowmodeRemaining > 0}
|
||||
/>
|
||||
{mentionQuery !== null && (
|
||||
<MentionDropdown
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useConversationStore } from "../stores/conversation.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
import { NewConversationModal } from "./NewConversationModal.tsx";
|
||||
|
||||
function otherMemberName(conv: { members: { id: string; username: string }[] }, currentUserId: string) {
|
||||
const other = conv.members.find((m) => m.id !== currentUserId);
|
||||
return other?.username || "unknown";
|
||||
}
|
||||
|
||||
export function ConversationList() {
|
||||
const conversations = useConversationStore((s) => s.conversations);
|
||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||
const setActive = useConversationStore((s) => s.setActiveConversation);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
|
||||
<span>[DIRECT MESSAGES]</span>
|
||||
<button
|
||||
onClick={() => setShowNew(true)}
|
||||
className="terminal-button text-xs"
|
||||
title="New conversation"
|
||||
>
|
||||
[+]
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
||||
{conversations.length === 0 && (
|
||||
<p className="text-gb-fg-f">[no conversations]</p>
|
||||
)}
|
||||
{conversations.map((conv) => {
|
||||
const name =
|
||||
conv.type === "group_dm"
|
||||
? conv.name || conv.members.map((m) => m.username).join(", ")
|
||||
: otherMemberName(conv, currentUser?.id || "");
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => setActive(conv.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
conv.id === activeId
|
||||
? "terminal-active"
|
||||
: "hover:bg-gb-bg-t text-gb-fg-s"
|
||||
}`}
|
||||
>
|
||||
<span className="text-gb-fg-f">@</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showNew && <NewConversationModal onClose={() => setShowNew(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,10 +16,21 @@ interface ChannelApiResponse {
|
||||
position: number;
|
||||
}
|
||||
|
||||
const SLOWMODE_OPTIONS = [
|
||||
{ label: 'Off', value: 0 },
|
||||
{ label: '5 seconds', value: 5 },
|
||||
{ label: '10 seconds', value: 10 },
|
||||
{ label: '30 seconds', value: 30 },
|
||||
{ label: '1 minute', value: 60 },
|
||||
{ label: '2 minutes', value: 120 },
|
||||
{ label: '5 minutes', value: 300 },
|
||||
];
|
||||
|
||||
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'text' | 'voice'>('text');
|
||||
const [category, setCategory] = useState('general');
|
||||
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -41,10 +52,11 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<ChannelApiResponse>(`/servers/${serverId}/channels`, {
|
||||
const result = await api.post<ChannelApiResponse>("/servers/" + serverId + "/channels", {
|
||||
name: name.trim(),
|
||||
type,
|
||||
category: category.trim() || 'general',
|
||||
slowmode_seconds: slowmodeSeconds,
|
||||
});
|
||||
const newChannel = {
|
||||
id: result.id,
|
||||
@@ -110,6 +122,18 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
||||
className="terminal-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gb-fg-f block mb-1">SLOWMODE:</label>
|
||||
<select
|
||||
value={slowmodeSeconds}
|
||||
onChange={(e) => setSlowmodeSeconds(parseInt(e.target.value, 10))}
|
||||
className="terminal-input w-full"
|
||||
>
|
||||
{SLOWMODE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className="text-xs text-gb-red">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function renderDMContent(content: string) {
|
||||
return (
|
||||
<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" />,
|
||||
p: ({ ...props }) => <span {...props} className="inline" />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function DMChat() {
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const activeId = useConversationStore((s) => s.activeConversationId);
|
||||
const setActive = useConversationStore((s) => s.setActiveConversation);
|
||||
const conversations = useConversationStore((s) => s.conversations);
|
||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
||||
const sendMessage = useConversationStore((s) => s.sendMessage);
|
||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const id = conversationId || activeId;
|
||||
const conversation = conversations.find((c) => c.id === id);
|
||||
const messages = id ? messagesByConv[id] || [] : [];
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setActive(id);
|
||||
fetchMessages(id);
|
||||
setError(null);
|
||||
}
|
||||
}, [id, setActive, fetchMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
}, [messages]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!id || !input.trim()) return;
|
||||
setError(null);
|
||||
try {
|
||||
await sendMessage(id, input.trim());
|
||||
setInput("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send");
|
||||
}
|
||||
};
|
||||
|
||||
const title = conversation
|
||||
? conversation.type === "group_dm"
|
||||
? conversation.name || conversation.members.map((m) => m.username).join(", ")
|
||||
: conversation.members.find((m) => m.id !== currentUser?.id)?.username || "DM"
|
||||
: id
|
||||
? "[LOADING...]"
|
||||
: "[NO CONVERSATION]";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gb-bg">
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
|
||||
@ {title}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||
{!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"><{msg.author_username}></span>{" "}
|
||||
<span className="text-gb-fg">{renderDMContent(msg.content)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{error && (
|
||||
<div className="px-3 pb-1">
|
||||
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
|
||||
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="type a message..."
|
||||
className="terminal-input w-full"
|
||||
disabled={!id}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
||||
import { useWebSocketStore } from '../stores/ws.ts';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
import { ServerBar } from './ServerBar.tsx';
|
||||
import { ChannelList } from './ChannelList.tsx';
|
||||
import { ConversationList } from './ConversationList.tsx';
|
||||
import { MemberList } from './MemberList.tsx';
|
||||
import { VoicePanel } from './VoicePanel.tsx';
|
||||
|
||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||
|
||||
function statusColor(status: UserStatus): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
@@ -21,11 +21,9 @@ function statusColor(status: UserStatus): string {
|
||||
return 'bg-gb-gray';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: UserStatus): string {
|
||||
return status.toUpperCase();
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
@@ -35,22 +33,21 @@ export function Layout() {
|
||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||
const wsConnect = useWebSocketStore((s) => s.connect);
|
||||
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
||||
const setActiveServer = useServerStore((s) => s.setActiveServer);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
|
||||
const [dmMode, setDmMode] = useState(false);
|
||||
useEffect(() => {
|
||||
fetchMe();
|
||||
wsConnect();
|
||||
return () => { wsDisconnect(); };
|
||||
}, [fetchMe, wsConnect, wsDisconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
||||
|
||||
const handleStatusChange = async (status: UserStatus) => {
|
||||
setShowStatusMenu(false);
|
||||
try {
|
||||
@@ -59,9 +56,7 @@ export function Layout() {
|
||||
// Error is surfaced via auth store; menu closes optimistically.
|
||||
}
|
||||
};
|
||||
|
||||
const currentStatus = user?.status ?? 'offline';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
|
||||
@@ -69,11 +64,9 @@ export function Layout() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-2">
|
||||
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
|
||||
@@ -114,8 +107,35 @@ export function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<div className="flex flex-col items-center w-16 bg-gb-bg-h border-r border-gb-bg-t py-2 gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setDmMode(false)}
|
||||
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
|
||||
!dmMode
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
|
||||
}`}
|
||||
title="Servers"
|
||||
>
|
||||
[S]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDmMode(true);
|
||||
setActiveServer(null);
|
||||
}}
|
||||
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
|
||||
dmMode
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t'
|
||||
}`}
|
||||
title="Direct Messages"
|
||||
>
|
||||
[@]
|
||||
</button>
|
||||
</div>
|
||||
<ServerBar />
|
||||
<ChannelList />
|
||||
{dmMode ? <ConversationList /> : <ChannelList />}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<VoicePanel />
|
||||
@@ -124,7 +144,7 @@ export function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MemberList />
|
||||
{!dmMode && <MemberList />}
|
||||
</div>
|
||||
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
|
||||
<span>TERM v1.0</span>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface MemberContextMenuProps {
|
||||
memberId: string;
|
||||
username: string;
|
||||
canKick: boolean;
|
||||
canBan: boolean;
|
||||
canMute: boolean;
|
||||
onMessage?: () => void;
|
||||
onKick?: (reason: string) => void;
|
||||
onBan?: (reason: string) => void;
|
||||
onMute?: (duration: string, reason: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MemberContextMenu({
|
||||
username,
|
||||
canKick,
|
||||
canBan,
|
||||
canMute,
|
||||
onMessage,
|
||||
onKick,
|
||||
onBan,
|
||||
onMute,
|
||||
onClose,
|
||||
}: MemberContextMenuProps) {
|
||||
const [mode, setMode] = useState<"menu" | "kick" | "ban" | "mute">("menu");
|
||||
const [reason, setReason] = useState("");
|
||||
const [duration, setDuration] = useState("1h");
|
||||
|
||||
if (mode === "kick") {
|
||||
return (
|
||||
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
|
||||
<div className="text-gb-orange mb-2">KICK {username.toUpperCase()}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="reason (optional)"
|
||||
className="terminal-input w-full mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onKick?.(reason)} className="flex-1 bg-gb-red text-gb-bg py-1 hover:bg-gb-orange">
|
||||
[KICK]
|
||||
</button>
|
||||
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
|
||||
[BACK]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "ban") {
|
||||
return (
|
||||
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
|
||||
<div className="text-gb-orange mb-2">BAN {username.toUpperCase()}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="reason (optional)"
|
||||
className="terminal-input w-full mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onBan?.(reason)} className="flex-1 bg-gb-red text-gb-bg py-1 hover:bg-gb-orange">
|
||||
[BAN]
|
||||
</button>
|
||||
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
|
||||
[BACK]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "mute") {
|
||||
return (
|
||||
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t p-3 font-mono text-xs shadow-lg min-w-[200px]">
|
||||
<div className="text-gb-orange mb-2">MUTE {username.toUpperCase()}</div>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="w-full mb-2 bg-gb-bg-s text-gb-fg text-xs border-none outline-none"
|
||||
>
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="6h">6 hours</option>
|
||||
<option value="1d">1 day</option>
|
||||
<option value="7d">7 days</option>
|
||||
<option value="">permanent</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="reason (optional)"
|
||||
className="terminal-input w-full mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onMute?.(duration, reason)} className="flex-1 bg-gb-yellow text-gb-bg py-1 hover:bg-gb-orange">
|
||||
[MUTE]
|
||||
</button>
|
||||
<button onClick={() => setMode("menu")} className="flex-1 bg-gb-bg-t text-gb-fg py-1 hover:bg-gb-bg-s">
|
||||
[BACK]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute z-50 bg-gb-bg border border-gb-bg-t py-1 font-mono text-xs shadow-lg min-w-[140px]">
|
||||
<button onClick={onMessage} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-fg">
|
||||
Message
|
||||
</button>
|
||||
{canKick && (
|
||||
<button onClick={() => setMode("kick")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-yellow">
|
||||
Kick
|
||||
</button>
|
||||
)}
|
||||
{canMute && (
|
||||
<button onClick={() => setMode("mute")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-orange">
|
||||
Mute
|
||||
</button>
|
||||
)}
|
||||
{canBan && (
|
||||
<button onClick={() => setMode("ban")} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-red">
|
||||
Ban
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-gb-bg-t my-1" />
|
||||
<button onClick={onClose} className="w-full text-left px-3 py-1 hover:bg-gb-bg-t text-gb-fg-f">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useServerStore } from "../stores/server.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
import { useMemberStore, type Member } from "../stores/member.ts";
|
||||
import { usePresenceStore } from "../stores/presence.ts";
|
||||
import type { UserStatus } from "../stores/auth.ts";
|
||||
import { MemberContextMenu } from "./MemberContextMenu.tsx";
|
||||
import { useConversationStore } from "../stores/conversation.ts";
|
||||
|
||||
function statusIcon(status: UserStatus): string {
|
||||
switch (status) {
|
||||
@@ -46,13 +49,63 @@ function usernameColor(status: UserStatus): string {
|
||||
function MemberRow({ member }: { member: Member }) {
|
||||
const presence = usePresenceStore((s) => s.presences[member.id]);
|
||||
const status = presence?.status ?? member.status;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const userPerms = useServerStore((s) => s.userPermissions);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
const createConversation = useConversationStore((s) => s.createConversation);
|
||||
|
||||
const canKick = userPerms?.kick_members ?? false;
|
||||
const canBan = userPerms?.ban_members ?? false;
|
||||
const canMute = userPerms?.mute_members ?? false;
|
||||
const isSelf = currentUser?.id === member.id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
||||
<span className={`truncate ${usernameColor(status)}`}>
|
||||
{member.display_name || member.username}
|
||||
</span>
|
||||
<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"
|
||||
>
|
||||
<span className={statusColor(status)}>{statusIcon(status)}</span>
|
||||
<span className={`truncate ${usernameColor(status)}`}>
|
||||
{member.display_name || member.username}
|
||||
</span>
|
||||
<span className="ml-auto text-gb-fg-f text-xs">[...]</span>
|
||||
</button>
|
||||
{menuOpen && !isSelf && activeServerId && (
|
||||
<MemberContextMenu
|
||||
memberId={member.id}
|
||||
username={member.username}
|
||||
canKick={canKick}
|
||||
canBan={canBan}
|
||||
canMute={canMute}
|
||||
onMessage={() => {
|
||||
createConversation([member.id]);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
onKick={(_reason) => {
|
||||
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
|
||||
.then(() => setMenuOpen(false));
|
||||
}}
|
||||
onBan={(reason) => {
|
||||
fetch(`/api/v1/servers/${activeServerId}/bans`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: member.id, reason }),
|
||||
}).then(() => setMenuOpen(false));
|
||||
}}
|
||||
onMute={(duration, reason) => {
|
||||
fetch(`/api/v1/servers/${activeServerId}/mutes`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: member.id, duration, reason }),
|
||||
}).then(() => setMenuOpen(false));
|
||||
}}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useMessageStore } from "../stores/message.ts";
|
||||
|
||||
interface MessageSearchProps {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
onClose: () => void;
|
||||
onJump?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function MessageSearch({ channelId, channelName, onClose, onJump }: MessageSearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const results = useMessageStore((s) => s.searchResultsByChannel[channelId] || []);
|
||||
const searchMessages = useMessageStore((s) => s.searchMessages);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (query.trim()) {
|
||||
searchMessages(channelId, query.trim());
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [query, channelId, searchMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => (i + 1) % results.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => (i - 1 + results.length) % results.length);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const msg = results[selectedIndex];
|
||||
if (msg && onJump) {
|
||||
onJump(msg.id);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [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="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>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="search messages..."
|
||||
className="terminal-input w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto font-mono text-xs">
|
||||
{results.length === 0 && query.trim() && (
|
||||
<div className="px-3 py-2 text-gb-fg-f">[no results]</div>
|
||||
)}
|
||||
{results.map((msg, idx) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
onClick={() => {
|
||||
onJump?.(msg.id);
|
||||
onClose();
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 border-b border-gb-bg-t last:border-b-0 ${
|
||||
idx === selectedIndex ? "bg-gb-bg-s" : "hover:bg-gb-bg-s/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-gb-fg-f">
|
||||
<span className="text-gb-aqua"><{msg.author_username}></span>
|
||||
<span className="text-gb-fg-s">{new Date(msg.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-gb-fg truncate">{msg.content}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useConversationStore } from "../stores/conversation.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { useServerStore } from "../stores/server.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
|
||||
interface NewConversationModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const createConversation = useConversationStore((s) => s.createConversation);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return members.filter(
|
||||
(m) =>
|
||||
m.id !== currentUserId &&
|
||||
(m.username.toLowerCase().includes(q) ||
|
||||
(m.display_name || "").toLowerCase().includes(q)),
|
||||
);
|
||||
}, [members, query, currentUserId]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (selected.length === 0) return;
|
||||
await createConversation(selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[300px] font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-gb-orange">NEW DIRECT MESSAGE</span>
|
||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="search members..."
|
||||
className="terminal-input w-full mb-3 text-sm"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 mb-3">
|
||||
{filtered.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => toggle(m.id)}
|
||||
className={`w-full text-left px-2 py-1 text-sm ${
|
||||
selected.includes(m.id)
|
||||
? "bg-gb-aqua text-gb-bg"
|
||||
: "hover:bg-gb-bg-t text-gb-fg-s"
|
||||
}`}
|
||||
>
|
||||
{m.username}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs">[no members found]</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={selected.length === 0}
|
||||
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
|
||||
>
|
||||
[START CONVERSATION]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
import { useChannelStore } from '../stores/channel.ts';
|
||||
import { useLayoutStore } from '../stores/layout.ts';
|
||||
import { CreateServerModal } from './CreateServerModal.tsx';
|
||||
import { JoinServerModal } from './JoinServerModal.tsx';
|
||||
|
||||
@@ -18,6 +19,7 @@ export function ServerBar() {
|
||||
const fetchServers = useServerStore((state) => state.fetchServers);
|
||||
const setActiveServer = useServerStore((state) => state.setActiveServer);
|
||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||
const { isDM, setDM } = useLayoutStore();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showJoin, setShowJoin] = useState(false);
|
||||
|
||||
@@ -28,26 +30,43 @@ export function ServerBar() {
|
||||
const handleSelect = (id: string) => {
|
||||
setActiveServer(id);
|
||||
setActiveChannel(null);
|
||||
setDM(false);
|
||||
};
|
||||
|
||||
const handleDM = () => {
|
||||
setActiveServer(null);
|
||||
setActiveChannel(null);
|
||||
setDM(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
|
||||
<button
|
||||
onClick={handleDM}
|
||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||
(isDM
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
||||
title="Direct messages"
|
||||
>
|
||||
[@]
|
||||
</button>
|
||||
<div className="w-8 h-px bg-gb-bg-t" />
|
||||
{servers.map((server) => (
|
||||
<button
|
||||
key={server.id}
|
||||
onClick={() => handleSelect(server.id)}
|
||||
className={`w-11 h-11 flex items-center justify-center font-mono text-sm border ${
|
||||
server.id === activeServerId
|
||||
className={'w-11 h-11 flex items-center justify-center font-mono text-sm border ' +
|
||||
(server.id === activeServerId && !isDM
|
||||
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua'
|
||||
}`}
|
||||
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua')}
|
||||
title={server.name}
|
||||
>
|
||||
{server.unread && server.id !== activeServerId ? (
|
||||
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
|
||||
) : (
|
||||
`[${getInitials(server.name)}]`
|
||||
'[' + getInitials(server.name) + ']'
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user