Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
12 changes: 7 additions & 5 deletions apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion apps/realtime/src/services/messages.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/realtime/src/services/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
MentionNotification,
RealtimeMessage,
UnreadNotification,
} from "@/lib/events"
} from "@repo/realtime-types"
import type { AccessibleChannel } from "./channel-access"

type MessageFanoutInput = {
Expand Down
2 changes: 1 addition & 1 deletion apps/realtime/src/services/read-states.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/chat/chat-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full flex-col overflow-hidden">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
<Skeleton className="size-5 rounded" />
<Skeleton className="h-4 w-28 rounded" />
</div>

{/* Messages skeleton */}
<div className="flex flex-1 flex-col-reverse overflow-hidden py-4">
{SKELETON_GROUPS.map((group) => (
<div key={group.key} className="px-4 py-0.5">
<div className="flex gap-3">
<Skeleton className="mt-0.5 size-10 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-baseline gap-2">
<Skeleton
className="h-3.5 rounded"
style={{ width: group.nameWidth }}
/>
<Skeleton className="h-3 w-10 rounded" />
</div>
{group.lines.map((width, i) => (
<Skeleton
key={`${group.key}-${i}`}
className="h-3.5 rounded"
style={{ width }}
/>
))}
</div>
</div>
</div>
))}
</div>

{/* Input skeleton */}
<div className="shrink-0 px-4 pb-6">
<Skeleton className="h-11 w-full rounded-lg" />
</div>
</div>
)
}
70 changes: 61 additions & 9 deletions apps/web/src/components/chat/message-list.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -39,23 +41,69 @@ 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,
isLoading,
hasMore,
onLoadMore,
}: MessageListProps) {
const scrollRef = useRef<HTMLDivElement>(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 (
<output
className="flex flex-1 items-center justify-center"
aria-live="polite"
>
<span className="text-sm text-muted-foreground">
Loading messages...
</span>
</output>
<div className="flex flex-1 flex-col-reverse overflow-hidden py-4">
{MESSAGE_SKELETON_GROUPS.map((group) => (
<div key={group.key} className="px-4 py-0.5">
<div className="flex gap-3">
<Skeleton className="mt-0.5 size-10 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<div className="flex items-baseline gap-2">
<Skeleton
className="h-3.5 rounded"
style={{ width: group.nameWidth }}
/>
<Skeleton className="h-3 w-10 rounded" />
</div>
{group.lines.map((width, i) => (
<Skeleton
key={`${group.key}-${i}`}
className="h-3.5 rounded"
style={{ width }}
/>
))}
</div>
</div>
</div>
))}
</div>
)
}

Expand All @@ -64,7 +112,11 @@ export function MessageList({
}

return (
<div className="flex flex-1 flex-col-reverse overflow-y-auto py-4">
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex flex-1 flex-col-reverse overflow-y-auto py-4"
>
{messages.map((msg, i) => {
const next = messages[i + 1]
const showHeader = !next || next.authorId !== msg.authorId
Expand Down
86 changes: 55 additions & 31 deletions apps/web/src/components/sidebar/channel-panel/user-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<LayoutGroup>
<div className="ml-auto flex items-center rounded-lg border border-border bg-background p-1 h-8">
{themes.map(({ value, icon: Icon, label }) => {
const isActive = theme === value
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className="relative flex h-6 w-6 items-center justify-center rounded-md cursor-pointer"
aria-pressed={isActive}
aria-label={`Switch to ${label} theme`}
>
{isActive && (
<motion.div
layoutId="theme-switcher-pill"
className="absolute inset-0 rounded-md bg-accent"
transition={{
type: "spring",
stiffness: 500,
damping: 30,
}}
/>
)}
<Icon
className={cn(
"relative z-10 h-3.5 w-3.5 transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
/>
</button>
)
})}
</div>
</LayoutGroup>
)
}

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 ?? ""

Expand All @@ -39,7 +89,6 @@ export function UserBar() {

const handleOpenSettings = () => {
// TODO: Navigate to settings page
// navigate({ to: "/settings" })
}

return (
Expand Down Expand Up @@ -89,32 +138,7 @@ export function UserBar() {
onSelect={(e) => e.preventDefault()}
>
Theme
<ToggleGroup
size="sm"
type="single"
className="ml-auto flex gap-1 items-center border border-border rounded-lg px-1 h-8"
value={theme}
onValueChange={(value) => value && setTheme(value)}
>
<ToggleGroupItem
className="h-6 w-6 p-1.5 rounded-md"
value="light"
>
<Sun className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
className="h-6 w-6 p-1.5 rounded-md"
value="dark"
>
<Moon className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
className="h-6 w-6 p-1.5 rounded-md"
value="system"
>
<Laptop className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<ThemeSwitcher theme={theme} setTheme={setTheme} />
</DropdownMenuItem>

<DropdownMenuSeparator />
Expand Down
Loading