diff --git a/.env.example b/.env.example
index a402d55..2a60a62 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,7 @@
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
DATABASE_URL=postgresql://user:password@localhost:5432/townhall
NEXT_PUBLIC_API_URL=http://localhost:8080
+NEXT_PUBLIC_REALTIME_URL=http://localhost:8000
NODE_ENV=development
PORT=8080
REALTIME_PORT=8000
diff --git a/apps/realtime/package.json b/apps/realtime/package.json
index 949efa0..fcb4e72 100644
--- a/apps/realtime/package.json
+++ b/apps/realtime/package.json
@@ -13,6 +13,7 @@
"@repo/auth": "workspace:*",
"@repo/db": "workspace:*",
"@repo/env": "workspace:*",
+ "@repo/realtime-types": "workspace:*",
"socket.io": "^4.8.1",
"zod": "^4.3.6"
},
diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts
index a533409..fb209df 100644
--- a/apps/realtime/src/index.ts
+++ b/apps/realtime/src/index.ts
@@ -2,19 +2,21 @@ import { createServer } from "node:http"
import { auth, type Session } from "@repo/auth"
import { db, eq, schema } from "@repo/db"
import { env } from "@repo/env/server"
-import { Server, type Socket } from "socket.io"
-import { toErrorMessage } from "@/lib/errors"
import type {
ClientToServerEvents,
InterServerEvents,
ServerToClientEvents,
-} from "@/lib/events"
+} from "@repo/realtime-types"
import {
+ channelRoom,
channelRoomPayloadSchema,
+ guildRoom,
markChannelReadPayloadSchema,
sendMessagePayloadSchema,
-} from "@/lib/events"
-import { channelRoom, guildRoom, userRoom } from "@/lib/rooms"
+ userRoom,
+} from "@repo/realtime-types"
+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"
diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts
index 6441f9c..4259436 100644
--- a/apps/realtime/src/services/messages.ts
+++ b/apps/realtime/src/services/messages.ts
@@ -1,5 +1,5 @@
import { db, eq, schema } from "@repo/db"
-import type { RealtimeMessage, SendMessagePayload } from "@/lib/events"
+import type { RealtimeMessage, SendMessagePayload } from "@repo/realtime-types"
import {
type AccessibleChannel,
assertUserCanAccessChannel,
diff --git a/apps/realtime/src/services/notifications.ts b/apps/realtime/src/services/notifications.ts
index f24eff5..d986f94 100644
--- a/apps/realtime/src/services/notifications.ts
+++ b/apps/realtime/src/services/notifications.ts
@@ -3,7 +3,7 @@ import type {
MentionNotification,
RealtimeMessage,
UnreadNotification,
-} from "@/lib/events"
+} from "@repo/realtime-types"
import type { AccessibleChannel } from "./channel-access"
type MessageFanoutInput = {
diff --git a/apps/realtime/src/services/read-states.ts b/apps/realtime/src/services/read-states.ts
index 0ca83cf..367b729 100644
--- a/apps/realtime/src/services/read-states.ts
+++ b/apps/realtime/src/services/read-states.ts
@@ -1,5 +1,5 @@
import { and, count, db, desc, eq, gt, ne, schema, sql } from "@repo/db"
-import type { ChannelReadState } from "@/lib/events"
+import type { ChannelReadState } from "@repo/realtime-types"
import { assertUserCanAccessChannel } from "./channel-access"
type MarkChannelReadInput = {
diff --git a/apps/web/package.json b/apps/web/package.json
index cc22255..4688d6c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -16,6 +16,7 @@
"@repo/api-client": "workspace:*",
"@repo/auth": "workspace:*",
"@repo/env": "workspace:*",
+ "@repo/realtime-types": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
@@ -30,6 +31,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"zod": "^4.3.6"
diff --git a/apps/web/src/components/chat/chat-skeleton.tsx b/apps/web/src/components/chat/chat-skeleton.tsx
new file mode 100644
index 0000000..b6303d3
--- /dev/null
+++ b/apps/web/src/components/chat/chat-skeleton.tsx
@@ -0,0 +1,55 @@
+import { Skeleton } from "@repo/ui/components/skeleton"
+
+const SKELETON_GROUPS = [
+ { key: "a", nameWidth: "5rem", lines: ["80%", "45%"] },
+ { key: "b", nameWidth: "6.5rem", lines: ["60%"] },
+ { key: "c", nameWidth: "4rem", lines: ["90%", "70%", "35%"] },
+ { key: "d", nameWidth: "5.5rem", lines: ["50%"] },
+ { key: "e", nameWidth: "7rem", lines: ["75%", "55%"] },
+ { key: "f", nameWidth: "4.5rem", lines: ["65%"] },
+ { key: "g", nameWidth: "6rem", lines: ["85%", "40%"] },
+]
+
+export function ChatSkeleton() {
+ return (
+
+ {/* Header skeleton */}
+
+
+
+
+
+ {/* Messages skeleton */}
+
+ {SKELETON_GROUPS.map((group) => (
+
+
+
+
+
+
+
+
+ {group.lines.map((width, i) => (
+
+ ))}
+
+
+
+ ))}
+
+
+ {/* Input skeleton */}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx
index 58d3ef4..0e5cea1 100644
--- a/apps/web/src/components/chat/message-list.tsx
+++ b/apps/web/src/components/chat/message-list.tsx
@@ -1,4 +1,6 @@
+import { Skeleton } from "@repo/ui/components/skeleton"
import { Hash, User, Users } from "lucide-react"
+import { useCallback, useEffect, useRef } from "react"
import type { Message } from "@/lib/api-types"
import type { ChatContext } from "./header"
import { MessageItem } from "./message-item"
@@ -39,6 +41,16 @@ function EmptyState({ context }: { context: ChatContext }) {
)
}
+const MESSAGE_SKELETON_GROUPS = [
+ { key: "a", nameWidth: "5rem", lines: ["80%", "45%"] },
+ { key: "b", nameWidth: "6.5rem", lines: ["60%"] },
+ { key: "c", nameWidth: "4rem", lines: ["90%", "70%", "35%"] },
+ { key: "d", nameWidth: "5.5rem", lines: ["50%"] },
+ { key: "e", nameWidth: "7rem", lines: ["75%", "55%"] },
+ { key: "f", nameWidth: "4.5rem", lines: ["65%"] },
+ { key: "g", nameWidth: "6rem", lines: ["85%", "40%"] },
+]
+
export function MessageList({
context,
messages,
@@ -46,16 +58,52 @@ export function MessageList({
hasMore,
onLoadMore,
}: MessageListProps) {
+ const scrollRef = useRef(null)
+ const isNearBottom = useRef(true)
+ const prevMessageCount = useRef(messages.length)
+
+ const handleScroll = useCallback(() => {
+ const el = scrollRef.current
+ if (!el) return
+ // flex-col-reverse: scrollTop is 0 at bottom, negative when scrolled up
+ isNearBottom.current = Math.abs(el.scrollTop) < 150
+ }, [])
+
+ useEffect(() => {
+ // Always scroll on initial load (count went from 0 to N), otherwise only if near bottom
+ if (prevMessageCount.current === 0 || isNearBottom.current) {
+ scrollRef.current?.scrollTo({ top: 0 })
+ }
+ prevMessageCount.current = messages.length
+ }, [messages.length])
+
if (isLoading) {
return (
-
+
+ {MESSAGE_SKELETON_GROUPS.map((group) => (
+
+
+
+
+
+
+
+
+ {group.lines.map((width, i) => (
+
+ ))}
+
+
+
+ ))}
+
)
}
@@ -64,7 +112,11 @@ export function MessageList({
}
return (
-
+
{messages.map((msg, i) => {
const next = messages[i + 1]
const showHeader = !next || next.authorId !== msg.authorId
diff --git a/apps/web/src/components/sidebar/channel-panel/user-bar.tsx b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx
index db87e33..4ec17c3 100644
--- a/apps/web/src/components/sidebar/channel-panel/user-bar.tsx
+++ b/apps/web/src/components/sidebar/channel-panel/user-bar.tsx
@@ -8,8 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/ui/components/dropdown-menu"
-import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/toggle-group"
-import { useNavigate } from "@tanstack/react-router"
+import { cn } from "@repo/ui/lib/utils"
import {
ChevronsUpDown,
Laptop,
@@ -18,14 +17,65 @@ import {
Settings,
Sun,
} from "lucide-react"
+import { LayoutGroup, motion } from "motion/react"
import { useTheme } from "next-themes"
import { UserAvatar } from "../../ui/user-avatar"
+const themes = [
+ { value: "light", icon: Sun, label: "Light" },
+ { value: "dark", icon: Moon, label: "Dark" },
+ { value: "system", icon: Laptop, label: "System" },
+] as const
+
+function ThemeSwitcher({
+ theme,
+ setTheme,
+}: {
+ theme: string | undefined
+ setTheme: (theme: string) => void
+}) {
+ return (
+
+
+ {themes.map(({ value, icon: Icon, label }) => {
+ const isActive = theme === value
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
export function UserBar() {
const { data: session } = authClient.useSession()
const { setTheme, theme } = useTheme()
- const navigate = useNavigate()
-
const name = session?.user.name ?? "User"
const email = session?.user.email ?? ""
@@ -39,7 +89,6 @@ export function UserBar() {
const handleOpenSettings = () => {
// TODO: Navigate to settings page
- // navigate({ to: "/settings" })
}
return (
@@ -89,32 +138,7 @@ export function UserBar() {
onSelect={(e) => e.preventDefault()}
>
Theme
-
value && setTheme(value)}
- >
-
-
-
-
-
-
-
-
-
-
+
diff --git a/apps/web/src/context/socket-context.tsx b/apps/web/src/context/socket-context.tsx
new file mode 100644
index 0000000..38b552b
--- /dev/null
+++ b/apps/web/src/context/socket-context.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from "react"
+import { createContext, useContext, useEffect, useState } from "react"
+import { type AppSocket, getSocket } from "@/lib/socket"
+
+const SocketContext = createContext
(null)
+
+export function useSocket() {
+ return useContext(SocketContext)
+}
+
+export function SocketProvider({
+ enabled,
+ children,
+}: {
+ enabled: boolean
+ children: ReactNode
+}) {
+ const [socket, setSocket] = useState(null)
+
+ useEffect(() => {
+ if (!enabled) return
+
+ const s = getSocket()
+
+ const onConnect = () => {
+ console.log("[socket] connected:", s.id)
+ }
+ const onDisconnect = (reason: string) => {
+ console.log("[socket] disconnected:", reason)
+ }
+ const onConnectError = (err: Error) => {
+ console.error("[socket] connection error:", err.message)
+ }
+
+ s.on("connect", onConnect)
+ s.on("disconnect", onDisconnect)
+ s.on("connect_error", onConnectError)
+
+ s.connect()
+ setSocket(s)
+
+ return () => {
+ s.off("connect", onConnect)
+ s.off("disconnect", onDisconnect)
+ s.off("connect_error", onConnectError)
+ s.disconnect()
+ setSocket(null)
+ }
+ }, [enabled])
+
+ return (
+ {children}
+ )
+}
diff --git a/apps/web/src/lib/realtime-adapter.ts b/apps/web/src/lib/realtime-adapter.ts
new file mode 100644
index 0000000..40440ca
--- /dev/null
+++ b/apps/web/src/lib/realtime-adapter.ts
@@ -0,0 +1,45 @@
+import type { RealtimeMessage } from "@repo/realtime-types"
+import type { Message, MessageAuthor } from "./api-types"
+
+export function realtimeMessageToMessage(rm: RealtimeMessage): Message {
+ return {
+ id: rm.id,
+ channelId: rm.channelId,
+ authorId: rm.author.id,
+ content: rm.content,
+ type: rm.type,
+ createdAt: rm.createdAt,
+ author: rm.author,
+ referencedMessageId: null,
+ attachments: [],
+ embeds: [],
+ pinned: false,
+ editedAt: null,
+ }
+}
+
+/**
+ * Creates an optimistic message that appears instantly in the UI.
+ * Uses `nonce` as the temporary `id` so it can be replaced when the server confirms.
+ */
+export function createOptimisticMessage(
+ nonce: string,
+ channelId: string,
+ content: string,
+ author: MessageAuthor
+): Message {
+ return {
+ id: nonce,
+ channelId,
+ authorId: author.id,
+ content,
+ type: "default",
+ createdAt: new Date().toISOString(),
+ author,
+ referencedMessageId: null,
+ attachments: [],
+ embeds: [],
+ pinned: false,
+ editedAt: null,
+ }
+}
diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts
new file mode 100644
index 0000000..da61cab
--- /dev/null
+++ b/apps/web/src/lib/socket.ts
@@ -0,0 +1,21 @@
+import { env } from "@repo/env/client"
+import type {
+ ClientToServerEvents,
+ ServerToClientEvents,
+} from "@repo/realtime-types"
+import { io, type Socket } from "socket.io-client"
+
+export type AppSocket = Socket
+
+let socket: AppSocket | null = null
+
+export function getSocket(): AppSocket {
+ if (!socket) {
+ socket = io(env.NEXT_PUBLIC_REALTIME_URL, {
+ autoConnect: false,
+ withCredentials: true,
+ transports: ["websocket"],
+ })
+ }
+ return socket
+}
diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx
index f58a948..1a062a6 100644
--- a/apps/web/src/routes/_authenticated.tsx
+++ b/apps/web/src/routes/_authenticated.tsx
@@ -9,6 +9,7 @@ import {
import { useEffect } from "react"
import { OnboardingDialog } from "../components/onboarding/onboarding-dialog"
import { Sidebar } from "../components/sidebar"
+import { SocketProvider } from "../context/socket-context"
const LAST_PATH_KEY = "townhall:last-path"
@@ -62,11 +63,13 @@ function AuthenticatedLayout() {
guilds?.length === 0
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
)
}
diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
index 96aba6c..6f421d6 100644
--- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
+++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx
@@ -1,9 +1,18 @@
-import { useQuery } from "@tanstack/react-query"
+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 { 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 { useSocket } from "@/context/socket-context"
import { apiClient } from "@/lib/api-client"
+import type { ListMessagesResponse } from "@/lib/api-types"
+import {
+ createOptimisticMessage,
+ realtimeMessageToMessage,
+} from "@/lib/realtime-adapter"
export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({
component: ChannelView,
@@ -11,6 +20,11 @@ export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({
function ChannelView() {
const { guildSlug, channelId } = Route.useParams()
+ const socket = useSocket()
+ const queryClient = useQueryClient()
+ const { data: session } = authClient.useSession()
+ // Track nonces for optimistic messages so we can replace them on confirm
+ const pendingNonces = useRef(new Set())
const { data, isPending, isError, error } = useQuery({
queryKey: ["channel", guildSlug, channelId],
@@ -25,12 +39,137 @@ function ChannelView() {
},
})
+ const { data: messagesData, isPending: messagesLoading } = useQuery({
+ queryKey: ["messages", channelId],
+ queryFn: async () => {
+ const res = await apiClient.v1.guilds[":guildSlug"].channels[
+ ":channelId"
+ ].messages.$get({
+ param: { guildSlug, channelId },
+ query: {},
+ })
+ if (!res.ok) throw new Error("Failed to fetch messages")
+ return res.json()
+ },
+ enabled: !!data,
+ })
+
+ // Join/leave the channel room for real-time messages
+ useEffect(() => {
+ if (!socket) return
+
+ socket.emit("channel:join", { channelId })
+
+ return () => {
+ socket.emit("channel:leave", { channelId })
+ }
+ }, [socket, channelId])
+
+ // Listen for incoming messages
+ useEffect(() => {
+ if (!socket) return
+
+ const handleMessageCreated = (
+ msg: Parameters[0]
+ ) => {
+ if (msg.channelId !== channelId) return
+
+ queryClient.setQueryData(
+ ["messages", channelId],
+ (old) => {
+ if (!old) return old
+ // If this message was sent by us, replace the optimistic entry
+ if (msg.nonce && pendingNonces.current.has(msg.nonce)) {
+ pendingNonces.current.delete(msg.nonce)
+ return {
+ ...old,
+ data: old.data.map((m) =>
+ m.id === msg.nonce ? realtimeMessageToMessage(msg) : m
+ ),
+ }
+ }
+ // Otherwise it's from someone else — prepend if not already present
+ if (old.data.some((m) => m.id === msg.id)) return old
+ return {
+ ...old,
+ data: [realtimeMessageToMessage(msg), ...old.data],
+ }
+ }
+ )
+ }
+
+ socket.on("message:created", handleMessageCreated)
+ return () => {
+ socket.off("message:created", handleMessageCreated)
+ }
+ }, [socket, channelId, queryClient])
+
+ const handleSend = useCallback(
+ (content: string) => {
+ if (!socket?.connected || !session?.user) return
+
+ const nonce = crypto.randomUUID()
+ pendingNonces.current.add(nonce)
+
+ const author = {
+ id: session.user.id,
+ name: session.user.name,
+ username: session.user.username ?? null,
+ displayUsername: session.user.displayUsername ?? null,
+ image: session.user.image ?? null,
+ }
+
+ // Insert optimistic message immediately
+ queryClient.setQueryData(
+ ["messages", channelId],
+ (old) => {
+ if (!old) return old
+ return {
+ ...old,
+ data: [
+ createOptimisticMessage(nonce, channelId, content, author),
+ ...old.data,
+ ],
+ }
+ }
+ )
+
+ socket.emit("message:send", { channelId, content, nonce }, (result) => {
+ if (!result.ok) {
+ console.error("[chat] send failed:", result.error)
+ // Remove the optimistic message on failure
+ pendingNonces.current.delete(nonce)
+ queryClient.setQueryData(
+ ["messages", channelId],
+ (old) => {
+ if (!old) return old
+ return { ...old, data: old.data.filter((m) => m.id !== nonce) }
+ }
+ )
+ return
+ }
+
+ // Replace optimistic message with the confirmed one
+ pendingNonces.current.delete(nonce)
+ queryClient.setQueryData(
+ ["messages", channelId],
+ (old) => {
+ if (!old) return old
+ return {
+ ...old,
+ data: old.data.map((m) =>
+ m.id === nonce ? realtimeMessageToMessage(result.message) : m
+ ),
+ }
+ }
+ )
+ })
+ },
+ [socket, channelId, queryClient, session]
+ )
+
if (isPending) {
- return (
-
- Loading...
-
- )
+ return
}
if (isError) {
@@ -60,8 +199,12 @@ function ChannelView() {
return (
-
- {}} />
+
+
)
}
diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx
index e8de5d1..7a25727 100644
--- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx
+++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx
@@ -1,9 +1,18 @@
-import { useQuery } from "@tanstack/react-query"
+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 { 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 { useSocket } from "@/context/socket-context"
import { apiClient } from "@/lib/api-client"
+import type { ListDMMessagesResponse } from "@/lib/api-types"
+import {
+ createOptimisticMessage,
+ realtimeMessageToMessage,
+} from "@/lib/realtime-adapter"
export const Route = createFileRoute("/_authenticated/dms/$dmId")({
component: DMConversation,
@@ -11,6 +20,10 @@ export const Route = createFileRoute("/_authenticated/dms/$dmId")({
function DMConversation() {
const { dmId } = Route.useParams()
+ const socket = useSocket()
+ const queryClient = useQueryClient()
+ const { data: session } = authClient.useSession()
+ const pendingNonces = useRef(new Set())
const { data: dm, isPending } = useQuery({
queryKey: ["dms", dmId],
@@ -21,12 +34,134 @@ function DMConversation() {
},
})
+ const { data: messagesData, isPending: messagesLoading } = useQuery({
+ queryKey: ["messages", dmId],
+ queryFn: async () => {
+ const res = await apiClient.v1.dms[":dmId"].messages.$get({
+ param: { dmId },
+ query: {},
+ })
+ if (!res.ok) throw new Error("Failed to fetch messages")
+ return res.json()
+ },
+ enabled: !!dm,
+ })
+
+ // Join/leave the DM channel room for real-time messages
+ useEffect(() => {
+ if (!socket) return
+
+ socket.emit("channel:join", { channelId: dmId })
+
+ return () => {
+ socket.emit("channel:leave", { channelId: dmId })
+ }
+ }, [socket, dmId])
+
+ // Listen for incoming messages
+ useEffect(() => {
+ if (!socket) return
+
+ const handleMessageCreated = (
+ msg: Parameters[0]
+ ) => {
+ if (msg.channelId !== dmId) return
+
+ queryClient.setQueryData(
+ ["messages", dmId],
+ (old) => {
+ if (!old) return old
+ if (msg.nonce && pendingNonces.current.has(msg.nonce)) {
+ pendingNonces.current.delete(msg.nonce)
+ return {
+ ...old,
+ data: old.data.map((m) =>
+ m.id === msg.nonce ? realtimeMessageToMessage(msg) : m
+ ),
+ }
+ }
+ if (old.data.some((m) => m.id === msg.id)) return old
+ return {
+ ...old,
+ data: [realtimeMessageToMessage(msg), ...old.data],
+ }
+ }
+ )
+ }
+
+ socket.on("message:created", handleMessageCreated)
+ return () => {
+ socket.off("message:created", handleMessageCreated)
+ }
+ }, [socket, dmId, queryClient])
+
+ const handleSend = useCallback(
+ (content: string) => {
+ if (!socket?.connected || !session?.user) return
+
+ const nonce = crypto.randomUUID()
+ pendingNonces.current.add(nonce)
+
+ const author = {
+ id: session.user.id,
+ name: session.user.name,
+ username: session.user.username ?? null,
+ displayUsername: session.user.displayUsername ?? null,
+ image: session.user.image ?? null,
+ }
+
+ queryClient.setQueryData(
+ ["messages", dmId],
+ (old) => {
+ if (!old) return old
+ return {
+ ...old,
+ data: [
+ createOptimisticMessage(nonce, dmId, content, author),
+ ...old.data,
+ ],
+ }
+ }
+ )
+
+ socket.emit(
+ "message:send",
+ { channelId: dmId, content, nonce },
+ (result) => {
+ if (!result.ok) {
+ console.error("[chat] send failed:", result.error)
+ pendingNonces.current.delete(nonce)
+ queryClient.setQueryData(
+ ["messages", dmId],
+ (old) => {
+ if (!old) return old
+ return { ...old, data: old.data.filter((m) => m.id !== nonce) }
+ }
+ )
+ return
+ }
+
+ pendingNonces.current.delete(nonce)
+ queryClient.setQueryData(
+ ["messages", dmId],
+ (old) => {
+ if (!old) return old
+ return {
+ ...old,
+ data: old.data.map((m) =>
+ m.id === nonce ? realtimeMessageToMessage(result.message) : m
+ ),
+ }
+ }
+ )
+ }
+ )
+ },
+ [socket, dmId, queryClient, session]
+ )
+
if (isPending) {
- return (
-
- Loading...
-
- )
+ return
}
if (!dm) {
@@ -61,8 +196,12 @@ function DMConversation() {
return (
-
- {}} />
+
+
)
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 604aa9e..d579bf4 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -21,6 +21,9 @@ export default defineConfig(({ mode }) => {
"process.env.NEXT_PUBLIC_API_URL": JSON.stringify(
env.NEXT_PUBLIC_API_URL
),
+ "process.env.NEXT_PUBLIC_REALTIME_URL": JSON.stringify(
+ env.NEXT_PUBLIC_REALTIME_URL
+ ),
"process.env.NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE": JSON.stringify(
env.NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE
),
diff --git a/packages/env/src/client.ts b/packages/env/src/client.ts
index fec7a20..a7c4239 100644
--- a/packages/env/src/client.ts
+++ b/packages/env/src/client.ts
@@ -3,11 +3,10 @@ import { z } from "zod"
/** 20 MB default — keep in sync with server.ts */
const DEFAULT_MAX_FILE_UPLOAD_SIZE = 20 * 1024 * 1024
-/** Adds a protocol to a URL if missing. Defaults to http:// for localhost/loopback, https:// otherwise. */
+/** Adds a protocol to a URL if missing. Defaults to http:// for localhost/loopback, https:// otherwise. Preserves ws:// and wss:// schemes. */
const addProtocol = (url: string) => {
const trimmed = url.trim()
- if (trimmed.startsWith("http://") || trimmed.startsWith("https://"))
- return trimmed
+ if (/^(https?|wss?):\/\//i.test(trimmed)) return trimmed
const isLocal =
trimmed.startsWith("localhost") || trimmed.startsWith("127.0.0.1")
return isLocal ? `http://${trimmed}` : `https://${trimmed}`
@@ -18,6 +17,7 @@ const clientSchema = z.object({
.enum(["development", "staging", "production", "test"])
.default("production"),
NEXT_PUBLIC_API_URL: z.string().min(1).transform(addProtocol),
+ NEXT_PUBLIC_REALTIME_URL: z.string().min(1).transform(addProtocol),
NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE: z.coerce
.number()
.default(DEFAULT_MAX_FILE_UPLOAD_SIZE),
@@ -26,6 +26,7 @@ const clientSchema = z.object({
export const env = clientSchema.parse({
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
+ NEXT_PUBLIC_REALTIME_URL: process.env.NEXT_PUBLIC_REALTIME_URL,
NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE:
process.env.NEXT_PUBLIC_MAX_FILE_UPLOAD_SIZE,
})
diff --git a/packages/realtime-types/package.json b/packages/realtime-types/package.json
new file mode 100644
index 0000000..c5828d4
--- /dev/null
+++ b/packages/realtime-types/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@repo/realtime-types",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "exports": {
+ ".": "./src/index.ts",
+ "./events": "./src/events.ts",
+ "./rooms": "./src/rooms.ts"
+ },
+ "scripts": {
+ "check-types": "tsc --noEmit"
+ },
+ "dependencies": {
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@repo/typescript-config": "workspace:*"
+ }
+}
diff --git a/apps/realtime/src/lib/events.ts b/packages/realtime-types/src/events.ts
similarity index 100%
rename from apps/realtime/src/lib/events.ts
rename to packages/realtime-types/src/events.ts
diff --git a/packages/realtime-types/src/index.ts b/packages/realtime-types/src/index.ts
new file mode 100644
index 0000000..dffcb93
--- /dev/null
+++ b/packages/realtime-types/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./events"
+export * from "./rooms"
diff --git a/apps/realtime/src/lib/rooms.ts b/packages/realtime-types/src/rooms.ts
similarity index 100%
rename from apps/realtime/src/lib/rooms.ts
rename to packages/realtime-types/src/rooms.ts
diff --git a/packages/realtime-types/tsconfig.json b/packages/realtime-types/tsconfig.json
new file mode 100644
index 0000000..45135ea
--- /dev/null
+++ b/packages/realtime-types/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@repo/typescript-config/base.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
+ },
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d0bf64f..7b9a4f4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,6 +84,9 @@ importers:
'@repo/env':
specifier: workspace:*
version: link:../../packages/env
+ '@repo/realtime-types':
+ specifier: workspace:*
+ version: link:../../packages/realtime-types
socket.io:
specifier: ^4.8.1
version: 4.8.3
@@ -121,11 +124,14 @@ importers:
'@repo/env':
specifier: workspace:*
version: link:../../packages/env
+ '@repo/realtime-types':
+ specifier: workspace:*
+ version: link:../../packages/realtime-types
'@repo/ui':
specifier: workspace:*
version: link:../../packages/ui
'@repo/utils':
- specifier: workspace:^
+ specifier: workspace:*
version: link:../../packages/utils
'@tailwindcss/postcss':
specifier: ^4.1.18
@@ -163,6 +169,9 @@ importers:
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
+ socket.io-client:
+ specifier: ^4.8.3
+ version: 4.8.3
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -344,6 +353,16 @@ importers:
specifier: workspace:*
version: link:../typescript-config
+ packages/realtime-types:
+ dependencies:
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
+ devDependencies:
+ '@repo/typescript-config':
+ specifier: workspace:*
+ version: link:../typescript-config
+
packages/typescript-config: {}
packages/ui:
@@ -3049,6 +3068,9 @@ packages:
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+ engine.io-client@6.6.4:
+ resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==}
+
engine.io-parser@5.2.3:
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
engines: {node: '>=10.0.0'}
@@ -4194,6 +4216,10 @@ packages:
socket.io-adapter@2.5.6:
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
+ socket.io-client@4.8.3:
+ resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
+ engines: {node: '>=10.0.0'}
+
socket.io-parser@4.2.5:
resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==}
engines: {node: '>=10.0.0'}
@@ -4591,6 +4617,10 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
+ xmlhttprequest-ssl@2.1.2:
+ resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
+ engines: {node: '>=0.4.0'}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6877,6 +6907,18 @@ snapshots:
dependencies:
once: 1.4.0
+ engine.io-client@6.6.4:
+ dependencies:
+ '@socket.io/component-emitter': 3.1.2
+ debug: 4.4.3
+ engine.io-parser: 5.2.3
+ ws: 8.18.3
+ xmlhttprequest-ssl: 2.1.2
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
engine.io-parser@5.2.3: {}
engine.io@6.6.5:
@@ -8162,6 +8204,17 @@ snapshots:
- supports-color
- utf-8-validate
+ socket.io-client@4.8.3:
+ dependencies:
+ '@socket.io/component-emitter': 3.1.2
+ debug: 4.4.3
+ engine.io-client: 6.6.4
+ socket.io-parser: 4.2.5
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
socket.io-parser@4.2.5:
dependencies:
'@socket.io/component-emitter': 3.1.2
@@ -8498,6 +8551,8 @@ snapshots:
is-wsl: 3.1.0
powershell-utils: 0.1.0
+ xmlhttprequest-ssl@2.1.2: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}