feat(mentions): support @all to mention all workspace members
Add @all mention type that notifies all workspace members (excluding agents). Includes backend parsing, notification expansion to all members, and frontend UI with autocomplete suggestion, rendering, and hover card.
This commit is contained in:
parent
e68091e4a8
commit
095b7f8185
7 changed files with 91 additions and 17 deletions
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
|
@ -15,6 +16,27 @@ function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
|||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
|
||||
if (type === "all") {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">All members</p>
|
||||
<p className="text-xs text-muted-foreground">Notifies all workspace members</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "member") {
|
||||
const member = members.find((m) => m.user_id === id);
|
||||
if (!member) return <>{children}</>;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Hash } from "lucide-react";
|
||||
import { Hash, Users } from "lucide-react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
|
@ -23,7 +23,7 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
|||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent" | "issue";
|
||||
type: "member" | "agent" | "issue" | "all";
|
||||
/** Secondary text shown below the label (e.g. issue title) */
|
||||
description?: string;
|
||||
}
|
||||
|
|
@ -99,7 +99,11 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
|||
}`}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item.type === "issue" ? (
|
||||
{item.type === "all" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Users className="h-3 w-3" />
|
||||
</span>
|
||||
) : item.type === "issue" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Hash className="h-3 w-3" />
|
||||
</span>
|
||||
|
|
@ -137,6 +141,12 @@ export function createMentionSuggestion(): Omit<
|
|||
const { issues } = useIssueStore.getState();
|
||||
const q = query.toLowerCase();
|
||||
|
||||
// Show "All members" option when query is empty or matches "all"
|
||||
const allItem: MentionItem[] =
|
||||
"all members".includes(q) || "all".includes(q)
|
||||
? [{ id: "all", label: "All members", type: "all" as const, description: "Notify all members" }]
|
||||
: [];
|
||||
|
||||
const memberItems: MentionItem[] = members
|
||||
.filter((m) => m.name.toLowerCase().includes(q))
|
||||
.map((m) => ({
|
||||
|
|
@ -162,7 +172,7 @@ export function createMentionSuggestion(): Omit<
|
|||
description: i.title,
|
||||
}));
|
||||
|
||||
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ const MentionExtension = Mention.configure({
|
|||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2], id: match[3] },
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -99,9 +99,9 @@ function createComponents(
|
|||
),
|
||||
// Links: Make clickable with callbacks, or render as mention
|
||||
a: ({ href, children }) => {
|
||||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
|
||||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
|
||||
if (href?.startsWith('mention://')) {
|
||||
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
|
||||
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/)
|
||||
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
|
||||
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
|
||||
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import (
|
|||
|
||||
// mention represents a parsed @mention from markdown content (local alias).
|
||||
type mention struct {
|
||||
Type string // "member" or "agent"
|
||||
ID string // user_id or agent_id
|
||||
Type string // "member", "agent", or "all"
|
||||
ID string // user_id, agent_id, or "all"
|
||||
}
|
||||
|
||||
// statusLabels maps DB status values to human-readable labels for notifications.
|
||||
|
|
@ -193,7 +193,8 @@ func notifyDirect(
|
|||
}
|
||||
|
||||
// notifyMentionedMembers creates inbox items for each @mentioned member,
|
||||
// excluding the actor and any IDs in the skip set.
|
||||
// excluding the actor and any IDs in the skip set. When an @all mention is
|
||||
// present, all workspace members are notified (excluding agents).
|
||||
func notifyMentionedMembers(
|
||||
bus *events.Bus,
|
||||
queries *db.Queries,
|
||||
|
|
@ -206,17 +207,40 @@ func notifyMentionedMembers(
|
|||
skip map[string]bool,
|
||||
details []byte,
|
||||
) {
|
||||
// Collect the set of member IDs to notify.
|
||||
recipientIDs := map[string]bool{}
|
||||
|
||||
hasAll := false
|
||||
for _, m := range mentions {
|
||||
if m.Type != "member" {
|
||||
if m.Type == "all" {
|
||||
hasAll = true
|
||||
continue
|
||||
}
|
||||
if m.ID == e.ActorID || skip[m.ID] {
|
||||
if m.Type == "member" {
|
||||
recipientIDs[m.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// If @all is present, expand to all workspace members.
|
||||
if hasAll {
|
||||
members, err := queries.ListMembers(context.Background(), parseUUID(e.WorkspaceID))
|
||||
if err != nil {
|
||||
slog.Error("failed to list members for @all mention", "workspace_id", e.WorkspaceID, "error", err)
|
||||
} else {
|
||||
for _, m := range members {
|
||||
recipientIDs[util.UUIDToString(m.UserID)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id := range recipientIDs {
|
||||
if id == e.ActorID || skip[id] {
|
||||
continue
|
||||
}
|
||||
item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
|
||||
WorkspaceID: parseUUID(e.WorkspaceID),
|
||||
RecipientType: "member",
|
||||
RecipientID: parseUUID(m.ID),
|
||||
RecipientID: parseUUID(id),
|
||||
Type: "mentioned",
|
||||
Severity: "info",
|
||||
IssueID: parseUUID(issueID),
|
||||
|
|
@ -226,7 +250,7 @@ func notifyMentionedMembers(
|
|||
Details: details,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("mention inbox creation failed", "mentioned_id", m.ID, "error", err)
|
||||
slog.Error("mention inbox creation failed", "mentioned_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
resp := inboxItemToResponse(item)
|
||||
|
|
|
|||
|
|
@ -198,6 +198,9 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I
|
|||
}
|
||||
assigneeID := uuidToString(issue.AssigneeID)
|
||||
for _, m := range mentions {
|
||||
if m.IsMentionAll() {
|
||||
return false // @all includes everyone — allow trigger
|
||||
}
|
||||
if m.ID == assigneeID {
|
||||
return false // Assignee is mentioned — allow trigger
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@ import "regexp"
|
|||
|
||||
// Mention represents a parsed @mention from markdown content.
|
||||
type Mention struct {
|
||||
Type string // "member" or "agent"
|
||||
ID string // user_id or agent_id
|
||||
Type string // "member", "agent", or "all"
|
||||
ID string // user_id, agent_id, or "all"
|
||||
}
|
||||
|
||||
// MentionRe matches [@Label](mention://type/id) in markdown.
|
||||
var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
|
||||
var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent|all)/([0-9a-fA-F-]+|all)\)`)
|
||||
|
||||
// IsMentionAll returns true if the mention is an @all mention.
|
||||
func (m Mention) IsMentionAll() bool {
|
||||
return m.Type == "all"
|
||||
}
|
||||
|
||||
// ParseMentions extracts deduplicated mentions from markdown content.
|
||||
func ParseMentions(content string) []Mention {
|
||||
|
|
@ -26,3 +31,13 @@ func ParseMentions(content string) []Mention {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HasMentionAll returns true if any mention in the slice is an @all mention.
|
||||
func HasMentionAll(mentions []Mention) bool {
|
||||
for _, m := range mentions {
|
||||
if m.IsMentionAll() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue