This commit is contained in:
2025-05-20 04:16:03 +03:00
parent 21a05dd202
commit 9531bff01a
88 changed files with 7797 additions and 2246 deletions

View File

@@ -0,0 +1,44 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import type { ChannelId, UserId } from "~/lib/api/types";
interface UserVoiceState {
deaf: boolean;
muted: boolean;
}
interface ChannelVoiceState {
users: Record<UserId, UserVoiceState>;
}
interface ChannelsVoiceState {
channels: Record<ChannelId, ChannelVoiceState>;
addUser: (channelId: ChannelId, userId: UserId, userVoiceState: UserVoiceState) => void;
removeUser: (channelId: ChannelId, userId: UserId) => void;
removeChannel: (channelId: ChannelId) => void;
}
export const useChannelsVoiceStateStore = create<ChannelsVoiceState>()(
immer(
(set, get) => ({
channels: {},
addUser: (channelId, userId, userVoiceState) => set((state) => {
if (!state.channels[channelId]) {
state.channels[channelId] = {
users: {}
}
}
state.channels[channelId].users[userId] = userVoiceState;
}),
removeUser: (channelId, userId) => set((state) => {
if (state.channels[channelId]) {
delete state.channels[channelId].users[userId];
}
}),
removeChannel: (channelId) => set((state) => {
delete state.channels[channelId];
})
})
)
)

171
app/stores/gateway-store.ts Normal file
View File

