mirror of https://github.com/usememos/memos
feat(mentions): add memo mention parsing, notifications, and rendering (#5811)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>pull/5817/head
parent
38fc22b754
commit
24fc8ab8ca
@ -0,0 +1,69 @@
|
||||
## References
|
||||
|
||||
- [Comments, mentions & reactions - Notion Help Center](https://www.notion.com/help/comments-mentions-and-reminders)
|
||||
- [Notification settings - Notion Help Center](https://www.notion.com/help/notification-settings)
|
||||
- [Mention a person or team - Confluence Cloud](https://support.atlassian.com/confluence-cloud/docs/mention-a-person-or-team/)
|
||||
- [Comment on Coda docs - Coda Help](https://help.coda.io/hc/en-us/articles/39555917053069-Comment-on-Coda-docs)
|
||||
- [Customize notifications from comments - Coda Help](https://help.coda.io/hc/en-us/articles/39555901119117-Customize-notifications-from-comments)
|
||||
|
||||
## Industry Baseline
|
||||
|
||||
`Comments, mentions & reactions - Notion Help Center` shows the most common editor-side behavior: typing `@` triggers real-time search, mentions can live inline in page bodies and comments, clicking an inbox item takes the user back to the exact context, and no notification is sent when the target cannot access the page. `Notification settings - Notion Help Center` also separates in-product inbox behavior from secondary delivery like desktop or email.
|
||||
|
||||
`Mention a person or team - Confluence Cloud` adds two useful guardrails for a collaborative editor: autocomplete suggestions appear directly from `@`, and notifications are intentionally deduplicated so people are notified on the first mention rather than on every repeated mention in the same page.
|
||||
|
||||
`Comment on Coda docs` and `Customize notifications from comments` show a narrower scope for mentions inside comments, but reinforce two patterns that matter for Memos: explicit `@` mentions are a distinct notification trigger from generic participation, and products often keep mention notifications separate from broader thread-subscription or owner-subscription rules.
|
||||
|
||||
Across these products, the default implementation is not “parse arbitrary display text and hope it matches a user.” The stable interaction is: search among valid workspace members, insert a canonical mention token, render it differently from plain text, and only notify when access and deduplication rules say the event is meaningful.
|
||||
|
||||
## Research Summary
|
||||
|
||||
Memos already has the right extension points to adopt that baseline without a storage redesign. The backend has a custom inline markdown extension pipeline for `#tag`, memo create and update both rebuild `MemoPayload`, and the inbox model already represents user-facing attention items. The frontend editor already has a trigger-character suggestion popup, the markdown renderer already recognizes custom inline nodes, and public user profiles are already routed by username.
|
||||
|
||||
The biggest mismatch is user discovery. The current `ListUsers` path is admin-only and exact-match oriented, while mention autocomplete needs a normal authenticated user search API that can return ranked candidates by username and display name. The second mismatch is notification shape: the inbox and API layers only understand memo-comment notifications today, so a mention feature cannot be expressed as a first-class notification without extending the inbox proto and inbox UI.
|
||||
|
||||
Research also suggests that Memos should stay narrower than Notion or Confluence. There is no existing concept of teams, group mentions, page mentions, or per-page ACLs. The codebase already treats usernames as the public user token and memo visibility as a coarse `PUBLIC/PROTECTED/PRIVATE` rule. The best fit is therefore person mentions only, keyed by canonical username, with notification rules that are access-aware and deduplicated across repeated edits.
|
||||
|
||||
## Design Goals
|
||||
|
||||
- Typing `@` in the memo editor or comment editor shows ranked, authenticated user candidates and inserts a canonical `@username` token on selection.
|
||||
- The backend extracts mention targets from memo/comment content during create, update, and payload rebuild, and produces the same mention set for equivalent content across all supported databases.
|
||||
- Mention notifications are created only for newly added targets, at most once per target per memo revision, and never for self-mentions or inaccessible private content.
|
||||
- Memo content renders resolved mentions as interactive inline entities and degrades unresolved tokens to plain text.
|
||||
- The inbox API and inbox UI expose mention notifications as a first-class type distinct from comment notifications.
|
||||
- The design does not require a relational schema migration; it only extends existing proto-backed JSON payloads and server/frontend code paths.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Adding group mentions, team mentions, page mentions, or date mentions.
|
||||
- Building a generic watch/subscription system for memo activity.
|
||||
- Sending mention notifications through email, push, Slack, or webhooks.
|
||||
- Making mention references survive username changes automatically.
|
||||
- Replacing the textarea editor with a richer block editor.
|
||||
- Redesigning memo visibility or introducing user-level memo sharing.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
Support only canonical `@username` mentions in this issue. The parser should recognize the same username token vocabulary that the API already accepts for public user names, instead of trying to match display names or arbitrary free text. This keeps mention authoring aligned with existing user resource naming and avoids ambiguous matches when multiple users share similar display names. Mention suggestions may show both display name and username, but the inserted source text remains `@username`.
|
||||
|
||||
Add a backend markdown mention extension parallel to the existing tag extension. Introduce `internal/markdown/ast.MentionNode`, `internal/markdown/parser.NewMentionParser()`, and `internal/markdown/extensions.MentionExtension`, then wire it into `internal/markdown/markdown.go` next to `TagExtension`. The mention parser should require a word boundary before `@` so email addresses and URLs do not become mentions, and it should normalize the captured token to lowercase before lookup because usernames are canonicalized that way in the API layer.
|
||||
|
||||
Extend `storepb.MemoPayload` with a repeated mention metadata field, for example `repeated Mention mentions`, where each item stores at least `username` and resolved `user_id`. The raw markdown remains the source of truth for author-visible text, but the payload becomes the normalized server-side mention set for diffing and notification decisions. This reuses the existing memo payload rebuild path and avoids reparsing memo bodies in multiple side-effect handlers. No SQL migration is required because memo payloads are already stored as proto-backed JSON blobs in each database driver.
|
||||
|
||||
Teach `memopayload.RebuildMemoPayload` to resolve mention metadata while rebuilding tags and properties. The extraction step should walk the markdown AST once, collect raw `@username` tokens, resolve them to active users via the store, deduplicate by `user_id`, and populate `memo.Payload.Mentions`. Unresolved usernames should not fail memo creation; they should simply be omitted from normalized mention metadata so the feature remains tolerant of free-typed text. This mirrors how the frontend can degrade unresolved tokens back to plain text.
|
||||
|
||||
Add a dedicated mention side-effect helper around memo create and update flows. On create, after the memo is persisted and the final payload is available, compute the normalized mentioned user set from `memo.Payload.Mentions` and create inbox items for allowed targets. On update, diff the previous and new normalized mention sets and only notify targets that were newly added in the latest saved revision. This follows the Confluence-style deduplication pattern and prevents repeated notifications when a memo is edited without changing its mention set. If a mention is removed and later re-added, it counts as newly added again and may generate a fresh inbox item.
|
||||
|
||||
Apply access and duplication rules before writing inbox rows. Self-mentions are ignored. For top-level memos, notify only when the target can already read the memo under current visibility rules. For comments, notify the mentioned user when they can read the comment context and are not already covered by the existing memo-comment notification to the parent memo owner for that same event. This keeps mention notifications meaningful and avoids sending an owner both a comment notification and a mention notification for the same comment creation unless future product requirements explicitly want both. For `PRIVATE` memos and `PRIVATE` comments, mentions remain author-visible text but do not generate inbox notifications for other users.
|
||||
|
||||
Extend inbox storage and API notifications with a dedicated mention type instead of overloading the existing comment type. Add `MEMO_MENTION` to `proto/store/inbox.proto` with a payload that can represent both top-level memos and comments, such as `memo_id` plus optional `related_memo_id`. Mirror that in `proto/api/v1/user_service.proto` with `UserNotification_MEMO_MENTION` and `MemoMentionPayload`. Reuse the current notification conversion pattern in `server/router/api/v1/user_service.go`: resolve memo names from stored IDs, return a first-class mention payload, and let the inbox page render a separate mention card component. This keeps the notification center composable as new activity types appear.
|
||||
|
||||
Add an authenticated user-search endpoint specifically for mention autocomplete. The repository already has a stale public-method placeholder for `SearchUsers`, but no proto or handler. Define `SearchUsers` in `proto/api/v1/user_service.proto`, remove it from the public ACL list, and implement it in `server/router/api/v1/user_service.go` as an authenticated RPC that accepts a short query string plus page size. Extend `store.FindUser` with search-oriented fields and implement driver-specific case-insensitive matching in SQLite, MySQL, and PostgreSQL over `username` and `nickname`, ordered by exact username match, username prefix, nickname prefix, then a stable fallback. This produces a usable editor candidate list without reusing the admin-only `ListUsers` contract.
|
||||
|
||||
Implement frontend mention suggestions by reusing the existing generic textarea suggestion system. Add a `MentionSuggestions` component beside `TagSuggestions`, hook it into `web/src/components/MemoEditor/Editor/index.tsx`, and back it with a debounced `useSearchUsers(query)` hook. The popup should render avatar, display name, and `@username`, while selection inserts `@username ` exactly. Because `useSuggestions` currently operates on local item arrays, it can stay generic if the mention hook owns the remote query and passes the current ranked results down as `items`.
|
||||
|
||||
Implement frontend mention rendering with a dedicated markdown plugin and component instead of trying to infer mentions from links or plain spans. Add `remarkMention` beside `remarkTag`, a `Mention` inline component beside `Tag`, and a mention type guard in `web/src/types/markdown.ts`. The renderer should link resolved mentions to `/u/:username`, show display name or username with avatar-based affordance when lookup data is available, and render unresolved mention text non-interactively. To avoid N-per-mention network fetches, `MemoContent` should collect mentioned usernames from content and hydrate them through the existing `useUsersByNames()` hook once per memo render tree.
|
||||
|
||||
Render mention notifications as their own inbox card. Reuse the existing `MemoCommentMessage` pattern, but resolve the source memo/comment and optional related memo from the `MemoMentionPayload`. The card should show who mentioned the user, in what memo or comment, a short snippet, and navigate to the relevant memo detail on click. `web/src/pages/Inboxes.tsx` should switch on both `MEMO_COMMENT` and `MEMO_MENTION` so the inbox can grow by type without silently discarding new notifications.
|
||||
|
||||
Do not solve username drift in this issue. If a user later changes username, existing raw markdown still contains the old `@username` text, and rebuilt payload metadata will stop resolving unless the old token still matches a live username. This is acceptable for the current scope because username-history and alias resolution are already out of scope elsewhere in the codebase. The alternative of storing opaque mention IDs in source markdown or adding a username-alias subsystem was rejected because it turns a contained collaboration feature into a broader identity migration project.
|
||||
@ -0,0 +1,37 @@
|
||||
## Execution Log
|
||||
|
||||
### T1: Add backend mention parsing and payload extraction
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `internal/markdown/ast/mention.go`, `internal/markdown/parser/mention.go`, `internal/markdown/extensions/mention.go`, `internal/markdown/markdown.go`, `internal/markdown/renderer/markdown_renderer.go`, `server/runner/memopayload/runner.go`, `server/router/api/v1/memo_service.go`, `server/router/api/v1/v1.go`, `server/router/api/v1/test/test_helper.go`, `internal/markdown/markdown_test.go`
|
||||
**Validation**: `go test ./internal/markdown` — PASS
|
||||
**Path Corrections**: `RebuildMemoPayload` needed `context + store` so mention resolution could happen during payload rebuild.
|
||||
**Deviations**: None
|
||||
|
||||
### T2: Add mention notifications and user search APIs
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `proto/store/memo.proto`, `proto/store/inbox.proto`, `proto/api/v1/user_service.proto`, `server/router/api/v1/user_service.go`, `server/router/api/v1/connect_services.go`, `server/router/api/v1/acl_config.go`, `server/router/api/v1/acl_config_test.go`, `server/router/api/v1/memo_mention_helpers.go`, `store/user.go`, `store/db/sqlite/user.go`, `store/db/postgres/user.go`, `store/db/mysql/user.go`, `server/router/api/v1/test/user_notification_test.go`, `server/router/api/v1/test/user_search_test.go`
|
||||
**Validation**: `go test ./server/router/api/v1/...` — PASS
|
||||
**Path Corrections**: Unknown legacy inbox message types are filtered server-side to keep unread counts aligned with rendered cards.
|
||||
**Deviations**: None
|
||||
|
||||
### T3: Add frontend mention autocomplete, rendering, and inbox UI
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `web/src/components/MemoEditor/Editor/MentionSuggestions.tsx`, `web/src/components/MemoEditor/Editor/index.tsx`, `web/src/components/MemoEditor/Editor/useSuggestions.ts`, `web/src/hooks/useUserQueries.ts`, `web/src/utils/remark-plugins/remark-mention.ts`, `web/src/components/MemoContent/MentionContext.tsx`, `web/src/components/MemoContent/Mention.tsx`, `web/src/components/MemoContent/index.tsx`, `web/src/components/MemoContent/ConditionalComponent.tsx`, `web/src/types/markdown.ts`, `web/src/components/Inbox/MemoMentionMessage.tsx`, `web/src/pages/Inboxes.tsx`
|
||||
**Validation**: `pnpm lint && pnpm build` — PASS
|
||||
**Path Corrections**: Editor autocomplete reused the existing generic suggestion hook by exposing the live query rather than duplicating keyboard navigation logic.
|
||||
**Deviations**: None
|
||||
|
||||
### T4: Regenerate code and validate the feature
|
||||
|
||||
**Status**: Completed
|
||||
**Files Changed**: `proto/gen/**`, `web/src/types/proto/**`
|
||||
**Validation**: `buf generate` — PASS; `go test ./internal/markdown ./server/router/api/v1/...` — PASS; `pnpm lint` — PASS; `pnpm build` — PASS
|
||||
**Path Corrections**: None
|
||||
**Deviations**: None
|
||||
|
||||
## Completion Declaration
|
||||
|
||||
All tasks completed successfully
|
||||
@ -0,0 +1,104 @@
|
||||
## Task List
|
||||
|
||||
### Task Index
|
||||
|
||||
T1: Add backend mention parsing and payload extraction [M] — T2: Add mention notifications and user search APIs [L] — T3: Add frontend mention autocomplete, rendering, and inbox UI [L] — T4: Regenerate code and validate the feature [M]
|
||||
|
||||
### T1: Add backend mention parsing and payload extraction [M]
|
||||
|
||||
**Objective**: Parse `@username` tokens into structured mention metadata during memo payload rebuilds.
|
||||
**Size**: M
|
||||
**Files**:
|
||||
- Create: `internal/markdown/ast/mention.go`
|
||||
- Create: `internal/markdown/parser/mention.go`
|
||||
- Create: `internal/markdown/extensions/mention.go`
|
||||
- Modify: `internal/markdown/markdown.go`
|
||||
- Modify: `internal/markdown/renderer/markdown_renderer.go`
|
||||
- Modify: `server/runner/memopayload/runner.go`
|
||||
- Modify: `server/router/api/v1/memo_service.go`
|
||||
- Test: `internal/markdown/markdown_test.go`
|
||||
**Implementation**:
|
||||
1. Add mention AST/parser/extension parallel to the existing tag implementation.
|
||||
2. Extend extracted markdown data and `MemoPayload` rebuild to collect normalized mentions and resolve them to users.
|
||||
3. Update memo create/update and background payload rebuild paths to use the new mention-aware payload builder.
|
||||
**Boundaries**: Do not add a relational schema migration.
|
||||
**Dependencies**: None
|
||||
**Expected Outcome**: Memo payloads carry normalized mention metadata rebuilt from markdown content.
|
||||
**Validation**: `go test ./internal/markdown` — expected `ok`
|
||||
|
||||
### T2: Add mention notifications and user search APIs [L]
|
||||
|
||||
**Objective**: Expose mention-aware APIs and create inbox items for newly added mentions.
|
||||
**Size**: L
|
||||
**Files**:
|
||||
- Modify: `proto/store/memo.proto`
|
||||
- Modify: `proto/store/inbox.proto`
|
||||
- Modify: `proto/api/v1/user_service.proto`
|
||||
- Modify: `server/router/api/v1/user_service.go`
|
||||
- Modify: `server/router/api/v1/connect_services.go`
|
||||
- Modify: `server/router/api/v1/acl_config.go`
|
||||
- Modify: `server/router/api/v1/acl_config_test.go`
|
||||
- Create: `server/router/api/v1/memo_mention_helpers.go`
|
||||
- Modify: `store/user.go`
|
||||
- Modify: `store/db/sqlite/user.go`
|
||||
- Modify: `store/db/postgres/user.go`
|
||||
- Modify: `store/db/mysql/user.go`
|
||||
- Test: `server/router/api/v1/test/user_notification_test.go`
|
||||
- Test: `server/router/api/v1/test/user_search_test.go`
|
||||
**Implementation**:
|
||||
1. Extend proto contracts with `MemoPayload.mentions`, `InboxMessage.MEMO_MENTION`, `UserNotification.MEMO_MENTION`, and `SearchUsers`.
|
||||
2. Implement authenticated user search over username and nickname.
|
||||
3. Add mention notification side effects for memo create/update/comment flows with diffing and duplicate suppression.
|
||||
4. Convert inbox rows into either comment or mention notifications and filter unknown legacy types.
|
||||
**Boundaries**: Do not add email/push/webhook mention delivery.
|
||||
**Dependencies**: T1
|
||||
**Expected Outcome**: Mentioned users receive inbox notifications and the editor has an API to fetch mention candidates.
|
||||
**Validation**: `go test ./server/router/api/v1/...` — expected `ok`
|
||||
|
||||
### T3: Add frontend mention autocomplete, rendering, and inbox UI [L]
|
||||
|
||||
**Objective**: Let users insert mentions from the editor and render/read them in the UI.
|
||||
**Size**: L
|
||||
**Files**:
|
||||
- Create: `web/src/components/MemoEditor/Editor/MentionSuggestions.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/Editor/index.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/Editor/useSuggestions.ts`
|
||||
- Modify: `web/src/hooks/useUserQueries.ts`
|
||||
- Create: `web/src/utils/remark-plugins/remark-mention.ts`
|
||||
- Create: `web/src/components/MemoContent/MentionContext.tsx`
|
||||
- Create: `web/src/components/MemoContent/Mention.tsx`
|
||||
- Modify: `web/src/components/MemoContent/index.tsx`
|
||||
- Modify: `web/src/components/MemoContent/ConditionalComponent.tsx`
|
||||
- Modify: `web/src/types/markdown.ts`
|
||||
- Create: `web/src/components/Inbox/MemoMentionMessage.tsx`
|
||||
- Modify: `web/src/pages/Inboxes.tsx`
|
||||
**Implementation**:
|
||||
1. Add `@` autocomplete backed by `SearchUsers`.
|
||||
2. Add markdown mention parsing/rendering and hydrate mentioned users once per memo render.
|
||||
3. Add a dedicated inbox card for memo mention notifications.
|
||||
**Boundaries**: Do not redesign the textarea editor.
|
||||
**Dependencies**: T2
|
||||
**Expected Outcome**: Users can insert, see, and open mentions from memo content and inbox notifications.
|
||||
**Validation**: `pnpm lint && pnpm build` — expected success
|
||||
|
||||
### T4: Regenerate code and validate the feature [M]
|
||||
|
||||
**Objective**: Regenerate generated code and verify backend/frontend behavior.
|
||||
**Size**: M
|
||||
**Files**:
|
||||
- Modify: `proto/gen/**`
|
||||
- Modify: `web/src/types/proto/**`
|
||||
**Implementation**:
|
||||
1. Run `buf generate` after proto changes.
|
||||
2. Re-run focused Go tests and frontend lint/build.
|
||||
**Boundaries**: Do not broaden into unrelated CI cleanup.
|
||||
**Dependencies**: T1, T2, T3
|
||||
**Expected Outcome**: Generated code matches the new APIs and validations pass.
|
||||
**Validation**: `buf generate`, `go test ./internal/markdown ./server/router/api/v1/...`, `pnpm lint`, `pnpm build`
|
||||
|
||||
## Out-of-Scope Tasks
|
||||
|
||||
- Group/team mentions
|
||||
- Username alias migration
|
||||
- Email or push delivery for mentions
|
||||
- Watch/subscription semantics beyond explicit mentions
|
||||
@ -0,0 +1,28 @@
|
||||
package ast
|
||||
|
||||
import (
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
)
|
||||
|
||||
// MentionNode represents an @mention in the markdown AST.
|
||||
type MentionNode struct {
|
||||
gast.BaseInline
|
||||
|
||||
// Username without the @ prefix.
|
||||
Username []byte
|
||||
}
|
||||
|
||||
// KindMention is the NodeKind for MentionNode.
|
||||
var KindMention = gast.NewNodeKind("Mention")
|
||||
|
||||
// Kind returns KindMention.
|
||||
func (*MentionNode) Kind() gast.NodeKind {
|
||||
return KindMention
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump for debugging.
|
||||
func (n *MentionNode) Dump(source []byte, level int) {
|
||||
gast.DumpHelper(n, source, level, map[string]string{
|
||||
"Username": string(n.Username),
|
||||
}, nil)
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/util"
|
||||
|
||||
mparser "github.com/usememos/memos/internal/markdown/parser"
|
||||
)
|
||||
|
||||
type mentionExtension struct{}
|
||||
|
||||
// MentionExtension is a goldmark extension for @mention syntax.
|
||||
var MentionExtension = &mentionExtension{}
|
||||
|
||||
// Extend extends the goldmark parser with mention support.
|
||||
func (*mentionExtension) Extend(m goldmark.Markdown) {
|
||||
m.Parser().AddOptions(
|
||||
parser.WithInlineParsers(
|
||||
// Priority 200 - run before standard link parser (500).
|
||||
util.Prioritized(mparser.NewMentionParser(), 200),
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
|
||||
mast "github.com/usememos/memos/internal/markdown/ast"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxMentionLength matches the username token length accepted by the API.
|
||||
MaxMentionLength = 32
|
||||
)
|
||||
|
||||
type mentionParser struct{}
|
||||
|
||||
// NewMentionParser creates a new inline parser for @mention syntax.
|
||||
func NewMentionParser() parser.InlineParser {
|
||||
return &mentionParser{}
|
||||
}
|
||||
|
||||
// Trigger returns the characters that trigger this parser.
|
||||
func (*mentionParser) Trigger() []byte {
|
||||
return []byte{'@'}
|
||||
}
|
||||
|
||||
func isValidMentionRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsNumber(r) || r == '-'
|
||||
}
|
||||
|
||||
func isMentionBoundary(r rune) bool {
|
||||
return unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)
|
||||
}
|
||||
|
||||
// Parse parses @mention syntax while avoiding email-address matches.
|
||||
func (*mentionParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.Node {
|
||||
line, _ := block.PeekLine()
|
||||
if len(line) == 0 || line[0] != '@' {
|
||||
return nil
|
||||
}
|
||||
|
||||
prev := block.PrecendingCharacter()
|
||||
if prev != '\n' && !isMentionBoundary(prev) {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := 1
|
||||
pos := start
|
||||
runeCount := 0
|
||||
hasLetterOrNumber := false
|
||||
|
||||
for pos < len(line) {
|
||||
r, size := utf8.DecodeRune(line[pos:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
break
|
||||
}
|
||||
if !isValidMentionRune(r) {
|
||||
break
|
||||
}
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
hasLetterOrNumber = true
|
||||
}
|
||||
runeCount++
|
||||
if runeCount > MaxMentionLength {
|
||||
break
|
||||
}
|
||||
pos += size
|
||||
}
|
||||
|
||||
if pos <= start || !hasLetterOrNumber {
|
||||
return nil
|
||||
}
|
||||
|
||||
username := line[start:pos]
|
||||
usernameCopy := make([]byte, len(username))
|
||||
copy(usernameCopy, username)
|
||||
|
||||
block.Advance(pos)
|
||||
|
||||
return &mast.MentionNode{
|
||||
Username: usernameCopy,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,34 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV1Service) listMemosByID(ctx context.Context, memoIDs []int32) (map[int32]*store.Memo, error) {
|
||||
if len(memoIDs) == 0 {
|
||||
return map[int32]*store.Memo{}, nil
|
||||
}
|
||||
|
||||
uniqueMemoIDs := make([]int32, 0, len(memoIDs))
|
||||
seenMemoIDs := make(map[int32]struct{}, len(memoIDs))
|
||||
for _, memoID := range memoIDs {
|
||||
if _, seen := seenMemoIDs[memoID]; seen {
|
||||
continue
|
||||
}
|
||||
seenMemoIDs[memoID] = struct{}{}
|
||||
uniqueMemoIDs = append(uniqueMemoIDs, memoID)
|
||||
}
|
||||
|
||||
memos, err := s.Store.ListMemos(ctx, &store.FindMemo{IDList: uniqueMemoIDs})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memosByID := make(map[int32]*store.Memo, len(memos))
|
||||
for _, memo := range memos {
|
||||
memosByID[memo.ID] = memo
|
||||
}
|
||||
return memosByID, nil
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
// suppressMentionKey is a context key used to suppress mention notification side effects
|
||||
// when CreateMemo is called internally from CreateMemoComment.
|
||||
type suppressMentionKey struct{}
|
||||
|
||||
func withSuppressMentionNotifications(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, suppressMentionKey{}, true)
|
||||
}
|
||||
|
||||
func isMentionNotificationSuppressed(ctx context.Context) bool {
|
||||
v, ok := ctx.Value(suppressMentionKey{}).(bool)
|
||||
return ok && v
|
||||
}
|
||||
|
||||
func (s *APIV1Service) resolveMentionTargets(ctx context.Context, content string) (map[int32]*store.User, error) {
|
||||
targets := make(map[int32]*store.User)
|
||||
if content == "" {
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
data, err := s.MarkdownService.ExtractAll([]byte(content))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to extract mentions")
|
||||
}
|
||||
if len(data.Mentions) == 0 {
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
normal := store.Normal
|
||||
users, err := s.Store.ListUsers(ctx, &store.FindUser{
|
||||
UsernameList: data.Mentions,
|
||||
RowStatus: &normal,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to resolve mention users")
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
targets[user.ID] = user
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func canUserAccessMentionContext(target *store.User, memo *store.Memo, relatedMemo *store.Memo) bool {
|
||||
if target == nil || memo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if relatedMemo != nil {
|
||||
if relatedMemo.Visibility == store.Private && target.ID != relatedMemo.CreatorID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if memo.Visibility == store.Private && target.ID != memo.CreatorID {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func shouldSkipMentionInbox(target *store.User, memo *store.Memo, relatedMemo *store.Memo) bool {
|
||||
if target == nil || memo == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if target.ID == memo.CreatorID {
|
||||
return true
|
||||
}
|
||||
|
||||
// Comment creation already generates a memo-comment inbox item for the parent creator.
|
||||
if relatedMemo != nil && target.ID == relatedMemo.CreatorID && memo.Visibility != store.Private && memo.CreatorID != relatedMemo.CreatorID {
|
||||
return true
|
||||
}
|
||||
|
||||
return !canUserAccessMentionContext(target, memo, relatedMemo)
|
||||
}
|
||||
|
||||
func (s *APIV1Service) dispatchMemoMentionNotifications(ctx context.Context, memo *store.Memo, relatedMemo *store.Memo, previousContent string) error {
|
||||
if memo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
currentTargets, err := s.resolveMentionTargets(ctx, memo.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(currentTargets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
previousTargets, err := s.resolveMentionTargets(ctx, previousContent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for userID, target := range currentTargets {
|
||||
if _, exists := previousTargets[userID]; exists {
|
||||
continue
|
||||
}
|
||||
if shouldSkipMentionInbox(target, memo, relatedMemo) {
|
||||
continue
|
||||
}
|
||||
|
||||
payload := &storepb.InboxMessage_MemoMentionPayload{
|
||||
MemoId: memo.ID,
|
||||
}
|
||||
if relatedMemo != nil {
|
||||
payload.RelatedMemoId = relatedMemo.ID
|
||||
}
|
||||
|
||||
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: memo.CreatorID,
|
||||
ReceiverID: target.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_MENTION,
|
||||
Payload: &storepb.InboxMessage_MemoMention{
|
||||
MemoMention: payload,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return errors.Wrap(err, "failed to create mention inbox")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) dispatchMemoMentionNotificationsBestEffort(ctx context.Context, memo *store.Memo, relatedMemo *store.Memo, previousContent string) {
|
||||
if err := s.dispatchMemoMentionNotifications(ctx, memo, relatedMemo, previousContent); err != nil {
|
||||
slog.Warn("Failed to dispatch memo mention notifications", slog.Any("err", err), slog.Int64("memo_id", int64(memo.ID)))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
|
||||
)
|
||||
|
||||
func TestBatchGetUsersReturnsExactUsernamesWithoutAuthentication(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
_, err := ts.CreateRegularUser(ctx, "batch-alpha")
|
||||
require.NoError(t, err)
|
||||
_, err = ts.CreateRegularUser(ctx, "batch-beta")
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := ts.Service.BatchGetUsers(ctx, &apiv1.BatchGetUsersRequest{
|
||||
Usernames: []string{"batch-alpha", "batch-beta", "missing-user", "batch-alpha"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Users, 2)
|
||||
|
||||
got := map[string]struct{}{}
|
||||
for _, user := range resp.Users {
|
||||
got[user.Username] = struct{}{}
|
||||
}
|
||||
_, ok := got["batch-alpha"]
|
||||
require.True(t, ok)
|
||||
_, ok = got["batch-beta"]
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestBatchGetUsersRejectsTooManyUsernames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
usernames := make([]string, 0, 101)
|
||||
for i := range 101 {
|
||||
usernames = append(usernames, fmt.Sprintf("user-%d", i))
|
||||
}
|
||||
|
||||
_, err := ts.Service.BatchGetUsers(ctx, &apiv1.BatchGetUsersRequest{
|
||||
Usernames: usernames,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "too many usernames")
|
||||
}
|
||||
@ -0,0 +1,164 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { AtSignIcon, CheckIcon, MessageSquareIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
notification: UserNotification;
|
||||
}
|
||||
|
||||
function MemoMentionMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const mentionPayload = notification.payload?.case === "memoMention" ? notification.payload.value : undefined;
|
||||
const sender = notification.senderUser;
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
await userServiceClient.updateUserNotification({
|
||||
notification: {
|
||||
name: notification.name,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
});
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
await userServiceClient.deleteUserNotification({
|
||||
name: notification.name,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
};
|
||||
|
||||
if (!mentionPayload) {
|
||||
return (
|
||||
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
|
||||
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUnread = notification.status === UserNotification_Status.UNREAD;
|
||||
const isCommentMention = Boolean(mentionPayload.relatedMemo);
|
||||
const targetName = mentionPayload.relatedMemo || mentionPayload.memo;
|
||||
|
||||
const handleNavigate = async () => {
|
||||
navigateTo(`/${targetName}`);
|
||||
if (isUnread) {
|
||||
await handleArchiveMessage(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
|
||||
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
|
||||
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<AtSignIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
|
||||
<span className="text-sm text-muted-foreground/80">mentioned you {isCommentMention ? "in a comment" : "in a memo"}</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
|
||||
at{" "}
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isUnread ? (
|
||||
<button
|
||||
onClick={() => handleArchiveMessage()}
|
||||
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.archive")}
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mentionPayload.relatedMemo && (
|
||||
<div className="pl-3 border-l-2 border-muted-foreground/20 mb-3">
|
||||
<p className="text-sm text-foreground/60 line-clamp-1 leading-relaxed">
|
||||
<span className="text-xs text-muted-foreground/50 font-medium mr-2 uppercase tracking-wide">Memo:</span>
|
||||
{mentionPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={handleNavigate}
|
||||
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||
<MessageSquareIcon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-primary/60 font-semibold mb-1 uppercase tracking-wider">
|
||||
{isCommentMention ? "Comment" : "Memo"}
|
||||
</p>
|
||||
<p className="text-sm text-foreground/90 line-clamp-2">
|
||||
{mentionPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty memo</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoMentionMessage;
|
||||
@ -0,0 +1,40 @@
|
||||
import type { Element } from "hast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MentionProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
node?: Element;
|
||||
"data-mention"?: string;
|
||||
children?: React.ReactNode;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export const Mention: React.FC<MentionProps> = ({
|
||||
"data-mention": dataMention,
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
resolved = false,
|
||||
...props
|
||||
}) => {
|
||||
const username = dataMention || "";
|
||||
|
||||
if (!resolved) {
|
||||
return (
|
||||
<span data-mention={username} title={`@${username}`} className={className} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/u/${username}`}
|
||||
className={cn("text-blue-600 underline-offset-2 hover:underline dark:text-blue-400", className)}
|
||||
data-mention={username}
|
||||
title={`@${username}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import { useUsersByUsernames } from "@/hooks/useUserQueries";
|
||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
||||
|
||||
const MentionResolutionContext = createContext<Set<string> | null>(null);
|
||||
|
||||
interface MentionResolutionProviderProps {
|
||||
contents: string[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const MentionResolutionProvider = ({ contents, children }: MentionResolutionProviderProps) => {
|
||||
const mentionUsernames = useMemo(() => Array.from(new Set(contents.flatMap((content) => extractMentionUsernames(content)))), [contents]);
|
||||
const { data: mentionUsers } = useUsersByUsernames(mentionUsernames);
|
||||
const resolvedMentionUsernames = useMemo(() => {
|
||||
if (!mentionUsers) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
|
||||
}, [mentionUsers]);
|
||||
|
||||
return <MentionResolutionContext.Provider value={resolvedMentionUsernames}>{children}</MentionResolutionContext.Provider>;
|
||||
};
|
||||
|
||||
export function useResolvedMentionUsernames(usernames: string[]) {
|
||||
const sharedResolvedMentionUsernames = useContext(MentionResolutionContext);
|
||||
const shouldUseSharedResolution = sharedResolvedMentionUsernames !== null;
|
||||
const { data: mentionUsers } = useUsersByUsernames(usernames, { enabled: !shouldUseSharedResolution });
|
||||
|
||||
return useMemo(() => {
|
||||
if (sharedResolvedMentionUsernames) {
|
||||
return sharedResolvedMentionUsernames;
|
||||
}
|
||||
if (!mentionUsers) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
|
||||
}, [sharedResolvedMentionUsernames, mentionUsers]);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,102 @@
|
||||
import type { Root, Text } from "mdast";
|
||||
import type { Node as UnistNode } from "unist";
|
||||
import { visit } from "unist-util-visit";
|
||||
import type { MentionNode, MentionNodeData } from "@/types/markdown";
|
||||
|
||||
const MAX_MENTION_LENGTH = 32;
|
||||
|
||||
function isMentionChar(char: string): boolean {
|
||||
return /[A-Za-z0-9-]/.test(char);
|
||||
}
|
||||
|
||||
function isMentionBoundary(char: string): boolean {
|
||||
if (!char) return true;
|
||||
return !isMentionChar(char);
|
||||
}
|
||||
|
||||
type Segment = { type: "text"; value: string } | { type: "mention"; value: string };
|
||||
|
||||
export function parseMentionsFromText(text: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const chars = [...text];
|
||||
let i = 0;
|
||||
|
||||
while (i < chars.length) {
|
||||
const prevChar = i > 0 ? chars[i - 1] : "";
|
||||
if (chars[i] === "@" && isMentionBoundary(prevChar) && i + 1 < chars.length && isMentionChar(chars[i + 1])) {
|
||||
let j = i + 1;
|
||||
while (j < chars.length && isMentionChar(chars[j]) && j - i - 1 < MAX_MENTION_LENGTH) {
|
||||
j++;
|
||||
}
|
||||
|
||||
const username = chars.slice(i + 1, j).join("");
|
||||
const hasLetterOrNumber = [...username].some((char) => /[A-Za-z0-9]/.test(char));
|
||||
if (username && hasLetterOrNumber) {
|
||||
segments.push({ type: "mention", value: username.toLowerCase() });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let j = i + 1;
|
||||
while (j < chars.length && chars[j] !== "@") {
|
||||
j++;
|
||||
}
|
||||
segments.push({ type: "text", value: chars.slice(i, j).join("") });
|
||||
i = j;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function extractMentionUsernames(text: string): string[] {
|
||||
const usernames = parseMentionsFromText(text)
|
||||
.filter((segment): segment is { type: "mention"; value: string } => segment.type === "mention")
|
||||
.map((segment) => segment.value);
|
||||
return Array.from(new Set(usernames));
|
||||
}
|
||||
|
||||
function createMentionNode(username: string): MentionNode {
|
||||
const data: MentionNodeData = {
|
||||
hName: "span",
|
||||
hProperties: {
|
||||
className: "mention",
|
||||
"data-mention": username,
|
||||
},
|
||||
hChildren: [{ type: "text", value: `@${username}` }],
|
||||
};
|
||||
|
||||
return {
|
||||
type: "mentionNode",
|
||||
value: username,
|
||||
data,
|
||||
} as MentionNode;
|
||||
}
|
||||
|
||||
export const remarkMention = () => {
|
||||
return (tree: Root) => {
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (node.type !== "text" || !parent || index === null) return;
|
||||
|
||||
const textNode = node as Text;
|
||||
const segments = parseMentionsFromText(textNode.value);
|
||||
if (segments.every((segment) => segment.type === "text")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newNodes = segments.map((segment) => {
|
||||
if (segment.type === "mention") {
|
||||
return createMentionNode(segment.value);
|
||||
}
|
||||
return {
|
||||
type: "text",
|
||||
value: segment.value,
|
||||
} as Text;
|
||||
});
|
||||
|
||||
if (typeof index === "number") {
|
||||
(parent.children as UnistNode[]).splice(index, 1, ...(newNodes as UnistNode[]));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue