From 0bffafd33909d2b2bb51e3d43ef2f8796083977b Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Sun, 22 Mar 2026 08:27:45 -0700 Subject: [PATCH 1/4] feat: added message search --- ROADMAP.md | 6 +- apps/api/src/routes/v1/dms/handlers.ts | 118 ++++++++++- apps/api/src/routes/v1/dms/index.ts | 1 + apps/api/src/routes/v1/dms/routes.ts | 24 +++ apps/api/src/routes/v1/dms/schema.ts | 31 ++- apps/api/src/routes/v1/guilds/handlers.ts | 104 +++++++++- apps/api/src/routes/v1/guilds/index.ts | 1 + apps/api/src/routes/v1/guilds/routes.ts | 28 +++ apps/api/src/routes/v1/guilds/schema.ts | 38 ++++ .../sidebar/channel-panel/search-bar.tsx | 194 +++++++++++++++++- .../components/sidebar/dm-panel/dm-panel.tsx | 2 +- 11 files changed, 534 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a6828d0..7ceae47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,7 +41,7 @@ - [x] Ally (friend) system with requests — schema, API, allies page, user profile popover with ally actions - [x] Direct messages — create 1:1 and group DMs with allies, new DM dialog - [x] User blocking — schema, API (block/unblock/list), realtime DM enforcement, blocked tab on allies page, block/unblock in profile popover, message collapse with click-to-reveal, typing/DM filtering -- [ ] Privacy settings +- [x] Privacy settings — user_privacy_settings table, API (get/update), DM/ally request/presence enforcement, Privacy & Safety settings UI, profile popover DM button ## Phase 4 — Tests & CI/CD @@ -52,7 +52,7 @@ ## Phase 5 — Polish -- [ ] Message search +- [x] Message search — guild-wide and DM search APIs, interactive search bar dropdown in both guild and DM panels - [x] Typing indicators - [x] Pinned messages panel - [ ] Thread support @@ -76,7 +76,7 @@ ## Phase 7 — v2 Features - [ ] Voice/video (Voice Chambers) -- [ ] Bots & webhooks +- [ ] Bots & webhooks (including inbound channel webhooks for integrations like GitHub PR notifications with @mentions) - [ ] Custom emojis (Sigils & Crests) - [ ] Server discovery - [ ] Forum channel posts diff --git a/apps/api/src/routes/v1/dms/handlers.ts b/apps/api/src/routes/v1/dms/handlers.ts index bba88fc..b98ad30 100644 --- a/apps/api/src/routes/v1/dms/handlers.ts +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -8,7 +8,7 @@ import { userBlock, userPrivacySettings, } from "@repo/db/schema" -import { and, count, desc, eq, inArray, ne, or, sql } from "drizzle-orm" +import { and, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { fetchMessagePage } from "@/lib/queries/messages" import type { AppRouteHandler } from "@/lib/types/app-types" @@ -17,6 +17,7 @@ import type { GetDMRoute, ListDMMessagesRoute, ListDMsRoute, + SearchDMMessagesRoute, } from "./routes" const emptyPage = (page: number) => ({ @@ -559,3 +560,118 @@ export const listDMMessages: AppRouteHandler = async ( HttpStatusCodes.OK ) } + +// ── Search ────────────────────────────────────────────── + +export const searchDMMessages: AppRouteHandler = async ( + c +) => { + const currentUser = c.var.user + const { query, page, perPage } = c.req.valid("query") + const offset = (page - 1) * perPage + + // Get all DM channel IDs the user is a member of + const dmChannels = await db + .select({ + id: channel.id, + name: channel.name, + type: channel.type, + }) + .from(channelMember) + .innerJoin(channel, eq(channelMember.channelId, channel.id)) + .where( + and( + eq(channelMember.userId, currentUser.id), + inArray(channel.type, ["dm", "group_dm"]) + ) + ) + + if (dmChannels.length === 0) { + return c.json(emptyPage(page), HttpStatusCodes.OK) + } + + const dmChannelIds = dmChannels.map((ch) => ch.id) + + // For DMs, get member names to use as channel labels + const dmMembers = await db + .select({ + channelId: channelMember.channelId, + name: user.name, + userId: user.id, + }) + .from(channelMember) + .innerJoin(user, eq(channelMember.userId, user.id)) + .where(inArray(channelMember.channelId, dmChannelIds)) + + const channelNameMap = new Map() + for (const ch of dmChannels) { + if (ch.type === "group_dm" && ch.name) { + channelNameMap.set(ch.id, ch.name) + } else { + const otherMembers = dmMembers.filter( + (m) => m.channelId === ch.id && m.userId !== currentUser.id + ) + channelNameMap.set( + ch.id, + otherMembers.map((m) => m.name).join(", ") || "DM" + ) + } + } + + const searchPattern = `%${query}%` + const whereConditions = and( + inArray(message.channelId, dmChannelIds), + ilike(message.content, searchPattern) + ) + + const [countResult, messages] = await Promise.all([ + db.select({ total: count() }).from(message).where(whereConditions), + db + .select({ + id: message.id, + content: message.content, + createdAt: message.createdAt, + channelId: message.channelId, + author: { + id: user.id, + name: user.name, + username: user.username, + displayUsername: user.displayUsername, + image: user.image, + }, + }) + .from(message) + .innerJoin(user, eq(message.authorId, user.id)) + .where(whereConditions) + .orderBy(desc(message.createdAt)) + .limit(perPage) + .offset(offset), + ]) + + const itemsTotal = countResult[0]?.total ?? 0 + const totalPages = Math.ceil(itemsTotal / perPage) + + return c.json( + { + itemsTotal, + currentPage: page, + nextPage: page < totalPages ? page + 1 : null, + prevPage: page > 1 ? page - 1 : null, + data: messages.map((msg) => ({ + id: msg.id, + content: msg.content ?? "", + createdAt: msg.createdAt.toISOString(), + channelId: msg.channelId, + channelName: channelNameMap.get(msg.channelId) ?? "DM", + author: { + id: msg.author.id, + name: msg.author.name, + username: msg.author.username, + displayUsername: msg.author.displayUsername, + image: msg.author.image, + }, + })), + }, + HttpStatusCodes.OK + ) +} diff --git a/apps/api/src/routes/v1/dms/index.ts b/apps/api/src/routes/v1/dms/index.ts index b59e235..afbff33 100644 --- a/apps/api/src/routes/v1/dms/index.ts +++ b/apps/api/src/routes/v1/dms/index.ts @@ -5,6 +5,7 @@ import * as routes from "./routes" const dmsRouter = createRouter() .openapi(routes.createDM, handlers.createDM) .openapi(routes.listDMs, handlers.listDMs) + .openapi(routes.searchDMMessages, handlers.searchDMMessages) .openapi(routes.getDM, handlers.getDM) .openapi(routes.listDMMessages, handlers.listDMMessages) diff --git a/apps/api/src/routes/v1/dms/routes.ts b/apps/api/src/routes/v1/dms/routes.ts index 601905c..e3e5772 100644 --- a/apps/api/src/routes/v1/dms/routes.ts +++ b/apps/api/src/routes/v1/dms/routes.ts @@ -18,6 +18,8 @@ import { listDMMessagesQuerySchema, listDMMessagesResponseSchema, listDMsResponseSchema, + searchDMMessagesQuerySchema, + searchDMMessagesResponseSchema, } from "./schema" export const createDM = createRoute({ @@ -112,6 +114,28 @@ export const listDMMessages = createRoute({ }, }) +export const searchDMMessages = createRoute({ + path: "/dms/search", + method: "get", + summary: "Search DM messages", + description: + "Searches messages across all DM and group DM conversations for the authenticated user.", + tags: ["DMs"], + middleware: [sessionAuthMiddleware] as const, + request: { + query: searchDMMessagesQuerySchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: searchDMMessagesResponseSchema, + description: "Search results", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + export type ListDMsRoute = typeof listDMs export type GetDMRoute = typeof getDM export type ListDMMessagesRoute = typeof listDMMessages +export type SearchDMMessagesRoute = typeof searchDMMessages diff --git a/apps/api/src/routes/v1/dms/schema.ts b/apps/api/src/routes/v1/dms/schema.ts index 519208f..c5c6bc9 100644 --- a/apps/api/src/routes/v1/dms/schema.ts +++ b/apps/api/src/routes/v1/dms/schema.ts @@ -3,8 +3,12 @@ import { selectChannelSchema } from "@repo/db/schema" import { listMessagesQuerySchema, listMessagesResponseSchema, + messageAuthorSchema, } from "@/lib/helpers/openapi/message-schemas" -import { paginatedResponseSchema } from "@/lib/helpers/openapi/schemas" +import { + paginatedResponseSchema, + paginationQuerySchema, +} from "@/lib/helpers/openapi/schemas" export const dmParamsSchema = z.object({ dmId: z @@ -63,3 +67,28 @@ export const getDMResponseSchema = dmChannelSchema export const listDMMessagesQuerySchema = listMessagesQuerySchema export const listDMMessagesResponseSchema = listMessagesResponseSchema + +// ── Search ────────────────────────────────────────────── + +export const searchDMMessagesQuerySchema = paginationQuerySchema.extend({ + query: z + .string() + .min(1) + .max(100) + .openapi({ + param: { name: "query", in: "query", required: true }, + example: "hello", + }), +}) + +const dmSearchResultSchema = z.object({ + id: z.string().uuid(), + content: z.string(), + createdAt: z.string().datetime(), + channelId: z.string().uuid(), + channelName: z.string(), + author: messageAuthorSchema, +}) + +export const searchDMMessagesResponseSchema = + paginatedResponseSchema(dmSearchResultSchema) diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index 56fa3c8..30c53f9 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -2,7 +2,7 @@ import { getGuildAuthorityPosition, getGuildRolePosition, } from "@repo/auth/permissions" -import { and, db, eq, schema } from "@repo/db" +import { and, count, db, desc, eq, ilike, inArray, 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" @@ -17,6 +17,7 @@ import type { ClearGuildMemberTimeoutRoute, KickGuildMemberRoute, ListGuildMembersRoute, + SearchMessagesRoute, TimeoutGuildMemberRoute, UpdateGuildMemberRoleRoute, } from "@/routes/v1/guilds/routes" @@ -467,3 +468,104 @@ export const clearGuildMemberTimeout: AppRouteHandler< HttpStatusCodes.OK ) } + +// ── Search ────────────────────────────────────────────── + +export const searchMessages: AppRouteHandler = async ( + c +) => { + const guild = c.var.guild + const { query, channelId, page, perPage } = c.req.valid("query") + const offset = (page - 1) * perPage + + const guildChannels = await db + .select({ + id: schema.channel.id, + name: schema.channel.name, + }) + .from(schema.channel) + .where( + and( + eq(schema.channel.guildId, guild.id), + inArray(schema.channel.type, ["text", "announcement", "forum"]) + ) + ) + + const emptyResult = { + itemsTotal: 0, + currentPage: page, + nextPage: null, + prevPage: null, + data: [], + } + + if (guildChannels.length === 0) { + return c.json(emptyResult, HttpStatusCodes.OK) + } + + const channelMap = new Map(guildChannels.map((ch) => [ch.id, ch.name])) + const searchChannelIds = channelId + ? guildChannels.filter((ch) => ch.id === channelId).map((ch) => ch.id) + : guildChannels.map((ch) => ch.id) + + if (searchChannelIds.length === 0) { + return c.json(emptyResult, HttpStatusCodes.OK) + } + + const searchPattern = `%${query}%` + const whereConditions = and( + inArray(schema.message.channelId, searchChannelIds), + ilike(schema.message.content, searchPattern) + ) + + const [countResult, messages] = await Promise.all([ + db.select({ total: count() }).from(schema.message).where(whereConditions), + db + .select({ + id: schema.message.id, + content: schema.message.content, + createdAt: schema.message.createdAt, + channelId: schema.message.channelId, + author: { + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }, + }) + .from(schema.message) + .innerJoin(schema.user, eq(schema.message.authorId, schema.user.id)) + .where(whereConditions) + .orderBy(desc(schema.message.createdAt)) + .limit(perPage) + .offset(offset), + ]) + + const itemsTotal = countResult[0]?.total ?? 0 + const totalPages = Math.ceil(itemsTotal / perPage) + + return c.json( + { + itemsTotal, + currentPage: page, + nextPage: page < totalPages ? page + 1 : null, + prevPage: page > 1 ? page - 1 : null, + data: messages.map((msg) => ({ + id: msg.id, + content: msg.content ?? "", + createdAt: msg.createdAt.toISOString(), + channelId: msg.channelId, + channelName: channelMap.get(msg.channelId) ?? "unknown", + author: { + id: msg.author.id, + name: msg.author.name, + username: msg.author.username, + displayUsername: msg.author.displayUsername, + image: msg.author.image, + }, + })), + }, + HttpStatusCodes.OK + ) +} diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts index 809ea43..50442dd 100644 --- a/apps/api/src/routes/v1/guilds/index.ts +++ b/apps/api/src/routes/v1/guilds/index.ts @@ -4,6 +4,7 @@ import * as routes from "@/routes/v1/guilds/routes" const guildsRouter = createRouter() .openapi(routes.listGuildMembers, handlers.listGuildMembers) + .openapi(routes.searchMessages, handlers.searchMessages) .openapi(routes.updateGuildMemberRole, handlers.updateGuildMemberRole) .openapi(routes.kickGuildMember, handlers.kickGuildMember) .openapi(routes.banGuildMember, handlers.banGuildMember) diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts index cca4f12..d4358d8 100644 --- a/apps/api/src/routes/v1/guilds/routes.ts +++ b/apps/api/src/routes/v1/guilds/routes.ts @@ -15,6 +15,8 @@ import { guildSlugParamsSchema, listGuildMembersResponseSchema, moderateGuildMemberResponseSchema, + searchMessagesQuerySchema, + searchMessagesResponseSchema, timeoutGuildMemberRequestSchema, timeoutGuildMemberResponseSchema, updateGuildMemberRoleRequestSchema, @@ -182,3 +184,29 @@ export const clearGuildMemberTimeout = createRoute({ }) export type ClearGuildMemberTimeoutRoute = typeof clearGuildMemberTimeout + +export const searchMessages = createRoute({ + path: "/guilds/{guildSlug}/search", + method: "get", + summary: "Search messages in a guild", + description: + "Searches messages across all channels in a guild. Optionally filter by channel.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + query: searchMessagesQuerySchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: searchMessagesResponseSchema, + description: "Search results", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type SearchMessagesRoute = typeof searchMessages diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/guilds/schema.ts index 5b9ff96..5401a32 100644 --- a/apps/api/src/routes/v1/guilds/schema.ts +++ b/apps/api/src/routes/v1/guilds/schema.ts @@ -1,5 +1,10 @@ import { z } from "@hono/zod-openapi" import { assignableGuildRoles } from "@repo/auth/permissions" +import { messageAuthorSchema } from "@/lib/helpers/openapi/message-schemas" +import { + paginatedResponseSchema, + paginationQuerySchema, +} from "@/lib/helpers/openapi/schemas" import { guildSlugParamsSchema } from "@/routes/v1/channels/schema" export { guildSlugParamsSchema } @@ -100,3 +105,36 @@ export const timeoutGuildMemberResponseSchema = z.object({ success: z.literal(true), member: guildMemberPresenceSchema, }) + +// ── Search ────────────────────────────────────────────── + +export const searchMessagesQuerySchema = paginationQuerySchema.extend({ + query: z + .string() + .min(1) + .max(100) + .openapi({ + param: { name: "query", in: "query", required: true }, + example: "hello", + }), + channelId: z + .string() + .uuid() + .optional() + .openapi({ + param: { name: "channelId", in: "query" }, + }), +}) + +const searchResultMessageSchema = z.object({ + id: z.string().uuid(), + content: z.string(), + createdAt: z.string().datetime(), + channelId: z.string().uuid(), + channelName: z.string(), + author: messageAuthorSchema, +}) + +export const searchMessagesResponseSchema = paginatedResponseSchema( + searchResultMessageSchema +) diff --git a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx index 348286c..a7707dc 100644 --- a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx @@ -1,12 +1,194 @@ -import { Search } from "lucide-react" +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" +import { formatTime } from "@repo/utils/date" +import { useQuery } from "@tanstack/react-query" +import { useNavigate, useParams } from "@tanstack/react-router" +import { Loader2, Search, X } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { MessageMarkdown } from "@/components/chat/message-markdown" +import { apiClient } from "@/lib/api-client" + +type SearchResult = { + id: string + content: string + createdAt: string + channelId: string + channelName: string + author: { + id: string + name: string + username: string | null + displayUsername: string | null + image: string | null + } +} + +type SearchResponse = { + itemsTotal: number + data: SearchResult[] +} + +export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { + const { guildSlug } = useParams({ strict: false }) + const navigate = useNavigate() + const [isOpen, setIsOpen] = useState(false) + const [query, setQuery] = useState("") + const [debouncedQuery, setDebouncedQuery] = useState("") + const inputRef = useRef(null) + const containerRef = useRef(null) + const debounceTimer = useRef | null>(null) + + const handleQueryChange = useCallback((value: string) => { + setQuery(value) + if (debounceTimer.current) clearTimeout(debounceTimer.current) + if (!value.trim()) { + setDebouncedQuery("") + return + } + debounceTimer.current = setTimeout(() => { + setDebouncedQuery(value.trim()) + }, 300) + }, []) + + useEffect(() => { + return () => { + if (debounceTimer.current) clearTimeout(debounceTimer.current) + } + }, []) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + const { data, isPending } = useQuery({ + queryKey: [ + mode === "guild" ? "guild-search" : "dm-search", + guildSlug, + debouncedQuery, + ], + queryFn: async (): Promise => { + if (mode === "dm") { + const res = await apiClient.v1.dms.search.$get({ + query: { query: debouncedQuery }, + }) + if (!res.ok) throw new Error("Search failed") + return res.json() + } + const res = await apiClient.v1.guilds[":guildSlug"].search.$get({ + param: { guildSlug: guildSlug as string }, + query: { query: debouncedQuery }, + }) + if (!res.ok) throw new Error("Search failed") + return res.json() + }, + enabled: debouncedQuery.length > 0 && (mode === "dm" || !!guildSlug), + }) + + const handleResultClick = (channelId: string) => { + setIsOpen(false) + setQuery("") + setDebouncedQuery("") + if (mode === "dm") { + void navigate({ to: "/dms/$dmId", params: { dmId: channelId } }) + } else { + void navigate({ + to: "/$guildSlug/$channelId", + params: { guildSlug: guildSlug as string, channelId }, + }) + } + } + + const handleClear = () => { + setQuery("") + setDebouncedQuery("") + inputRef.current?.focus() + } -export function SearchBar() { return ( -
-
- - Search +
+
+ + handleQueryChange(e.target.value)} + onFocus={() => setIsOpen(true)} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + {query && ( + + )}
+ + {isOpen && debouncedQuery.length > 0 && ( +
+ {isPending && ( +
+ +
+ )} + {data && data.data.length === 0 && ( +
+ No results found +
+ )} + {data?.data.map((msg) => ( + + ))} + {data && data.itemsTotal > data.data.length && ( +
+ {data.itemsTotal} results found — showing first {data.data.length} +
+ )} +
+ )}
) } diff --git a/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx index b7dae06..06626a2 100644 --- a/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx +++ b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx @@ -16,7 +16,7 @@ export function DMPanel() { return (
- +
+ ) + } + + return ( +
+
+ + handleQueryChange(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && handleClose()} + className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground" + /> + +
+ + {debouncedQuery.length > 0 && ( +
+ {isPending && ( +
+ +
+ )} + {data && data.data.length === 0 && ( +
+ No results found +
+ )} + {data?.data.map((msg) => ( + + ))} + {data && data.itemsTotal > data.data.length && ( +
+ {data.itemsTotal} results — showing first {data.data.length} +
+ )} +
+ )} +
+ ) +} diff --git a/apps/web/src/components/chat/header.tsx b/apps/web/src/components/chat/header.tsx index ca117be..841af55 100644 --- a/apps/web/src/components/chat/header.tsx +++ b/apps/web/src/components/chat/header.tsx @@ -5,6 +5,7 @@ import { TooltipTrigger, } from "@repo/ui/components/tooltip" import { Hash, Pin } from "lucide-react" +import { HeaderSearch } from "./header-search" export type ChatContext = | { type: "channel"; name: string; topic?: string } @@ -18,9 +19,11 @@ function nameInitial(name: string) { export function ChatHeader({ context, + channelId, onTogglePinnedMessages, }: { context: ChatContext + channelId: string onTogglePinnedMessages?: () => void }) { return ( @@ -52,8 +55,12 @@ export function ChatHeader({ {context.memberCount} members )} - {context.type === "channel" && onTogglePinnedMessages && ( -
+
+ + {context.type === "channel" && onTogglePinnedMessages && (
- )} + )} +
) } diff --git a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx index a7707dc..a41ae31 100644 --- a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx @@ -39,7 +39,10 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { const handleQueryChange = useCallback((value: string) => { setQuery(value) - if (debounceTimer.current) clearTimeout(debounceTimer.current) + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + debounceTimer.current = null + } if (!value.trim()) { setDebouncedQuery("") return @@ -92,21 +95,34 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { enabled: debouncedQuery.length > 0 && (mode === "dm" || !!guildSlug), }) - const handleResultClick = (channelId: string) => { + const handleResultClick = (channelId: string, messageId: string) => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + debounceTimer.current = null + } setIsOpen(false) setQuery("") setDebouncedQuery("") if (mode === "dm") { - void navigate({ to: "/dms/$dmId", params: { dmId: channelId } }) + void navigate({ + to: "/dms/$dmId", + params: { dmId: channelId }, + search: { msgId: messageId }, + }) } else { void navigate({ to: "/$guildSlug/$channelId", params: { guildSlug: guildSlug as string, channelId }, + search: { msgId: messageId }, }) } } const handleClear = () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + debounceTimer.current = null + } setQuery("") setDebouncedQuery("") inputRef.current?.focus() @@ -119,7 +135,7 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { handleQueryChange(e.target.value)} onFocus={() => setIsOpen(true)} @@ -153,7 +169,7 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { key={msg.id} type="button" className="w-full border-b border-border/50 px-3 py-2.5 text-left transition-colors hover:bg-accent last:border-b-0" - onClick={() => handleResultClick(msg.channelId)} + onClick={() => handleResultClick(msg.channelId, msg.id)} >
diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index bf288df..745859d 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -28,12 +28,30 @@ import { useTypingIndicator } from "@/hooks/use-typing-indicator" import { apiClient } from "@/lib/api-client" import type { ListMessagesResponse } from "@/lib/api-types" +type ChannelSearchParams = { + msgId?: string +} + export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ component: ChannelView, + validateSearch: (search: Record): ChannelSearchParams => ({ + msgId: typeof search.msgId === "string" ? search.msgId : undefined, + }), }) +function scrollToMessage(messageId: string) { + const el = document.querySelector(`[data-message-id="${messageId}"]`) + if (!el) return false + el.scrollIntoView({ behavior: "smooth", block: "center" }) + el.classList.add("bg-primary/10") + setTimeout(() => el.classList.remove("bg-primary/10"), 2000) + return true +} + function ChannelView() { const { guildSlug, channelId } = Route.useParams() + const { msgId } = Route.useSearch() + const navigate = Route.useNavigate() const socket = useSocket() const queryClient = useQueryClient() const { view, setView, clearView } = useRightSidebar() @@ -91,6 +109,18 @@ function ChannelView() { enabled: !!data, }) + // Scroll to a specific message when navigating from search + useEffect(() => { + if (!msgId || messagesLoading || !messagesData?.data.length) return + // Give DOM time to render + const timer = setTimeout(() => { + if (scrollToMessage(msgId)) { + void navigate({ search: {}, replace: true }) + } + }, 100) + return () => clearTimeout(timer) + }, [msgId, messagesLoading, messagesData, navigate]) + const { data: guildMembersData } = useQuery({ queryKey: ["guild-members", guildSlug], queryFn: async () => { @@ -256,6 +286,7 @@ function ChannelView() { ): DMSearchParams => ({ + msgId: typeof search.msgId === "string" ? search.msgId : undefined, + }), }) +function scrollToMessage(messageId: string) { + const el = document.querySelector(`[data-message-id="${messageId}"]`) + if (!el) return false + el.scrollIntoView({ behavior: "smooth", block: "center" }) + el.classList.add("bg-primary/10") + setTimeout(() => el.classList.remove("bg-primary/10"), 2000) + return true +} + function DMConversation() { const { dmId } = Route.useParams() + const { msgId } = Route.useSearch() + const navigate = Route.useNavigate() const socket = useSocket() const queryClient = useQueryClient() const { data: session } = authClient.useSession() @@ -55,6 +73,17 @@ function DMConversation() { enabled: !!dm, }) + // Scroll to a specific message when navigating from search + useEffect(() => { + if (!msgId || messagesLoading || !messagesData?.data.length) return + const timer = setTimeout(() => { + if (scrollToMessage(msgId)) { + void navigate({ search: {}, replace: true }) + } + }, 100) + return () => clearTimeout(timer) + }, [msgId, messagesLoading, messagesData, navigate]) + // Join/leave the DM channel room for real-time messages useEffect(() => { if (!socket) return @@ -182,7 +211,7 @@ function DMConversation() { className="relative flex h-full flex-col overflow-hidden" > - + Date: Sun, 22 Mar 2026 12:32:21 -0700 Subject: [PATCH 4/4] feat: amde right panel collapsible and resizable with animated transitions --- apps/web/src/components/chat/header.tsx | 19 ++++- apps/web/src/components/sidebar/index.tsx | 79 ++++++++++++++++++- .../right-panel/guild-members-panel.tsx | 24 +++--- .../right-panel/pinned-messages-panel.tsx | 11 ++- .../right-panel/right-sidebar-context.tsx | 71 ++++++++++++++++- .../right-panel/right-sidebar-panel.tsx | 4 +- biome.json | 3 + packages/ui/package.json | 2 +- packages/ui/src/components/resizable.tsx | 1 + pnpm-lock.yaml | 10 +-- 10 files changed, 198 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/chat/header.tsx b/apps/web/src/components/chat/header.tsx index 841af55..49de6fb 100644 --- a/apps/web/src/components/chat/header.tsx +++ b/apps/web/src/components/chat/header.tsx @@ -4,7 +4,8 @@ import { TooltipContent, TooltipTrigger, } from "@repo/ui/components/tooltip" -import { Hash, Pin } from "lucide-react" +import { Hash, PanelRight, Pin } from "lucide-react" +import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" import { HeaderSearch } from "./header-search" export type ChatContext = @@ -26,6 +27,8 @@ export function ChatHeader({ channelId: string onTogglePinnedMessages?: () => void }) { + const { isCollapsed, toggleCollapsed } = useRightSidebar() + return (
{context.type === "channel" && ( @@ -74,6 +77,20 @@ export function ChatHeader({ Pinned Messages )} + {isCollapsed && context.type === "channel" && ( + + + + + Show Members + + )}
) diff --git a/apps/web/src/components/sidebar/index.tsx b/apps/web/src/components/sidebar/index.tsx index d6656e1..993c523 100644 --- a/apps/web/src/components/sidebar/index.tsx +++ b/apps/web/src/components/sidebar/index.tsx @@ -4,7 +4,10 @@ import { ResizablePanelGroup, useDefaultLayout, } from "@repo/ui/components/resizable" +import { cn } from "@repo/ui/lib/utils" import { useParams } from "@tanstack/react-router" +import { AnimatePresence, motion } from "motion/react" +import { useCallback, useRef, useState } from "react" import { ChannelPanel } from "./channel-panel/channel-panel" import { DMPanel } from "./dm-panel/dm-panel" import { GuildBar } from "./guild-bar/guild-bar" @@ -16,13 +19,51 @@ import { RightSidebarPanel } from "./right-panel/right-sidebar-panel" function SidebarLayout({ children }: { children: React.ReactNode }) { const { guildSlug } = useParams({ strict: false }) - const { view } = useRightSidebar() + const { view, isCollapsed, panelWidth, setPanelWidth, isHydrated } = + useRightSidebar() + const [isResizing, setIsResizing] = useState(false) + const panelRef = useRef(null) + const widthRef = useRef(panelWidth) const { defaultLayout, onLayoutChange } = useDefaultLayout({ groupId: "townhall-sidebar", storage: localStorage, }) + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + setIsResizing(true) + + const startX = e.clientX + const startWidth = panelWidth + + const handleMouseMove = (moveEvent: MouseEvent) => { + const delta = startX - moveEvent.clientX + const newWidth = Math.min(Math.max(startWidth + delta, 240), 480) + if (panelRef.current) { + panelRef.current.style.width = `${newWidth}px` + } + widthRef.current = newWidth + } + + const handleMouseUp = () => { + // Commit width first so the next render has the correct value + setPanelWidth(widthRef.current) + // Use rAF to clear resizing after React has committed the new width + requestAnimationFrame(() => setIsResizing(false)) + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + }, + [panelWidth, setPanelWidth] + ) + + const showRightPanel = !!view && !!guildSlug + return (
@@ -34,11 +75,41 @@ function SidebarLayout({ children }: { children: React.ReactNode }) { {guildSlug ? : } - +
-
{children}
- {view && } +
{children}
+ {showRightPanel && isHydrated && ( + + {!isCollapsed && ( + +
+
+ +
+ + )} + + )}
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 4736851..579156f 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 @@ -36,7 +36,7 @@ import { ScrollArea } from "@repo/ui/components/scroll-area" import { Skeleton } from "@repo/ui/components/skeleton" import { cn } from "@repo/ui/lib/utils" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { MoreHorizontal, Users } from "lucide-react" +import { MoreHorizontal, PanelRight } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { toast } from "sonner" import { UserAvatar } from "@/components/ui/user-avatar" @@ -55,6 +55,7 @@ import { canUpdateGuildMemberRoles, formatGuildRole, } from "@/lib/permissions" +import { useRightSidebar } from "./right-sidebar-context" import type { GuildMembersSidebarView } from "./right-sidebar-types" const statusStyles: Record = { @@ -303,6 +304,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const socket = useSocket() const queryClient = useQueryClient() const { data: session } = authClient.useSession() + const { toggleCollapsed } = useRightSidebar() const [moderationDialog, setModerationDialog] = useState(null) const queryKey = useMemo( @@ -613,7 +615,6 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const members = data?.members ?? [] const onlineMembers = members.filter((member) => member.status !== "offline") const offlineMembers = members.filter((member) => member.status === "offline") - const guildName = data?.guildName?.trim() || "Guild" const isModerationDialogOpen = moderationDialog !== null const moderationDialogTitle = moderationDialog?.type === "kick" ? "Kick member" : "Ban member" @@ -632,14 +633,17 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { return ( <>
-
-
- - {guildName} Members -
-

- {members.length} total members -

+
+ + {members.length} members + +
{hasActiveMemberError && ( diff --git a/apps/web/src/components/sidebar/right-panel/pinned-messages-panel.tsx b/apps/web/src/components/sidebar/right-panel/pinned-messages-panel.tsx index 315d2b0..d04b259 100644 --- a/apps/web/src/components/sidebar/right-panel/pinned-messages-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/pinned-messages-panel.tsx @@ -1,7 +1,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" import { formatTime } from "@repo/utils/date" import { useQuery } from "@tanstack/react-query" -import { ArrowLeft, Pin } from "lucide-react" +import { ArrowLeft, PanelRight, Pin } from "lucide-react" import { MessageMarkdown } from "@/components/chat/message-markdown" import { apiClient } from "@/lib/api-client" import { useRightSidebar } from "./right-sidebar-context" @@ -17,7 +17,7 @@ export function PinnedMessagesPanel({ }: { view: PinnedMessagesSidebarView }) { - const { setView } = useRightSidebar() + const { setView, toggleCollapsed } = useRightSidebar() const goBack = () => { setView({ @@ -52,6 +52,13 @@ export function PinnedMessagesPanel({ Pinned Messages +
{isPending && ( 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 index a5de162..c4d5647 100644 --- a/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-context.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useCallback, useContext, + useEffect, useMemo, useState, } from "react" @@ -12,24 +13,92 @@ interface RightSidebarContextValue { view: RightSidebarView | null setView: (view: RightSidebarView | null) => void clearView: () => void + isCollapsed: boolean + toggleCollapsed: () => void + panelWidth: number + setPanelWidth: (width: number) => void + isHydrated: boolean +} + +const PANEL_COLLAPSED_KEY = "townhall-right-panel-collapsed" +const PANEL_WIDTH_KEY = "townhall-right-panel-width" +const DEFAULT_WIDTH = 280 + +function getStoredCollapsed(): boolean { + try { + return localStorage.getItem(PANEL_COLLAPSED_KEY) === "true" + } catch { + return false + } +} + +function getStoredWidth(): number { + try { + const stored = localStorage.getItem(PANEL_WIDTH_KEY) + if (stored) { + const parsed = Number.parseInt(stored, 10) + if (parsed >= 240 && parsed <= 480) return parsed + } + } catch {} + return DEFAULT_WIDTH } const RightSidebarContext = createContext(null) export function RightSidebarProvider({ children }: { children: ReactNode }) { const [view, setView] = useState(null) + const [isCollapsed, setIsCollapsed] = useState(true) + const [panelWidth, setPanelWidthState] = useState(DEFAULT_WIDTH) + const [isHydrated, setIsHydrated] = useState(false) + + // Hydrate from localStorage on mount + useEffect(() => { + setIsCollapsed(getStoredCollapsed()) + setPanelWidthState(getStoredWidth()) + setIsHydrated(true) + }, []) const clearView = useCallback(() => { setView(null) }, []) + const toggleCollapsed = useCallback(() => { + setIsCollapsed((prev) => { + const next = !prev + try { + localStorage.setItem(PANEL_COLLAPSED_KEY, String(next)) + } catch {} + return next + }) + }, []) + + const setPanelWidth = useCallback((width: number) => { + setPanelWidthState(width) + try { + localStorage.setItem(PANEL_WIDTH_KEY, String(width)) + } catch {} + }, []) + const value = useMemo( () => ({ view, setView, clearView, + isCollapsed, + toggleCollapsed, + panelWidth, + setPanelWidth, + isHydrated, }), - [view, clearView] + [ + view, + clearView, + isCollapsed, + toggleCollapsed, + panelWidth, + setPanelWidth, + isHydrated, + ] ) return ( 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 index 520bb2d..ddc461b 100644 --- a/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx @@ -26,7 +26,7 @@ function PlaceholderSidebar({ export function RightSidebarPanel({ view }: { view: RightSidebarView }) { return ( - +
) } diff --git a/biome.json b/biome.json index e59811a..e0a7ce9 100644 --- a/biome.json +++ b/biome.json @@ -45,6 +45,9 @@ "style": { "noNonNullAssertion": "error" }, + "a11y": { + "noStaticElementInteractions": "off" + }, "security": { "noDangerouslySetInnerHtml": "error" } diff --git a/packages/ui/package.json b/packages/ui/package.json index 697b93e..990ed45 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,7 +26,7 @@ "react": "^19.2.4", "react-day-picker": "^9.14.0", "react-dom": "^19.2.4", - "react-resizable-panels": "^4.6.4", + "react-resizable-panels": "^4.7.5", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0" diff --git a/packages/ui/src/components/resizable.tsx b/packages/ui/src/components/resizable.tsx index d9c4e7b..ce03038 100644 --- a/packages/ui/src/components/resizable.tsx +++ b/packages/ui/src/components/resizable.tsx @@ -49,5 +49,6 @@ function ResizableHandle({ ) } +export type { PanelImperativeHandle } from "react-resizable-panels" export { useDefaultLayout } from "react-resizable-panels" export { ResizableHandle, ResizablePanel, ResizablePanelGroup } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d783cb..d5554c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -504,8 +504,8 @@ importers: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) react-resizable-panels: - specifier: ^4.6.4 - version: 4.6.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^4.7.5 + version: 4.7.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -5373,8 +5373,8 @@ packages: '@types/react': optional: true - react-resizable-panels@4.6.4: - resolution: {integrity: sha512-E7Szs1xyaMZ7xOI2gG4TECNz4r/gmpV1AsXyZRnER6OQnfFf9uclFmrHHZR3h/iF8vQS+nQ1LKyZv9bzwGxPSg==} + react-resizable-panels@4.7.5: + resolution: {integrity: sha512-ma22FpbUolymMK6xIwZOzzNxszi59kZdJiw805byxuGBrjAs8HngpQrrgEp5dj1OOV2jVFBCJxhVult6G+2KaQ==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -11367,7 +11367,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.13 - react-resizable-panels@4.6.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-resizable-panels@4.7.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4)