feat: add SMTP email support and fix profile/permissions bugs

This commit is contained in:
root
2026-06-30 19:29:08 +00:00
parent 4ded582dc4
commit f4437c5e1d
26 changed files with 643 additions and 129 deletions
+12
View File
@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { LoginForm } from './components/LoginForm.tsx';
import { Layout } from './components/Layout.tsx';
import { ChatArea } from './components/ChatArea.tsx';
@@ -16,6 +17,17 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
}
function App() {
const fetchMe = useAuthStore((state) => state.fetchMe);
const [init, setInit] = useState(false);
useEffect(() => {
fetchMe().finally(() => setInit(true));
}, [fetchMe]);
if (!init) {
return <div className="h-screen w-screen bg-gb-bg flex items-center justify-center text-gb-fg-s font-mono text-xs">Loading...</div>;
}
return (
<BrowserRouter>
<Routes>
+1 -1
View File
@@ -82,7 +82,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
const result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
const newChannel = {
id: result.id,
serverId: result.server_id,
server_id: result.server_id,
name: result.name,
type: result.type,
category: result.category || null,
+10 -10
View File
@@ -3,6 +3,7 @@ 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 { useLayoutStore } from '../stores/layout.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { ConversationList } from './ConversationList.tsx';
@@ -29,7 +30,6 @@ 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);
@@ -38,14 +38,14 @@ export function Layout() {
const navigate = useNavigate();
const location = useLocation();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [dmMode, setDmMode] = useState(false);
const isDM = useLayoutStore((s) => s.isDM);
const setDM = useLayoutStore((s) => s.setDM);
const [showServerSettings, setShowServerSettings] = useState(false);
const activeServerId = useServerStore((s) => s.activeServerId);
useEffect(() => {
fetchMe();
wsConnect();
return () => { wsDisconnect(); };
}, [fetchMe, wsConnect, wsDisconnect]);
}, [wsConnect, wsDisconnect]);
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true });
@@ -120,9 +120,9 @@ export function Layout() {
<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)}
onClick={() => setDM(false)}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
!dmMode
!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'
}`}
@@ -132,11 +132,11 @@ export function Layout() {
</button>
<button
onClick={() => {
setDmMode(true);
setDM(true);
setActiveServer(null);
}}
className={`w-11 h-11 flex items-center justify-center font-mono text-xs border rounded ${
dmMode
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'
}`}
@@ -146,7 +146,7 @@ export function Layout() {
</button>
</div>
<ServerBar />
{dmMode ? <ConversationList /> : <ChannelList />}
{isDM ? <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 />
@@ -155,7 +155,7 @@ export function Layout() {
</div>
</div>
</div>
{!dmMode && <MemberList />}
{!isDM && <MemberList />}
</div>
{showServerSettings && activeServerId && (
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
+15 -3
View File
@@ -6,6 +6,8 @@ import { usePresenceStore } from "../stores/presence.ts";
import type { UserStatus } from "../stores/auth.ts";
import { MemberContextMenu } from "./MemberContextMenu.tsx";
import { useConversationStore } from "../stores/conversation.ts";
import { useLayoutStore } from "../stores/layout.ts";
import { useNavigate } from "react-router-dom";
function statusIcon(status: UserStatus): string {
switch (status) {
@@ -53,7 +55,10 @@ function MemberRow({ member }: { member: Member }) {
const currentUser = useAuthStore((s) => s.user);
const userPerms = useServerStore((s) => s.userPermissions);
const activeServerId = useServerStore((s) => s.activeServerId);
const setActiveServer = useServerStore((s) => s.setActiveServer);
const createConversation = useConversationStore((s) => s.createConversation);
const setDM = useLayoutStore((s) => s.setDM);
const navigate = useNavigate();
const canKick = userPerms?.kick_members ?? false;
const canBan = userPerms?.ban_members ?? false;
@@ -79,9 +84,16 @@ function MemberRow({ member }: { member: Member }) {
canKick={canKick}
canBan={canBan}
canMute={canMute}
onMessage={() => {
createConversation([member.id]);
setMenuOpen(false);
onMessage={async () => {
try {
const conv = await createConversation([member.id]);
setMenuOpen(false);
setActiveServer(null);
setDM(true);
navigate(`/dm/${conv.id}`);
} catch (err) {
console.error("Failed to start DM:", err);
}
}}
onKick={(_reason) => {
fetch(`/api/v1/servers/${activeServerId}/members/${member.id}`, { method: "DELETE", credentials: "include" })
+13 -1
View File
@@ -15,7 +15,19 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
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 members = useMemo(() => {
const map = new Map<string, any>();
if (activeServerId && membersByServer[activeServerId]) {
membersByServer[activeServerId].forEach((m) => map.set(m.id, m));
} else {
// Global search if no active server
Object.values(membersByServer).forEach((list) => {
list.forEach((m) => map.set(m.id, m));
});
}
return Array.from(map.values());
}, [activeServerId, membersByServer]);
const filtered = useMemo(() => {
const q = query.toLowerCase();
+32 -12
View File
@@ -322,20 +322,40 @@ export function UserSettings() {
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
NOTIFICATIONS:
</label>
<div className="flex items-center gap-3">
<button
type="button"
onClick={isSubscribed ? unsubscribe : subscribe}
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{isSubscribed ? '● subscribed' : '○ not subscribed'}
</span>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<button
type="button"
onClick={async () => {
if (Notification.permission !== 'granted') {
await Notification.requestPermission();
// Force a re-render to update the permission status
setDisplayName(displayName + ' '); setTimeout(() => setDisplayName(displayName), 0);
}
}}
className={`terminal-button text-xs ${Notification.permission === 'granted' ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{Notification.permission === 'granted' ? '[DESKTOP GRANTED]' : '[ENABLE DESKTOP]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{Notification.permission === 'granted' ? '● allowed' : '○ not allowed'}
</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={isSubscribed ? unsubscribe : subscribe}
className={`terminal-button text-xs ${isSubscribed ? 'border-gb-green text-gb-green' : 'border-gb-orange text-gb-orange'}`}
>
{isSubscribed ? '[DISABLE PUSH]' : '[ENABLE PUSH]'}
</button>
<span className="text-gb-fg-f text-xs font-mono">
{isSubscribed ? '● subscribed' : '○ not subscribed'}
</span>
</div>
</div>
<p className="text-gb-fg-f text-xs font-mono mt-2">
Receive push notifications for @mentions and DMs when the app is closed.
Enable Desktop to get notifications while the app is running in the background. Enable Push to get notifications when the app is fully closed.
</p>
</div>
)}
+1 -1
View File
@@ -131,7 +131,7 @@ export const useAuthStore = create<AuthState>((set) => ({
fetchMe: async () => {
set({ isLoading: true, error: null });
try {
const user = await api.get<User>("/auth/me");
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
+5 -5
View File
@@ -5,7 +5,7 @@ export type ChannelType = 'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'li
export interface Channel {
id: string;
serverId: string;
server_id: string;
name: string;
type: ChannelType;
category: string | null;
@@ -52,22 +52,22 @@ export const useChannelStore = create<ChannelState>((set) => ({
addChannel: (channel) =>
set((state) => {
const list = state.channelsByServer[channel.serverId] || [];
const list = state.channelsByServer[channel.server_id] || [];
return {
channelsByServer: {
...state.channelsByServer,
[channel.serverId]: [...list, channel],
[channel.server_id]: [...list, channel],
},
};
}),
updateChannel: (channel) =>
set((state) => {
const list = state.channelsByServer[channel.serverId] || [];
const list = state.channelsByServer[channel.server_id] || [];
return {
channelsByServer: {
...state.channelsByServer,
[channel.serverId]: list.map((c) => (c.id === channel.id ? channel : c)),
[channel.server_id]: list.map((c) => (c.id === channel.id ? channel : c)),
},
};
}),
+1 -1
View File
@@ -20,7 +20,7 @@ export const useTypingStore = create<TypingState>((set, get) => ({
sendTypingStart: (channelId) => {
const ws = useWebSocketStore.getState();
if (ws.connected) {
ws.send({ type: 'TYPING_START', payload: { channel_id: channelId } });
ws.send({ type: 'TYPING_START', data: { channel_id: channelId } });
}
},
+6 -8
View File
@@ -11,7 +11,7 @@ import {
} from 'livekit-client';
import { api } from '../lib/api.ts';
import { useWebSocketStore } from './ws.ts';
import { useAuthStore } from './auth.ts';
export interface VoiceParticipant {
identity: string;
@@ -201,7 +201,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
const wsSend = useWebSocketStore.getState().send;
wsSend({
type: 'VOICE_JOIN',
payload: { room_name: channelId },
data: { room_name: channelId },
});
} catch (err) {
set({
@@ -220,7 +220,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
if (channelId) {
wsSend({
type: 'VOICE_LEAVE',
payload: { room_name: channelId },
data: { room_name: channelId },
});
}
@@ -255,7 +255,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
const wsSend = useWebSocketStore.getState().send;
wsSend({
type: 'VOICE_MUTE',
payload: { room_name: get().currentRoom, muted: newMuted },
data: { room_name: get().currentRoom, muted: newMuted },
});
},
@@ -285,7 +285,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
const wsSend = useWebSocketStore.getState().send;
wsSend({
type: 'VOICE_DEAFEN',
payload: { room_name: get().currentRoom, deafened: newDeafened },
data: { room_name: get().currentRoom, deafened: newDeafened },
});
},
@@ -335,13 +335,11 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
whisperTo: (targetUserId, targetUsername, message) => {
const wsSend = useWebSocketStore.getState().send;
const me = useAuthStore.getState().user;
wsSend({
type: 'VOICE_WHISPER',
payload: {
data: {
target_user_id: targetUserId,
target_username: targetUsername,
from_username: me?.username || 'unknown',
message: message || '',
},
});
+62 -45
View File
@@ -5,6 +5,7 @@ import { useServerStore } from './server.ts';
import { usePresenceStore } from './presence.ts';
import { useTypingStore } from './typing.ts';
import { useVoiceStore } from './voice.ts';
import { useAuthStore } from './auth.ts';
import type { Message } from './message.ts';
import type { Channel } from './channel.ts';
import type { Server } from './server.ts';
@@ -14,7 +15,7 @@ type UnknownPayload = Record<string, unknown>;
export interface WsEvent {
type: string;
payload: UnknownPayload;
data?: UnknownPayload;
}
interface WebSocketState {
@@ -25,6 +26,17 @@ interface WebSocketState {
send: (event: WsEvent) => void;
}
let unreadCount = 0;
if (typeof window !== 'undefined') {
window.addEventListener('focus', () => {
unreadCount = 0;
if (document.title.match(/^\(\d+\)\s/)) {
document.title = document.title.replace(/^\(\d+\)\s/, '');
}
});
}
function getWsHost(): string {
const { protocol, host } = window.location;
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
@@ -35,32 +47,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function extractMessage(payload: UnknownPayload): Message | null {
if (!isRecord(payload.message)) return null;
return payload.message as unknown as Message;
}
function extractChannel(payload: UnknownPayload): Channel | null {
if (!isRecord(payload.channel)) return null;
return payload.channel as unknown as Channel;
}
function extractServer(payload: UnknownPayload): Server | null {
if (!isRecord(payload.server)) return null;
return payload.server as unknown as Server;
}
function extractIds(payload: UnknownPayload): { channel_id: string; message_id: string } | null {
if (typeof payload.channel_id !== 'string' || typeof payload.message_id !== 'string') {
function extractIds(payload: UnknownPayload | undefined): { channel_id: string; message_id: string } | null {
if (!payload || typeof payload.channel_id !== 'string' || typeof payload.message_id !== 'string') {
return null;
}
return { channel_id: payload.channel_id, message_id: payload.message_id };
}
function extractChannelId(payload: UnknownPayload): string | null {
return typeof payload.channel_id === 'string' ? payload.channel_id : null;
}
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
socket: null,
connected: false,
@@ -115,7 +108,7 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}
if (data.type === 'ping') {
get().send({ type: 'pong', payload: {} });
get().send({ type: 'pong', data: {} });
return;
}
@@ -127,51 +120,75 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
const removeChannel = useChannelStore.getState().removeChannel;
const updateServer = useServerStore.getState().updateServer;
const payload = data.data || {};
switch (data.type) {
case 'message': {
const msg = extractMessage(data.payload);
if (msg) addMessage(msg);
case 'MESSAGE_CREATE': {
if (isRecord(payload)) {
const msg = payload as unknown as Message;
addMessage(msg);
// Desktop notification
const currentUserId = useAuthStore.getState().user?.id;
if (msg.author_id !== currentUserId && !document.hasFocus()) {
// Update browser tab title
unreadCount++;
if (document.title.match(/^\(\d+\)\s/)) {
document.title = document.title.replace(/^\(\d+\)\s/, `(${unreadCount}) `);
} else {
document.title = `(${unreadCount}) ` + document.title;
}
if (Notification.permission === 'granted') {
const title = msg.author_username ? `New message from ${msg.author_display_name || msg.author_username}` : 'New message';
const notification = new Notification(title, {
body: msg.content,
icon: '/icons/icon-192.png',
});
notification.onclick = () => {
window.focus();
notification.close();
};
}
}
}
break;
}
case 'message_updated': {
const msg = extractMessage(data.payload);
if (msg) updateMessage(msg);
case 'MESSAGE_UPDATE': {
if (isRecord(payload)) updateMessage(payload as unknown as Message);
break;
}
case 'message_deleted': {
const ids = extractIds(data.payload);
case 'MESSAGE_DELETE': {
const ids = extractIds(payload);
if (ids) removeMessage(ids.channel_id, ids.message_id);
break;
}
case 'channel_created': {
const channel = extractChannel(data.payload);
if (channel) addChannel(channel);
case 'CHANNEL_CREATE': {
if (isRecord(payload)) addChannel(payload as unknown as Channel);
break;
}
case 'channel_updated': {
const channel = extractChannel(data.payload);
if (channel) updateChannel(channel);
case 'CHANNEL_UPDATE': {
if (isRecord(payload)) updateChannel(payload as unknown as Channel);
break;
}
case 'channel_deleted': {
const channelId = extractChannelId(data.payload);
case 'CHANNEL_DELETE': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : null;
if (channelId) removeChannel(channelId);
break;
}
case 'server_updated': {
const server = extractServer(data.payload);
if (server) updateServer(server);
case 'SERVER_UPDATE': {
if (isRecord(payload)) updateServer(payload as unknown as Server);
break;
}
case 'PRESENCE_UPDATE': {
const { user_id, username, status } = data.payload as Record<string, string>;
const { user_id, username, status } = payload as Record<string, string>;
if (user_id && username && status) {
usePresenceStore.getState().updatePresence(user_id, username, status as UserStatus);
}
break;
}
case 'TYPING_START': {
const { channel_id, user_id, username } = data.payload as Record<string, string>;
const { channel_id, user_id, username } = payload as Record<string, string>;
if (channel_id && user_id && username) {
useTypingStore.getState()._handleTypingEvent({ channel_id, user_id, username });
}
@@ -179,7 +196,7 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}
case 'VOICE_WHISPER': {
// Forwarded from backend — only intended recipient gets this
const { from_user_id, from_username, message } = data.payload as Record<string, string>;
const { from_user_id, from_username, message } = payload as Record<string, string>;
useVoiceStore.getState()._addWhisper({
fromUserId: from_user_id || '',
fromUsername: from_username || 'unknown',