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
+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',