Skip to content
Merged

Dev #25

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
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -52,11 +52,11 @@

## 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
- [ ] Desktop notifications (browser Notification API for mentions, DMs, etc.)
- [ ] Desktop app (Tauri) with native notifications for mentions, DMs, etc.
- [ ] Notification preferences
- [x] Reaction tooltips (who reacted with each emoji)
- [x] User profile popover (bio, status, online indicator, ally actions)
Expand All @@ -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
Expand Down
124 changes: 123 additions & 1 deletion apps/api/src/routes/v1/dms/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -17,6 +17,7 @@ import type {
GetDMRoute,
ListDMMessagesRoute,
ListDMsRoute,
SearchDMMessagesRoute,
} from "./routes"

const emptyPage = (page: number) => ({
Expand Down Expand Up @@ -559,3 +560,124 @@ export const listDMMessages: AppRouteHandler<ListDMMessagesRoute> = async (
HttpStatusCodes.OK
)
}

// ── Search ──────────────────────────────────────────────

export const searchDMMessages: AppRouteHandler<SearchDMMessagesRoute> = async (
c
) => {
const currentUser = c.var.user
const { query, page, perPage, dmId } = 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"]),
dmId ? eq(channel.id, dmId) : undefined
)
)

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 membersByChannel = new Map<string, typeof dmMembers>()
for (const m of dmMembers) {
const list = membersByChannel.get(m.channelId) ?? []
list.push(m)
membersByChannel.set(m.channelId, list)
}

const channelNameMap = new Map<string, string>()
for (const ch of dmChannels) {
if (ch.type === "group_dm" && ch.name) {
channelNameMap.set(ch.id, ch.name)
} else {
const others = (membersByChannel.get(ch.id) ?? []).filter(
(m) => m.userId !== currentUser.id
)
channelNameMap.set(ch.id, others.map((m) => m.name).join(", ") || "DM")
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const escaped = query.replace(/[%_\\]/g, (ch) => `\\${ch}`)
const searchPattern = `%${escaped}%`
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
)
}
1 change: 1 addition & 0 deletions apps/api/src/routes/v1/dms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/routes/v1/dms/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
listDMMessagesQuerySchema,
listDMMessagesResponseSchema,
listDMsResponseSchema,
searchDMMessagesQuerySchema,
searchDMMessagesResponseSchema,
} from "./schema"

export const createDM = createRoute({
Expand Down Expand Up @@ -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
38 changes: 37 additions & 1 deletion apps/api/src/routes/v1/dms/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,3 +67,35 @@ 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",
}),
dmId: z
.string()
.uuid()
.optional()
.openapi({
param: { name: "dmId", in: "query" },
}),
})

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)
105 changes: 104 additions & 1 deletion apps/api/src/routes/v1/guilds/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -17,6 +17,7 @@ import type {
ClearGuildMemberTimeoutRoute,
KickGuildMemberRoute,
ListGuildMembersRoute,
SearchMessagesRoute,
TimeoutGuildMemberRoute,
UpdateGuildMemberRoleRoute,
} from "@/routes/v1/guilds/routes"
Expand Down Expand Up @@ -467,3 +468,105 @@ export const clearGuildMemberTimeout: AppRouteHandler<
HttpStatusCodes.OK
)
}

// ── Search ──────────────────────────────────────────────

export const searchMessages: AppRouteHandler<SearchMessagesRoute> = 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 escaped = query.replace(/[%_\\]/g, (ch) => `\\${ch}`)
const searchPattern = `%${escaped}%`
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
)
}
Loading