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 apps/api/src/lib/helpers/openapi/message-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const messageAuthorSchema = z.object({

export const messageWithAuthorSchema = selectMessageSchema.extend({
author: messageAuthorSchema,
mentions: z.array(messageAuthorSchema),
})

export const listMessagesQuerySchema = paginationQuerySchema
Expand Down
56 changes: 53 additions & 3 deletions apps/api/src/lib/queries/messages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db } from "@repo/db"
import { message, user } from "@repo/db/schema"
import { count, desc, eq } from "drizzle-orm"
import { message, messageMention, user } from "@repo/db/schema"
import { and, count, desc, eq, inArray } from "drizzle-orm"

export async function fetchMessagePage(
channelId: string,
Expand Down Expand Up @@ -45,12 +45,62 @@ export async function fetchMessagePage(

const itemsTotal = countResult[0]?.total ?? 0
const totalPages = Math.ceil(itemsTotal / perPage)
const messageIds = messages.map((msg) => msg.id)

const mentionRows =
messageIds.length > 0
? await db
.select({
messageId: messageMention.messageId,
id: user.id,
name: user.name,
username: user.username,
displayUsername: user.displayUsername,
image: user.image,
})
.from(messageMention)
.innerJoin(user, eq(messageMention.mentionedUserId, user.id))
.where(
and(
inArray(messageMention.messageId, messageIds),
eq(messageMention.mentionType, "direct")
)
)
: []
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const mentionsByMessageId = new Map<
string,
Array<{
id: string
name: string
username: string | null
displayUsername: string | null
image: string | null
}>
>()

for (const mentionRow of mentionRows) {
const existingMentions = mentionsByMessageId.get(mentionRow.messageId) ?? []
existingMentions.push({
id: mentionRow.id,
name: mentionRow.name,
username: mentionRow.username,
displayUsername: mentionRow.displayUsername,
image: mentionRow.image,
})
mentionsByMessageId.set(mentionRow.messageId, existingMentions)
}

const messagesWithMentions = messages.map((msg) => ({
...msg,
mentions: mentionsByMessageId.get(msg.id) ?? [],
}))

return {
itemsTotal,
currentPage: page,
nextPage: page < totalPages ? page + 1 : null,
prevPage: page > 1 ? page - 1 : null,
data: messages,
data: messagesWithMentions,
}
}
4 changes: 4 additions & 0 deletions apps/api/src/routes/v1/guilds/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export const listGuildMembers: AppRouteHandler<ListGuildMembersRoute> = async (
userId: schema.guildMember.userId,
role: schema.guildMember.role,
name: schema.user.name,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
image: schema.user.image,
})
.from(schema.guildMember)
Expand All @@ -65,6 +67,8 @@ export const listGuildMembers: AppRouteHandler<ListGuildMembersRoute> = async (
members: memberRows.map((member) => ({
userId: member.userId,
name: member.name,
username: member.username,
displayUsername: member.displayUsername,
image: member.image,
role: member.role,
status: onlineUserIds.has(member.userId)
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/v1/guilds/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export { guildSlugParamsSchema }
export const guildMemberPresenceSchema = z.object({
userId: z.string().uuid(),
name: z.string(),
username: z.string().nullable(),
displayUsername: z.string().nullable(),
image: z.string().nullable(),
role: z.string(),
status: z.enum(["online", "offline"]),
Expand Down
15 changes: 10 additions & 5 deletions apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,16 +296,21 @@ io.on("connection", (socket) => {
payload: parsed,
})

socket
.to(channelRoom(parsed.channelId))
.emit("message:created", createdMessage.message)

const fanout = await buildMessageFanout({
authorId: socket.data.user.id,
channel: createdMessage.channel,
message: createdMessage.message,
})

const messageWithMentions = {
...createdMessage.message,
mentions: fanout.messageMentions,
}

socket
.to(channelRoom(parsed.channelId))
.emit("message:created", messageWithMentions)

for (const unreadNotification of fanout.unreadNotifications) {
io.to(userRoom(unreadNotification.userId)).emit(
"notification:unread",
Expand All @@ -320,7 +325,7 @@ io.on("connection", (socket) => {
)
}

ack?.({ ok: true, message: createdMessage.message })
ack?.({ ok: true, message: messageWithMentions })
} catch (error) {
ack?.({ ok: false, error: toErrorMessage(error) })
}
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/services/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export async function createMessage(input: CreateMessageInput) {
displayUsername: messageWithAuthor.authorDisplayUsername,
image: messageWithAuthor.authorImage,
},
mentions: [],
}

if (input.payload.nonce) {
Expand Down
47 changes: 47 additions & 0 deletions apps/realtime/src/services/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { and, db, eq, inArray, schema } from "@repo/db"
import type {
MentionNotification,
RealtimeMessage,
RealtimeMessageMention,
UnreadNotification,
} from "@repo/realtime-types"
import type { AccessibleChannel } from "./channel-access"
Expand All @@ -26,6 +27,8 @@ type NotificationInsertType = Extract<

const USER_MENTION_REGEX =
/<@([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})>/gi
const MARKDOWN_USER_MENTION_REGEX =
/\[@[^\]]*?\bid="([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})"[^\]]*]/gi
const EVERYONE_MENTION_REGEX = /(^|\s)@everyone\b/i
const mentionNotificationTypes = ["direct_mention", "everyone_mention"] as const

Expand All @@ -39,6 +42,13 @@ function extractDirectMentionUserIds(content: string) {
}
}

for (const match of content.matchAll(MARKDOWN_USER_MENTION_REGEX)) {
const userId = match[1]
if (userId) {
userIds.add(userId)
}
}

return userIds
}

Expand Down Expand Up @@ -129,9 +139,45 @@ export async function buildMessageFanout(input: MessageFanoutInput) {
messageContent: input.message.content ?? "",
})

const mentionedUserIds = Array.from(mentionTypeByUserId.entries())
.filter(([, mentionType]) => mentionType === "direct")
.map(([userId]) => userId)
const mentionUsers =
mentionedUserIds.length > 0
? await db
.select({
id: schema.user.id,
name: schema.user.name,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
image: schema.user.image,
})
.from(schema.user)
.where(inArray(schema.user.id, mentionedUserIds))
: []

const mentionUserMap = new Map(mentionUsers.map((user) => [user.id, user]))
const messageMentions: RealtimeMessageMention[] = mentionedUserIds.flatMap(
(userId) => {
const mentionUser = mentionUserMap.get(userId)
if (!mentionUser) return []

return [
{
id: mentionUser.id,
name: mentionUser.name,
username: mentionUser.username,
displayUsername: mentionUser.displayUsername,
image: mentionUser.image,
},
]
}
)

if (mentionTypeByUserId.size === 0) {
return {
unreadNotifications,
messageMentions,
mentionNotifications: [] as Array<
UserTargetedPayload<MentionNotification>
>,
Expand Down Expand Up @@ -255,6 +301,7 @@ export async function buildMessageFanout(input: MessageFanoutInput) {

return {
unreadNotifications,
messageMentions,
mentionNotifications,
}
}
8 changes: 8 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.120.3",
"@tiptap/extension-mention": "^3.20.0",
"@tiptap/markdown": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@tiptap/suggestion": "^3.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
Expand All @@ -31,6 +37,8 @@
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
Expand Down
105 changes: 105 additions & 0 deletions apps/web/src/components/chat/composer/mention-suggestion-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { cn } from "@repo/ui/lib/utils"
import type { SuggestionKeyDownProps } from "@tiptap/suggestion"
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from "react"
import type { MentionCandidate } from "./mention-types"

export interface MentionSuggestionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
}

export interface MentionSuggestionListProps {
items: MentionCandidate[]
command: (item: MentionCandidate) => void
}

export const MentionSuggestionList = forwardRef<
MentionSuggestionListRef,
MentionSuggestionListProps
>(function MentionSuggestionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0)

const selectItem = useCallback(
(index: number) => {
const item = items[index]
if (!item) return false
command(item)
return true
},
[items, command]
)

useEffect(() => {
setSelectedIndex((currentIndex) => {
if (items.length === 0) return 0
return Math.min(currentIndex, items.length - 1)
})
}, [items.length])

useImperativeHandle(
ref,
() => ({
onKeyDown: ({ event }) => {
if (items.length === 0) return false

if (event.key === "ArrowDown") {
event.preventDefault()
setSelectedIndex((currentIndex) => (currentIndex + 1) % items.length)
return true
}

if (event.key === "ArrowUp") {
event.preventDefault()
setSelectedIndex(
(currentIndex) => (currentIndex + items.length - 1) % items.length
)
return true
}

if (event.key === "Enter") {
event.preventDefault()
return selectItem(selectedIndex)
}

return false
},
}),
[items.length, selectedIndex, selectItem]
)

if (items.length === 0) {
return (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No matches
</div>
)
}

return (
<div className="max-h-56 overflow-y-auto p-1">
{items.map((item, index) => (
<button
key={item.id}
type="button"
className={cn(
"flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/70"
)}
onMouseDown={(event) => {
event.preventDefault()
selectItem(index)
}}
>
@{item.label}
</button>
))}
</div>
)
})
9 changes: 9 additions & 0 deletions apps/web/src/components/chat/composer/mention-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface MentionCandidate {
id: string
label: string
search?: string
name?: string
username?: string | null
displayUsername?: string | null
image?: string | null
}
Loading