feat: add SMTP email support and fix profile/permissions bugs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,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)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user