feat: realtime status, mentions, push notifications

This commit is contained in:
2026-06-29 15:52:34 -04:00
parent 282a436995
commit 6c8defb6fd
10 changed files with 387 additions and 34 deletions
+112 -11
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useMemo } from "react";
import { useMessageStore } from "../stores/message.ts";
import { useChannelStore } from "../stores/channel.ts";
import { useServerStore } from "../stores/server.ts";
import { useMemberStore } from "../stores/member.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { MentionDropdown } from "./MentionDropdown";
function formatTime(iso: string): string {
const date = new Date(iso);
@@ -11,6 +13,41 @@ function formatTime(iso: string): string {
return `${hours}:${minutes}`;
}
function renderContent(content: string, memberUsernames: Set<string>) {
// Split on @username, preserving mentions.
const parts: (string | { mention: 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));
}
const username = match[1];
if (memberUsernames.has(username)) {
parts.push({ mention: username });
} else {
parts.push(match[0]);
}
last = mentionRe.lastIndex;
}
if (last < content.length) {
parts.push(content.slice(last));
}
return parts.map((part, idx) => {
if (typeof part === "string") {
return <span key={idx}>{part}</span>;
}
return (
<span key={idx} className="text-gb-aqua">
@{part.mention}
</span>
);
});
}
export function ChatArea() {
const activeChannelId = useChannelStore((s) => s.activeChannelId);
const channelsByServer = useChannelStore((s) => s.channelsByServer);
@@ -21,15 +58,23 @@ export function ChatArea() {
const isLoading = useMessageStore((s) => s.isLoading);
const fetchMessages = useMessageStore((s) => s.fetchMessages);
const sendMessage = useMessageStore((s) => s.sendMessage);
const membersByServer = useMemberStore((s) => s.membersByServer);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [showGifPicker, setShowGifPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const channels = activeServerId
? channelsByServer[activeServerId] || []
: [];
const activeChannel = channels.find((c) => c.id === activeChannelId);
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
const memberUsernames = useMemo(
() => new Set(members.map((m) => m.username)),
[members],
);
useEffect(() => {
if (activeChannelId) {
@@ -42,6 +87,49 @@ export function ChatArea() {
bottomRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length;
setInput(value);
// Detect an unfinished mention at cursor position.
const beforeCursor = value.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) {
setMentionQuery(null);
return;
}
const between = beforeCursor.slice(atIndex + 1);
if (between.includes(" ") || between.includes("\n")) {
setMentionQuery(null);
return;
}
setMentionQuery(between);
};
const handleMentionSelect = (username: string) => {
const inputEl = inputRef.current;
if (!inputEl) {
setInput((prev) => `${prev}${username} `);
setMentionQuery(null);
return;
}
const cursor = inputEl.selectionStart ?? input.length;
const beforeCursor = input.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) return;
const before = input.slice(0, atIndex);
const after = input.slice(cursor);
const next = `${before}@${username} ${after}`;
setInput(next);
setMentionQuery(null);
requestAnimationFrame(() => {
const pos = atIndex + username.length + 2; // @username + space
inputEl.focus();
inputEl.setSelectionRange(pos, pos);
});
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeChannelId || !input.trim()) return;
@@ -49,6 +137,7 @@ export function ChatArea() {
try {
await sendMessage(activeChannelId, input.trim());
setInput("");
setMentionQuery(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
@@ -87,7 +176,9 @@ export function ChatArea() {
<span className="text-gb-aqua">
&lt;{message.author_username}&gt;
</span>{" "}
<span className="text-gb-fg">{message.content}</span>
<span className="text-gb-fg">
{renderContent(message.content, memberUsernames)}
</span>
</div>
))}
<div ref={bottomRef} />
@@ -105,16 +196,26 @@ export function ChatArea() {
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<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 flex-1 min-w-0"
disabled={!activeChannelId}
/>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId}
/>
{mentionQuery !== null && (
<MentionDropdown
query={mentionQuery}
members={members}
onSelect={handleMentionSelect}
/>
)}
</div>
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}