@@ -0,0 +1,171 @@
import type { QueryClient } from '@tanstack/react-query';
import { create } from 'zustand';
import { messageSchema, type ChannelId, type Message, type MessageId, type ServerId } from '~/lib/api/types';
import { GatewayClient } from '~/lib/websocket/gateway/client';
import {
ConnectionState,
EventType,
type EventData,
type VoiceServerUpdateEvent
} from '~/lib/websocket/gateway/types';
import { useChannelsVoiceStateStore } from './channels-voice-state';
import { usePrivateChannelsStore } from './private-channels-store';
import { useServerChannelsStore } from './server-channels-store';
import { useServerListStore } from './server-list-store';
import { useUsersStore } from './users-store';
const GATEWAY_URL = 'ws://localhost:12345/gateway/ws';
const HANDLERS = {
[EventType.ADD_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER }>['data']) => {
useServerListStore.getState().addServer(data.server);
},
[EventType.REMOVE_SERVER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER }>['data']) => {
useServerListStore.getState().removeServer(data.serverId);
useServerChannelsStore.getState().removeServer(data.serverId);
useChannelsVoiceStateStore.getState().removeChannel(data.serverId);
},
[EventType.ADD_DM_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_DM_CHANNEL }>['data']) => {
usePrivateChannelsStore.getState().addChannel(data.channel);
},
[EventType.REMOVE_DM_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_DM_CHANNEL }>['data']) => {
usePrivateChannelsStore.getState().removeChannel(data.channelId);
useChannelsVoiceStateStore.getState().removeChannel(data.channelId);
},
[EventType.ADD_SERVER_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER_CHANNEL }>['data']) => {
useServerChannelsStore.getState().addChannel(data.channel);
},
[EventType.REMOVE_SERVER_CHANNEL]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER_CHANNEL }>['data']) => {
useServerChannelsStore.getState().removeChannel(data.serverId, data.channelId);
useChannelsVoiceStateStore.getState().removeChannel(data.serverId);
},
[EventType.ADD_USER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_USER }>['data']) => {
useUsersStore.getState().addUser(data.user);
},
[EventType.REMOVE_USER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_USER }>['data']) => {
useUsersStore.getState().removeUser(data.userId);
},
[EventType.ADD_SERVER_MEMBER]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_SERVER_MEMBER }>['data']) => {
useUsersStore.getState().addUser(data.user);
},
[EventType.REMOVE_SERVER_MEMBER]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_SERVER_MEMBER }>['data']) => {
useUsersStore.getState().removeUser(data.userId);
},
[EventType.ADD_MESSAGE]: (self: GatewayState, data: Extract<EventData, { type: EventType.ADD_MESSAGE }>['data']) => {
const message = messageSchema.parse(data.message)
if (self.queryClient) {
self.queryClient.setQueryData(['messages', message.channelId], (oldData: {
pages: Message[][],
pageParams: MessageId[]
}) => {
return {
pages: oldData?.pages ? [[message, ...oldData.pages[0]], ...oldData.pages.slice(1)] : [[message]],
pageParams: oldData?.pageParams ?? [undefined, message.id]
}
});
}
},
[EventType.REMOVE_MESSAGE]: (self: GatewayState, data: Extract<EventData, { type: EventType.REMOVE_MESSAGE }>['data']) => {
if (self.queryClient) {
self.queryClient.setQueryData(['messages', data.channelId], (oldData: any) => {
if (!oldData) return [];
return oldData.filter((message: any) => message.id !== data.messageId);
});
}
},
[EventType.VOICE_CHANNEL_CONNECTED]: (self: GatewayState, data: Extract<EventData, { type: EventType.VOICE_CHANNEL_CONNECTED }>['data']) => {
useChannelsVoiceStateStore.getState().addUser(data.channelId, data.userId, {
deaf: false,
muted: false
});
},
[EventType.VOICE_CHANNEL_DISCONNECTED]: (self: GatewayState, data: Extract<EventData, { type: EventType.VOICE_CHANNEL_DISCONNECTED }>['data']) => {
useChannelsVoiceStateStore.getState().removeUser(data.channelId, data.userId);
},
}
interface GatewayState {
client: GatewayClient | null;
queryClient: QueryClient | null;
status: ConnectionState;
connect: (token: string) => void;
disconnect: () => void;
setQueryClient: (client: QueryClient) => void;
updateVoiceState: (serverId: ServerId, channelId: ChannelId) => void;
requestVoiceStates: (serverId: ServerId) => void;
onVoiceServerUpdate: (handler: (event: VoiceServerUpdateEvent['data']) => void | Promise<void>) => (() => void);
}
export const useGatewayStore = create<GatewayState>()((set, get) => {
const client = new GatewayClient(GATEWAY_URL);
const voiceHandlers = new Set<(event: VoiceServerUpdateEvent['data']) => void>();
client.onEvent(EventType.VOICE_SERVER_UPDATE, (event) => {
voiceHandlers.forEach(handler => handler(event));
});
for (const [type, handler] of Object.entries(HANDLERS)) {
client.onEvent(type, (data: any) => {
handler(get(), data);
});
}
return {
client,
queryClient: null,
status: ConnectionState.DISCONNECTED,
connect: (token) => {
client.connect(token);
set({ status: client.connectionState });
client.onControl('stateChange', (state) => {
set({ status: state });
});
},
disconnect: () => {
client.disconnect();
set({ status: ConnectionState.DISCONNECTED });
},
setQueryClient: (queryClient) => {
set({ queryClient });
},
updateVoiceState: (serverId, channelId) => {
client.updateVoiceState(serverId, channelId);
},
requestVoiceStates: (serverId) => {
client.requestVoiceStates(serverId);
},
onVoiceServerUpdate: (handler) => {
voiceHandlers.add(handler);
return () => {
console.log("removing voice server update handler", handler);
voiceHandlers.delete(handler);
};
}
};
});

49
app/stores/modal-store.ts Normal file
View File

