Unverified Commit 24fc8ab8 authored by memoclaw's avatar memoclaw Committed by GitHub

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

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
parent 38fc22b7
## 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 memo’s previous mention set)
- What happens if someone types `@username` in content the target cannot access? (default: render the token as a mention in the author’s 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.
This diff is collapsed.
## 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
## 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
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)
}
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),
),
)
}
......@@ -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
}
......
......@@ -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
......
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,
}
}
......@@ -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)
......
......@@ -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;
}
}
......
......@@ -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"))
}
......
This diff is collapsed.
......@@ -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
......
......@@ -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,
......
......@@ -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:
......
......@@ -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,
},
......
......@@ -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;
}
}
......@@ -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": {},
......
......@@ -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
......
......@@ -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 {
......
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
}
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)))
}
}
......@@ -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,
......
......@@ -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,
......
......@@ -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)
}
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")
}
This diff is collapsed.
......@@ -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,
......
......@@ -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{}
}
......
......@@ -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)
......
......@@ -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,
......
......@@ -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,
......
......@@ -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
......
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>
......
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;
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 };
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>
);
};
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]);
}
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} />;
}
......
......@@ -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,
};
}
......@@ -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))}
{/* 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>
<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} />}
{/* 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;
......
......@@ -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,
});
}
......@@ -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>
......
......@@ -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>
);
};
......
......@@ -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;
}
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment