feat(conversations): add @mentions support (#87)

Add @user, @channel, @here, and @Compass agent mentions
to the conversations module. TipTap Mention extension with
suggestion popup, message_mentions junction table for
efficient querying, push notifications for mentioned users
respecting notifyLevel preferences, and edit support with
mention re-extraction. Also fix crypto.randomUUID fallback
for non-secure contexts.

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-15 19:22:30 -07:00 committed by GitHub
parent 21edd5469c
commit d4914c1a46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 5613 additions and 17 deletions

View File

@ -107,6 +107,7 @@
"sonner": "^2.0.7",
"streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.2",
@ -613,6 +614,8 @@
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@ -2543,6 +2546,8 @@
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"tippy.js": ["tippy.js@6.3.7", "", { "dependencies": { "@popperjs/core": "^2.9.0" } }, "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ=="],
"tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="],
"tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="],

View File

@ -0,0 +1,8 @@
CREATE TABLE `message_mentions` (
`id` text PRIMARY KEY NOT NULL,
`message_id` text NOT NULL,
`mention_type` text NOT NULL,
`target_id` text,
`created_at` text NOT NULL,
FOREIGN KEY (`message_id`) REFERENCES `messages`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@ -176,6 +176,13 @@
"when": 1771105729640,
"tag": "0024_thankful_slayback",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1771205359100,
"tag": "0025_chunky_silverclaw",
"breakpoints": true
}
]
}

View File

@ -132,6 +132,7 @@
"sonner": "^2.0.7",
"streamdown": "^2.1.0",
"tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7",
"tokenlens": "^1.3.1",
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.2",

View File

