This commit is contained in:
2025-05-21 18:03:22 +03:00
parent 4419151510
commit 074d6674b8
34 changed files with 99 additions and 79 deletions

View File

@@ -14,11 +14,11 @@ interface AppLayoutProps {
}
export default function AppLayout({ children }: AppLayoutProps) {
let servers = Object.values(useServerListStore(useShallow((state) => state.servers)));
const servers = Object.values(useServerListStore(useShallow((state) => state.servers)));
const matches = useMatches();
let list = React.useMemo(() => {
const list = React.useMemo(() => {
return matches
.map(
(match) =>
@@ -46,7 +46,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
<aside className="flex flex-col gap-2 p-2 h-full">
<HomeButton />
<Separator />
{servers.map((server, _) => (
{servers.map((server) => (
<React.Fragment key={server.id}>
<ServerButton server={server} />
</React.Fragment>

View File

@@ -18,14 +18,13 @@ export default function ChannelArea({ channel }: ChannelAreaProps) {
);
};
const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isPending, status } =
useInfiniteQuery({
queryKey: ["messages", channelId],
initialPageParam: undefined,
queryFn: fetchMessages,
getNextPageParam: (lastPage) => (lastPage.length < 50 ? undefined : lastPage[lastPage.length - 1]?.id),
staleTime: Infinity,
});
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, status } = useInfiniteQuery({
queryKey: ["messages", channelId],
initialPageParam: undefined,
queryFn: fetchMessages,
getNextPageParam: (lastPage) => (lastPage.length < 50 ? undefined : lastPage[lastPage.length - 1]?.id),
staleTime: Infinity,
});
const fetchNextPageVisible = () => {
if (!isFetchingNextPage && hasNextPage) fetchNextPage();

View File

@@ -86,7 +86,7 @@ export default function ChatMessage({ message }: ChatMessageProps) {
<div className="row-start-2 col-start-2 row-span-1 col-span-1">
<div className="wrap-break-word contain-inline-size">{message.content}</div>
<div className="grid gap-2 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 justify-start">
{message.attachments.map((file, i) => (
{message.attachments.map((file) => (
<div key={file.id}>
<ChatMessageAttachment file={file} />
</div>

View File

@@ -20,7 +20,7 @@ interface ChannelListItemProps {
}
const onDeleteChannel = async (channel: ServerChannel) => {
const response = await deleteChannel(channel.serverId, channel.id);
await deleteChannel(channel.serverId, channel.id);
};
interface ChannelItemWrapperProps {

View File

@@ -1,6 +1,5 @@
import { Settings } from "lucide-react";
import { useNavigate } from "react-router";
import { ModalType, useModalStore } from "~/stores/modal-store";
import { useTokenStore } from "~/stores/token-store";
import { Button } from "../ui/button";
import {
@@ -13,13 +12,8 @@ import {
export function SettingsButton() {
const setToken = useTokenStore((state) => state.setToken);
const onOpen = useModalStore((state) => state.onOpen);
const navigate = useNavigate();
const onUpdateProfile = () => {
onOpen(ModalType.UPDATE_PROFILE);
};
const onOpenSettings = () => {
navigate("/app/settings");
};
@@ -33,7 +27,7 @@ export function SettingsButton() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Settings className="size-5 m-1.5" />
<Settings className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>

View File

@@ -1,4 +1,5 @@
import { PhoneMissed, Signal } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import { useServerChannelsStore } from "~/stores/server-channels-store";
import { useServerListStore } from "~/stores/server-list-store";
import { useUsersStore } from "~/stores/users-store";
@@ -11,14 +12,14 @@ import { OnlineStatus } from "./online-status";
import { SettingsButton } from "./settings-button";
function VoiceStatus({ voiceState }: { voiceState: { serverId: string; channelId: string } }) {
// const webrtcState = useWebRTCStore(state => state.status)
const leaveVoiceChannel = () => {
useVoiceStateStore.getState().leaveVoiceChannel();
};
const channel = useServerChannelsStore((state) => state.channels[voiceState.serverId]?.[voiceState.channelId]);
const server = useServerListStore((state) => state.servers[voiceState.serverId]);
const channel = useServerChannelsStore(
useShallow((state) => state.channels[voiceState.serverId]?.[voiceState.channelId]),
);
const server = useServerListStore(useShallow((state) => state.servers[voiceState.serverId]));
return (
<div className="gap-1 flex justify-between items-center ">
@@ -29,8 +30,8 @@ function VoiceStatus({ voiceState }: { voiceState: { serverId: string; channelId
</div>
</div>
<Button variant="secondary" size="none" onClick={leaveVoiceChannel}>
<PhoneMissed className="size-5 m-1.5" />
<Button variant="destructive" size="icon" onClick={leaveVoiceChannel}>
<PhoneMissed className="size-5" />
</Button>
</div>
);

View File

@@ -7,7 +7,7 @@ import { useTokenStore } from "~/stores/token-store";
export function GatewayWebSocketConnectionManager() {
const token = useTokenStore((state) => state.token);
const { setQueryClient } = useGatewayStore();
const setQueryClient = useGatewayStore((state) => state.setQueryClient);
const queryClient = useQueryClient();
useEffect(() => {
@@ -17,7 +17,7 @@ export function GatewayWebSocketConnectionManager() {
useEffect(() => {
const { status, connect, disconnect } = useGatewayStore.getState();
if (!!token) {
if (token) {
connect(token);
} else {
if (status === ConnectionState.CONNECTED) {

View File

@@ -35,7 +35,7 @@ export function WebRTCConnectionManager() {
voiceState.leaveVoiceChannel();
unsubscribe();
};
}, []);
});
useEffect(() => {
if (webrtc.status === ConnectionState.DISCONNECTED) {

View File

@@ -27,7 +27,7 @@ export default function CreateServerChannelModal() {
const isModalOpen = type === ModalType.CREATE_SERVER_CHANNEL && isOpen;
let form = useForm<z.infer<typeof schema>>({
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
type: ChannelType.SERVER_TEXT,
@@ -40,7 +40,7 @@ export default function CreateServerChannelModal() {
};
const onSubmit = async (values: z.infer<typeof schema>) => {
const response = await import("~/lib/api/client/server").then((m) =>
await import("~/lib/api/client/server").then((m) =>
m.default.createChannel((data as CreateServerChannelModalData["data"]).serverId, values),
);

View File

@@ -16,7 +16,7 @@ export default function CreateServerInviteModal() {
const isModalOpen = type === ModalType.CREATE_SERVER_INVITE && isOpen;
const inviteLink = `${origin}/app/invite/${inviteCode}`;
const onOpenChange = (openState: boolean) => {
const onOpenChange = () => {
onClose();
};

View File

@@ -28,11 +28,11 @@ export default function CreateServerModal() {
const isModalOpen = type === ModalType.CREATE_SERVER && isOpen;
let form = useForm<z.infer<typeof schema>>({
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
});
const onOpenChange = (openState: boolean) => {
const onOpenChange = () => {
form.reset();
onClose();
};
@@ -43,7 +43,7 @@ export default function CreateServerModal() {
iconId = (await file.uploadFile(values.icon))[0];
}
const response = await server.create({
await server.create({
name: values.name,
iconId,
});

View File

@@ -31,7 +31,7 @@ export default function UpdateProfileModal() {
const isModalOpen = type === ModalType.UPDATE_PROFILE && isOpen;
let form = useForm<z.infer<typeof schema>>({
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
});
@@ -50,7 +50,7 @@ export default function UpdateProfileModal() {
avatarId = (await file.uploadFile(values.avatar))[0];
}
const response = await patchUser({
await patchUser({
displayName: values.displayName,
avatarId,
});

View File

@@ -17,8 +17,8 @@ export function ThemeToggle() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<Sun className="size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -24,7 +24,7 @@ export const useFetchUser = (userId: UserId) => {
export const useFetchUsers = (userIds: UserId[]) => {
const query = useQuery({
queryKey: ["users", userIds],
queryKey: ["users", ...userIds],
queryFn: async () => {
const userIdsToFetch = userIds.filter((userId) => !useUsersStore.getState().users[userId]);

View File

@@ -9,7 +9,7 @@ export async function paginatedMessages(channelId: ChannelId, limit: number, bef
},
});
return (response.data as any[]).map((value, _) => messageSchema.parse(value));
return (response.data as unknown[]).map((value) => messageSchema.parse(value));
}
export async function sendMessage(channelId: ChannelId, content: string, attachments?: Uuid[]) {

View File

@@ -44,7 +44,7 @@ export interface FullUser {
email: string;
bot: boolean;
system: boolean;
settings: any;
settings: unknown;
}
export interface Server {

View File

@@ -23,14 +23,14 @@ export function formatFileSize(bytes: number, decimals = 2): string {
}
export function createPrefixedLogger(prefix: string, styles?: string[]) {
const result: Record<string, (...args: any[]) => void> = {};
const result: Record<string, (...args: unknown[]) => void> = {};
const methods = ["log", "trace", "debug", "info", "warn", "error"] as const;
for (const methodName of methods) {
const originalMethod = console[methodName].bind(console);
result[methodName] = (...args: any[]) => {
result[methodName] = (...args: unknown[]) => {
if (typeof args[0] === "string") {
originalMethod(`${prefix} ${args[0]}`, ...(styles || []), ...args.slice(1));
} else {

View File

@@ -142,7 +142,7 @@ export class GatewayClient {
this.socket.onerror = this.onSocketError.bind(this);
this.socket.onclose = this.onSocketClose.bind(this);
} catch (error) {
this.emitError(new Error("Failed to create WebSocket connection"));
this.emitError(new Error("Failed to create WebSocket connection", { cause: error }));
this.setState(ConnectionState.ERROR);
}
}
@@ -242,6 +242,7 @@ export class GatewayClient {
}
private handleEventMessage(event: EventData): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitEvent(event.type, event.data as any);
}
@@ -274,6 +275,7 @@ export class GatewayClient {
private emitControl<K extends keyof ControlEvents>(event: K, ...args: Parameters<ControlEvents[K]>): void {
const handler = this.eventHandlers[event];
if (handler) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
(handler as Function)(...args);
}
}
@@ -281,6 +283,7 @@ export class GatewayClient {
private emitEvent<K extends keyof GatewayEvents>(event: K, ...args: Parameters<GatewayEvents[K]>): void {
const handler = this.serverEventHandlers[event];
if (handler) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
(handler as Function)(...args);
}
}

View File

@@ -1,6 +1,14 @@
import type { ChannelId, Message, MessageId, PartialUser, Server, ServerId, UserId } from "~/lib/api/types";
type Channel = any; // TODO: Define Channel type
import type {
Channel,
ChannelId,
Message,
MessageId,
PartialUser,
Server,
ServerChannel,
ServerId,
UserId,
} from "~/lib/api/types";
export enum ServerMessageType {
AUTHENTICATE_ACCEPTED = "AUTHENTICATE_ACCEPTED",
@@ -111,7 +119,7 @@ export interface AddDmChannelEvent {
type: EventType.ADD_DM_CHANNEL;
data: {
channel: Channel;
recipients: PartialUser[];
recipients: UserId[];
};
}
@@ -125,7 +133,7 @@ export interface RemoveDmChannelEvent {
export interface AddServerChannelEvent {
type: EventType.ADD_SERVER_CHANNEL;
data: {
channel: Channel;
channel: ServerChannel;
};
}

View File

@@ -71,7 +71,7 @@ export class WebRTCClient {
this.socket.onmessage = this.handleServerMessage;
this.socket.onerror = (event) => {
this.handleError(new Error("WebSocket error occurred"));
this.handleError(new Error("WebSocket error occurred", { cause: event }));
};
this.socket.onclose = (e) => {
@@ -264,4 +264,4 @@ export class WebRTCClient {
};
}
const { log, warn, ...other } = createPrefixedLogger("%cWebRTC WS%c:", ["color: blue; font-weight: bold;", ""]);
const { log, warn } = createPrefixedLogger("%cWebRTC WS%c:", ["color: blue; font-weight: bold;", ""]);

View File

@@ -8,7 +8,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const response = await import("~/lib/api/client/server").then((m) => m.default.getInvite(inviteCode));
return redirect(`/app/server/${response.id}`);
} catch (error) {
} catch {
return redirect("/app/@me");
}
}

View File

@@ -3,6 +3,7 @@ import { Check } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
import ChannelArea from "~/components/channel-area";
import { Badge } from "~/components/ui/badge";
import { useFetchUsers } from "~/hooks/use-fetch-user";
import { usePrivateChannelsStore } from "~/stores/private-channels-store";
import { useUsersStore } from "~/stores/users-store";
@@ -12,18 +13,26 @@ export default function Channel({ params }: Route.ComponentProps) {
const nativeChannel = usePrivateChannelsStore(useShallow((state) => state.channels[channelId]));
const recipients = nativeChannel?.recipients?.filter((recipient) => recipient !== currentUserId) || [];
useFetchUsers(recipients);
const recipientsUsers =
useUsersStore(useShallow((state) => recipients.map((recipient) => state.users[recipient]).filter(Boolean))) ||
[];
if (!nativeChannel) return null;
const recipients = nativeChannel.recipients.filter((recipient) => recipient.id !== currentUserId);
const renderSystemBadge = recipients.some((recipient) => recipient.system) && recipients.length === 1;
const renderSystemBadge = recipientsUsers.some((recipient) => recipient.system) && recipients.length === 1;
const channel = {
...nativeChannel,
name: (
<>
<div className="flex items-center gap-2">
<div>{recipients.map((recipient) => recipient.displayName || recipient.username).join(", ")}</div>
<div>
{recipientsUsers.map((recipient) => recipient.displayName || recipient.username).join(", ")}
</div>
{renderSystemBadge && (
<Badge variant="default">
{" "}

View File

@@ -104,7 +104,7 @@ function ListComponent() {
<div className="p-2 flex flex-col gap-1 h-full">
{channels
.sort((a, b) => ((a.lastMessageId ?? a.id) < (b.lastMessageId ?? b.id) ? 1 : -1))
.map((channel, _) => (
.map((channel) => (
<React.Fragment key={channel.id}>
<ServerChannelListItem channel={channel} />
</React.Fragment>

View File

@@ -37,7 +37,7 @@ export default function Settings() {
avatarId = (await file.uploadFile(values.avatar))[0];
}
const response = await patchUser({
await patchUser({
displayName:
values.displayName === user?.displayName
? undefined

View File

@@ -38,8 +38,8 @@ export async function clientLoader() {
}
export default function Login() {
let navigate = useNavigate();
let setToken = useTokenStore((state) => state.setToken);
const navigate = useNavigate();
const setToken = useTokenStore((state) => state.setToken);
const { setCurrentUserId, addUser } = useUsersStore(
useShallow((state) => {
return {
@@ -126,7 +126,7 @@ export default function Login() {
</CardContent>
<CardFooter style={{ viewTransitionName: "auth-card-footer-view" }}>
<div className="flex items-center">
<span className="text-muted-foreground text-sm">Don't have an account?</span>
<span className="text-muted-foreground text-sm">Don&apos;t have an account?</span>
<Link
className={buttonVariants({
variant: "link",

View File

@@ -21,7 +21,7 @@ const schema = z.object({
});
export default function Register() {
let navigate = useNavigate();
const navigate = useNavigate();
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),

View File

@@ -1,7 +1,6 @@
import { redirect } from "react-router";
import type { Route } from "./+types/index";
export function meta({}: Route.MetaArgs) {
export function meta() {
return [{ title: "New React Router App" }];
}

View File

@@ -19,7 +19,7 @@ interface ChannelsVoiceState {
}
export const useChannelsVoiceStateStore = create<ChannelsVoiceState>()(
immer((set, get) => ({
immer((set) => ({
channels: {},
addUser: (channelId, userId, userVoiceState) =>
set((state) => {

View File

@@ -109,9 +109,9 @@ const HANDLERS = {
data: Extract<EventData, { type: EventType.REMOVE_MESSAGE }>["data"],
) => {
if (self.queryClient) {
self.queryClient.setQueryData(["messages", data.channelId], (oldData: any) => {
self.queryClient.setQueryData(["messages", data.channelId], (oldData: Message[]) => {
if (!oldData) return [];
return oldData.filter((message: any) => message.id !== data.messageId);
return oldData.filter((message: Message) => message.id !== data.messageId);
});
}
},
@@ -159,6 +159,7 @@ export const useGatewayStore = create<GatewayState>()((set, get) => {
});
for (const [type, handler] of Object.entries(HANDLERS)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client.onEvent(type, (data: any) => {
handler(get(), data);
});

View File

@@ -12,7 +12,7 @@ type ServerChannelsStore = {
};
export const useServerChannelsStore = create<ServerChannelsStore>()(
immer((set, get) => ({
immer((set) => ({
channels: {},
addServer: (serverId) =>
set((state) => {

View File

@@ -9,7 +9,7 @@ type TokenStore = {
export const useTokenStore = create<TokenStore>()(
persist(
(set, get) => ({
(set) => ({
token: undefined,
setToken: (token?: string) => set({ token }),
removeToken: () => set({ token: undefined }),

View File

@@ -16,7 +16,7 @@ type UsersStore = {
const usersFetcher = batshitCreate({
fetcher: async (userIds: UserId[]) => {
let users = [];
const users = [];
for (const userId of userIds) {
users.push(getUser(userId));
@@ -32,7 +32,7 @@ export const useUsersStore = create<UsersStore>()(
users: {},
currentUserId: undefined,
fetchUsersIfNotPresent: async (userIds) => {
let userPromises: Promise<PartialUser>[] = [];
const userPromises: Promise<PartialUser>[] = [];
for (const userId of userIds) {
const user = get().users[userId];
if (!user) {
@@ -68,6 +68,6 @@ export const useUsersStore = create<UsersStore>()(
state.currentUserId = userId;
}),
getCurrentUser: () => (!!get().currentUserId ? (get().users[get().currentUserId!] as FullUser) : undefined),
getCurrentUser: () => (get().currentUserId ? (get().users[get().currentUserId!] as FullUser) : undefined),
})),
);

View File

@@ -15,7 +15,7 @@ interface WebRTCState {
createOffer: (localStream: MediaStream) => Promise<void>;
}
export const useWebRTCStore = create<WebRTCState>()((set, get) => {
export const useWebRTCStore = create<WebRTCState>()((set) => {
const client = new WebRTCClient(
VOICE_GATEWAY_URL,
(state) => set({ status: state }),

View File

@@ -1,6 +1,6 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import pluginReact from "eslint-plugin-react";
import reactCompiler from "eslint-plugin-react-compiler";
import reactHooks from "eslint-plugin-react-hooks";
import { defineConfig } from "eslint/config";
import globals from "globals";
@@ -16,8 +16,14 @@ export default defineConfig([
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
languageOptions: { globals: globals.browser },
},
tseslint.configs.recommended,
pluginReact.configs.recommended,
reactHooks.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
reactHooks.configs["recommended-latest"],
reactCompiler.configs.recommended,
{
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
rules: {
"react/react-in-jsx-scope": "off",
},
},
]);