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
|
||||
|
||||
Reference in New Issue
Block a user