@ -1,7 +1,7 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq, and, desc, lt, sql } from "drizzle-orm"
import { eq, and, desc, lt, sql, like, or } from "drizzle-orm"
import { marked } from "marked"
import { getDb } from "@/db"
import {
@ -9,10 +9,12 @@ import {
messageReactions,
channelMembers,
channelReadState,
messageMentions,
type NewMessage,
type NewMessageReaction,
type NewMessageMention,
} from "@/db/schema-conversations"
import { users } from "@/db/schema"
import { users, organizationMembers } from "@/db/schema"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { revalidatePath } from "next/cache"
@ -35,7 +37,7 @@ const ALLOWED_TAGS = new Set([
"span", "div",
])
const ALLOWED_ATTR = new Set(["href", "src", "alt", "title", "class", "id", "target", "rel"])
const ALLOWED_ATTR = new Set(["href", "src", "alt", "title", "class", "id", "target", "rel", "data-type", "data-id", "data-mention-type"])
// Regex to strip script tags and event handlers
const DANGEROUS_PATTERNS = [
@ -60,7 +62,7 @@ function sanitizeHtml(html: string): string {
sanitized = sanitized.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => {
if (ALLOWED_TAGS.has(tagName.toLowerCase())) {
// For allowed tags, filter attributes
return match.replace(/(\w+)\s*=\s*["'][^"']*["']/gi, (attrMatch, attrName) => {
return match.replace(/([\w-]+)\s*=\s*["'][^"']*["']/gi, (attrMatch, attrName) => {
if (ALLOWED_ATTR.has(attrName.toLowerCase())) {
// Only allow safe URL schemes in href/src
if (attrName.toLowerCase() === "href" || attrName.toLowerCase() === "src") {
@ -86,10 +88,70 @@ async function renderMarkdown(content: string): Promise<string> {
return sanitizeHtml(html)
}
export async function searchMentionableUsers(
query: string,
organizationId: string
) {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const searchPattern = `%${query}%`
const results = await db
.select({
id: users.id,
displayName: users.displayName,
email: users.email,
avatarUrl: users.avatarUrl,
})
.from(users)
.innerJoin(
organizationMembers,
eq(organizationMembers.userId, users.id)
)
.where(
and(
eq(organizationMembers.organizationId, organizationId),
or(
like(users.displayName, searchPattern),
like(users.email, searchPattern),
like(users.firstName, searchPattern),
like(users.lastName, searchPattern)
)
)
)
.limit(10)
const data = results.map((u) => ({
...u,
type: "user" as const,
}))
return { success: true, data }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to search users",
}
}
}
type MentionInput = {
mentionType: "user" | "channel" | "here" | "agent"
targetId: string | null
}
export async function sendMessage(data: {
channelId: string
content: string
threadId?: string
mentions?: Array<MentionInput>
contentHtml?: string
}) {
try {
const user = await getCurrentUser()
@ -124,8 +186,10 @@ export async function sendMessage(data: {
const now = new Date().toISOString()
const messageId = crypto.randomUUID()
// Pre-render markdown to sanitized HTML
const contentHtml = await renderMarkdown(data.content)
// Use provided HTML or render markdown to sanitized HTML
const contentHtml = data.contentHtml
? sanitizeHtml(data.contentHtml)
: await renderMarkdown(data.content)
const newMessage: NewMessage = {
id: messageId,
@ -145,6 +209,37 @@ export async function sendMessage(data: {
await db.insert(messages).values(newMessage)
// insert mentions if provided
if (data.mentions && data.mentions.length > 0) {
const mentionRows: NewMessageMention[] = data.mentions.map((m) => ({
id: crypto.randomUUID(),
messageId,
mentionType: m.mentionType,
targetId: m.targetId,
createdAt: now,
}))
await db.insert(messageMentions).values(mentionRows)
// fire-and-forget notification (don't await, don't block on error)
const envRecord = env as unknown as Record<string, string>
const fcmKey = envRecord.FCM_SERVER_KEY
if (fcmKey) {
import("@/lib/conversations/notify-mentions")
.then(({ notifyMentionedUsers }) =>
notifyMentionedUsers(
env.DB,
fcmKey,
messageId,
data.channelId,
user.id,
user.displayName ?? user.email ?? "Someone",
data.mentions ?? [],
)
)
.catch(console.error)
}
}
// if this is a thread reply, update parent message
if (data.threadId) {
await db
@ -207,7 +302,14 @@ export async function sendMessage(data: {
}
}
export async function editMessage(messageId: string, newContent: string) {
export async function editMessage(
messageId: string,
newContent: string,
options?: {
mentions?: Array<MentionInput>
contentHtml?: string
}
) {
try {
const user = await getCurrentUser()
if (!user) {
@ -240,8 +342,10 @@ export async function editMessage(messageId: string, newContent: string) {
const now = new Date().toISOString()
// Re-render markdown to sanitized HTML
const contentHtml = await renderMarkdown(newContent)
// Use provided HTML or re-render markdown to sanitized HTML
const contentHtml = options?.contentHtml
? sanitizeHtml(options.contentHtml)
: await renderMarkdown(newContent)
await db
.update(messages)
@ -252,6 +356,27 @@ export async function editMessage(messageId: string, newContent: string) {
})
.where(eq(messages.id, messageId))
// update mentions if provided
if (options?.mentions !== undefined) {
// delete existing mentions
await db
.delete(messageMentions)
.where(eq(messageMentions.messageId, messageId))
// insert new mentions
if (options.mentions.length > 0) {
const mentionRows: NewMessageMention[] = options.mentions.map((m) => ({
id: crypto.randomUUID(),
messageId,
mentionType: m.mentionType,
targetId: m.targetId,
createdAt: now,
}))
await db.insert(messageMentions).values(mentionRows)
}
// Note: we do NOT re-notify on edit (MVP requirement)
}
revalidatePath("/dashboard")
return { success: true }
} catch (err) {

View File

@ -36,7 +36,11 @@ export default async function ChannelPage({
channelId={channelId}
initialMessages={messages}
/>
<MessageComposer channelId={channelId} channelName={channel.name} />
<MessageComposer
channelId={channelId}
channelName={channel.name}
organizationId={channel.organizationId}
/>
</div>
<ThreadPanel />
</>

View File

@ -229,4 +229,13 @@
pointer-events: none;
float: left;
height: 0;
}
/* mention pill styling in messages and editor */
.mention {
border-radius: calc(var(--radius) - 4px);
background-color: color-mix(in oklch, var(--primary) 10%, transparent);
padding: 0.125rem 0.25rem;
font-weight: 500;
color: var(--primary);
}

View File

@ -205,7 +205,9 @@ export function ChatProvider({
// generate initial ID client-side only to avoid hydration mismatch
React.useEffect(() => {
setConversationId((prev) =>
prev === "" ? crypto.randomUUID() : prev
prev === ""
? (crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`)
: prev
)
}, [])
const [resumeLoaded, setResumeLoaded] =

View File

@ -0,0 +1,298 @@
"use client"
import * as React from "react"
import { ReactRenderer, type Editor } from "@tiptap/react"
import tippy, { type Instance as TippyInstance } from "tippy.js"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Hash, Bot, Users } from "lucide-react"
import { searchMentionableUsers } from "@/app/actions/chat-messages"
import { cn } from "@/lib/utils"
type MentionItem = {
readonly id: string
readonly label: string
readonly type: "group" | "agent" | "user"
readonly avatarUrl?: string | null
}
type MentionListProps = {
readonly items: readonly MentionItem[]
readonly command: (item: { id: string; label: string }) => void
readonly organizationId: string
}
const MentionList = React.forwardRef<
{ onKeyDown: (props: { event: KeyboardEvent }) => boolean },
MentionListProps
>((props, ref) => {
const [selectedIndex, setSelectedIndex] = React.useState(0)
const selectItem = React.useCallback(
(index: number) => {
const item = props.items[index]
if (item) {
props.command({ id: item.id, label: item.label })
}
},
[props]
)
const upHandler = React.useCallback(() => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
}, [selectedIndex, props.items.length])
const downHandler = React.useCallback(() => {
setSelectedIndex((selectedIndex + 1) % props.items.length)
}, [selectedIndex, props.items.length])
const enterHandler = React.useCallback(() => {
selectItem(selectedIndex)
}, [selectedIndex, selectItem])
React.useEffect(() => {
setSelectedIndex(0)
}, [props.items])
React.useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
upHandler()
return true
}
if (event.key === "ArrowDown") {
downHandler()
return true
}
if (event.key === "Enter" || event.key === "Tab") {
enterHandler()
return true
}
return false
},
}))
if (props.items.length === 0) {
return null
}
// group items by type
const groups = props.items.filter((item) => item.type === "group")
const agents = props.items.filter((item) => item.type === "agent")
const people = props.items.filter((item) => item.type === "user")
const getItemIndex = (item: MentionItem) => props.items.indexOf(item)
return (
<div className="max-h-[300px] overflow-y-auto rounded-lg border bg-popover p-1 shadow-lg">
{groups.length > 0 && (
<div className="mb-1">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Groups
</div>
{groups.map((item) => {
const index = getItemIndex(item)
return (
<button
key={item.id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
)}
onClick={() => selectItem(index)}
>
<div className="flex h-6 w-6 items-center justify-center rounded bg-muted">
{item.id === "channel" ? (
<Hash className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
</div>
<span>{item.label}</span>
</button>
)
})}
</div>
)}
{agents.length > 0 && (
<div className="mb-1">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Agents
</div>
{agents.map((item) => {
const index = getItemIndex(item)
return (
<button
key={item.id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
)}
onClick={() => selectItem(index)}
>
<div className="flex h-6 w-6 items-center justify-center rounded bg-primary/10">
<Bot className="h-3.5 w-3.5 text-primary" />
</div>
<span>{item.label}</span>
</button>
)
})}
</div>
)}
{people.length > 0 && (
<div>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
People
</div>
{people.map((item) => {
const index = getItemIndex(item)
return (
<button
key={item.id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
index === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
)}
onClick={() => selectItem(index)}
>
<Avatar className="h-6 w-6">
<AvatarImage src={item.avatarUrl ?? undefined} />
<AvatarFallback className="text-xs">
{item.label.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{item.label}</span>
</button>
)
})}
</div>
)}
</div>
)
})
MentionList.displayName = "MentionList"
export function createMentionSuggestion(organizationId: string) {
return {
items: async ({ query }: { query: string }) => {
// static entries
const staticItems: MentionItem[] = [
{ id: "channel", label: "channel", type: "group" },
{ id: "here", label: "here", type: "group" },
{ id: "compass-agent", label: "Compass", type: "agent" },
]
// fetch users
const usersResult = await searchMentionableUsers(query, organizationId)
const userItems: MentionItem[] = usersResult.success && usersResult.data
? usersResult.data.map((user) => ({
id: user.id,
label: user.displayName ?? user.email,
type: "user" as const,
avatarUrl: user.avatarUrl,
}))
: []
// combine and filter
const allItems = [...staticItems, ...userItems]
const lowerQuery = query.toLowerCase()
if (!query) {
return allItems
}
return allItems.filter((item) =>
item.label.toLowerCase().includes(lowerQuery)
)
},
render: () => {
let component: ReactRenderer | null = null
let popup: TippyInstance[] | null = null
return {
onStart: (props: unknown) => {
const p = props as {
editor: Editor
clientRect: (() => DOMRect) | null
items: readonly MentionItem[]
command: (item: { id: string; label: string }) => void
}
component = new ReactRenderer(MentionList, {
props: {
...p,
organizationId,
},
editor: p.editor,
})
if (!p.clientRect) {
return
}
popup = tippy("body", {
getReferenceClientRect: p.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
},
onUpdate(props: unknown) {
const p = props as {
editor: Editor
clientRect: (() => DOMRect) | null
items: readonly MentionItem[]
command: (item: { id: string; label: string }) => void
}
component?.updateProps({
...p,
organizationId,
})
if (!p.clientRect) {
return
}
popup?.[0]?.setProps({
getReferenceClientRect: p.clientRect,
})
},
onKeyDown(props: { event: KeyboardEvent }) {
if (props.event.key === "Escape") {
popup?.[0]?.hide()
return true
}
const ref = component?.ref as { onKeyDown?: (props: { event: KeyboardEvent }) => boolean } | undefined
return ref?.onKeyDown?.(props) ?? false
},
onExit() {
popup?.[0]?.destroy()
component?.destroy()
},
}
},
}
}

View File

@ -5,6 +5,7 @@ import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import Link from "@tiptap/extension-link"
import Mention from "@tiptap/extension-mention"
import { Bold, Italic, Code, Link as LinkIcon, List, ListOrdered, Send, Paperclip, Smile } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
@ -12,18 +13,56 @@ import { sendMessage } from "@/app/actions/chat-messages"
import { setTyping } from "@/app/actions/conversations-realtime"
import { useRouter } from "next/navigation"
import { cn } from "@/lib/utils"
import { createMentionSuggestion } from "./mention-suggestion"
type MessageComposerProps = {
readonly channelId: string
readonly channelName: string
readonly organizationId: string
readonly threadId?: string
readonly placeholder?: string
readonly onSent?: () => void
}
type MentionInput = {
mentionType: "user" | "channel" | "here" | "agent"
targetId: string | null
}
function extractMentions(json: Record<string, unknown>): Array<MentionInput> {
const mentions: Array<MentionInput> = []
function walk(node: Record<string, unknown>) {
if (node.type === "mention" && node.attrs) {
const attrs = node.attrs as Record<string, string>
const id = attrs.id
if (id === "channel") {
mentions.push({ mentionType: "channel", targetId: null })
} else if (id === "here") {
mentions.push({ mentionType: "here", targetId: null })
} else if (id === "compass-agent") {
mentions.push({ mentionType: "agent", targetId: "compass-agent" })
} else {
mentions.push({ mentionType: "user", targetId: id })
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child as Record<string, unknown>)
}
}
}
walk(json)
return mentions
}
export function MessageComposer({
channelId,
channelName,
organizationId,
threadId,
placeholder,
onSent,
@ -63,6 +102,13 @@ export function MessageComposer({
class: "text-primary underline underline-offset-2",
},
}),
Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(organizationId),
renderText({ node }) {
return `@${node.attrs.label ?? node.attrs.id}`
},
}),
],
editorProps: {
attributes: {
@ -85,10 +131,15 @@ export function MessageComposer({
setError(null)
try {
const mentions = extractMentions(editor.getJSON() as Record<string, unknown>)
const contentHtml = editor.getHTML()
const result = await sendMessage({
channelId,
content,
contentHtml,
threadId,
mentions: mentions.length > 0 ? mentions : undefined,
})
if (result.success) {

View File

@ -8,6 +8,7 @@ import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import { useConversations, type ThreadMessage } from "@/contexts/conversations-context"
import { getThreadMessages } from "@/app/actions/chat-messages"
import { getChannel } from "@/app/actions/conversations"
import { MessageItem } from "./message-item"
import { MessageComposer } from "./message-composer"
import { useIsMobile } from "@/hooks/use-mobile"
@ -17,6 +18,7 @@ export function ThreadPanel() {
const isMobile = useIsMobile()
const [replies, setReplies] = React.useState<readonly ThreadMessage[]>([])
const [loading, setLoading] = React.useState(false)
const [organizationId, setOrganizationId] = React.useState<string | null>(null)
const [panelWidth, setPanelWidth] = React.useState(400)
const [isResizing, setIsResizing] = React.useState(false)
const dragStartX = React.useRef(0)
@ -25,18 +27,24 @@ export function ThreadPanel() {
React.useEffect(() => {
if (!threadMessageId) {
setReplies([])
setOrganizationId(null)
return
}
setLoading(true)
getThreadMessages(threadMessageId).then((result) => {
if (result.success && result.data) {
// replies come DESC from server; reverse for chronological
setReplies([...result.data].reverse())
Promise.all([
getThreadMessages(threadMessageId),
threadParentMessage ? getChannel(threadParentMessage.channelId) : null,
]).then(([messagesResult, channelResult]) => {
if (messagesResult.success && messagesResult.data) {
setReplies([...messagesResult.data].reverse())
}
if (channelResult?.success && channelResult.data) {
setOrganizationId(channelResult.data.organizationId)
}
setLoading(false)
})
}, [threadMessageId])
}, [threadMessageId, threadParentMessage])
// resize handlers (follow ChatPanelShell pattern)
React.useEffect(() => {
@ -165,10 +173,11 @@ export function ThreadPanel() {
</div>
</ScrollArea>
{threadParentMessage && (
{threadParentMessage && organizationId && (
<MessageComposer
channelId={threadParentMessage.channelId}
channelName="thread"
organizationId={organizationId}
threadId={threadMessageId ?? undefined}
placeholder="Reply to thread..."
onSent={refreshReplies}

View File

@ -82,6 +82,17 @@ export const messageReactions = sqliteTable("message_reactions", {
createdAt: text("created_at").notNull(),
})
// message_mentions - @user, @channel, @here, @agent mentions
export const messageMentions = sqliteTable("message_mentions", {
id: text("id").primaryKey(),
messageId: text("message_id")
.notNull()
.references(() => messages.id, { onDelete: "cascade" }),
mentionType: text("mention_type").notNull(), // "user" | "channel" | "here" | "agent"
targetId: text("target_id"), // userId for user, "compass-agent" for agent, null for channel/here
createdAt: text("created_at").notNull(),
})
// channel_members - who can access which channels
export const channelMembers = sqliteTable("channel_members", {
id: text("id").primaryKey(),
@ -156,6 +167,8 @@ export type MessageAttachment = typeof messageAttachments.$inferSelect
export type NewMessageAttachment = typeof messageAttachments.$inferInsert
export type MessageReaction = typeof messageReactions.$inferSelect
export type NewMessageReaction = typeof messageReactions.$inferInsert
export type MessageMention = typeof messageMentions.$inferSelect
export type NewMessageMention = typeof messageMentions.$inferInsert
export type ChannelMember = typeof channelMembers.$inferSelect
export type NewChannelMember = typeof channelMembers.$inferInsert
export type ChannelReadState = typeof channelReadState.$inferSelect

View File

@ -0,0 +1,83 @@
import { getDb } from "@/db"
import { channelMembers, userPresence } from "@/db/schema-conversations"
import { eq } from "drizzle-orm"
import { sendPushNotification } from "@/lib/push/send"
type MentionTarget = Readonly<{
mentionType: "user" | "channel" | "here" | "agent"
targetId: string | null
}>
export async function notifyMentionedUsers(
d1: D1Database,
fcmServerKey: string,
messageId: string,
channelId: string,
senderId: string,
senderName: string,
mentions: ReadonlyArray<MentionTarget>,
): Promise<void> {
// resolve each mention to a set of userIds
const db = getDb(d1)
const notifyUserIds = new Set<string>()
for (const mention of mentions) {
switch (mention.mentionType) {
case "user": {
if (mention.targetId && mention.targetId !== senderId) {
notifyUserIds.add(mention.targetId)
}
break
}
case "channel": {
// all channel members
const members = await db
.select({ userId: channelMembers.userId, notifyLevel: channelMembers.notifyLevel })
.from(channelMembers)
.where(eq(channelMembers.channelId, channelId))
for (const m of members) {
if (m.userId !== senderId && m.notifyLevel !== "none") {
notifyUserIds.add(m.userId)
}
}
break
}
case "here": {
// online channel members (check userPresence)
const members = await db
.select({ userId: channelMembers.userId, notifyLevel: channelMembers.notifyLevel })
.from(channelMembers)
.where(eq(channelMembers.channelId, channelId))
const onlineUsers = await db
.select({ userId: userPresence.userId })
.from(userPresence)
.where(eq(userPresence.status, "online"))
const onlineSet = new Set(onlineUsers.map(u => u.userId))
for (const m of members) {
if (m.userId !== senderId && m.notifyLevel !== "none" && onlineSet.has(m.userId)) {
notifyUserIds.add(m.userId)
}
}
break
}
case "agent": {
// skip -- agents don't have push tokens
break
}
}
}
// send push notifications
const promises = Array.from(notifyUserIds).map(userId =>
sendPushNotification(d1, fcmServerKey, {
userId,
title: `${senderName} mentioned you`,
body: "You were mentioned in a conversation",
data: { channelId, messageId, type: "mention" },
}).catch(err => console.error(`Push failed for ${userId}:`, err))
)
await Promise.allSettled(promises)
}