feat(mentions): add memo mention parsing, notifications, and rendering (#5811)

Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
This commit is contained in:
memoclaw 2026-04-06 22:16:53 +08:00 committed by GitHub
parent 38fc22b754
commit 24fc8ab8ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 2667 additions and 509 deletions

View File

@ -0,0 +1,49 @@
## Background & Context
Memos stores memo bodies as markdown, rebuilds derived memo metadata into `MemoPayload`, exposes user notifications through the inbox model, and renders memo content in the React client with custom markdown plugins. The requested `@someone` feature spans both top-level memos and memo comments: users need to type `@`, pick a valid person, render the mention inline, and notify the mentioned user. The current product already has adjacent primitives for this work: a backend markdown extension for `#tag`, an inbox-backed notification center, a generic editor suggestion popup, public user profiles under username-based routes, and a memo update path that already rebuilds payloads on create and edit.
External product behavior is consistent on the core interaction but different on scope. Notion supports real-time `@` suggestions inside pages, comments, and discussions, stores mention notifications in an inbox, and suppresses notification if the mentioned user cannot access the content. Confluence supports autocomplete mentions for people and teams, sends a notification on the first mention, and does not keep notifying on repeated mentions in the same page. Coda supports `@` mentions inside comment threads, treats mentions and thread participation as notification triggers, and allows broader comment-subscription settings beyond explicit mentions. These patterns suggest that the common baseline for Memos is inline autocomplete, access-aware notification, deduplication, and a clear separation between mention notifications and broader thread-subscription features.
## Issue Statement
Memos does not currently recognize `@username` tokens as structured content in memo bodies or comment bodies, does not expose any non-admin user-search endpoint that the editor can use to suggest mentionable users, does not persist or diff mention metadata during memo create or update flows, and does not have an inbox or API notification type for mentions. As a result, `@someone` currently behaves as plain text and cannot drive inline rendering, target validation, or notification delivery.
## Current State
- `server/router/api/v1/memo_service.go:32-159` creates memos by copying raw `request.Memo.Content` into `store.Memo`, enforcing length limits, and calling `memopayload.RebuildMemoPayload`; `server/router/api/v1/memo_service.go:404-510` rebuilds payload only when `content` changes during memo updates.
- `server/router/api/v1/memo_service.go:590-681` creates memo comments by internally creating another memo and only generates inbox notifications for non-private comments to the parent memo creator via `InboxMessage_MEMO_COMMENT`.
- `server/router/api/v1/memo_update_helpers.go:27-77` only dispatches webhook and SSE side effects after memo updates; there is no mention-diff side-effect hook.
- `internal/markdown/markdown.go:20-24` defines extracted markdown metadata as `Tags` plus `Property`; `internal/markdown/markdown.go:68-89` only wires the custom tag extension; `internal/markdown/markdown.go:324-386` extracts tags and properties but no mention metadata.
- `internal/markdown/extensions/tag.go:13-23` and the related tag parser/AST types are the only custom inline markdown extension path today.
- `proto/store/memo.proto:7-29` limits `MemoPayload` to `property`, `location`, and `tags`; there is no repeated mention field or structured mention metadata.
- `proto/store/inbox.proto:7-24` defines only `InboxMessage_MEMO_COMMENT`; `proto/api/v1/user_service.proto:592-679` defines only `UserNotification_MEMO_COMMENT`.
- `server/router/api/v1/user_service.go:1272-1312` lists notifications by filtering inbox rows to `InboxMessage_MEMO_COMMENT` only; `server/router/api/v1/user_service.go:1433-1524` converts only that message type into API notifications.
- `web/src/pages/Inboxes.tsx:19-114` and `web/src/components/Inbox/MemoCommentMessage.tsx` only render memo comment notifications; other notification types would currently be dropped.
- `server/router/api/v1/user_service.go:32-70` exposes `ListUsers` only to admins, and `store/user.go:59-74` plus `store/db/sqlite/user.go:88-175` support exact-match user filtering but no general search, ranking, or pagination for mention autocomplete.
- `server/router/api/v1/acl_config.go:20-27` whitelists `/memos.api.v1.UserService/SearchUsers`, but `proto/api/v1/user_service.proto:16-120` does not define a `SearchUsers` RPC and there is no server implementation.
- `web/src/components/MemoEditor/Editor/index.tsx:189-214`, `web/src/components/MemoEditor/Editor/useSuggestions.ts:28-158`, and `web/src/components/MemoEditor/Editor/TagSuggestions.tsx:10-49` provide a reusable textarea suggestion popup, but it is only instantiated for `#tag`.
- `web/src/components/MemoContent/index.tsx:53-136`, `web/src/utils/remark-plugins/remark-tag.ts:24-112`, and `web/src/components/MemoContent/Tag.tsx` parse and render `#tag` as a structured inline element; there is no `remarkMention` equivalent.
- `web/src/hooks/useUserQueries.ts:176-245` has `useListUsers()` for admin listing and `useUsersByNames()` for fetching known usernames one by one, but nothing that returns ranked candidates for an in-editor `@` query.
- `web/src/router/index.tsx:65-72` already routes public user profiles at `u/:username`, so inline mention rendering can target username-based profile URLs without inventing a new frontend route.
## Non-Goals
- Adding group mentions, team mentions, page mentions, or date mentions.
- Building a general “watch this memo/thread” subscription system beyond explicit mentions.
- Adding email, push, Slack, or webhook delivery for mentions in this issue.
- Redesigning memo visibility, access control, or per-user sharing semantics.
- Making old mentions follow username changes automatically.
- Redesigning the editor away from the current textarea-based implementation.
## Open Questions
- Which content surfaces are in scope for `@mention`? (default: top-level memos and memo comments, because both already share the same memo content pipeline)
- What mention token syntax should be recognized? (default: `@username` only, using canonical usernames rather than display names)
- Should edits trigger mention notifications after the initial create? (default: yes, but only for newly added mention targets compared with the memos previous mention set)
- What happens if someone types `@username` in content the target cannot access? (default: render the token as a mention in the authors view, but do not send a notification unless the target can already access the memo/comment under existing visibility rules)
- Should mentioning yourself create an inbox item? (default: no, because self-mentions do not require attention routing)
- Should the mention candidate API be public like `GetUser`, or authenticated like the editor? (default: authenticated only, because ranked user search is a broader directory-enumeration surface than fetching a known public profile)
## Scope
**L** — The work crosses markdown parsing, memo payload extraction, memo create/update side effects, inbox and notification protos, user search APIs, three SQL drivers, React editor autocomplete, markdown rendering, and inbox UI. The repository already contains adjacent pieces for tags and comment notifications, but `@mention` requires stitching several existing subsystems together rather than extending a single isolated module.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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),
),
)
}

View File