@@ -0,0 +1,49 @@
import { create } from "zustand";
import type { ServerId } from "~/lib/api/types";
export enum ModalType {
CREATE_SERVER = "CREATE_SERVER",
CREATE_SERVER_CHANNEL = "CREATE_CHANNEL",
CREATE_SERVER_INVITE = "CREATE_SERVER_INVITE",
DELETE_SERVER_CONFIRM = "DELETE_SERVER_CONFIRM",
UPDATE_PROFILE = "UPDATE_PROFILE",
}
export type CreateServerInviteModalData = {
type: ModalType.CREATE_SERVER_INVITE;
data: {
serverId: ServerId;
};
};
export type DeleteServerConfirmModalData = {
type: ModalType.CREATE_SERVER_CHANNEL;
data: {
serverId: ServerId;
}
};
export type CreateServerChannelModalData = {
type: ModalType.CREATE_SERVER_CHANNEL;
data: {
serverId: ServerId;
}
};
export type ModalData = CreateServerChannelModalData | CreateServerInviteModalData | DeleteServerConfirmModalData;
interface ModalState {
type: ModalType | null;
data?: ModalData['data'];
isOpen: boolean;
onOpen: (type: ModalType, data?: ModalData['data']) => void;
onClose: () => void;
}
export const useModalStore = create<ModalState>()((set) => ({
type: null,
data: undefined,
isOpen: false,
onOpen: (type, data) => set({ type, data, isOpen: true }),
onClose: () => set({ type: null, isOpen: false }),
}));

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import type { ChannelId, RecipientChannel } from '~/lib/api/types'
type PrivateChannelsStore = {
channels: Record<ChannelId, RecipientChannel>
addChannels: (channels: RecipientChannel[]) => void
addChannel: (channel: RecipientChannel) => void
removeChannel: (channelId: ChannelId) => void
}
export const usePrivateChannelsStore = create<PrivateChannelsStore>()(
immer(
(set) => ({
channels: {},
addChannels: (channels: RecipientChannel[]) => set((state) => {
for (const channel of channels) {
state.channels[channel.id] = channel
}
}),
addChannel: (channel: RecipientChannel) => set((state) => { state.channels[channel.id] = channel }),
removeChannel: (channelId: ChannelId) => set((state) => { delete state.channels[channelId] }),
})
)
)

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import type { ChannelId, ServerChannel, ServerId } from "~/lib/api/types"
type ServerChannelsStore = {
channels: Record<ServerId, Record<ChannelId, ServerChannel>>
addServer: (serverId: ServerId) => void
addChannel: (channel: ServerChannel) => void
addChannels: (channels: ServerChannel[]) => void
removeChannel: (serverId: ServerId, channelId: ChannelId) => void
removeServer: (serverId: ServerId) => void
}
export const useServerChannelsStore = create<ServerChannelsStore>()(
immer(
(set, get) => ({
channels: {},
addServer: (serverId) => set((state) => {
state.channels[serverId] = {}
}),
addChannel: (channel) => set((state) => {
if (state.channels[channel.serverId] === undefined) {
state.channels[channel.serverId] = {}
}
state.channels[channel.serverId][channel.id] = channel
}),
addChannels: (channels) => set((state) => {
for (const channel of channels) {
if (state.channels[channel.serverId] === undefined) {
state.channels[channel.serverId] = {}
}
state.channels[channel.serverId][channel.id] = channel
}
}),
removeChannel: (serverId, channelId) => set((state) => { delete state.channels[serverId][channelId] }),
removeServer: (serverId) => set((state) => { delete state.channels[serverId] }),
})
)
)

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import type { Server, ServerId, Uuid } from '~/lib/api/types'
type ServerListStore = {
servers: Record<ServerId, Server>
addServers: (newServers: Server[]) => void
addServer: (server: Server) => void
removeServer: (serverId: Uuid) => void
}
export const useServerListStore = create<ServerListStore>()(
immer(
(set) => ({
servers: {},
addServers: (servers: Server[]) => set((state) => {
for (const server of servers) {
state.servers[server.id] = server
}
}),
addServer: (server: Server) => set((state) => { state.servers[server.id] = server }),
removeServer: (serverId: Uuid) => set((state) => { delete state.servers[serverId] }),
})
)
)

21
app/stores/token-store.ts Normal file
View File

@@ -0,0 +1,21 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type TokenStore = {
token?: string
setToken: (token?: string) => void
removeToken: () => void
}
export const useTokenStore = create<TokenStore>()(
persist(
(set, get) => ({
token: undefined,
setToken: (token?: string) => set({ token }),
removeToken: () => set({ token: undefined }),
}),
{
name: 'token',
},
),
)

View File

