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:
parent
21edd5469c
commit
d4914c1a46
5
bun.lock
5
bun.lock
@ -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=="],
|
||||
|
||||
8
drizzle/0025_chunky_silverclaw.sql
Normal file
8
drizzle/0025_chunky_silverclaw.sql
Normal 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
|
||||
);
|
||||
4981
drizzle/meta/0025_snapshot.json
Normal file
4981
drizzle/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -176,6 +176,13 @@
|
||||
"when": 1771105729640,
|
||||
"tag": "0024_thankful_slayback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1771205359100,
|
||||
"tag": "0025_chunky_silverclaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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] =
|
||||
|
||||
298
src/components/conversations/mention-suggestion.tsx
Normal file
298
src/components/conversations/mention-suggestion.tsx
Normal 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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
83
src/lib/conversations/notify-mentions.ts
Normal file
83
src/lib/conversations/notify-mentions.ts
Normal 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user