From 2a7fe2629b8bf36d75aba90b160816841f877538 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Wed, 25 Feb 2026 17:28:07 -0800 Subject: [PATCH 1/5] feat: added right sidebar panel with multiple uses --- apps/web/src/components/sidebar/index.tsx | 23 ++- .../right-panel/guild-members-panel.tsx | 178 ++++++++++++++++++ .../right-panel/right-sidebar-context.tsx | 48 +++++ .../right-panel/right-sidebar-panel.tsx | 46 +++++ .../right-panel/right-sidebar-types.ts | 26 +++ .../_authenticated/$guildSlug/$channelId.tsx | 24 ++- apps/web/src/routes/_authenticated/dms.tsx | 8 + packages/auth/src/lib/auth.ts | 2 + packages/realtime-types/src/events.ts | 2 + 9 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx create mode 100644 apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx create mode 100644 apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx create mode 100644 apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts diff --git a/apps/web/src/components/sidebar/index.tsx b/apps/web/src/components/sidebar/index.tsx index eba0ed5..d6656e1 100644 --- a/apps/web/src/components/sidebar/index.tsx +++ b/apps/web/src/components/sidebar/index.tsx @@ -8,9 +8,15 @@ import { useParams } from "@tanstack/react-router" import { ChannelPanel } from "./channel-panel/channel-panel" import { DMPanel } from "./dm-panel/dm-panel" import { GuildBar } from "./guild-bar/guild-bar" +import { + RightSidebarProvider, + useRightSidebar, +} from "./right-panel/right-sidebar-context" +import { RightSidebarPanel } from "./right-panel/right-sidebar-panel" -export function Sidebar({ children }: { children: React.ReactNode }) { +function SidebarLayout({ children }: { children: React.ReactNode }) { const { guildSlug } = useParams({ strict: false }) + const { view } = useRightSidebar() const { defaultLayout, onLayoutChange } = useDefaultLayout({ groupId: "townhall-sidebar", @@ -29,8 +35,21 @@ export function Sidebar({ children }: { children: React.ReactNode }) { {guildSlug ? : } - {children} + +
+
{children}
+ {view && } +
+
) } + +export function Sidebar({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx new file mode 100644 index 0000000..a2c1cfe --- /dev/null +++ b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx @@ -0,0 +1,178 @@ +import type { ActiveGuild, ActiveGuildMember } from "@repo/auth" +import { authClient } from "@repo/auth/client" +import type { PresenceStatus } from "@repo/realtime-types" +import { ScrollArea } from "@repo/ui/components/scroll-area" +import { Skeleton } from "@repo/ui/components/skeleton" +import { cn } from "@repo/ui/lib/utils" +import { useQuery } from "@tanstack/react-query" +import { Users } from "lucide-react" +import { useMemo } from "react" +import { UserAvatar } from "../../ui/user-avatar" +import type { GuildMembersSidebarView } from "./right-sidebar-types" + +function mapGuildMembersToRows( + members: ActiveGuild["members"] | undefined, + sessionUserId: string | null, + presenceByUserId?: Record +) { + return (members ?? []) + .map((member: ActiveGuildMember) => { + const id = member.user?.id ?? member.userId ?? member.id + const fallbackName = member.user?.email ?? "Unknown member" + return { + id, + name: member.user?.name?.trim() || fallbackName, + image: member.user?.image ?? null, + role: member.role ?? "member", + status: + presenceByUserId?.[id] ?? + (id === sessionUserId ? "online" : "offline"), + } + }) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +type GuildMemberRow = ReturnType[number] + +const statusStyles: Record = { + online: "bg-emerald-500", + offline: "bg-muted-foreground/40", + idle: "bg-amber-500", + dnd: "bg-rose-500", +} + +const statusLabel: Record = { + online: "Online", + offline: "Offline", + idle: "Idle", + dnd: "Do Not Disturb", +} + +function formatRole(role: string) { + if (!role) return "Member" + return role.charAt(0).toUpperCase() + role.slice(1) +} + +function MemberSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) +} + +function MemberRow({ member }: { member: GuildMemberRow }) { + return ( +
+
+ + +
+
+
{member.name}
+
+ {formatRole(member.role)} +
+
+ + {statusLabel[member.status]} + +
+ ) +} + +export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { + const { data: session } = authClient.useSession() + + const { data: guild, isPending } = useQuery({ + queryKey: ["active-guild", view.guildSlug], + queryFn: async () => { + const res = await authClient.organization.getFullOrganization() + return res.data ?? null + }, + }) + + const members = useMemo(() => { + const sessionUserId = session?.user.id ?? null + return mapGuildMembersToRows( + guild?.members, + sessionUserId, + view.presenceByUserId + ) + }, [guild?.members, session?.user.id, view.presenceByUserId]) + + const onlineMembers = members.filter((member) => member.status !== "offline") + const offlineMembers = members.filter((member) => member.status === "offline") + const guildName = guild?.name?.trim() || "Guild" + + return ( +
+
+
+ + {guildName} Members +
+

+ {members.length} total • presence is currently mocked +

+
+ + {isPending ? ( + + ) : ( + + {members.length === 0 ? ( +
+ No members found for this guild. +
+ ) : ( +
+ {onlineMembers.length > 0 && ( +
+
+ Online — {onlineMembers.length} +
+
+ {onlineMembers.map((member) => ( + + ))} +
+
+ )} + + {offlineMembers.length > 0 && ( +
+
+ Offline — {offlineMembers.length} +
+
+ {offlineMembers.map((member) => ( + + ))} +
+
+ )} +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx b/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx new file mode 100644 index 0000000..a5de162 --- /dev/null +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx @@ -0,0 +1,48 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from "react" +import type { RightSidebarView } from "./right-sidebar-types" + +interface RightSidebarContextValue { + view: RightSidebarView | null + setView: (view: RightSidebarView | null) => void + clearView: () => void +} + +const RightSidebarContext = createContext(null) + +export function RightSidebarProvider({ children }: { children: ReactNode }) { + const [view, setView] = useState(null) + + const clearView = useCallback(() => { + setView(null) + }, []) + + const value = useMemo( + () => ({ + view, + setView, + clearView, + }), + [view, clearView] + ) + + return ( + + {children} + + ) +} + +export function useRightSidebar() { + const context = useContext(RightSidebarContext) + if (!context) { + throw new Error("useRightSidebar must be used within RightSidebarProvider") + } + return context +} diff --git a/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx b/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx new file mode 100644 index 0000000..5773e12 --- /dev/null +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx @@ -0,0 +1,46 @@ +import { Image, MessageSquareQuote } from "lucide-react" +import type { ReactNode } from "react" +import { GuildMembersPanel } from "./guild-members-panel" +import type { RightSidebarView } from "./right-sidebar-types" + +function PlaceholderSidebar({ + title, + description, + icon, +}: { + title: string + description: string + icon: ReactNode +}) { + return ( +
+
+ {icon} +
+

{title}

+

{description}

+
+ ) +} + +export function RightSidebarPanel({ view }: { view: RightSidebarView }) { + return ( + + ) +} diff --git a/apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts b/apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts new file mode 100644 index 0000000..8f27a85 --- /dev/null +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts @@ -0,0 +1,26 @@ +import type { PresenceStatus } from "@repo/realtime-types" + +export type GuildMembersSidebarView = { + type: "guild-members" + guildSlug: string + channelId: string + presenceByUserId?: Record +} + +export type ThreadSidebarView = { + type: "thread" + guildSlug: string + channelId: string + threadId: string +} + +export type AttachmentsSidebarView = { + type: "attachments" + guildSlug: string + channelId: string +} + +export type RightSidebarView = + | GuildMembersSidebarView + | ThreadSidebarView + | AttachmentsSidebarView diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 6f421d6..c1285ed 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -1,11 +1,12 @@ import { authClient } from "@repo/auth/client" import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" -import { useCallback, useEffect, useRef } from "react" +import { useCallback, useEffect, useMemo, useRef } from "react" import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { ChatHeader } from "@/components/chat/header" import { MessageInput } from "@/components/chat/message-input" import { MessageList } from "@/components/chat/message-list" +import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" import { useSocket } from "@/context/socket-context" import { apiClient } from "@/lib/api-client" import type { ListMessagesResponse } from "@/lib/api-types" @@ -22,9 +23,30 @@ function ChannelView() { const { guildSlug, channelId } = Route.useParams() const socket = useSocket() const queryClient = useQueryClient() + const { setView, clearView } = useRightSidebar() const { data: session } = authClient.useSession() // Track nonces for optimistic messages so we can replace them on confirm const pendingNonces = useRef(new Set()) + const rightSidebarView = useMemo( + () => + ({ + type: "guild-members" as const, + guildSlug, + channelId, + }) as const, + [guildSlug, channelId] + ) + + useEffect(() => { + setView(rightSidebarView) + }, [setView, rightSidebarView]) + + useEffect( + () => () => { + clearView() + }, + [clearView] + ) const { data, isPending, isError, error } = useQuery({ queryKey: ["channel", guildSlug, channelId], diff --git a/apps/web/src/routes/_authenticated/dms.tsx b/apps/web/src/routes/_authenticated/dms.tsx index feb4317..4fde6a7 100644 --- a/apps/web/src/routes/_authenticated/dms.tsx +++ b/apps/web/src/routes/_authenticated/dms.tsx @@ -1,9 +1,17 @@ import { createFileRoute, Outlet } from "@tanstack/react-router" +import { useEffect } from "react" +import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" export const Route = createFileRoute("/_authenticated/dms")({ component: DMsLayout, }) function DMsLayout() { + const { clearView } = useRightSidebar() + + useEffect(() => { + clearView() + }, [clearView]) + return } diff --git a/packages/auth/src/lib/auth.ts b/packages/auth/src/lib/auth.ts index c589fe3..0c5933b 100644 --- a/packages/auth/src/lib/auth.ts +++ b/packages/auth/src/lib/auth.ts @@ -193,3 +193,5 @@ export const auth = betterAuth({ }) export type Session = typeof auth.$Infer.Session +export type ActiveGuild = typeof auth.$Infer.ActiveOrganization +export type ActiveGuildMember = ActiveGuild["members"][number] diff --git a/packages/realtime-types/src/events.ts b/packages/realtime-types/src/events.ts index 673da6e..589fd0a 100644 --- a/packages/realtime-types/src/events.ts +++ b/packages/realtime-types/src/events.ts @@ -1,5 +1,7 @@ import { z } from "zod" +export type PresenceStatus = "online" | "offline" | "idle" | "dnd" + export const channelRoomPayloadSchema = z.object({ channelId: z.string().uuid(), }) From 6584764061a3c8f344155ccd326613eb768aa87d Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Thu, 26 Feb 2026 10:00:53 -0800 Subject: [PATCH 2/5] feat(presence): add API-backed guild members sidebar with realtime status sync - add `GET /v1/guilds/{guildSlug}/members` route, schemas, handler, and router wiring - fetch right-sidebar member data via `apiClient` instead of `getFullOrganization` - derive sidebar member types from API client (`InferResponseType`) and remove inline hardcoded member types - restore/simplify guild members panel rendering with online/offline grouping - subscribe to socket presence events in the sidebar and patch React Query cache on: - `connect` - `presence:ready` - `presence:user:update` - simplify channel route sidebar view state to `{ type, guildSlug, channelId }` - add shared presence Redis key/events in realtime types and update realtime presence service typing - update API/realtime package deps and lockfile for Redis + shared realtime types --- .env.example | 1 + apps/api/package.json | 2 + apps/api/src/app.ts | 2 + apps/api/src/lib/redis.ts | 25 +++ apps/api/src/routes/v1/guilds/handlers.ts | 66 +++++++ apps/api/src/routes/v1/guilds/index.ts | 10 + apps/api/src/routes/v1/guilds/routes.ts | 34 ++++ apps/api/src/routes/v1/guilds/schema.ts | 19 ++ apps/realtime/package.json | 2 + apps/realtime/src/index.ts | 130 ++++++++++++- apps/realtime/src/services/presence.ts | 55 ++++++ .../right-panel/guild-members-panel.tsx | 173 ++++++++++++------ .../right-panel/right-sidebar-types.ts | 3 - apps/web/src/lib/api-types.ts | 10 + .../_authenticated/$guildSlug/$channelId.tsx | 27 +-- packages/env/src/server.ts | 1 + packages/realtime-types/src/events.ts | 28 +++ pnpm-lock.yaml | 135 ++++++++++++++ 18 files changed, 635 insertions(+), 88 deletions(-) create mode 100644 apps/api/src/lib/redis.ts create mode 100644 apps/api/src/routes/v1/guilds/handlers.ts create mode 100644 apps/api/src/routes/v1/guilds/index.ts create mode 100644 apps/api/src/routes/v1/guilds/routes.ts create mode 100644 apps/api/src/routes/v1/guilds/schema.ts create mode 100644 apps/realtime/src/services/presence.ts diff --git a/.env.example b/.env.example index 2a60a62..d44b62c 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,5 @@ NEXT_PUBLIC_REALTIME_URL=http://localhost:8000 NODE_ENV=development PORT=8080 REALTIME_PORT=8000 +REDIS_URL=redis://127.0.0.1:6379 SELF_HOSTED=true diff --git a/apps/api/package.json b/apps/api/package.json index 93d1ad7..950329a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,12 +15,14 @@ "@repo/auth": "workspace:*", "@repo/db": "workspace:*", "@repo/env": "workspace:*", + "@repo/realtime-types": "workspace:*", "dotenv": "^17.2.4", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", "hono-pino": "^0.8.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", + "redis": "^4.7.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8b82992..a85a270 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi" import index from "@/routes/index.route" import channelsRouter from "@/routes/v1/channels/index" import dmsRouter from "@/routes/v1/dms/index" +import guildsRouter from "@/routes/v1/guilds/index" import waitlistRouter from "@/routes/waitlist/index" const app = createApp() @@ -28,6 +29,7 @@ app.route("/", index) const routes = app .route("/", waitlistRouter) .route("/v1", channelsRouter) + .route("/v1", guildsRouter) .route("/v1", dmsRouter) export type AppType = typeof routes diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts new file mode 100644 index 0000000..2c4d829 --- /dev/null +++ b/apps/api/src/lib/redis.ts @@ -0,0 +1,25 @@ +import { env } from "@repo/env/server" +import { createClient, type RedisClientType } from "redis" + +const redisClient: RedisClientType = createClient({ url: env.REDIS_URL }) + +let connectPromise: Promise | null = null + +redisClient.on("error", (error) => { + console.error("[api] redis error:", error) +}) + +export async function getRedisClient() { + if (redisClient.isOpen) { + return redisClient + } + + if (!connectPromise) { + connectPromise = redisClient.connect().finally(() => { + connectPromise = null + }) + } + + await connectPromise + return redisClient +} diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts new file mode 100644 index 0000000..7da47df --- /dev/null +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -0,0 +1,66 @@ +import { db, eq, schema } from "@repo/db" +import { PRESENCE_ONLINE_USERS_SET_KEY } from "@repo/realtime-types" +import { asc } from "drizzle-orm" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { getRedisClient } from "@/lib/redis" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { ListGuildMembersRoute } from "./routes" + +async function listOnlineUserIds(userIds: string[]) { + if (userIds.length === 0) return new Set() + + try { + const redis = await getRedisClient() + const membership = await Promise.all( + userIds.map((userId) => + redis.sIsMember(PRESENCE_ONLINE_USERS_SET_KEY, userId) + ) + ) + + const onlineIds = userIds.filter((_, index) => membership[index] === true) + + return new Set(onlineIds) + } catch (error) { + console.error("[api] failed to read presence from redis:", error) + return new Set() + } +} + +export const listGuildMembers: AppRouteHandler = async ( + c +) => { + const guild = c.var.guild + + const memberRows = await db + .select({ + userId: schema.guildMember.userId, + role: schema.guildMember.role, + name: schema.user.name, + image: schema.user.image, + }) + .from(schema.guildMember) + .innerJoin(schema.user, eq(schema.guildMember.userId, schema.user.id)) + .where(eq(schema.guildMember.guildId, guild.id)) + .orderBy(asc(schema.user.name)) + + const userIds = memberRows.map((row) => row.userId) + const onlineUserIds = await listOnlineUserIds(userIds) + + return c.json( + { + guildId: guild.id, + guildSlug: guild.slug, + guildName: guild.name, + members: memberRows.map((member) => ({ + userId: member.userId, + name: member.name, + image: member.image, + role: member.role, + status: onlineUserIds.has(member.userId) + ? ("online" as const) + : ("offline" as const), + })), + }, + HttpStatusCodes.OK + ) +} diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts new file mode 100644 index 0000000..a5543fe --- /dev/null +++ b/apps/api/src/routes/v1/guilds/index.ts @@ -0,0 +1,10 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "./handlers" +import * as routes from "./routes" + +const guildsRouter = createRouter().openapi( + routes.listGuildMembers, + handlers.listGuildMembers +) + +export default guildsRouter diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts new file mode 100644 index 0000000..1fec3ee --- /dev/null +++ b/apps/api/src/routes/v1/guilds/routes.ts @@ -0,0 +1,34 @@ +import { createRoute } from "@hono/zod-openapi" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import jsonContent from "@/lib/helpers/openapi/json-content" +import { + forbiddenSchema, + internalServerErrorSchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { guildAuthMiddleware } from "@/middleware/guild-auth" +import { guildSlugParamsSchema, listGuildMembersResponseSchema } from "./schema" + +export const listGuildMembers = createRoute({ + path: "/guilds/{guildSlug}/members", + method: "get", + summary: "List guild members with presence", + description: + "Returns all guild members and their current online/offline status.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listGuildMembersResponseSchema, + description: "Guild members with presence status", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ListGuildMembersRoute = typeof listGuildMembers diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/guilds/schema.ts new file mode 100644 index 0000000..c34b020 --- /dev/null +++ b/apps/api/src/routes/v1/guilds/schema.ts @@ -0,0 +1,19 @@ +import { z } from "@hono/zod-openapi" +import { guildSlugParamsSchema } from "@/routes/v1/channels/schema" + +export { guildSlugParamsSchema } + +export const guildMemberPresenceSchema = z.object({ + userId: z.string().uuid(), + name: z.string(), + image: z.string().nullable(), + role: z.string(), + status: z.enum(["online", "offline"]), +}) + +export const listGuildMembersResponseSchema = z.object({ + guildId: z.string().uuid(), + guildSlug: z.string(), + guildName: z.string(), + members: z.array(guildMemberPresenceSchema), +}) diff --git a/apps/realtime/package.json b/apps/realtime/package.json index fcb4e72..732d59e 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -10,10 +10,12 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@socket.io/redis-adapter": "^8.3.0", "@repo/auth": "workspace:*", "@repo/db": "workspace:*", "@repo/env": "workspace:*", "@repo/realtime-types": "workspace:*", + "redis": "^4.7.0", "socket.io": "^4.8.1", "zod": "^4.3.6" }, diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index fb209df..e0873c2 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -12,19 +12,28 @@ import { channelRoomPayloadSchema, guildRoom, markChannelReadPayloadSchema, + presenceSubscribePayloadSchema, sendMessagePayloadSchema, userRoom, } from "@repo/realtime-types" +import { createAdapter } from "@socket.io/redis-adapter" +import { createClient } from "redis" import { Server, type Socket } from "socket.io" import { toErrorMessage } from "@/lib/errors" import { assertUserCanAccessChannel } from "@/services/channel-access" import { createMessage } from "@/services/messages" import { buildMessageFanout } from "@/services/notifications" +import { + listOnlineUserIds, + markUserConnected, + markUserDisconnected, +} from "@/services/presence" import { markChannelRead } from "@/services/read-states" type SocketData = { user: Session["user"] session: Session["session"] + guildIds?: string[] } type RealtimeSocket = Socket< @@ -63,6 +72,20 @@ const corsOrigins = (env.REALTIME_CORS_ORIGIN || defaultOrigins.join(",")) .map((origin) => origin.trim()) .filter(Boolean) +const redisPubClient = createClient({ url: env.REDIS_URL }) +const redisSubClient = redisPubClient.duplicate() +const redisPresenceClient = redisPubClient.duplicate() + +redisPubClient.on("error", (error) => { + console.error("[realtime] redis pub error:", error) +}) +redisSubClient.on("error", (error) => { + console.error("[realtime] redis sub error:", error) +}) +redisPresenceClient.on("error", (error) => { + console.error("[realtime] redis presence error:", error) +}) + const httpServer = createServer((req, res) => { if (req.url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }) @@ -125,13 +148,30 @@ async function initializeConnection(socket: RealtimeSocket) { .from(schema.guildMember) .where(eq(schema.guildMember.userId, socket.data.user.id)) - const guildPresenceRooms = guildMembershipRows.map((row) => - guildRoom(row.guildId) - ) + const guildIds = guildMembershipRows.map((row) => row.guildId) + socket.data.guildIds = guildIds + + const guildPresenceRooms = guildIds.map((guildId) => guildRoom(guildId)) if (guildPresenceRooms.length > 0) { await socket.join(guildPresenceRooms) } + const { becameOnline } = await markUserConnected( + redisPresenceClient, + socket.data.user.id, + socket.id + ) + + if (becameOnline) { + for (const guildId of guildIds) { + io.to(guildRoom(guildId)).emit("presence:user:update", { + guildId, + userId: socket.data.user.id, + status: "online", + }) + } + } + socket.emit("presence:ready", { userId: socket.data.user.id, rooms: { @@ -153,6 +193,41 @@ async function initializeConnection(socket: RealtimeSocket) { } io.on("connection", (socket) => { + socket.on("presence:subscribe", async (payload, ack) => { + try { + const parsed = presenceSubscribePayloadSchema.parse(payload) + const guildIds = socket.data.guildIds ?? [] + + if (!guildIds.includes(parsed.guildId)) { + ack?.({ ok: false, error: "Forbidden" }) + return + } + + const guildMemberRows = await db + .select({ + userId: schema.guildMember.userId, + }) + .from(schema.guildMember) + .where(eq(schema.guildMember.guildId, parsed.guildId)) + + const userIds = [...new Set(guildMemberRows.map((row) => row.userId))] + const onlineUserIds = await listOnlineUserIds( + redisPresenceClient, + userIds + ) + + ack?.({ + ok: true, + snapshot: { + guildId: parsed.guildId, + onlineUserIds, + }, + }) + } catch (error) { + ack?.({ ok: false, error: toErrorMessage(error) }) + } + }) + socket.on("channel:join", async (payload, ack) => { try { const parsed = channelRoomPayloadSchema.parse(payload) @@ -228,10 +303,53 @@ io.on("connection", (socket) => { } }) + socket.on("disconnect", () => { + void (async () => { + try { + const { becameOffline } = await markUserDisconnected( + redisPresenceClient, + socket.data.user.id, + socket.id + ) + + if (!becameOffline) return + + for (const guildId of socket.data.guildIds ?? []) { + io.to(guildRoom(guildId)).emit("presence:user:update", { + guildId, + userId: socket.data.user.id, + status: "offline", + }) + } + } catch (error) { + console.error("[realtime] disconnect presence cleanup failed:", { + socketId: socket.id, + userId: socket.data.user.id, + error, + }) + } + })() + }) + void initializeConnection(socket) }) -httpServer.listen(realtimePort, () => { - console.log(`Realtime server running on port ${realtimePort}`) - console.log(`Allowed origins: ${corsOrigins.join(", ")}`) +async function bootstrap() { + await Promise.all([ + redisPubClient.connect(), + redisSubClient.connect(), + redisPresenceClient.connect(), + ]) + + io.adapter(createAdapter(redisPubClient, redisSubClient)) + + httpServer.listen(realtimePort, () => { + console.log(`Realtime server running on port ${realtimePort}`) + console.log(`Allowed origins: ${corsOrigins.join(", ")}`) + }) +} + +bootstrap().catch((error) => { + console.error("[realtime] failed to start:", error) + process.exit(1) }) diff --git a/apps/realtime/src/services/presence.ts b/apps/realtime/src/services/presence.ts new file mode 100644 index 0000000..a101c79 --- /dev/null +++ b/apps/realtime/src/services/presence.ts @@ -0,0 +1,55 @@ +import { PRESENCE_ONLINE_USERS_SET_KEY } from "@repo/realtime-types" +import type { createClient } from "redis" + +type RedisClient = ReturnType + +function userSocketsKey(userId: string) { + return `presence:user:${userId}:sockets` +} + +export async function markUserConnected( + redis: RedisClient, + userId: string, + socketId: string +) { + await redis.sAdd(userSocketsKey(userId), socketId) + const socketCount = await redis.sCard(userSocketsKey(userId)) + + if (socketCount === 1) { + await redis.sAdd(PRESENCE_ONLINE_USERS_SET_KEY, userId) + return { becameOnline: true } + } + + return { becameOnline: false } +} + +export async function markUserDisconnected( + redis: RedisClient, + userId: string, + socketId: string +) { + await redis.sRem(userSocketsKey(userId), socketId) + const socketCount = await redis.sCard(userSocketsKey(userId)) + + if (socketCount === 0) { + await Promise.all([ + redis.sRem(PRESENCE_ONLINE_USERS_SET_KEY, userId), + redis.del(userSocketsKey(userId)), + ]) + return { becameOffline: true } + } + + return { becameOffline: false } +} + +export async function listOnlineUserIds(redis: RedisClient, userIds: string[]) { + if (userIds.length === 0) return [] + + const membership = await Promise.all( + userIds.map((userId) => + redis.sIsMember(PRESENCE_ONLINE_USERS_SET_KEY, userId) + ) + ) + + return userIds.filter((_, index) => membership[index] === true) +} diff --git a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx index a2c1cfe..7c46431 100644 --- a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx @@ -1,65 +1,41 @@ -import type { ActiveGuild, ActiveGuildMember } from "@repo/auth" -import { authClient } from "@repo/auth/client" -import type { PresenceStatus } from "@repo/realtime-types" +import type { PresenceUserUpdate } from "@repo/realtime-types" import { ScrollArea } from "@repo/ui/components/scroll-area" import { Skeleton } from "@repo/ui/components/skeleton" import { cn } from "@repo/ui/lib/utils" -import { useQuery } from "@tanstack/react-query" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { Users } from "lucide-react" -import { useMemo } from "react" -import { UserAvatar } from "../../ui/user-avatar" +import { useEffect, useMemo } from "react" +import { UserAvatar } from "@/components/ui/user-avatar" +import { useSocket } from "@/context/socket-context" +import { apiClient } from "@/lib/api-client" +import type { + GuildMemberPresence, + ListGuildMembersResponse, +} from "@/lib/api-types" import type { GuildMembersSidebarView } from "./right-sidebar-types" -function mapGuildMembersToRows( - members: ActiveGuild["members"] | undefined, - sessionUserId: string | null, - presenceByUserId?: Record -) { - return (members ?? []) - .map((member: ActiveGuildMember) => { - const id = member.user?.id ?? member.userId ?? member.id - const fallbackName = member.user?.email ?? "Unknown member" - return { - id, - name: member.user?.name?.trim() || fallbackName, - image: member.user?.image ?? null, - role: member.role ?? "member", - status: - presenceByUserId?.[id] ?? - (id === sessionUserId ? "online" : "offline"), - } - }) - .sort((a, b) => a.name.localeCompare(b.name)) -} - -type GuildMemberRow = ReturnType[number] - -const statusStyles: Record = { +const statusStyles: Record = { online: "bg-emerald-500", offline: "bg-muted-foreground/40", - idle: "bg-amber-500", - dnd: "bg-rose-500", } -const statusLabel: Record = { +const statusLabel: Record = { online: "Online", offline: "Offline", - idle: "Idle", - dnd: "Do Not Disturb", } -function formatRole(role: string) { +function formatRole(role: GuildMemberPresence["role"]) { if (!role) return "Member" return role.charAt(0).toUpperCase() + role.slice(1) } -function MemberSkeleton() { +function MembersSkeleton() { return (
- {Array.from({ length: 6 }).map((_, i) => ( + {Array.from({ length: 6 }).map((_, index) => (
@@ -73,7 +49,7 @@ function MemberSkeleton() { ) } -function MemberRow({ member }: { member: GuildMemberRow }) { +function MemberRow({ member }: { member: GuildMemberPresence }) { return (
@@ -99,28 +75,99 @@ function MemberRow({ member }: { member: GuildMemberRow }) { } export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { - const { data: session } = authClient.useSession() + const socket = useSocket() + const queryClient = useQueryClient() + const queryKey = useMemo( + () => ["guild-members", view.guildSlug] as const, + [view.guildSlug] + ) - const { data: guild, isPending } = useQuery({ - queryKey: ["active-guild", view.guildSlug], + const { data, isPending, isError } = useQuery({ + queryKey, queryFn: async () => { - const res = await authClient.organization.getFullOrganization() - return res.data ?? null + const res = await apiClient.v1.guilds[":guildSlug"].members.$get({ + param: { guildSlug: view.guildSlug }, + }) + if (!res.ok) throw new Error("Failed to fetch guild members") + return res.json() }, }) - const members = useMemo(() => { - const sessionUserId = session?.user.id ?? null - return mapGuildMembersToRows( - guild?.members, - sessionUserId, - view.presenceByUserId - ) - }, [guild?.members, session?.user.id, view.presenceByUserId]) + useEffect(() => { + if (!socket || !data?.guildId) return + + const applySnapshot = (onlineUserIds: string[]) => { + const onlineSet = new Set(onlineUserIds) + queryClient.setQueryData( + queryKey, + (current) => { + if (!current) return current + return { + ...current, + members: current.members.map((member) => ({ + ...member, + status: onlineSet.has(member.userId) ? "online" : "offline", + })), + } + } + ) + } + + const requestSnapshot = () => { + socket.emit("presence:subscribe", { guildId: data.guildId }, (result) => { + if (!result.ok) return + applySnapshot(result.snapshot.onlineUserIds) + }) + } + + const onPresenceReady = () => { + requestSnapshot() + } + const onConnect = () => { + requestSnapshot() + } + + const onPresenceUpdate = (payload: PresenceUserUpdate) => { + if (payload.guildId !== data.guildId) return + const nextStatus: GuildMemberPresence["status"] = + payload.status === "offline" ? "offline" : "online" + + queryClient.setQueryData( + queryKey, + (current) => { + if (!current) return current + return { + ...current, + members: current.members.map((member) => + member.userId === payload.userId + ? { ...member, status: nextStatus } + : member + ), + } + } + ) + } + + socket.on("presence:ready", onPresenceReady) + socket.on("connect", onConnect) + socket.on("presence:user:update", onPresenceUpdate) + + if (socket.connected) { + requestSnapshot() + } + + return () => { + socket.off("presence:ready", onPresenceReady) + socket.off("connect", onConnect) + socket.off("presence:user:update", onPresenceUpdate) + } + }, [socket, data?.guildId, queryClient, queryKey]) + + const members = data?.members ?? [] const onlineMembers = members.filter((member) => member.status !== "offline") const offlineMembers = members.filter((member) => member.status === "offline") - const guildName = guild?.name?.trim() || "Guild" + const guildName = data?.guildName?.trim() || "Guild" return (
@@ -130,12 +177,16 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { {guildName} Members

- {members.length} total • presence is currently mocked + {members.length} total members

{isPending ? ( - + + ) : isError ? ( +
+ Failed to load members. +
) : ( {members.length === 0 ? ( @@ -147,11 +198,11 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { {onlineMembers.length > 0 && (
- Online — {onlineMembers.length} + Online - {onlineMembers.length}
{onlineMembers.map((member) => ( - + ))}
@@ -160,11 +211,11 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { {offlineMembers.length > 0 && (
- Offline — {offlineMembers.length} + Offline - {offlineMembers.length}
{offlineMembers.map((member) => ( - + ))}
diff --git a/apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts b/apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts index 8f27a85..434bd29 100644 --- a/apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-types.ts @@ -1,10 +1,7 @@ -import type { PresenceStatus } from "@repo/realtime-types" - export type GuildMembersSidebarView = { type: "guild-members" guildSlug: string channelId: string - presenceByUserId?: Record } export type ThreadSidebarView = { diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 0ba3b0a..18b9bc6 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -46,3 +46,13 @@ export type ListDMMessagesResponse = InferResponseType< DMMessagesClient["$get"], 200 > + +// ── Guild Members ────────────────────────────────────────── + +type GuildMembersClient = Client["v1"]["guilds"][":guildSlug"]["members"] + +export type ListGuildMembersResponse = InferResponseType< + GuildMembersClient["$get"], + 200 +> +export type GuildMemberPresence = ListGuildMembersResponse["members"][number] diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index c1285ed..101bf68 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -1,7 +1,7 @@ import { authClient } from "@repo/auth/client" import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" -import { useCallback, useEffect, useMemo, useRef } from "react" +import { useCallback, useEffect, useRef } from "react" import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { ChatHeader } from "@/components/chat/header" import { MessageInput } from "@/components/chat/message-input" @@ -27,26 +27,17 @@ function ChannelView() { const { data: session } = authClient.useSession() // Track nonces for optimistic messages so we can replace them on confirm const pendingNonces = useRef(new Set()) - const rightSidebarView = useMemo( - () => - ({ - type: "guild-members" as const, - guildSlug, - channelId, - }) as const, - [guildSlug, channelId] - ) useEffect(() => { - setView(rightSidebarView) - }, [setView, rightSidebarView]) - - useEffect( - () => () => { + setView({ + type: "guild-members", + guildSlug, + channelId, + }) + return () => { clearView() - }, - [clearView] - ) + } + }, [setView, clearView, guildSlug, channelId]) const { data, isPending, isError, error } = useQuery({ queryKey: ["channel", guildSlug, channelId], diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index a3f2da6..10a41ab 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -31,6 +31,7 @@ const serverSchema = z.object({ PORT: z.coerce.number().int().min(1).max(65535).default(8080), REALTIME_PORT: z.coerce.number().int().min(1).max(65535).default(8000), REALTIME_CORS_ORIGIN: z.string().default(DEFAULT_REALTIME_CORS_ORIGIN), + REDIS_URL: z.string().url(), BETTER_AUTH_SECRET: z.string().min(1), SELF_HOSTED: z.coerce.boolean().default(true), MAX_FILE_UPLOAD_SIZE: z.coerce.number().default(DEFAULT_MAX_FILE_UPLOAD_SIZE), diff --git a/packages/realtime-types/src/events.ts b/packages/realtime-types/src/events.ts index 589fd0a..7638e16 100644 --- a/packages/realtime-types/src/events.ts +++ b/packages/realtime-types/src/events.ts @@ -1,6 +1,11 @@ import { z } from "zod" export type PresenceStatus = "online" | "offline" | "idle" | "dnd" +export const PRESENCE_ONLINE_USERS_SET_KEY = "presence:online-users" + +export const presenceSubscribePayloadSchema = z.object({ + guildId: z.string().uuid(), +}) export const channelRoomPayloadSchema = z.object({ channelId: z.string().uuid(), @@ -22,6 +27,9 @@ export type SendMessagePayload = z.infer export type MarkChannelReadPayload = z.infer< typeof markChannelReadPayloadSchema > +export type PresenceSubscribePayload = z.infer< + typeof presenceSubscribePayloadSchema +> type OkResult = { ok: true } type ErrorResult = { ok: false; error: string } @@ -70,6 +78,21 @@ export type MarkChannelReadAck = ( result: { ok: true; state: ChannelReadState } | ErrorResult ) => void +export type PresenceSnapshot = { + guildId: string + onlineUserIds: string[] +} + +export type PresenceSubscribeAck = ( + result: { ok: true; snapshot: PresenceSnapshot } | ErrorResult +) => void + +export type PresenceUserUpdate = { + guildId: string + userId: string + status: PresenceStatus +} + export type UnreadNotification = { channelId: string guildId: string | null @@ -87,6 +110,10 @@ export type MentionNotification = { } export interface ClientToServerEvents { + "presence:subscribe": ( + payload: PresenceSubscribePayload, + ack?: PresenceSubscribeAck + ) => void "channel:join": (payload: ChannelRoomPayload, ack?: JoinLeaveAck) => void "channel:leave": (payload: ChannelRoomPayload, ack?: JoinLeaveAck) => void "message:send": (payload: SendMessagePayload, ack?: SendMessageAck) => void @@ -104,6 +131,7 @@ export interface ServerToClientEvents { guilds: string[] } }) => void + "presence:user:update": (payload: PresenceUserUpdate) => void "message:created": (payload: RealtimeMessage) => void "notification:unread": (payload: UnreadNotification) => void "notification:mention": (payload: MentionNotification) => void diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b9a4f4..6244d72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@repo/env': specifier: workspace:* version: link:../../packages/env + '@repo/realtime-types': + specifier: workspace:* + version: link:../../packages/realtime-types dotenv: specifier: ^17.2.4 version: 17.2.4 @@ -59,6 +62,9 @@ importers: pino-pretty: specifier: ^13.0.0 version: 13.1.3 + redis: + specifier: ^4.7.0 + version: 4.7.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -87,6 +93,12 @@ importers: '@repo/realtime-types': specifier: workspace:* version: link:../../packages/realtime-types + '@socket.io/redis-adapter': + specifier: ^8.3.0 + version: 8.3.0(socket.io-adapter@2.5.6) + redis: + specifier: ^4.7.0 + version: 4.7.1 socket.io: specifier: ^4.8.1 version: 4.8.3 @@ -2178,6 +2190,35 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2316,6 +2357,12 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@socket.io/redis-adapter@8.3.0': + resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==} + engines: {node: '>=10.0.0'} + peerDependencies: + socket.io-adapter: ^2.5.4 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2794,6 +2841,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -2884,6 +2935,15 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3255,6 +3315,10 @@ packages: fzf@0.5.2: resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3782,6 +3846,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + notepack.io@3.0.1: + resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4076,6 +4143,9 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4469,6 +4539,10 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + uid2@1.0.0: + resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} + engines: {node: '>= 4.0.0'} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4628,6 +4702,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -6236,6 +6313,32 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -6319,6 +6422,15 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.6)': + dependencies: + debug: 4.3.7 + notepack.io: 3.0.1 + socket.io-adapter: 2.5.6 + uid2: 1.0.0 + transitivePeerDependencies: + - supports-color + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -6770,6 +6882,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + code-block-writer@13.0.3: {} color-convert@2.0.1: @@ -6832,6 +6946,10 @@ snapshots: dateformat@4.6.3: {} + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -7211,6 +7329,8 @@ snapshots: fzf@0.5.2: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -7624,6 +7744,8 @@ snapshots: normalize-path@3.0.0: {} + notepack.io@3.0.1: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -7963,6 +8085,15 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -8454,6 +8585,8 @@ snapshots: ufo@1.6.3: {} + uid2@1.0.0: {} + undici-types@7.16.0: {} unicorn-magic@0.3.0: {} @@ -8557,6 +8690,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@2.8.2: {} yargs-parser@21.1.1: {} From d12856126e8327e0aad804f6aaddfd31b8ea1720 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Thu, 26 Feb 2026 10:26:01 -0800 Subject: [PATCH 3/5] fix(presence): harden guild presence sync (atomic redis ops, init gating, batched membership) and align guild API imports/docs --- apps/api/src/routes/v1/guilds/handlers.ts | 21 ++++-- apps/api/src/routes/v1/guilds/index.ts | 4 +- apps/api/src/routes/v1/guilds/routes.ts | 2 + apps/realtime/src/index.ts | 21 +++++- apps/realtime/src/services/presence.ts | 65 ++++++++++++------- .../right-panel/guild-members-panel.tsx | 11 ++-- 6 files changed, 87 insertions(+), 37 deletions(-) diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index 7da47df..a483915 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -4,18 +4,29 @@ import { asc } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { getRedisClient } from "@/lib/redis" import type { AppRouteHandler } from "@/lib/types/app-types" -import type { ListGuildMembersRoute } from "./routes" +import type { ListGuildMembersRoute } from "@/routes/v1/guilds/routes" + +const PRESENCE_MEMBERSHIP_CHUNK_SIZE = 250 async function listOnlineUserIds(userIds: string[]) { if (userIds.length === 0) return new Set() try { const redis = await getRedisClient() - const membership = await Promise.all( - userIds.map((userId) => - redis.sIsMember(PRESENCE_ONLINE_USERS_SET_KEY, userId) + const membership: boolean[] = [] + + for ( + let index = 0; + index < userIds.length; + index += PRESENCE_MEMBERSHIP_CHUNK_SIZE + ) { + const chunk = userIds.slice(index, index + PRESENCE_MEMBERSHIP_CHUNK_SIZE) + const chunkMembership = await redis.smIsMember( + PRESENCE_ONLINE_USERS_SET_KEY, + chunk ) - ) + membership.push(...chunkMembership) + } const onlineIds = userIds.filter((_, index) => membership[index] === true) diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts index a5543fe..85b26c9 100644 --- a/apps/api/src/routes/v1/guilds/index.ts +++ b/apps/api/src/routes/v1/guilds/index.ts @@ -1,6 +1,6 @@ import { createRouter } from "@/lib/helpers/app/create-app" -import * as handlers from "./handlers" -import * as routes from "./routes" +import * as handlers from "@/routes/v1/guilds/handlers" +import * as routes from "@/routes/v1/guilds/routes" const guildsRouter = createRouter().openapi( routes.listGuildMembers, diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts index 1fec3ee..565bd47 100644 --- a/apps/api/src/routes/v1/guilds/routes.ts +++ b/apps/api/src/routes/v1/guilds/routes.ts @@ -4,6 +4,7 @@ import jsonContent from "@/lib/helpers/openapi/json-content" import { forbiddenSchema, internalServerErrorSchema, + notFoundSchema, unauthorizedSchema, } from "@/lib/helpers/openapi/schemas" import { guildAuthMiddleware } from "@/middleware/guild-auth" @@ -27,6 +28,7 @@ export const listGuildMembers = createRoute({ }), [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, }, }) diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index e0873c2..1bf03cc 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -34,6 +34,8 @@ type SocketData = { user: Session["user"] session: Session["session"] guildIds?: string[] + initialized?: boolean + initPromise?: Promise } type RealtimeSocket = Socket< @@ -179,6 +181,8 @@ async function initializeConnection(socket: RealtimeSocket) { guilds: guildPresenceRooms, }, }) + + return true } catch (error) { console.error( "initializeConnection failed (schema.guildMember lookup or socket.join):", @@ -189,12 +193,24 @@ async function initializeConnection(socket: RealtimeSocket) { } ) socket.disconnect(true) + return false } } io.on("connection", (socket) => { + socket.data.initialized = false + socket.on("presence:subscribe", async (payload, ack) => { try { + if (!socket.data.initialized) { + await socket.data.initPromise + } + + if (!socket.data.initialized) { + ack?.({ ok: false, error: "Socket initialization incomplete" }) + return + } + const parsed = presenceSubscribePayloadSchema.parse(payload) const guildIds = socket.data.guildIds ?? [] @@ -331,7 +347,10 @@ io.on("connection", (socket) => { })() }) - void initializeConnection(socket) + socket.data.initPromise = initializeConnection(socket).then((initialized) => { + socket.data.initialized = initialized + return initialized + }) }) async function bootstrap() { diff --git a/apps/realtime/src/services/presence.ts b/apps/realtime/src/services/presence.ts index a101c79..a5e9f7f 100644 --- a/apps/realtime/src/services/presence.ts +++ b/apps/realtime/src/services/presence.ts @@ -3,6 +3,31 @@ import type { createClient } from "redis" type RedisClient = ReturnType +const MARK_USER_CONNECTED_SCRIPT = ` +redis.call("SADD", KEYS[1], ARGV[1]) +local socketCount = redis.call("SCARD", KEYS[1]) +if socketCount == 1 then + redis.call("SADD", KEYS[2], ARGV[2]) + return 1 +end +return 0 +` + +const MARK_USER_DISCONNECTED_SCRIPT = ` +redis.call("SREM", KEYS[1], ARGV[1]) +local socketCount = redis.call("SCARD", KEYS[1]) +if socketCount == 0 then + redis.call("SREM", KEYS[2], ARGV[2]) + redis.call("DEL", KEYS[1]) + return 1 +end +return 0 +` + +function toRedisBoolean(value: unknown) { + return value === true || value === 1 || value === "1" +} + function userSocketsKey(userId: string) { return `presence:user:${userId}:sockets` } @@ -12,15 +37,12 @@ export async function markUserConnected( userId: string, socketId: string ) { - await redis.sAdd(userSocketsKey(userId), socketId) - const socketCount = await redis.sCard(userSocketsKey(userId)) - - if (socketCount === 1) { - await redis.sAdd(PRESENCE_ONLINE_USERS_SET_KEY, userId) - return { becameOnline: true } - } + const result = await redis.eval(MARK_USER_CONNECTED_SCRIPT, { + keys: [userSocketsKey(userId), PRESENCE_ONLINE_USERS_SET_KEY], + arguments: [socketId, userId], + }) - return { becameOnline: false } + return { becameOnline: toRedisBoolean(result) } } export async function markUserDisconnected( @@ -28,28 +50,21 @@ export async function markUserDisconnected( userId: string, socketId: string ) { - await redis.sRem(userSocketsKey(userId), socketId) - const socketCount = await redis.sCard(userSocketsKey(userId)) - - if (socketCount === 0) { - await Promise.all([ - redis.sRem(PRESENCE_ONLINE_USERS_SET_KEY, userId), - redis.del(userSocketsKey(userId)), - ]) - return { becameOffline: true } - } - - return { becameOffline: false } + const result = await redis.eval(MARK_USER_DISCONNECTED_SCRIPT, { + keys: [userSocketsKey(userId), PRESENCE_ONLINE_USERS_SET_KEY], + arguments: [socketId, userId], + }) + + return { becameOffline: toRedisBoolean(result) } } export async function listOnlineUserIds(redis: RedisClient, userIds: string[]) { if (userIds.length === 0) return [] - const membership = await Promise.all( - userIds.map((userId) => - redis.sIsMember(PRESENCE_ONLINE_USERS_SET_KEY, userId) - ) + const membership = await redis.smIsMember( + PRESENCE_ONLINE_USERS_SET_KEY, + userIds ) - return userIds.filter((_, index) => membership[index] === true) + return userIds.filter((_, index) => toRedisBoolean(membership[index])) } diff --git a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx index 7c46431..5359432 100644 --- a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx @@ -92,9 +92,10 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { return res.json() }, }) + const guildId = data?.guildId useEffect(() => { - if (!socket || !data?.guildId) return + if (!socket || !guildId) return const applySnapshot = (onlineUserIds: string[]) => { const onlineSet = new Set(onlineUserIds) @@ -114,7 +115,9 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { } const requestSnapshot = () => { - socket.emit("presence:subscribe", { guildId: data.guildId }, (result) => { + if (!guildId) return + + socket.emit("presence:subscribe", { guildId }, (result) => { if (!result.ok) return applySnapshot(result.snapshot.onlineUserIds) }) @@ -129,7 +132,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { } const onPresenceUpdate = (payload: PresenceUserUpdate) => { - if (payload.guildId !== data.guildId) return + if (!guildId || payload.guildId !== guildId) return const nextStatus: GuildMemberPresence["status"] = payload.status === "offline" ? "offline" : "online" @@ -162,7 +165,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { socket.off("connect", onConnect) socket.off("presence:user:update", onPresenceUpdate) } - }, [socket, data?.guildId, queryClient, queryKey]) + }, [socket, guildId, queryClient, queryKey]) const members = data?.members ?? [] const onlineMembers = members.filter((member) => member.status !== "offline") From cd3719617f2f67ca975b2974e1952981c4c72e71 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Thu, 26 Feb 2026 10:43:14 -0800 Subject: [PATCH 4/5] fix: fixed init promise on realtime sserver --- apps/realtime/src/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 1bf03cc..3476568 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -199,6 +199,10 @@ async function initializeConnection(socket: RealtimeSocket) { io.on("connection", (socket) => { socket.data.initialized = false + socket.data.initPromise = initializeConnection(socket).then((initialized) => { + socket.data.initialized = initialized + return initialized + }) socket.on("presence:subscribe", async (payload, ack) => { try { @@ -346,11 +350,6 @@ io.on("connection", (socket) => { } })() }) - - socket.data.initPromise = initializeConnection(socket).then((initialized) => { - socket.data.initialized = initialized - return initialized - }) }) async function bootstrap() { From f7286e7bd63fc09525b68d46934ee35b52ce3776 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Thu, 26 Feb 2026 10:59:19 -0800 Subject: [PATCH 5/5] fix(realtime): prevent stale online broadcasts during init/disconnect race --- apps/realtime/src/index.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 3476568..409972f 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -36,6 +36,7 @@ type SocketData = { guildIds?: string[] initialized?: boolean initPromise?: Promise + isAlive?: boolean } type RealtimeSocket = Socket< @@ -140,6 +141,7 @@ io.use(async (socket, next) => { async function initializeConnection(socket: RealtimeSocket) { try { + const initSocketId = socket.id const userPresenceRoom = userRoom(socket.data.user.id) await socket.join(userPresenceRoom) @@ -161,10 +163,26 @@ async function initializeConnection(socket: RealtimeSocket) { const { becameOnline } = await markUserConnected( redisPresenceClient, socket.data.user.id, - socket.id + initSocketId ) - if (becameOnline) { + const isCurrentSocketAlive = + socket.data.isAlive === true && + socket.connected && + socket.id === initSocketId + + if (!isCurrentSocketAlive) { + if (becameOnline) { + await markUserDisconnected( + redisPresenceClient, + socket.data.user.id, + initSocketId + ) + } + return false + } + + if (becameOnline && isCurrentSocketAlive) { for (const guildId of guildIds) { io.to(guildRoom(guildId)).emit("presence:user:update", { guildId, @@ -198,6 +216,7 @@ async function initializeConnection(socket: RealtimeSocket) { } io.on("connection", (socket) => { + socket.data.isAlive = true socket.data.initialized = false socket.data.initPromise = initializeConnection(socket).then((initialized) => { socket.data.initialized = initialized @@ -324,6 +343,8 @@ io.on("connection", (socket) => { }) socket.on("disconnect", () => { + socket.data.isAlive = false + void (async () => { try { const { becameOffline } = await markUserDisconnected(