@@ -0,0 +1,92 @@
import { useQuery } from "@tanstack/react-query"
import { create as batshitCreate, keyResolver } from "@yornaath/batshit"
import { create } from "zustand"
import { immer } from "zustand/middleware/immer"
import { getUser } from "~/lib/api/client/user"
import type { FullUser, PartialUser, UserId } from "~/lib/api/types"
type UsersStore = {
users: Record<UserId, PartialUser>
currentUserId: UserId | undefined
fetchUsersIfNotPresent: (userIds: UserId[]) => Promise<void>
addUser: (user: PartialUser) => void
removeUser: (userId: UserId) => void
setCurrentUserId: (userId: UserId) => void
getCurrentUser: () => FullUser | undefined
}
const usersFetcher = batshitCreate({
fetcher: async (userIds: UserId[]) => {
let users = []
for (const userId of userIds) {
users.push(getUser(userId))
}
return await Promise.all(users)
},
resolver: keyResolver("id")
})
export const useUserQuery = (userId: UserId) => useQuery(
{
queryKey: ["users", userId],
queryFn: async () => {
const user = await getUser(userId)
return user
},
select: (data) => {
useUsersStore.getState().addUser(data)
return data
}
}
)
export const useUsersStore = create<UsersStore>()(
immer(
(set, get) => ({
users: {},
currentUserId: undefined,
fetchUsersIfNotPresent: async (userIds) => {
let userPromises: Promise<PartialUser>[] = []
for (const userId of userIds) {
const user = get().users[userId]
if (!user) {
userPromises.push(usersFetcher.fetch(userId))
}
}
const users = await Promise.all(userPromises)
const activeUsers = users.filter(Boolean)
set((state) => {
for (const user of activeUsers) {
if (user?.id)
state.users[user.id] = user
}
})
},
addUser: (user) => set((state) => {
if (user.id !== get().currentUserId)
state.users[user.id] = user
else {
const currentUser = get().users[user.id]
if (currentUser)
state.users[user.id] = { ...currentUser, ...user }
else
state.users[user.id] = user
}
}),
removeUser: (userId) => set((state) => {
delete state.users[userId]
}),
setCurrentUserId: (userId) => set((state) => {
state.currentUserId = userId
}),
getCurrentUser: () => !!get().currentUserId ? get().users[get().currentUserId!] as FullUser : undefined
}),
)
)

View File

@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { useWebRTCStore } from './webrtc-store';
interface VoiceState {
activeChannel: { serverId: string; channelId: string } | null;
error: string | null;
// Actions
joinVoiceChannel: (serverId: string, channelId: string) => void;
leaveVoiceChannel: () => void;
setError: (error: string) => void;
resetError: () => void;
}
export const useVoiceStateStore = create<VoiceState>()((set, get) => {
return {
activeChannel: null,
error: null,
joinVoiceChannel: (serverId, channelId) => {
set({
activeChannel: { serverId, channelId },
error: null
});
},
leaveVoiceChannel: () => {
const currentState = get();
if (currentState.activeChannel) {
useWebRTCStore.getState().disconnect();
set({
activeChannel: null,
});
}
},
setError: (error) => {
set({ error });
},
resetError: () => {
set({ error: null });
}
};
});

View File

@@ -0,0 +1,54 @@
import { create } from 'zustand';
import { WebRTCClient } from '~/lib/websocket/voice/client';
import { ConnectionState } from '~/lib/websocket/voice/types';
import { useVoiceStateStore } from './voice-state-store';
const VOICE_GATEWAY_URL = 'ws://localhost:12345/voice/ws';
interface WebRTCState {
client: WebRTCClient | null;
status: ConnectionState;
remoteStream: MediaStream | null;
error: string | null;
connect: (token: string) => Promise<void>;
disconnect: () => void;
createOffer: (localStream: MediaStream) => Promise<void>;
}
export const useWebRTCStore = create<WebRTCState>()((set, get) => {
const client = new WebRTCClient(
VOICE_GATEWAY_URL,
(state) => set({ status: state }),
(error) => {
set({
status: ConnectionState.ERROR,
error: error.message
});
useVoiceStateStore.getState().setError(error.message);
},
(stream) => set({ remoteStream: stream })
);
return {
client,
status: ConnectionState.DISCONNECTED,
remoteStream: null,
error: null,
connect: async (token) => {
await client.connect(token);
},
disconnect: () => {
client.disconnect();
set({
status: ConnectionState.DISCONNECTED,
remoteStream: null
});
},
createOffer: async (localStream) => {
await client.createOffer(localStream);
}
};
});