feat: realtime status, mentions, push notifications
This commit is contained in:
+112
-11
@@ -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">
|
||||
<{message.author_username}>
|
||||
</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)}
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/auth.ts';
|
||||
import { useAuthStore, type UserStatus } from '../stores/auth.ts';
|
||||
import { useWebSocketStore } from '../stores/ws.ts';
|
||||
import { ServerBar } from './ServerBar.tsx';
|
||||
import { ChannelList } from './ChannelList.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':
|
||||
return 'bg-gb-green';
|
||||
case 'idle':
|
||||
return 'bg-gb-yellow';
|
||||
case 'dnd':
|
||||
return 'bg-gb-red';
|
||||
default:
|
||||
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);
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const fetchMe = useAuthStore((state) => state.fetchMe);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||
const wsConnect = useWebSocketStore((s) => s.connect);
|
||||
const wsDisconnect = useWebSocketStore((s) => s.disconnect);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMe();
|
||||
@@ -30,6 +51,17 @@ export function Layout() {
|
||||
}
|
||||
}, [isLoading, isAuthenticated, location.pathname, navigate]);
|
||||
|
||||
const handleStatusChange = async (status: UserStatus) => {
|
||||
setShowStatusMenu(false);
|
||||
try {
|
||||
await updateProfile({ status });
|
||||
} catch (err) {
|
||||
// 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">
|
||||
@@ -48,9 +80,33 @@ export function Layout() {
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s">
|
||||
<span className="text-gb-orange font-bold">DUMPSTER</span>
|
||||
<div className="flex items-center gap-4 text-xs text-gb-fg-s">
|
||||
<span>
|
||||
USER: <span className="text-gb-aqua">{user?.username || 'unknown'}</span>
|
||||
</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowStatusMenu((prev) => !prev)}
|
||||
className="flex items-center gap-2 hover:text-gb-fg transition-colors"
|
||||
title="Change status"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${statusColor(currentStatus)}`} />
|
||||
<span className="text-gb-aqua">{user?.username || 'unknown'}</span>
|
||||
<span className="text-gb-fg-f">[{statusLabel(currentStatus)}]</span>
|
||||
</button>
|
||||
{showStatusMenu && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-32 bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
||||
{STATUS_CYCLE.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => handleStatusChange(s)}
|
||||
className="w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${statusColor(s)}`} />
|
||||
<span>{statusLabel(s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
|
||||
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
||||
[LOGOUT]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Member } from "../stores/member.ts";
|
||||
|
||||
interface MentionDropdownProps {
|
||||
query: string;
|
||||
members: Member[];
|
||||
onSelect: (username: string) => void;
|
||||
}
|
||||
|
||||
export function MentionDropdown({ query, members, onSelect }: MentionDropdownProps) {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = members
|
||||
.filter(
|
||||
(m) =>
|
||||
m.username.toLowerCase().includes(q) ||
|
||||
m.display_name?.toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-1 z-50 w-64 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
|
||||
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
|
||||
MENTION
|
||||
</div>
|
||||
{filtered.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => 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>
|
||||
<span className="truncate">{m.display_name || m.username}</span>
|
||||
{m.display_name && m.display_name !== m.username && (
|
||||
<span className="text-gb-fg-f text-xs">({m.username})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface UpdateProfilePayload {
|
||||
bio?: string;
|
||||
accent_color?: string;
|
||||
status_text?: string;
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface MemberState {
|
||||
membersByServer: Record<string, Member[]>;
|
||||
isLoading: boolean;
|
||||
fetchMembers: (serverId: string) => Promise<void>;
|
||||
updateMemberStatus: (userId: string, status: Member["status"]) => void;
|
||||
}
|
||||
|
||||
export const useMemberStore = create<MemberState>((set) => ({
|
||||
@@ -34,4 +35,15 @@ export const useMemberStore = create<MemberState>((set) => ({
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateMemberStatus: (userId, status) =>
|
||||
set((state) => {
|
||||
const next: Record<string, Member[]> = {};
|
||||
for (const [serverId, list] of Object.entries(state.membersByServer)) {
|
||||
next[serverId] = list.map((m) =>
|
||||
m.id === userId ? { ...m, status } : m,
|
||||
);
|
||||
}
|
||||
return { membersByServer: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user