@ -20,6 +20,7 @@ import (
// ExtractedData contains all metadata extracted from markdown in a single pass.
type ExtractedData struct {
Tags []string
Mentions []string
Property *storepb.MemoPayload_Property
}
@ -62,7 +63,8 @@ type service struct {
type Option func(*config)
type config struct {
enableTags bool
enableTags bool
enableMentions bool
}
// WithTagExtension enables #tag parsing.
@ -72,6 +74,13 @@ func WithTagExtension() Option {
}
}
// WithMentionExtension enables @mention parsing.
func WithMentionExtension() Option {
return func(c *config) {
c.enableMentions = true
}
}
// NewService creates a new markdown service with the given options.
func NewService(opts ...Option) Service {
cfg := &config{}
@ -87,6 +96,9 @@ func NewService(opts ...Option) Service {
if cfg.enableTags {
exts = append(exts, extensions.TagExtension)
}
if cfg.enableMentions {
exts = append(exts, extensions.MentionExtension)
}
md := goldmark.New(
goldmark.WithExtensions(exts...),
@ -330,6 +342,7 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) {
data := &ExtractedData{
Tags: []string{},
Mentions: []string{},
Property: &storepb.MemoPayload_Property{},
}
@ -345,6 +358,9 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) {
if tagNode, ok := n.(*mast.TagNode); ok {
data.Tags = append(data.Tags, string(tagNode.Tag))
}
if mentionNode, ok := n.(*mast.MentionNode); ok {
data.Mentions = append(data.Mentions, strings.ToLower(string(mentionNode.Username)))
}
// Check if the first block-level child of the document is an H1 heading.
if !firstBlockChecked && n.Parent() != nil && n.Parent().Kind() == gast.KindDocument {
@ -382,6 +398,7 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) {
// Deduplicate tags while preserving original case
data.Tags = uniquePreserveCase(data.Tags)
data.Mentions = uniquePreserveCase(data.Mentions)
return data, nil
}

View File

@ -340,6 +340,15 @@ func TestExtractAllTitle(t *testing.T) {
}
}
func TestExtractAllMentions(t *testing.T) {
svc := NewService(WithTagExtension(), WithMentionExtension())
data, err := svc.ExtractAll([]byte("Hi @Alice and @bob. Email support@example.com should stay plain. #tag"))
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alice", "bob"}, data.Mentions)
assert.ElementsMatch(t, []string{"tag"}, data.Tags)
}
func TestExtractTags(t *testing.T) {
tests := []struct {
name string

View File

@ -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,
}
}

View File

@ -156,6 +156,10 @@ func (r *MarkdownRenderer) renderNode(node gast.Node, source []byte, depth int)
r.buf.WriteByte('#')
r.buf.Write(n.Tag)
case *mast.MentionNode:
r.buf.WriteByte('@')
r.buf.Write(n.Username)
default:
// For unknown nodes, try to render children
r.renderChildren(n, source, depth)

View File

@ -19,6 +19,14 @@ service UserService {
option (google.api.http) = {get: "/api/v1/users"};
}
// BatchGetUsers returns active users by usernames.
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse) {
option (google.api.http) = {
post: "/api/v1/users:batchGet"
body: "*"
};
}
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
rpc GetUser(GetUserRequest) returns (User) {
@ -242,6 +250,14 @@ message ListUsersResponse {
int32 total_size = 3;
}
message BatchGetUsersRequest {
repeated string usernames = 1;
}
message BatchGetUsersResponse {
repeated User users = 1;
}
message GetUserRequest {
// Required. The resource name of the user.
// Format: users/{username}
@ -612,6 +628,9 @@ message UserNotification {
(google.api.resource_reference) = {type: "memos.api.v1/User"}
];
// The sender user details.
User sender_user = 8 [(google.api.field_behavior) = OUTPUT_ONLY];
// The status of the notification.
Status status = 3 [(google.api.field_behavior) = OPTIONAL];
@ -623,6 +642,7 @@ message UserNotification {
oneof payload {
MemoCommentPayload memo_comment = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
MemoMentionPayload memo_mention = 7 [(google.api.field_behavior) = OUTPUT_ONLY];
}
message MemoCommentPayload {
@ -633,6 +653,28 @@ message UserNotification {
// The name of related memo.
// Format: memos/{memo}
string related_memo = 2;
// Preview text of the comment memo.
string memo_snippet = 3;
// Preview text of the related memo.
string related_memo_snippet = 4;
}
message MemoMentionPayload {
// The memo that contains the mention.
// Format: memos/{memo}
string memo = 1;
// The related parent memo when the mention was created in a comment.
// Format: memos/{memo}
string related_memo = 2;
// Preview text of the memo that contains the mention.
string memo_snippet = 3;
// Preview text of the related parent memo.
string related_memo_snippet = 4;
}
enum Status {
@ -644,6 +686,7 @@ message UserNotification {
enum Type {
TYPE_UNSPECIFIED = 0;
MEMO_COMMENT = 1;
MEMO_MENTION = 2;
}
}

View File

@ -36,6 +36,9 @@ const (
const (
// UserServiceListUsersProcedure is the fully-qualified name of the UserService's ListUsers RPC.
UserServiceListUsersProcedure = "/memos.api.v1.UserService/ListUsers"
// UserServiceBatchGetUsersProcedure is the fully-qualified name of the UserService's BatchGetUsers
// RPC.
UserServiceBatchGetUsersProcedure = "/memos.api.v1.UserService/BatchGetUsers"
// UserServiceGetUserProcedure is the fully-qualified name of the UserService's GetUser RPC.
UserServiceGetUserProcedure = "/memos.api.v1.UserService/GetUser"
// UserServiceCreateUserProcedure is the fully-qualified name of the UserService's CreateUser RPC.
@ -95,6 +98,8 @@ const (
type UserServiceClient interface {
// ListUsers returns a list of users.
ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error)
// BatchGetUsers returns active users by usernames.
BatchGetUsers(context.Context, *connect.Request[v1.BatchGetUsersRequest]) (*connect.Response[v1.BatchGetUsersResponse], error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
@ -155,6 +160,12 @@ func NewUserServiceClient(httpClient connect.HTTPClient, baseURL string, opts ..
connect.WithSchema(userServiceMethods.ByName("ListUsers")),
connect.WithClientOptions(opts...),
),
batchGetUsers: connect.NewClient[v1.BatchGetUsersRequest, v1.BatchGetUsersResponse](
httpClient,
baseURL+UserServiceBatchGetUsersProcedure,
connect.WithSchema(userServiceMethods.ByName("BatchGetUsers")),
connect.WithClientOptions(opts...),
),
getUser: connect.NewClient[v1.GetUserRequest, v1.User](
httpClient,
baseURL+UserServiceGetUserProcedure,
@ -275,6 +286,7 @@ func NewUserServiceClient(httpClient connect.HTTPClient, baseURL string, opts ..
// userServiceClient implements UserServiceClient.
type userServiceClient struct {
listUsers *connect.Client[v1.ListUsersRequest, v1.ListUsersResponse]
batchGetUsers *connect.Client[v1.BatchGetUsersRequest, v1.BatchGetUsersResponse]
getUser *connect.Client[v1.GetUserRequest, v1.User]
createUser *connect.Client[v1.CreateUserRequest, v1.User]
updateUser *connect.Client[v1.UpdateUserRequest, v1.User]
@ -301,6 +313,11 @@ func (c *userServiceClient) ListUsers(ctx context.Context, req *connect.Request[
return c.listUsers.CallUnary(ctx, req)
}
// BatchGetUsers calls memos.api.v1.UserService.BatchGetUsers.
func (c *userServiceClient) BatchGetUsers(ctx context.Context, req *connect.Request[v1.BatchGetUsersRequest]) (*connect.Response[v1.BatchGetUsersResponse], error) {
return c.batchGetUsers.CallUnary(ctx, req)
}
// GetUser calls memos.api.v1.UserService.GetUser.
func (c *userServiceClient) GetUser(ctx context.Context, req *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error) {
return c.getUser.CallUnary(ctx, req)
@ -400,6 +417,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, req *con
type UserServiceHandler interface {
// ListUsers returns a list of users.
ListUsers(context.Context, *connect.Request[v1.ListUsersRequest]) (*connect.Response[v1.ListUsersResponse], error)
// BatchGetUsers returns active users by usernames.
BatchGetUsers(context.Context, *connect.Request[v1.BatchGetUsersRequest]) (*connect.Response[v1.BatchGetUsersResponse], error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error)
@ -456,6 +475,12 @@ func NewUserServiceHandler(svc UserServiceHandler, opts ...connect.HandlerOption
connect.WithSchema(userServiceMethods.ByName("ListUsers")),
connect.WithHandlerOptions(opts...),
)
userServiceBatchGetUsersHandler := connect.NewUnaryHandler(
UserServiceBatchGetUsersProcedure,
svc.BatchGetUsers,
connect.WithSchema(userServiceMethods.ByName("BatchGetUsers")),
connect.WithHandlerOptions(opts...),
)
userServiceGetUserHandler := connect.NewUnaryHandler(
UserServiceGetUserProcedure,
svc.GetUser,
@ -574,6 +599,8 @@ func NewUserServiceHandler(svc UserServiceHandler, opts ...connect.HandlerOption
switch r.URL.Path {
case UserServiceListUsersProcedure:
userServiceListUsersHandler.ServeHTTP(w, r)
case UserServiceBatchGetUsersProcedure:
userServiceBatchGetUsersHandler.ServeHTTP(w, r)
case UserServiceGetUserProcedure:
userServiceGetUserHandler.ServeHTTP(w, r)
case UserServiceCreateUserProcedure:
@ -625,6 +652,10 @@ func (UnimplementedUserServiceHandler) ListUsers(context.Context, *connect.Reque
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.UserService.ListUsers is not implemented"))
}
func (UnimplementedUserServiceHandler) BatchGetUsers(context.Context, *connect.Request[v1.BatchGetUsersRequest]) (*connect.Response[v1.BatchGetUsersResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.UserService.BatchGetUsers is not implemented"))
}
func (UnimplementedUserServiceHandler) GetUser(context.Context, *connect.Request[v1.GetUserRequest]) (*connect.Response[v1.User], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.UserService.GetUser is not implemented"))
}

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,33 @@ func local_request_UserService_ListUsers_0(ctx context.Context, marshaler runtim
return msg, metadata, err
}
func request_UserService_BatchGetUsers_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq BatchGetUsersRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.BatchGetUsers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_UserService_BatchGetUsers_0(ctx context.Context, marshaler runtime.Marshaler, server UserServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq BatchGetUsersRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.BatchGetUsers(ctx, &protoReq)
return msg, metadata, err
}
var filter_UserService_GetUser_0 = &utilities.DoubleArray{Encoding: map[string]int{"name": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}}
func request_UserService_GetUser_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
@ -1071,6 +1098,26 @@ func RegisterUserServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux
}
forward_UserService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_UserService_BatchGetUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.UserService/BatchGetUsers", runtime.WithHTTPPathPattern("/api/v1/users:batchGet"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_UserService_BatchGetUsers_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_UserService_BatchGetUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_UserService_GetUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -1508,6 +1555,23 @@ func RegisterUserServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux
}
forward_UserService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_UserService_BatchGetUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.UserService/BatchGetUsers", runtime.WithHTTPPathPattern("/api/v1/users:batchGet"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_UserService_BatchGetUsers_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_UserService_BatchGetUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_UserService_GetUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -1836,6 +1900,7 @@ func RegisterUserServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux
var (
pattern_UserService_ListUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "users"}, ""))
pattern_UserService_BatchGetUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "users"}, "batchGet"))
pattern_UserService_GetUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "users", "name"}, ""))
pattern_UserService_CreateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "users"}, ""))
pattern_UserService_UpdateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "users", "user.name"}, ""))
@ -1859,6 +1924,7 @@ var (
var (
forward_UserService_ListUsers_0 = runtime.ForwardResponseMessage
forward_UserService_BatchGetUsers_0 = runtime.ForwardResponseMessage
forward_UserService_GetUser_0 = runtime.ForwardResponseMessage
forward_UserService_CreateUser_0 = runtime.ForwardResponseMessage
forward_UserService_UpdateUser_0 = runtime.ForwardResponseMessage

View File

@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
UserService_ListUsers_FullMethodName = "/memos.api.v1.UserService/ListUsers"
UserService_BatchGetUsers_FullMethodName = "/memos.api.v1.UserService/BatchGetUsers"
UserService_GetUser_FullMethodName = "/memos.api.v1.UserService/GetUser"
UserService_CreateUser_FullMethodName = "/memos.api.v1.UserService/CreateUser"
UserService_UpdateUser_FullMethodName = "/memos.api.v1.UserService/UpdateUser"
@ -48,6 +49,8 @@ const (
type UserServiceClient interface {
// ListUsers returns a list of users.
ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error)
// BatchGetUsers returns active users by usernames.
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
@ -109,6 +112,16 @@ func (c *userServiceClient) ListUsers(ctx context.Context, in *ListUsersRequest,
return out, nil
}
func (c *userServiceClient) BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUsersResponse)
err := c.cc.Invoke(ctx, UserService_BatchGetUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(User)
@ -305,6 +318,8 @@ func (c *userServiceClient) DeleteUserNotification(ctx context.Context, in *Dele
type UserServiceServer interface {
// ListUsers returns a list of users.
ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error)
// BatchGetUsers returns active users by usernames.
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
// GetUser gets a user by username.
// Format: users/{username} (e.g., users/steven)
GetUser(context.Context, *GetUserRequest) (*User, error)
@ -359,6 +374,9 @@ type UnimplementedUserServiceServer struct{}
func (UnimplementedUserServiceServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented")
}
func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method BatchGetUsers not implemented")
}
func (UnimplementedUserServiceServer) GetUser(context.Context, *GetUserRequest) (*User, error) {
return nil, status.Error(codes.Unimplemented, "method GetUser not implemented")
}
@ -455,6 +473,24 @@ func _UserService_ListUsers_Handler(srv interface{}, ctx context.Context, dec fu
return interceptor(ctx, in, info, handler)
}
func _UserService_BatchGetUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUsersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).BatchGetUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_BatchGetUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).BatchGetUsers(ctx, req.(*BatchGetUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_GetUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserRequest)
if err := dec(in); err != nil {
@ -808,6 +844,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListUsers",
Handler: _UserService_ListUsers_Handler,
},
{
MethodName: "BatchGetUsers",
Handler: _UserService_BatchGetUsers_Handler,
},
{
MethodName: "GetUser",
Handler: _UserService_GetUser_Handler,

View File

@ -1977,6 +1977,31 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/users:batchGet:
post:
tags:
- UserService
description: BatchGetUsers returns active users by usernames.
operationId: UserService_BatchGetUsers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/BatchGetUsersRequest'
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/BatchGetUsersResponse'
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/users:stats:
get:
tags:
@ -2050,6 +2075,20 @@ components:
type: array
items:
type: string
BatchGetUsersRequest:
type: object
properties:
usernames:
type: array
items:
type: string
BatchGetUsersResponse:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/User'
Color:
type: object
properties:
@ -3192,6 +3231,11 @@ components:
description: |-
The sender of the notification.
Format: users/{user}
senderUser:
readOnly: true
allOf:
- $ref: '#/components/schemas/User'
description: The sender user details.
status:
enum:
- STATUS_UNSPECIFIED
@ -3210,6 +3254,7 @@ components:
enum:
- TYPE_UNSPECIFIED
- MEMO_COMMENT
- MEMO_MENTION
type: string
description: The type of the notification.
format: enum
@ -3217,6 +3262,10 @@ components:
readOnly: true
allOf:
- $ref: '#/components/schemas/UserNotification_MemoCommentPayload'
memoMention:
readOnly: true
allOf:
- $ref: '#/components/schemas/UserNotification_MemoMentionPayload'
UserNotification_MemoCommentPayload:
type: object
properties:
@ -3230,6 +3279,31 @@ components:
description: |-
The name of related memo.
Format: memos/{memo}
memoSnippet:
type: string
description: Preview text of the comment memo.
relatedMemoSnippet:
type: string
description: Preview text of the related memo.
UserNotification_MemoMentionPayload:
type: object
properties:
memo:
type: string
description: |-
The memo that contains the mention.
Format: memos/{memo}
relatedMemo:
type: string
description: |-
The related parent memo when the mention was created in a comment.
Format: memos/{memo}
memoSnippet:
type: string
description: Preview text of the memo that contains the mention.
relatedMemoSnippet:
type: string
description: Preview text of the related parent memo.
UserSetting:
type: object
properties:

View File

@ -27,6 +27,8 @@ const (
InboxMessage_TYPE_UNSPECIFIED InboxMessage_Type = 0
// Memo comment notification.
InboxMessage_MEMO_COMMENT InboxMessage_Type = 1
// Memo mention notification.
InboxMessage_MEMO_MENTION InboxMessage_Type = 2
)
// Enum value maps for InboxMessage_Type.
@ -34,10 +36,12 @@ var (
InboxMessage_Type_name = map[int32]string{
0: "TYPE_UNSPECIFIED",
1: "MEMO_COMMENT",
2: "MEMO_MENTION",
}
InboxMessage_Type_value = map[string]int32{
"TYPE_UNSPECIFIED": 0,
"MEMO_COMMENT": 1,
"MEMO_MENTION": 2,
}
)
@ -75,6 +79,7 @@ type InboxMessage struct {
// Types that are valid to be assigned to Payload:
//
// *InboxMessage_MemoComment
// *InboxMessage_MemoMention
Payload isInboxMessage_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -133,6 +138,15 @@ func (x *InboxMessage) GetMemoComment() *InboxMessage_MemoCommentPayload {
return nil
}
func (x *InboxMessage) GetMemoMention() *InboxMessage_MemoMentionPayload {
if x != nil {
if x, ok := x.Payload.(*InboxMessage_MemoMention); ok {
return x.MemoMention
}
}
return nil
}
type isInboxMessage_Payload interface {
isInboxMessage_Payload()
}
@ -141,8 +155,14 @@ type InboxMessage_MemoComment struct {
MemoComment *InboxMessage_MemoCommentPayload `protobuf:"bytes,2,opt,name=memo_comment,json=memoComment,proto3,oneof"`
}
type InboxMessage_MemoMention struct {
MemoMention *InboxMessage_MemoMentionPayload `protobuf:"bytes,3,opt,name=memo_mention,json=memoMention,proto3,oneof"`
}
func (*InboxMessage_MemoComment) isInboxMessage_Payload() {}
func (*InboxMessage_MemoMention) isInboxMessage_Payload() {}
type InboxMessage_MemoCommentPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
MemoId int32 `protobuf:"varint,1,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
@ -195,20 +215,77 @@ func (x *InboxMessage_MemoCommentPayload) GetRelatedMemoId() int32 {
return 0
}
type InboxMessage_MemoMentionPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
MemoId int32 `protobuf:"varint,1,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
RelatedMemoId int32 `protobuf:"varint,2,opt,name=related_memo_id,json=relatedMemoId,proto3" json:"related_memo_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InboxMessage_MemoMentionPayload) Reset() {
*x = InboxMessage_MemoMentionPayload{}
mi := &file_store_inbox_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *InboxMessage_MemoMentionPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InboxMessage_MemoMentionPayload) ProtoMessage() {}
func (x *InboxMessage_MemoMentionPayload) ProtoReflect() protoreflect.Message {
mi := &file_store_inbox_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use InboxMessage_MemoMentionPayload.ProtoReflect.Descriptor instead.
func (*InboxMessage_MemoMentionPayload) Descriptor() ([]byte, []int) {
return file_store_inbox_proto_rawDescGZIP(), []int{0, 1}
}
func (x *InboxMessage_MemoMentionPayload) GetMemoId() int32 {
if x != nil {
return x.MemoId
}
return 0
}
func (x *InboxMessage_MemoMentionPayload) GetRelatedMemoId() int32 {
if x != nil {
return x.RelatedMemoId
}
return 0
}
var File_store_inbox_proto protoreflect.FileDescriptor
const file_store_inbox_proto_rawDesc = "" +
"\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\xa7\x02\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\xe3\x03\n" +
"\fInboxMessage\x122\n" +
"\x04type\x18\x01 \x01(\x0e2\x1e.memos.store.InboxMessage.TypeR\x04type\x12Q\n" +
"\fmemo_comment\x18\x02 \x01(\v2,.memos.store.InboxMessage.MemoCommentPayloadH\x00R\vmemoComment\x1aU\n" +
"\fmemo_comment\x18\x02 \x01(\v2,.memos.store.InboxMessage.MemoCommentPayloadH\x00R\vmemoComment\x12Q\n" +
"\fmemo_mention\x18\x03 \x01(\v2,.memos.store.InboxMessage.MemoMentionPayloadH\x00R\vmemoMention\x1aU\n" +
"\x12MemoCommentPayload\x12\x17\n" +
"\amemo_id\x18\x01 \x01(\x05R\x06memoId\x12&\n" +
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\".\n" +
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\x1aU\n" +
"\x12MemoMentionPayload\x12\x17\n" +
"\amemo_id\x18\x01 \x01(\x05R\x06memoId\x12&\n" +
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\"@\n" +
"\x04Type\x12\x14\n" +
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
"\fMEMO_COMMENT\x10\x01B\t\n" +
"\fMEMO_COMMENT\x10\x01\x12\x10\n" +
"\fMEMO_MENTION\x10\x02B\t\n" +
"\apayloadB\x95\x01\n" +
"\x0fcom.memos.storeB\n" +
"InboxProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3"
@ -226,20 +303,22 @@ func file_store_inbox_proto_rawDescGZIP() []byte {
}
var file_store_inbox_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_store_inbox_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_store_inbox_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_store_inbox_proto_goTypes = []any{
(InboxMessage_Type)(0), // 0: memos.store.InboxMessage.Type
(*InboxMessage)(nil), // 1: memos.store.InboxMessage
(*InboxMessage_MemoCommentPayload)(nil), // 2: memos.store.InboxMessage.MemoCommentPayload
(*InboxMessage_MemoMentionPayload)(nil), // 3: memos.store.InboxMessage.MemoMentionPayload
}
var file_store_inbox_proto_depIdxs = []int32{
0, // 0: memos.store.InboxMessage.type:type_name -> memos.store.InboxMessage.Type
2, // 1: memos.store.InboxMessage.memo_comment:type_name -> memos.store.InboxMessage.MemoCommentPayload
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
3, // 2: memos.store.InboxMessage.memo_mention:type_name -> memos.store.InboxMessage.MemoMentionPayload
3, // [3:3] is the sub-list for method output_type
3, // [3:3] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_store_inbox_proto_init() }
@ -249,6 +328,7 @@ func file_store_inbox_proto_init() {
}
file_store_inbox_proto_msgTypes[0].OneofWrappers = []any{
(*InboxMessage_MemoComment)(nil),
(*InboxMessage_MemoMention)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
@ -256,7 +336,7 @@ func file_store_inbox_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_inbox_proto_rawDesc), len(file_store_inbox_proto_rawDesc)),
NumEnums: 1,
NumMessages: 2,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -10,15 +10,23 @@ message InboxMessage {
int32 related_memo_id = 2;
}
message MemoMentionPayload {
int32 memo_id = 1;
int32 related_memo_id = 2;
}
// The type of the inbox message.
Type type = 1;
oneof payload {
MemoCommentPayload memo_comment = 2;
MemoMentionPayload memo_mention = 3;
}
enum Type {
TYPE_UNSPECIFIED = 0;
// Memo comment notification.
MEMO_COMMENT = 1;
// Memo mention notification.
MEMO_MENTION = 2;
}
}

View File

@ -20,10 +20,10 @@ var PublicMethods = map[string]struct{}{
// User Service - public user profiles and stats
"/memos.api.v1.UserService/CreateUser": {}, // Allow first user registration
"/memos.api.v1.UserService/GetUser": {},
"/memos.api.v1.UserService/BatchGetUsers": {},
"/memos.api.v1.UserService/GetUserAvatar": {},
"/memos.api.v1.UserService/GetUserStats": {},
"/memos.api.v1.UserService/ListAllUserStats": {},
"/memos.api.v1.UserService/SearchUsers": {},
// Identity Provider Service - SSO buttons on login page
"/memos.api.v1.IdentityProviderService/ListIdentityProviders": {},

View File

@ -18,10 +18,10 @@ func TestPublicMethodsArePublic(t *testing.T) {
// User Service
"/memos.api.v1.UserService/CreateUser",
"/memos.api.v1.UserService/GetUser",
"/memos.api.v1.UserService/BatchGetUsers",
"/memos.api.v1.UserService/GetUserAvatar",
"/memos.api.v1.UserService/GetUserStats",
"/memos.api.v1.UserService/ListAllUserStats",
"/memos.api.v1.UserService/SearchUsers",
// Identity Provider Service
"/memos.api.v1.IdentityProviderService/ListIdentityProviders",
// Memo Service

View File

@ -79,6 +79,14 @@ func (s *ConnectServiceHandler) ListUsers(ctx context.Context, req *connect.Requ
return connect.NewResponse(resp), nil
}
func (s *ConnectServiceHandler) BatchGetUsers(ctx context.Context, req *connect.Request[v1pb.BatchGetUsersRequest]) (*connect.Response[v1pb.BatchGetUsersResponse], error) {
resp, err := s.APIV1Service.BatchGetUsers(ctx, req.Msg)
if err != nil {
return nil, convertGRPCError(err)
}
return connect.NewResponse(resp), nil
}
func (s *ConnectServiceHandler) GetUser(ctx context.Context, req *connect.Request[v1pb.GetUserRequest]) (*connect.Response[v1pb.User], error) {
resp, err := s.APIV1Service.GetUser(ctx, req.Msg)
if err != nil {

View File

@ -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
}

View File

@ -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)))
}
}

View File

@ -89,7 +89,7 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
if len(create.Content) > contentLengthLimit {
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
}
if err := memopayload.RebuildMemoPayload(create, s.MarkdownService); err != nil {
if err := memopayload.RebuildMemoPayload(ctx, create, s.MarkdownService); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
}
if request.Memo.Location != nil {
@ -160,6 +160,10 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
})
}
if !isMentionNotificationSuppressed(ctx) {
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, nil, "")
}
return memoMessage, nil
}
@ -433,8 +437,12 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
update := &store.UpdateMemo{
ID: memo.ID,
}
var previousContent string
contentUpdated := false
for _, path := range request.UpdateMask.Paths {
if path == "content" {
contentUpdated = true
previousContent = memo.Content
contentLengthLimit, err := s.getContentLengthLimit(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get content length limit")
@ -443,7 +451,7 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
}
memo.Content = request.Memo.Content
if err := memopayload.RebuildMemoPayload(memo, s.MarkdownService); err != nil {
if err := memopayload.RebuildMemoPayload(ctx, memo, s.MarkdownService); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
}
update.Content = &memo.Content
@ -505,6 +513,9 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
if err != nil {
return nil, errors.Wrap(err, "failed to build updated memo state")
}
if contentUpdated {
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, parentMemo, previousContent)
}
s.dispatchMemoUpdatedSideEffects(ctx, memo, parentMemo, memoMessage)
return memoMessage, nil
@ -614,7 +625,7 @@ func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.Crea
// Create the memo comment first; suppress the generic memo.created SSE event
// since CreateMemoComment broadcasts memo.comment.created for the parent instead.
memoComment, err := s.CreateMemo(withSuppressSSE(ctx), &v1pb.CreateMemoRequest{
memoComment, err := s.CreateMemo(withSuppressMentionNotifications(withSuppressSSE(ctx)), &v1pb.CreateMemoRequest{
Memo: request.Comment,
MemoId: request.CommentId,
})
@ -670,6 +681,8 @@ func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.Crea
slog.Warn("Failed to dispatch memo comment created webhook", slog.Any("err", err))
}
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, relatedMemo, "")
// Broadcast live refresh event for the parent memo so subscribers see the new comment.
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventMemoCommentCreated,

View File

@ -42,6 +42,7 @@ func NewTestService(t *testing.T) *TestService {
secret := "test-secret"
markdownService := markdown.NewService(
markdown.WithTagExtension(),
markdown.WithMentionExtension(),
)
service := &apiv1.APIV1Service{
Secret: secret,

View File

@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/fieldmaskpb"
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
@ -51,10 +52,14 @@ func TestListUserNotificationsIncludesMemoCommentPayload(t *testing.T) {
notification := resp.Notifications[0]
require.Contains(t, notification.Name, fmt.Sprintf("users/%s/notifications/", owner.Username))
require.Equal(t, fmt.Sprintf("users/%s", commenter.Username), notification.Sender)
require.NotNil(t, notification.SenderUser)
require.Equal(t, commenter.Username, notification.SenderUser.Username)
require.Equal(t, apiv1.UserNotification_MEMO_COMMENT, notification.Type)
require.NotNil(t, notification.GetMemoComment())
require.Equal(t, comment.Name, notification.GetMemoComment().Memo)
require.Equal(t, memo.Name, notification.GetMemoComment().RelatedMemo)
require.Equal(t, "Comment content", notification.GetMemoComment().MemoSnippet)
require.Equal(t, "Base memo", notification.GetMemoComment().RelatedMemoSnippet)
}
func TestListUserNotificationsStoresMemoCommentPayloadInInbox(t *testing.T) {
@ -199,3 +204,142 @@ func TestListUserNotificationsRejectsNumericParent(t *testing.T) {
require.Error(t, err)
require.Contains(t, err.Error(), "invalid user name")
}
func TestListUserNotificationsIncludesMemoMentionPayload(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
author, err := ts.CreateRegularUser(ctx, "mention-author")
require.NoError(t, err)
authorCtx := ts.CreateUserContext(ctx, author.ID)
target, err := ts.CreateRegularUser(ctx, "mention-target")
require.NoError(t, err)
targetCtx := ts.CreateUserContext(ctx, target.ID)
memo, err := ts.Service.CreateMemo(authorCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: fmt.Sprintf("Hello @%s", target.Username),
Visibility: apiv1.Visibility_PUBLIC,
},
})
require.NoError(t, err)
resp, err := ts.Service.ListUserNotifications(targetCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%s", target.Username),
})
require.NoError(t, err)
require.Len(t, resp.Notifications, 1)
require.Equal(t, apiv1.UserNotification_MEMO_MENTION, resp.Notifications[0].Type)
require.NotNil(t, resp.Notifications[0].GetMemoMention())
require.Equal(t, memo.Name, resp.Notifications[0].GetMemoMention().Memo)
require.Empty(t, resp.Notifications[0].GetMemoMention().RelatedMemo)
require.Equal(t, author.Username, resp.Notifications[0].SenderUser.Username)
require.Equal(t, "Hello", resp.Notifications[0].GetMemoMention().MemoSnippet)
}
func TestCreateMemoCommentMentionDoesNotDuplicateOwnerNotification(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
owner, err := ts.CreateRegularUser(ctx, "mention-owner")
require.NoError(t, err)
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
commenter, err := ts.CreateRegularUser(ctx, "mention-commenter")
require.NoError(t, err)
commenterCtx := ts.CreateUserContext(ctx, commenter.ID)
memo, err := ts.Service.CreateMemo(ownerCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "Base memo",
Visibility: apiv1.Visibility_PUBLIC,
},
})
require.NoError(t, err)
_, err = ts.Service.CreateMemoComment(commenterCtx, &apiv1.CreateMemoCommentRequest{
Name: memo.Name,
Comment: &apiv1.Memo{
Content: fmt.Sprintf("Hi @%s", owner.Username),
Visibility: apiv1.Visibility_PUBLIC,
},
})
require.NoError(t, err)
resp, err := ts.Service.ListUserNotifications(ownerCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%s", owner.Username),
})
require.NoError(t, err)
require.Len(t, resp.Notifications, 1)
require.Equal(t, apiv1.UserNotification_MEMO_COMMENT, resp.Notifications[0].Type)
}
func TestUpdateMemoMentionOnlyNotifiesNewTargets(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
author, err := ts.CreateRegularUser(ctx, "mention-update-author")
require.NoError(t, err)
authorCtx := ts.CreateUserContext(ctx, author.ID)
firstTarget, err := ts.CreateRegularUser(ctx, "mention-update-first")
require.NoError(t, err)
firstTargetCtx := ts.CreateUserContext(ctx, firstTarget.ID)
secondTarget, err := ts.CreateRegularUser(ctx, "mention-update-second")
require.NoError(t, err)
secondTargetCtx := ts.CreateUserContext(ctx, secondTarget.ID)
memo, err := ts.Service.CreateMemo(authorCtx, &apiv1.CreateMemoRequest{
Memo: &apiv1.Memo{
Content: "",
Visibility: apiv1.Visibility_PUBLIC,
},
})
require.NoError(t, err)
updatedMemo, err := ts.Service.UpdateMemo(authorCtx, &apiv1.UpdateMemoRequest{
Memo: &apiv1.Memo{
Name: memo.Name,
Content: fmt.Sprintf("Hello @%s", firstTarget.Username),
Visibility: apiv1.Visibility_PUBLIC,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"content"}},
})
require.NoError(t, err)
firstResp, err := ts.Service.ListUserNotifications(firstTargetCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%s", firstTarget.Username),
})
require.NoError(t, err)
require.Len(t, firstResp.Notifications, 1)
require.Equal(t, apiv1.UserNotification_MEMO_MENTION, firstResp.Notifications[0].Type)
require.Equal(t, updatedMemo.Name, firstResp.Notifications[0].GetMemoMention().Memo)
_, err = ts.Service.UpdateMemo(authorCtx, &apiv1.UpdateMemoRequest{
Memo: &apiv1.Memo{
Name: memo.Name,
Content: fmt.Sprintf("Hello again @%s and @%s", firstTarget.Username, secondTarget.Username),
Visibility: apiv1.Visibility_PUBLIC,
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"content"}},
})
require.NoError(t, err)
firstResp, err = ts.Service.ListUserNotifications(firstTargetCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%s", firstTarget.Username),
})
require.NoError(t, err)
require.Len(t, firstResp.Notifications, 1)
secondResp, err := ts.Service.ListUserNotifications(secondTargetCtx, &apiv1.ListUserNotificationsRequest{
Parent: fmt.Sprintf("users/%s", secondTarget.Username),
})
require.NoError(t, err)
require.Len(t, secondResp.Notifications, 1)
require.Equal(t, apiv1.UserNotification_MEMO_MENTION, secondResp.Notifications[0].Type)
}

View File

@ -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")
}

View File

@ -29,6 +29,8 @@ import (
"github.com/usememos/memos/store"
)
const maxBatchGetUsers = 100
func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersRequest) (*v1pb.ListUsersResponse, error) {
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
@ -70,6 +72,56 @@ func (s *APIV1Service) ListUsers(ctx context.Context, request *v1pb.ListUsersReq
return response, nil
}
func normalizeBatchUsernames(usernames []string) []string {
uniqueUsernames := make([]string, 0, len(usernames))
seen := make(map[string]struct{}, len(usernames))
for _, username := range usernames {
username = strings.TrimSpace(strings.ToLower(username))
if username == "" || !base.UIDMatcher.MatchString(username) {
continue
}
if _, ok := seen[username]; ok {
continue
}
seen[username] = struct{}{}
uniqueUsernames = append(uniqueUsernames, username)
}
return uniqueUsernames
}
func (s *APIV1Service) BatchGetUsers(ctx context.Context, request *v1pb.BatchGetUsersRequest) (*v1pb.BatchGetUsersResponse, error) {
if len(request.Usernames) == 0 {
return &v1pb.BatchGetUsersResponse{Users: []*v1pb.User{}}, nil
}
uniqueUsernames := normalizeBatchUsernames(request.Usernames)
if len(uniqueUsernames) > maxBatchGetUsers {
return nil, status.Errorf(codes.InvalidArgument, "too many usernames (max %d)", maxBatchGetUsers)
}
if len(uniqueUsernames) == 0 {
return &v1pb.BatchGetUsersResponse{Users: []*v1pb.User{}}, nil
}
normal := store.Normal
users, err := s.Store.ListUsers(ctx, &store.FindUser{
UsernameList: uniqueUsernames,
RowStatus: &normal,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
currentUser, _ := s.fetchCurrentUser(ctx)
response := &v1pb.BatchGetUsersResponse{
Users: make([]*v1pb.User, 0, len(users)),
}
for _, user := range users {
response.Users = append(response.Users, convertUserFromStore(user, currentUser))
}
return response, nil
}
func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest) (*v1pb.User, error) {
user, err := ResolveUserByName(ctx, s.Store, request.Name)
if err != nil {
@ -1269,12 +1321,9 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Fetch inbox items from storage
// Filter at database level to only include MEMO_COMMENT notifications (ignore legacy VERSION_UPDATE entries)
memoCommentType := storepb.InboxMessage_MEMO_COMMENT
// Fetch inbox items from storage.
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ReceiverID: &userID,
MessageType: &memoCommentType,
ReceiverID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
@ -1289,10 +1338,14 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
memosByID, err := s.listMemosByID(ctx, collectInboxMemoIDs(inboxes))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
}
notifications := []*v1pb.UserNotification{}
for _, inbox := range inboxes {
notification, err := s.convertInboxToUserNotificationWithUsers(ctx, inbox, usersByID)
notification, err := s.convertInboxToUserNotificationWithUsersAndMemos(inbox, currentUser, usersByID, memosByID)
if err != nil {
if status.Code(err) == codes.NotFound {
slog.Warn("Skipping notification with missing user",
@ -1304,6 +1357,9 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.
}
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
if notification.Type == v1pb.UserNotification_TYPE_UNSPECIFIED {
continue
}
notifications = append(notifications, notification)
}
@ -1379,7 +1435,7 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
}
notification, err := s.convertInboxToUserNotification(ctx, updatedInbox)
notification, err := s.convertInboxToUserNotification(ctx, updatedInbox, currentUser)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
@ -1432,15 +1488,43 @@ func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb
// convertInboxToUserNotification converts a storage-layer inbox to an API notification.
// This handles the mapping between the internal inbox representation and the public API.
func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox *store.Inbox) (*v1pb.UserNotification, error) {
func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox *store.Inbox, viewer *store.User) (*v1pb.UserNotification, error) {
usersByID, err := s.listUsersByID(ctx, []int32{inbox.ReceiverID, inbox.SenderID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
return s.convertInboxToUserNotificationWithUsers(ctx, inbox, usersByID)
memosByID, err := s.listMemosByID(ctx, collectInboxMemoIDs([]*store.Inbox{inbox}))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
}
return s.convertInboxToUserNotificationWithUsersAndMemos(inbox, viewer, usersByID, memosByID)
}
func (s *APIV1Service) convertInboxToUserNotificationWithUsers(ctx context.Context, inbox *store.Inbox, usersByID map[int32]*store.User) (*v1pb.UserNotification, error) {
func collectInboxMemoIDs(inboxes []*store.Inbox) []int32 {
memoIDs := make([]int32, 0, len(inboxes)*2)
for _, inbox := range inboxes {
if inbox == nil || inbox.Message == nil {
continue
}
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
payload := inbox.Message.GetMemoComment()
if payload != nil {
memoIDs = append(memoIDs, payload.MemoId, payload.RelatedMemoId)
}
case storepb.InboxMessage_MEMO_MENTION:
payload := inbox.Message.GetMemoMention()
if payload != nil {
memoIDs = append(memoIDs, payload.MemoId, payload.RelatedMemoId)
}
default:
// Ignore notification types without memo references.
}
}
return memoIDs
}
func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(inbox *store.Inbox, viewer *store.User, usersByID map[int32]*store.User, memosByID map[int32]*store.Memo) (*v1pb.UserNotification, error) {
receiver := usersByID[inbox.ReceiverID]
if receiver == nil {
return nil, status.Errorf(codes.NotFound, "notification receiver not found")
@ -1453,6 +1537,7 @@ func (s *APIV1Service) convertInboxToUserNotificationWithUsers(ctx context.Conte
notification := &v1pb.UserNotification{
Name: fmt.Sprintf("%s/notifications/%d", BuildUserName(receiver.Username), inbox.ID),
Sender: BuildUserName(sender.Username),
SenderUser: convertUserFromStore(sender, viewer),
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
}
@ -1471,54 +1556,126 @@ func (s *APIV1Service) convertInboxToUserNotificationWithUsers(ctx context.Conte
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
notification.Type = v1pb.UserNotification_MEMO_COMMENT
payload, err := s.convertMemoCommentNotificationPayload(viewer, inbox.Message, memosByID)
if err != nil {
return nil, err
}
if payload != nil {
notification.Payload = &v1pb.UserNotification_MemoComment{
MemoComment: payload,
}
}
case storepb.InboxMessage_MEMO_MENTION:
notification.Type = v1pb.UserNotification_MEMO_MENTION
payload, err := s.convertMemoMentionNotificationPayload(viewer, inbox.Message, memosByID)
if err != nil {
return nil, err
}
if payload != nil {
notification.Payload = &v1pb.UserNotification_MemoMention{
MemoMention: payload,
}
}
default:
notification.Type = v1pb.UserNotification_TYPE_UNSPECIFIED
}
payload, err := s.convertUserNotificationPayload(ctx, inbox.Message)
if err != nil {
return nil, err
}
if payload != nil {
notification.Payload = &v1pb.UserNotification_MemoComment{
MemoComment: payload,
}
}
}
return notification, nil
}
func (s *APIV1Service) convertUserNotificationPayload(ctx context.Context, message *storepb.InboxMessage) (*v1pb.UserNotification_MemoCommentPayload, error) {
func canViewerAccessMemo(viewer *store.User, memo *store.Memo) bool {
if memo == nil {
return false
}
if viewer != nil && isSuperUser(viewer) {
return true
}
if memo.Visibility == store.Private {
return viewer != nil && viewer.ID == memo.CreatorID
}
if memo.Visibility == store.Protected {
return viewer != nil
}
return true
}
func (s *APIV1Service) memoNotificationSnippet(memo *store.Memo) (string, error) {
if memo == nil || memo.Content == "" {
return "", nil
}
snippet, err := s.getMemoContentSnippet(memo.Content)
if err != nil {
return "", err
}
return snippet, nil
}
func (s *APIV1Service) convertMemoCommentNotificationPayload(viewer *store.User, message *storepb.InboxMessage, memosByID map[int32]*store.Memo) (*v1pb.UserNotification_MemoCommentPayload, error) {
memoComment := message.GetMemoComment()
if message == nil || message.Type != storepb.InboxMessage_MEMO_COMMENT || memoComment == nil {
return nil, nil
}
commentMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoComment.MemoId,
ExcludeContent: true,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get comment memo")
}
if commentMemo == nil {
commentMemo := memosByID[memoComment.MemoId]
if !canViewerAccessMemo(viewer, commentMemo) {
return nil, nil
}
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &memoComment.RelatedMemoId,
ExcludeContent: true,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get related memo")
}
if relatedMemo == nil {
relatedMemo := memosByID[memoComment.RelatedMemoId]
if !canViewerAccessMemo(viewer, relatedMemo) {
return nil, nil
}
memoSnippet, err := s.memoNotificationSnippet(commentMemo)
if err != nil {
return nil, errors.Wrap(err, "failed to get comment memo snippet")
}
relatedMemoSnippet, err := s.memoNotificationSnippet(relatedMemo)
if err != nil {
return nil, errors.Wrap(err, "failed to get related memo snippet")
}
return &v1pb.UserNotification_MemoCommentPayload{
Memo: fmt.Sprintf("%s%s", MemoNamePrefix, commentMemo.UID),
RelatedMemo: fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID),
Memo: fmt.Sprintf("%s%s", MemoNamePrefix, commentMemo.UID),
RelatedMemo: fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID),
MemoSnippet: memoSnippet,
RelatedMemoSnippet: relatedMemoSnippet,
}, nil
}
func (s *APIV1Service) convertMemoMentionNotificationPayload(viewer *store.User, message *storepb.InboxMessage, memosByID map[int32]*store.Memo) (*v1pb.UserNotification_MemoMentionPayload, error) {
memoMention := message.GetMemoMention()
if message == nil || message.Type != storepb.InboxMessage_MEMO_MENTION || memoMention == nil {
return nil, nil
}
memo := memosByID[memoMention.MemoId]
if !canViewerAccessMemo(viewer, memo) {
return nil, nil
}
memoSnippet, err := s.memoNotificationSnippet(memo)
if err != nil {
return nil, errors.Wrap(err, "failed to get mention memo snippet")
}
payload := &v1pb.UserNotification_MemoMentionPayload{
Memo: fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID),
MemoSnippet: memoSnippet,
}
if memoMention.RelatedMemoId != 0 {
relatedMemo := memosByID[memoMention.RelatedMemoId]
if canViewerAccessMemo(viewer, relatedMemo) {
payload.RelatedMemo = fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID)
relatedMemoSnippet, err := s.memoNotificationSnippet(relatedMemo)
if err != nil {
return nil, errors.Wrap(err, "failed to get related memo snippet")
}
payload.RelatedMemoSnippet = relatedMemoSnippet
}
}
return payload, nil
}

View File

@ -39,6 +39,7 @@ type APIV1Service struct {
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store) *APIV1Service {
markdownService := markdown.NewService(
markdown.WithTagExtension(),
markdown.WithMentionExtension(),
)
return &APIV1Service{
Secret: secret,

View File

@ -49,7 +49,7 @@ func (r *Runner) RunOnce(ctx context.Context) {
// Process batch
batchSuccessCount := 0
for _, memo := range memos {
if err := RebuildMemoPayload(memo, r.MarkdownService); err != nil {
if err := RebuildMemoPayload(ctx, memo, r.MarkdownService); err != nil {
slog.Error("failed to rebuild memo payload", "err", err, "memoID", memo.ID)
continue
}
@ -71,7 +71,7 @@ func (r *Runner) RunOnce(ctx context.Context) {
}
}
func RebuildMemoPayload(memo *store.Memo, markdownService markdown.Service) error {
func RebuildMemoPayload(_ context.Context, memo *store.Memo, markdownService markdown.Service) error {
if memo.Payload == nil {
memo.Payload = &storepb.MemoPayload{}
}

View File

@ -83,6 +83,7 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U
func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User, error) {
where, args := []string{"1 = 1"}, []any{}
orderBy := []string{"`created_ts` DESC", "`row_status` DESC"}
if len(find.Filters) > 0 {
return nil, errors.Errorf("user filters are not supported")
@ -104,6 +105,22 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
return list
}()...)
}
if len(find.UsernameList) > 0 {
placeholders := make([]string, 0, len(find.UsernameList))
for range find.UsernameList {
placeholders = append(placeholders, "?")
}
where, args = append(where, fmt.Sprintf("`username` IN (%s)", strings.Join(placeholders, ", "))), append(args, func() []any {
list := make([]any, 0, len(find.UsernameList))
for _, username := range find.UsernameList {
list = append(list, username)
}
return list
}()...)
}
if v := find.RowStatus; v != nil {
where, args = append(where, "`row_status` = ?"), append(args, *v)
}
if v := find.Username; v != nil {
where, args = append(where, "`username` = ?"), append(args, *v)
}
@ -116,8 +133,17 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
if v := find.Nickname; v != nil {
where, args = append(where, "`nickname` = ?"), append(args, *v)
}
orderBy := []string{"`created_ts` DESC", "`row_status` DESC"}
if v := find.Search; v != nil && strings.TrimSpace(*v) != "" {
query := strings.ToLower(strings.TrimSpace(*v))
where, args = append(where, "(LOWER(`username`) LIKE ? OR LOWER(`nickname`) LIKE ?)"), append(args, "%"+query+"%", "%"+query+"%")
orderBy = []string{
"CASE WHEN LOWER(`username`) = ? THEN 0 WHEN LOWER(`username`) LIKE ? THEN 1 WHEN LOWER(`nickname`) LIKE ? THEN 2 ELSE 3 END",
"CHAR_LENGTH(`username`) ASC",
"`created_ts` DESC",
"`row_status` DESC",
}
args = append(args, query, query+"%", query+"%")
}
query := "SELECT `id`, `username`, `role`, `email`, `nickname`, `password_hash`, `avatar_url`, `description`, UNIX_TIMESTAMP(`created_ts`), UNIX_TIMESTAMP(`updated_ts`), `row_status` FROM `user` WHERE " + strings.Join(where, " AND ") + " ORDER BY " + strings.Join(orderBy, ", ")
if v := find.Limit; v != nil {
query += fmt.Sprintf(" LIMIT %d", *v)

View File

@ -86,6 +86,7 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U
func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User, error) {
where, args := []string{"1 = 1"}, []any{}
orderBy := []string{"created_ts DESC", "row_status DESC"}
if len(find.Filters) > 0 {
return nil, errors.Errorf("user filters are not supported")
@ -102,6 +103,17 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
}
where = append(where, fmt.Sprintf("id IN (%s)", strings.Join(holders, ", ")))
}
if len(find.UsernameList) > 0 {
holders := make([]string, 0, len(find.UsernameList))
for _, username := range find.UsernameList {
holders = append(holders, placeholder(len(args)+1))
args = append(args, username)
}
where = append(where, fmt.Sprintf("username IN (%s)", strings.Join(holders, ", ")))
}
if v := find.RowStatus; v != nil {
where, args = append(where, "row_status = "+placeholder(len(args)+1)), append(args, *v)
}
if v := find.Username; v != nil {
where, args = append(where, "username = "+placeholder(len(args)+1)), append(args, *v)
}
@ -114,8 +126,19 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
if v := find.Nickname; v != nil {
where, args = append(where, "nickname = "+placeholder(len(args)+1)), append(args, *v)
}
orderBy := []string{"created_ts DESC", "row_status DESC"}
if v := find.Search; v != nil && strings.TrimSpace(*v) != "" {
query := strings.ToLower(strings.TrimSpace(*v))
where, args = append(where, "(LOWER(username) LIKE "+placeholder(len(args)+1)+" OR LOWER(nickname) LIKE "+placeholder(len(args)+2)+")"), append(args, "%"+query+"%", "%"+query+"%")
orderBy = []string{
"CASE WHEN LOWER(username) = " + placeholder(len(args)+1) + " THEN 0 " +
"WHEN LOWER(username) LIKE " + placeholder(len(args)+2) + " THEN 1 " +
"WHEN LOWER(nickname) LIKE " + placeholder(len(args)+3) + " THEN 2 ELSE 3 END",
"LENGTH(username) ASC",
"created_ts DESC",
"row_status DESC",
}
args = append(args, query, query+"%", query+"%")
}
query := `
SELECT
id,

View File

@ -87,6 +87,7 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U
func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User, error) {
where, args := []string{"1 = 1"}, []any{}
orderBy := []string{"created_ts DESC", "row_status DESC"}
if len(find.Filters) > 0 {
return nil, errors.Errorf("user filters are not supported")
@ -108,6 +109,22 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
return list
}()...)
}
if len(find.UsernameList) > 0 {
placeholders := make([]string, 0, len(find.UsernameList))
for range find.UsernameList {
placeholders = append(placeholders, "?")
}
where, args = append(where, fmt.Sprintf("username IN (%s)", strings.Join(placeholders, ", "))), append(args, func() []any {
list := make([]any, 0, len(find.UsernameList))
for _, username := range find.UsernameList {
list = append(list, username)
}
return list
}()...)
}
if v := find.RowStatus; v != nil {
where, args = append(where, "row_status = ?"), append(args, *v)
}
if v := find.Username; v != nil {
where, args = append(where, "username = ?"), append(args, *v)
}
@ -120,8 +137,17 @@ func (d *DB) ListUsers(ctx context.Context, find *store.FindUser) ([]*store.User
if v := find.Nickname; v != nil {
where, args = append(where, "nickname = ?"), append(args, *v)
}
orderBy := []string{"created_ts DESC", "row_status DESC"}
if v := find.Search; v != nil && strings.TrimSpace(*v) != "" {
query := strings.ToLower(strings.TrimSpace(*v))
where, args = append(where, "(LOWER(username) LIKE ? OR LOWER(nickname) LIKE ?)"), append(args, "%"+query+"%", "%"+query+"%")
orderBy = []string{
"CASE WHEN LOWER(username) = ? THEN 0 WHEN LOWER(username) LIKE ? THEN 1 WHEN LOWER(nickname) LIKE ? THEN 2 ELSE 3 END",
"LENGTH(username) ASC",
"created_ts DESC",
"row_status DESC",
}
args = append(args, query, query+"%", query+"%")
}
query := `
SELECT
id,

View File

@ -60,11 +60,14 @@ type FindUser struct {
ID *int32
IDList []int32
UsernameList []string
RowStatus *RowStatus
Username *string
Role *Role
Email *string
Nickname *string
Search *string
// Domain specific fields
Filters []string

View File

@ -1,16 +1,11 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import UserAvatar from "@/components/UserAvatar";
import { memoServiceClient, userServiceClient } from "@/connect";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import { userServiceClient } from "@/connect";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useUser } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -21,53 +16,8 @@ interface Props {
function MemoCommentMessage({ notification }: Props) {
const t = useTranslate();
const navigateTo = useNavigateTo();
const [relatedMemo, setRelatedMemo] = useState<Memo | undefined>(undefined);
const [commentMemo, setCommentMemo] = useState<Memo | undefined>(undefined);
const [senderName, setSenderName] = useState<string | undefined>(undefined);
const [initialized, setInitialized] = useState<boolean>(false);
const [hasError, setHasError] = useState<boolean>(false);
const { data: sender } = useUser(senderName || "", { enabled: !!senderName });
useAsyncEffect(async () => {
if (notification.payload?.case !== "memoComment") {
setHasError(true);
return;
}
try {
const memoCommentPayload = notification.payload.value;
const memo = await memoServiceClient.getMemo({
name: memoCommentPayload.relatedMemo,
});
setRelatedMemo(memo);
const comment = await memoServiceClient.getMemo({
name: memoCommentPayload.memo,
});
setCommentMemo(comment);
setSenderName(notification.sender);
setInitialized(true);
} catch (error) {
handleError(error, () => {}, {
context: "Failed to fetch memo comment notification",
onError: () => setHasError(true),
});
return;
}
}, [notification.payload, notification.sender]);
const handleNavigateToMemo = async () => {
if (!relatedMemo) {
return;
}
navigateTo(`/${relatedMemo.name}`);
if (notification.status === UserNotification_Status.UNREAD) {
handleArchiveMessage(true);
}
};
const commentPayload = notification.payload?.case === "memoComment" ? notification.payload.value : undefined;
const sender = notification.senderUser;
const handleArchiveMessage = async (silence = false) => {
await userServiceClient.updateUserNotification({
@ -89,22 +39,7 @@ function MemoCommentMessage({ notification }: Props) {
toast.success(t("message.deleted-successfully"));
};
if (!initialized && !hasError) {
return (
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-muted/10 animate-pulse">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-muted/50 shrink-0" />
<div className="flex-1 space-y-3">
<div className="h-4 bg-muted/50 rounded-md w-2/5" />
<div className="h-3 bg-muted/40 rounded-md w-3/4" />
<div className="h-20 bg-muted/30 rounded-xl" />
</div>
</div>
</div>
);
}
if (hasError) {
if (!commentPayload) {
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">
@ -128,6 +63,13 @@ function MemoCommentMessage({ notification }: Props) {
const isUnread = notification.status === UserNotification_Status.UNREAD;
const handleNavigateToMemo = async () => {
navigateTo(`/${commentPayload.relatedMemo}`);
if (isUnread) {
await handleArchiveMessage(true);
}
};
return (
<div
className={cn(
@ -135,11 +77,9 @@ function MemoCommentMessage({ notification }: Props) {
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
)}
>
{/* Unread indicator bar */}
{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">
{/* Avatar & Icon */}
<div className="relative shrink-0">
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
<div
@ -152,9 +92,7 @@ function MemoCommentMessage({ notification }: Props) {
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<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>
@ -188,35 +126,29 @@ function MemoCommentMessage({ notification }: Props) {
</div>
</div>
{/* Original Memo Snippet */}
{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">Original:</span>
{relatedMemo.content || <span className="italic text-muted-foreground/40">Empty memo</span>}
</p>
</div>
)}
<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">Original:</span>
{commentPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
</p>
</div>
{/* Comment Preview */}
{commentMemo && (
<div
onClick={handleNavigateToMemo}
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">
<MessageCircleIcon 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">Comment</p>
<p className="text-sm text-foreground/90 line-clamp-2">
{commentMemo.content || <span className="italic text-muted-foreground/50">Empty comment</span>}
</p>
</div>
<div
onClick={handleNavigateToMemo}
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">
<MessageCircleIcon 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">Comment</p>
<p className="text-sm text-foreground/90 line-clamp-2">
{commentPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty comment</span>}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -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;

View File

@ -1,6 +1,6 @@
import type { Element } from "hast";
import React from "react";
import { isTagElement, isTaskListItemElement } from "@/types/markdown";
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
/**
* Creates a conditional component that renders different components
@ -33,4 +33,4 @@ export const createConditionalComponent = <P extends Record<string, unknown>>(
};
// Re-export type guards for convenience
export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };
export { isMentionElement as isMentionNode, isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };

View File

@ -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>
);
};

View File

@ -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]);
}

View File

@ -1,6 +1,6 @@
import type { Element } from "hast";
import { ChevronDown, ChevronUp } from "lucide-react";
import { memo } from "react";
import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
@ -12,18 +12,40 @@ import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { extractMentionUsernames, remarkMention } from "@/utils/remark-plugins/remark-mention";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { isMentionNode, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
import { Mention } from "./Mention";
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import type { MemoContentProps } from "./types";
function getMentionUsername(node: Element, children?: React.ReactNode): string {
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return dataMention;
}
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
if (typeof camelDataMention === "string" && camelDataMention !== "") {
return camelDataMention;
}
const text = Array.isArray(children) ? children.join("") : children;
if (typeof text === "string" && text.startsWith("@")) {
return text.slice(1).toLowerCase();
}
return "";
}
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate();
@ -32,6 +54,8 @@ const MemoContent = (props: MemoContentProps) => {
mode: showCompactMode,
toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact));
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
@ -51,7 +75,7 @@ const MemoContent = (props: MemoContentProps) => {
onDoubleClick={onDoubleClick}
>
<ReactMarkdown
remarkPlugins={[remarkDisableSetext, remarkMath, remarkGfm, remarkBreaks, remarkTag, remarkPreserveType]}
remarkPlugins={[remarkDisableSetext, remarkMath, remarkGfm, remarkBreaks, remarkMention, remarkTag, remarkPreserveType]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
@ -69,6 +93,10 @@ const MemoContent = (props: MemoContentProps) => {
}) as React.ComponentType<React.ComponentProps<"input">>,
span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
const { node, ...rest } = spanProps;
if (node && isMentionNode(node)) {
const username = getMentionUsername(node, spanProps.children);
return <Mention {...spanProps} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
}
if (node && isTagNode(node)) {
return <Tag {...spanProps} />;
}

View File

@ -22,6 +22,7 @@ export interface UseSuggestionsReturn<T> {
suggestions: T[];
selectedIndex: number;
isVisible: boolean;
searchQuery: string;
handleItemSelect: (item: T) => void;
}
@ -52,12 +53,17 @@ export function useSuggestions<T>({
const hide = () => setPosition(null);
const suggestionsRef = useRef<T[]>([]);
const searchQueryRef = useRef("");
suggestionsRef.current = (() => {
const [word] = getCurrentWord();
if (!word.startsWith(triggerChar)) return [];
const searchQuery = word.slice(triggerChar.length).toLowerCase();
return filterItems(items, searchQuery);
searchQueryRef.current = word.slice(triggerChar.length).toLowerCase();
return filterItems(items, searchQueryRef.current);
})();
if (suggestionsRef.current.length === 0) {
const [word] = getCurrentWord();
searchQueryRef.current = word.startsWith(triggerChar) ? word.slice(triggerChar.length).toLowerCase() : "";
}
const isVisibleRef = useRef(false);
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
@ -153,6 +159,7 @@ export function useSuggestions<T>({
suggestions: suggestionsRef.current,
selectedIndex,
isVisible: isVisibleRef.current,
searchQuery: searchQueryRef.current,
handleItemSelect: handleAutocomplete,
};
}

View File

@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { matchPath } from "react-router-dom";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
@ -145,37 +146,39 @@ const PagedMemoList = (props: Props) => {
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const children = (
<div className="flex flex-col justify-start w-full max-w-2xl mx-auto">
{/* Show skeleton loader during initial load */}
{isLoading ? (
<Skeleton showCreator={props.showCreator} count={4} />
) : (
<>
{showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" placeholder={t("editor.any-thoughts")} /> : null}
<MemoFilters />
{sortedMemoList.map((memo) => props.renderer(memo))}
<MentionResolutionProvider contents={sortedMemoList.map((memo) => memo.content)}>
<div className="flex flex-col justify-start w-full max-w-2xl mx-auto">
{/* Show skeleton loader during initial load */}
{isLoading ? (
<Skeleton showCreator={props.showCreator} count={4} />
) : (
<>
{showMemoEditor ? <MemoEditor className="mb-2" cacheKey="home-memo-editor" placeholder={t("editor.any-thoughts")} /> : null}
<MemoFilters />
{sortedMemoList.map((memo) => props.renderer(memo))}
{/* Loading indicator for pagination */}
{isFetchingNextPage && <Skeleton showCreator={props.showCreator} count={2} />}
{/* Loading indicator for pagination */}
{isFetchingNextPage && <Skeleton showCreator={props.showCreator} count={2} />}
{/* Empty state or back-to-top button */}
{!isFetchingNextPage && (
<>
{!hasNextPage && sortedMemoList.length === 0 ? (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-2 text-muted-foreground">{t("message.no-data")}</p>
</div>
) : (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
</div>
)}
</>
)}
</>
)}
</div>
{/* Empty state or back-to-top button */}
{!isFetchingNextPage && (
<>
{!hasNextPage && sortedMemoList.length === 0 ? (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-2 text-muted-foreground">{t("message.no-data")}</p>
</div>
) : (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
</div>
)}
</>
)}
</>
)}
</div>
</MentionResolutionProvider>
);
return children;

View File

@ -6,6 +6,8 @@ import { buildUserSettingName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
const BATCH_GET_USERS_LIMIT = 100;
// Query keys factory
export const userKeys = {
all: ["users"] as const,
@ -16,7 +18,8 @@ export const userKeys = {
currentUser: () => [...userKeys.all, "current"] as const,
shortcuts: () => [...userKeys.all, "shortcuts"] as const,
notifications: () => [...userKeys.all, "notifications"] as const,
byNames: (names: string[]) => [...userKeys.all, "byNames", ...names.sort()] as const,
byNames: (names: string[]) => [...userKeys.all, "byNames", ...[...names].sort()] as const,
byUsernames: (usernames: string[]) => [...userKeys.all, "byUsernames", ...[...usernames].sort()] as const,
};
export function useUser(name: string, options?: { enabled?: boolean }) {
@ -244,3 +247,30 @@ export function useUsersByNames(names: string[]) {
staleTime: 1000 * 60 * 5, // 5 minutes - user profiles don't change often
});
}
// Hook to fetch multiple users by usernames (returns Map<username, User>)
export function useUsersByUsernames(usernames: string[], options?: { enabled?: boolean }) {
const enabled = (options?.enabled ?? true) && usernames.length > 0;
const uniqueUsernames = Array.from(new Set(usernames));
return useQuery({
queryKey: userKeys.byUsernames(uniqueUsernames),
queryFn: async () => {
const batches = [];
for (let i = 0; i < uniqueUsernames.length; i += BATCH_GET_USERS_LIMIT) {
batches.push(uniqueUsernames.slice(i, i + BATCH_GET_USERS_LIMIT));
}
const responses = await Promise.all(batches.map((batch) => userServiceClient.batchGetUsers({ usernames: batch })));
const usersByUsername = new Map(responses.flatMap((response) => response.users).map((user) => [user.username, user] as const));
const userMap = new Map<string, User | undefined>();
for (const username of uniqueUsernames) {
userMap.set(username, usersByUsername.get(username));
}
return userMap;
},
enabled,
staleTime: 1000 * 60 * 5,
});
}

View File

@ -4,6 +4,7 @@ import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react";
import { useState } from "react";
import Empty from "@/components/Empty";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
import MemoMentionMessage from "@/components/Inbox/MemoMentionMessage";
import MobileHeader from "@/components/MobileHeader";
import useMediaQuery from "@/hooks/useMediaQuery";
import { useNotifications } from "@/hooks/useUserQueries";
@ -108,6 +109,9 @@ const Inboxes = () => {
if (notification.type === UserNotification_Type.MEMO_COMMENT) {
return <MemoCommentMessage key={notification.name} notification={notification} />;
}
if (notification.type === UserNotification_Type.MEMO_MENTION) {
return <MemoMentionMessage key={notification.name} notification={notification} />;
}
return null;
})}
</div>

View File

@ -3,6 +3,7 @@ import { ArrowUpLeftFromCircleIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
import MemoCommentSection from "@/components/MemoCommentSection";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
@ -73,6 +74,7 @@ const MemoDetail = () => {
const displayMemo = isShareMode
? { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) }
: memo;
const mentionResolutionContents = [displayMemo.content, ...comments.map((comment) => comment.content)];
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
@ -81,40 +83,42 @@ const MemoDetail = () => {
<MemoDetailSidebarDrawer memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
</MobileHeader>
)}
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
<div className={cn("w-full md:w-[calc(100%-15rem)]")}>
{parentMemo && (
<div className="w-auto inline-block mb-2">
<Link
className="px-3 py-1 border border-border rounded-lg max-w-xs w-auto text-sm flex flex-row justify-start items-center flex-nowrap text-muted-foreground hover:shadow hover:opacity-80"
to={`/${parentMemo.name}`}
state={locationState}
viewTransition
>
<ArrowUpLeftFromCircleIcon className="w-4 h-auto shrink-0 opacity-60 mr-2" />
<span className="truncate">{parentMemo.content}</span>
</Link>
<MentionResolutionProvider contents={mentionResolutionContents}>
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
<div className={cn("w-full md:w-[calc(100%-15rem)]")}>
{parentMemo && (
<div className="w-auto inline-block mb-2">
<Link
className="px-3 py-1 border border-border rounded-lg max-w-xs w-auto text-sm flex flex-row justify-start items-center flex-nowrap text-muted-foreground hover:shadow hover:opacity-80"
to={`/${parentMemo.name}`}
state={locationState}
viewTransition
>
<ArrowUpLeftFromCircleIcon className="w-4 h-auto shrink-0 opacity-60 mr-2" />
<span className="truncate">{parentMemo.content}</span>
</Link>
</div>
)}
<MemoView
key={`${displayMemo.name}-${displayMemo.displayTime}`}
memo={displayMemo}
compact={false}
parentPage={locationState?.from}
shareImageDialogOpen={shareImageDialogOpen}
showCreator
showVisibility
showPinned
onShareImageDialogOpenChange={setShareImageDialogOpen}
/>
<MemoCommentSection memo={displayMemo} comments={comments} parentPage={locationState?.from} />
</div>
{md && (
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
<MemoDetailSidebar className="py-6" memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
</div>
)}
<MemoView
key={`${displayMemo.name}-${displayMemo.displayTime}`}
memo={displayMemo}
compact={false}
parentPage={locationState?.from}
shareImageDialogOpen={shareImageDialogOpen}
showCreator
showVisibility
showPinned
onShareImageDialogOpenChange={setShareImageDialogOpen}
/>
<MemoCommentSection memo={displayMemo} comments={comments} parentPage={locationState?.from} />
</div>
{md && (
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
<MemoDetailSidebar className="py-6" memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
</div>
)}
</div>
</MentionResolutionProvider>
</section>
);
};

View File

@ -6,17 +6,34 @@ export interface TagNode {
data: TagNodeData;
}
export interface MentionNode {
type: "mentionNode";
value: string;
data: MentionNodeData;
}
export interface TagNodeData {
hName: "span";
hProperties: TagNodeProperties;
hChildren: Array<{ type: "text"; value: string }>;
}
export interface MentionNodeData {
hName: "span";
hProperties: MentionNodeProperties;
hChildren: Array<{ type: "text"; value: string }>;
}
export interface TagNodeProperties {
className: string;
"data-tag": string;
}
export interface MentionNodeProperties {
className: string;
"data-mention": string;
}
export interface ExtendedData extends Data {
mdastType?: string;
}
@ -30,10 +47,39 @@ export function isTagElement(node: HastElement): boolean {
return true;
}
const dataTag = node.properties?.["data-tag"];
if (typeof dataTag === "string" && dataTag !== "") {
return true;
}
const className = node.properties?.className;
if (Array.isArray(className) && className.includes("tag")) {
return true;
}
if (typeof className === "string" && className.split(/\s+/).includes("tag")) {
return true;
}
return false;
}
export function isMentionElement(node: HastElement): boolean {
if (hasExtendedData(node) && node.data.mdastType === "mentionNode") {
return true;
}
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return true;
}
const className = node.properties?.className;
if (Array.isArray(className) && className.includes("mention")) {
return true;
}
if (typeof className === "string" && className.split(/\s+/).includes("mention")) {
return true;
}
return false;
}

File diff suppressed because one or more lines are too long

View File

@ -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[]));
}
});
};
};