feat(spaces): deliver invitations to the inbox (#6266)

This commit is contained in:
Johnny 2026-09-04 08:58:49 +08:00 committed by GitHub
parent e963d326ab
commit 230e3a1d11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1498 additions and 240 deletions

View file

@ -172,6 +172,8 @@ MCP memo operations reuse the same memo policy; Space management is not exposed
The active Space scopes collaborative resource browsing and creation, including Home, Explore, and attachment lists. Archived, Inbox, and user profiles remain user-global and their routes do not inherit the active Space. Global Settings provides a Spaces section for viewing received invitations and managing joined Spaces, metadata, members, roles, and pending invitations; it is a management surface rather than another Space switcher.
Creating an invitation also delivers a `SPACE_INVITATION` inbox notification to the invitee, with the same email dispatch as memo notifications when email is enabled. The notification carries the read-only Space summary and offered role, and the invitee can accept or decline from the Inbox. Accepting archives the notification and keeps it as history while the membership is active; declining or revoking deletes it. At read time the notification fails closed: it is omitted unless the receiver still holds the pending invitation or is an active member of the Space, so a deleted Space or a revoked offer leaks no metadata.
### Security invariants
Before `SPACE` can be stored, one shared, memo-local, fail-closed policy must cover point reads, lists and counts, files, reactions, relations, notifications, email, webhooks, shares, search, statistics, public feeds, and MCP. Child resources resolve the memo they directly belong to. Application `ADMIN` receives no implicit bypass.
@ -213,4 +215,4 @@ Files for `PRIVATE`, `PROTECTED`, and `SPACE` memos use `private, no-store`; pub
## Deferred design
Email and external-user invitations, invitation expiration, inviter attribution, invitation history, open enrollment, account erasure beyond the membership guard, notification delivery and retention policy, application-admin moderation and recovery, audit history, soft deletion, restoration, retryable external-object cleanup, delivery-time cancellation of queued email/webhooks, broader concurrent-mutation hardening, and asynchronous deletion of very large Spaces remain deferred. Later work must preserve invitee consent, independent memo authorization, non-propagating relations, and explicit Space aggregate deletion unless this design is revisited.
Email and external-user invitations, invitation expiration, inviter attribution, invitation history, open enrollment, account erasure beyond the membership guard, notification retention policy and per-user notification preferences, application-admin moderation and recovery, audit history, soft deletion, restoration, retryable external-object cleanup, delivery-time cancellation of queued email/webhooks, broader concurrent-mutation hardening, and asynchronous deletion of very large Spaces remain deferred. Later work must preserve invitee consent, independent memo authorization, non-propagating relations, and explicit Space aggregate deletion unless this design is revisited.

View file

@ -3,6 +3,7 @@ syntax = "proto3";
package memos.api.v1;
import "api/v1/common.proto";
import "api/v1/space_service.proto";
import "google/api/annotations.proto";
import "google/api/client.proto";
import "google/api/field_behavior.proto";
@ -832,6 +833,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];
SpaceInvitationPayload space_invitation = 9 [(google.api.field_behavior) = OUTPUT_ONLY];
}
message MemoCommentPayload {
@ -866,6 +868,29 @@ message UserNotification {
string related_memo_snippet = 4;
}
message SpaceInvitationPayload {
// The invitation resource.
// Format: spaces/{space}/invitations/{username}
string space_invitation = 1 [(google.api.resource_reference) = {type: "memos.api.v1/SpaceInvitation"}];
// The read-only Space summary carried by the invitation.
Space space = 2;
// The role the invitation offers.
SpaceMember.Role role = 3;
// The current state of the invitation for the receiver.
State state = 4;
enum State {
STATE_UNSPECIFIED = 0;
// The receiver has not yet accepted or declined the invitation.
PENDING = 1;
// The receiver accepted the invitation and is an active member.
ACCEPTED = 2;
}
}
enum Status {
STATUS_UNSPECIFIED = 0;
UNREAD = 1;
@ -876,6 +901,7 @@ message UserNotification {
TYPE_UNSPECIFIED = 0;
MEMO_COMMENT = 1;
MEMO_MENTION = 2;
SPACE_INVITATION = 3;
}
}

View file

@ -189,6 +189,7 @@ const (
UserNotification_TYPE_UNSPECIFIED UserNotification_Type = 0
UserNotification_MEMO_COMMENT UserNotification_Type = 1
UserNotification_MEMO_MENTION UserNotification_Type = 2
UserNotification_SPACE_INVITATION UserNotification_Type = 3
)
// Enum value maps for UserNotification_Type.
@ -197,11 +198,13 @@ var (
0: "TYPE_UNSPECIFIED",
1: "MEMO_COMMENT",
2: "MEMO_MENTION",
3: "SPACE_INVITATION",
}
UserNotification_Type_value = map[string]int32{
"TYPE_UNSPECIFIED": 0,
"MEMO_COMMENT": 1,
"MEMO_MENTION": 2,
"SPACE_INVITATION": 3,
}
)
@ -232,6 +235,57 @@ func (UserNotification_Type) EnumDescriptor() ([]byte, []int) {
return file_api_v1_user_service_proto_rawDescGZIP(), []int{38, 1}
}
type UserNotification_SpaceInvitationPayload_State int32
const (
UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED UserNotification_SpaceInvitationPayload_State = 0
// The receiver has not yet accepted or declined the invitation.
UserNotification_SpaceInvitationPayload_PENDING UserNotification_SpaceInvitationPayload_State = 1
// The receiver accepted the invitation and is an active member.
UserNotification_SpaceInvitationPayload_ACCEPTED UserNotification_SpaceInvitationPayload_State = 2
)
// Enum value maps for UserNotification_SpaceInvitationPayload_State.
var (
UserNotification_SpaceInvitationPayload_State_name = map[int32]string{
0: "STATE_UNSPECIFIED",
1: "PENDING",
2: "ACCEPTED",
}
UserNotification_SpaceInvitationPayload_State_value = map[string]int32{
"STATE_UNSPECIFIED": 0,
"PENDING": 1,
"ACCEPTED": 2,
}
)
func (x UserNotification_SpaceInvitationPayload_State) Enum() *UserNotification_SpaceInvitationPayload_State {
p := new(UserNotification_SpaceInvitationPayload_State)
*p = x
return p
}
func (x UserNotification_SpaceInvitationPayload_State) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (UserNotification_SpaceInvitationPayload_State) Descriptor() protoreflect.EnumDescriptor {
return file_api_v1_user_service_proto_enumTypes[4].Descriptor()
}
func (UserNotification_SpaceInvitationPayload_State) Type() protoreflect.EnumType {
return &file_api_v1_user_service_proto_enumTypes[4]
}
func (x UserNotification_SpaceInvitationPayload_State) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use UserNotification_SpaceInvitationPayload_State.Descriptor instead.
func (UserNotification_SpaceInvitationPayload_State) EnumDescriptor() ([]byte, []int) {
return file_api_v1_user_service_proto_rawDescGZIP(), []int{38, 2, 0}
}
type User struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The resource name of the user.
@ -2591,6 +2645,7 @@ type UserNotification struct {
//
// *UserNotification_MemoComment
// *UserNotification_MemoMention
// *UserNotification_SpaceInvitation
Payload isUserNotification_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -2693,6 +2748,15 @@ func (x *UserNotification) GetMemoMention() *UserNotification_MemoMentionPayload
return nil
}
func (x *UserNotification) GetSpaceInvitation() *UserNotification_SpaceInvitationPayload {
if x != nil {
if x, ok := x.Payload.(*UserNotification_SpaceInvitation); ok {
return x.SpaceInvitation
}
}
return nil
}
type isUserNotification_Payload interface {
isUserNotification_Payload()
}
@ -2705,10 +2769,16 @@ type UserNotification_MemoMention struct {
MemoMention *UserNotification_MemoMentionPayload `protobuf:"bytes,7,opt,name=memo_mention,json=memoMention,proto3,oneof"`
}
type UserNotification_SpaceInvitation struct {
SpaceInvitation *UserNotification_SpaceInvitationPayload `protobuf:"bytes,9,opt,name=space_invitation,json=spaceInvitation,proto3,oneof"`
}
func (*UserNotification_MemoComment) isUserNotification_Payload() {}
func (*UserNotification_MemoMention) isUserNotification_Payload() {}
func (*UserNotification_SpaceInvitation) isUserNotification_Payload() {}
type ListUserNotificationsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The parent user resource.
@ -3369,11 +3439,84 @@ func (x *UserNotification_MemoMentionPayload) GetRelatedMemoSnippet() string {
return ""
}
type UserNotification_SpaceInvitationPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The invitation resource.
// Format: spaces/{space}/invitations/{username}
SpaceInvitation string `protobuf:"bytes,1,opt,name=space_invitation,json=spaceInvitation,proto3" json:"space_invitation,omitempty"`
// The read-only Space summary carried by the invitation.
Space *Space `protobuf:"bytes,2,opt,name=space,proto3" json:"space,omitempty"`
// The role the invitation offers.
Role SpaceMember_Role `protobuf:"varint,3,opt,name=role,proto3,enum=memos.api.v1.SpaceMember_Role" json:"role,omitempty"`
// The current state of the invitation for the receiver.
State UserNotification_SpaceInvitationPayload_State `protobuf:"varint,4,opt,name=state,proto3,enum=memos.api.v1.UserNotification_SpaceInvitationPayload_State" json:"state,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UserNotification_SpaceInvitationPayload) Reset() {
*x = UserNotification_SpaceInvitationPayload{}
mi := &file_api_v1_user_service_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UserNotification_SpaceInvitationPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UserNotification_SpaceInvitationPayload) ProtoMessage() {}
func (x *UserNotification_SpaceInvitationPayload) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_user_service_proto_msgTypes[52]
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 UserNotification_SpaceInvitationPayload.ProtoReflect.Descriptor instead.
func (*UserNotification_SpaceInvitationPayload) Descriptor() ([]byte, []int) {
return file_api_v1_user_service_proto_rawDescGZIP(), []int{38, 2}
}
func (x *UserNotification_SpaceInvitationPayload) GetSpaceInvitation() string {
if x != nil {
return x.SpaceInvitation
}
return ""
}
func (x *UserNotification_SpaceInvitationPayload) GetSpace() *Space {
if x != nil {
return x.Space
}
return nil
}
func (x *UserNotification_SpaceInvitationPayload) GetRole() SpaceMember_Role {
if x != nil {
return x.Role
}
return SpaceMember_ROLE_UNSPECIFIED
}
func (x *UserNotification_SpaceInvitationPayload) GetState() UserNotification_SpaceInvitationPayload_State {
if x != nil {
return x.State
}
return UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
}
var File_api_v1_user_service_proto protoreflect.FileDescriptor
const file_api_v1_user_service_proto_rawDesc = "" +
"\n" +
"\x19api/v1/user_service.proto\x12\fmemos.api.v1\x1a\x13api/v1/common.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17google/type/color.proto\"\xc1\x04\n" +
"\x19api/v1/user_service.proto\x12\fmemos.api.v1\x1a\x13api/v1/common.proto\x1a\x1aapi/v1/space_service.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17google/type/color.proto\"\xc1\x04\n" +
"\x04User\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x120\n" +
"\x04role\x18\x02 \x01(\x0e2\x17.memos.api.v1.User.RoleB\x03\xe0A\x02R\x04role\x12\x1f\n" +
@ -3588,7 +3731,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x04name\x18\x01 \x01(\tB \xe0A\x02\xfaA\x1a\n" +
"\x18memos.api.v1/UserWebhookR\x04name\"L\n" +
"#GetUserWebhookSigningSecretResponse\x12%\n" +
"\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\"\xda\b\n" +
"\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\"\xaf\f\n" +
"\x10UserNotification\x12\x1a\n" +
"\x04name\x18\x01 \x01(\tB\x06\xe0A\x03\xe0A\bR\x04name\x121\n" +
"\x06sender\x18\x02 \x01(\tB\x19\xe0A\x03\xfaA\x13\n" +
@ -3600,7 +3743,8 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"createTime\x12<\n" +
"\x04type\x18\x05 \x01(\x0e2#.memos.api.v1.UserNotification.TypeB\x03\xe0A\x03R\x04type\x12[\n" +
"\fmemo_comment\x18\x06 \x01(\v21.memos.api.v1.UserNotification.MemoCommentPayloadB\x03\xe0A\x03H\x00R\vmemoComment\x12[\n" +
"\fmemo_mention\x18\a \x01(\v21.memos.api.v1.UserNotification.MemoMentionPayloadB\x03\xe0A\x03H\x00R\vmemoMention\x1a\xa0\x01\n" +
"\fmemo_mention\x18\a \x01(\v21.memos.api.v1.UserNotification.MemoMentionPayloadB\x03\xe0A\x03H\x00R\vmemoMention\x12g\n" +
"\x10space_invitation\x18\t \x01(\v25.memos.api.v1.UserNotification.SpaceInvitationPayloadB\x03\xe0A\x03H\x00R\x0fspaceInvitation\x1a\xa0\x01\n" +
"\x12MemoCommentPayload\x12\x12\n" +
"\x04memo\x18\x01 \x01(\tR\x04memo\x12!\n" +
"\frelated_memo\x18\x02 \x01(\tR\vrelatedMemo\x12!\n" +
@ -3610,16 +3754,27 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x04memo\x18\x01 \x01(\tR\x04memo\x12!\n" +
"\frelated_memo\x18\x02 \x01(\tR\vrelatedMemo\x12!\n" +
"\fmemo_snippet\x18\x03 \x01(\tR\vmemoSnippet\x120\n" +
"\x14related_memo_snippet\x18\x04 \x01(\tR\x12relatedMemoSnippet\":\n" +
"\x14related_memo_snippet\x18\x04 \x01(\tR\x12relatedMemoSnippet\x1a\xd3\x02\n" +
"\x16SpaceInvitationPayload\x12L\n" +
"\x10space_invitation\x18\x01 \x01(\tB!\xfaA\x1e\n" +
"\x1cmemos.api.v1/SpaceInvitationR\x0fspaceInvitation\x12)\n" +
"\x05space\x18\x02 \x01(\v2\x13.memos.api.v1.SpaceR\x05space\x122\n" +
"\x04role\x18\x03 \x01(\x0e2\x1e.memos.api.v1.SpaceMember.RoleR\x04role\x12Q\n" +
"\x05state\x18\x04 \x01(\x0e2;.memos.api.v1.UserNotification.SpaceInvitationPayload.StateR\x05state\"9\n" +
"\x05State\x12\x15\n" +
"\x11STATE_UNSPECIFIED\x10\x00\x12\v\n" +
"\aPENDING\x10\x01\x12\f\n" +
"\bACCEPTED\x10\x02\":\n" +
"\x06Status\x12\x16\n" +
"\x12STATUS_UNSPECIFIED\x10\x00\x12\n" +
"\n" +
"\x06UNREAD\x10\x01\x12\f\n" +
"\bARCHIVED\x10\x02\"@\n" +
"\bARCHIVED\x10\x02\"V\n" +
"\x04Type\x12\x14\n" +
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
"\fMEMO_COMMENT\x10\x01\x12\x10\n" +
"\fMEMO_MENTION\x10\x02:p\xeaAm\n" +
"\fMEMO_MENTION\x10\x02\x12\x14\n" +
"\x10SPACE_INVITATION\x10\x03:p\xeaAm\n" +
"\x1dmemos.api.v1/UserNotification\x12)users/{user}/notifications/{notification}\x1a\x04name*\rnotifications2\fnotificationB\t\n" +
"\apayload\"\xb4\x01\n" +
"\x1cListUserNotificationsRequest\x121\n" +
@ -3683,176 +3838,184 @@ func file_api_v1_user_service_proto_rawDescGZIP() []byte {
return file_api_v1_user_service_proto_rawDescData
}
var file_api_v1_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 52)
var file_api_v1_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 53)
var file_api_v1_user_service_proto_goTypes = []any{
(User_Role)(0), // 0: memos.api.v1.User.Role
(UserSetting_Key)(0), // 1: memos.api.v1.UserSetting.Key
(UserNotification_Status)(0), // 2: memos.api.v1.UserNotification.Status
(UserNotification_Type)(0), // 3: memos.api.v1.UserNotification.Type
(*User)(nil), // 4: memos.api.v1.User
(*ListUsersRequest)(nil), // 5: memos.api.v1.ListUsersRequest
(*ListUsersResponse)(nil), // 6: memos.api.v1.ListUsersResponse
(*BatchGetUsersRequest)(nil), // 7: memos.api.v1.BatchGetUsersRequest
(*BatchGetUsersResponse)(nil), // 8: memos.api.v1.BatchGetUsersResponse
(*GetUserRequest)(nil), // 9: memos.api.v1.GetUserRequest
(*CreateUserRequest)(nil), // 10: memos.api.v1.CreateUserRequest
(*UpdateUserRequest)(nil), // 11: memos.api.v1.UpdateUserRequest
(*DeleteUserRequest)(nil), // 12: memos.api.v1.DeleteUserRequest
(*UserStats)(nil), // 13: memos.api.v1.UserStats
(*GetUserStatsRequest)(nil), // 14: memos.api.v1.GetUserStatsRequest
(*ListAllUserStatsRequest)(nil), // 15: memos.api.v1.ListAllUserStatsRequest
(*ListAllUserStatsResponse)(nil), // 16: memos.api.v1.ListAllUserStatsResponse
(*UserSetting)(nil), // 17: memos.api.v1.UserSetting
(*GetUserSettingRequest)(nil), // 18: memos.api.v1.GetUserSettingRequest
(*UpdateUserSettingRequest)(nil), // 19: memos.api.v1.UpdateUserSettingRequest
(*ListUserSettingsRequest)(nil), // 20: memos.api.v1.ListUserSettingsRequest
(*ListUserSettingsResponse)(nil), // 21: memos.api.v1.ListUserSettingsResponse
(*LinkedIdentity)(nil), // 22: memos.api.v1.LinkedIdentity
(*ListLinkedIdentitiesRequest)(nil), // 23: memos.api.v1.ListLinkedIdentitiesRequest
(*ListLinkedIdentitiesResponse)(nil), // 24: memos.api.v1.ListLinkedIdentitiesResponse
(*CreateLinkedIdentityRequest)(nil), // 25: memos.api.v1.CreateLinkedIdentityRequest
(*GetLinkedIdentityRequest)(nil), // 26: memos.api.v1.GetLinkedIdentityRequest
(*DeleteLinkedIdentityRequest)(nil), // 27: memos.api.v1.DeleteLinkedIdentityRequest
(*PersonalAccessToken)(nil), // 28: memos.api.v1.PersonalAccessToken
(*ListPersonalAccessTokensRequest)(nil), // 29: memos.api.v1.ListPersonalAccessTokensRequest
(*ListPersonalAccessTokensResponse)(nil), // 30: memos.api.v1.ListPersonalAccessTokensResponse
(*CreatePersonalAccessTokenRequest)(nil), // 31: memos.api.v1.CreatePersonalAccessTokenRequest
(*CreatePersonalAccessTokenResponse)(nil), // 32: memos.api.v1.CreatePersonalAccessTokenResponse
(*DeletePersonalAccessTokenRequest)(nil), // 33: memos.api.v1.DeletePersonalAccessTokenRequest
(*UserWebhook)(nil), // 34: memos.api.v1.UserWebhook
(*ListUserWebhooksRequest)(nil), // 35: memos.api.v1.ListUserWebhooksRequest
(*ListUserWebhooksResponse)(nil), // 36: memos.api.v1.ListUserWebhooksResponse
(*CreateUserWebhookRequest)(nil), // 37: memos.api.v1.CreateUserWebhookRequest
(*UpdateUserWebhookRequest)(nil), // 38: memos.api.v1.UpdateUserWebhookRequest
(*DeleteUserWebhookRequest)(nil), // 39: memos.api.v1.DeleteUserWebhookRequest
(*GetUserWebhookSigningSecretRequest)(nil), // 40: memos.api.v1.GetUserWebhookSigningSecretRequest
(*GetUserWebhookSigningSecretResponse)(nil), // 41: memos.api.v1.GetUserWebhookSigningSecretResponse
(*UserNotification)(nil), // 42: memos.api.v1.UserNotification
(*ListUserNotificationsRequest)(nil), // 43: memos.api.v1.ListUserNotificationsRequest
(*ListUserNotificationsResponse)(nil), // 44: memos.api.v1.ListUserNotificationsResponse
(*UpdateUserNotificationRequest)(nil), // 45: memos.api.v1.UpdateUserNotificationRequest
(*DeleteUserNotificationRequest)(nil), // 46: memos.api.v1.DeleteUserNotificationRequest
nil, // 47: memos.api.v1.UserStats.TagCountEntry
(*UserStats_MemoTypeStats)(nil), // 48: memos.api.v1.UserStats.MemoTypeStats
(*UserSetting_GeneralSetting)(nil), // 49: memos.api.v1.UserSetting.GeneralSetting
(*UserSetting_TagMetadata)(nil), // 50: memos.api.v1.UserSetting.TagMetadata
(*UserSetting_TagsSetting)(nil), // 51: memos.api.v1.UserSetting.TagsSetting
(*UserSetting_WebhooksSetting)(nil), // 52: memos.api.v1.UserSetting.WebhooksSetting
nil, // 53: memos.api.v1.UserSetting.TagsSetting.TagsEntry
(*UserNotification_MemoCommentPayload)(nil), // 54: memos.api.v1.UserNotification.MemoCommentPayload
(*UserNotification_MemoMentionPayload)(nil), // 55: memos.api.v1.UserNotification.MemoMentionPayload
(State)(0), // 56: memos.api.v1.State
(*timestamppb.Timestamp)(nil), // 57: google.protobuf.Timestamp
(*fieldmaskpb.FieldMask)(nil), // 58: google.protobuf.FieldMask
(*color.Color)(nil), // 59: google.type.Color
(*emptypb.Empty)(nil), // 60: google.protobuf.Empty
(User_Role)(0), // 0: memos.api.v1.User.Role
(UserSetting_Key)(0), // 1: memos.api.v1.UserSetting.Key
(UserNotification_Status)(0), // 2: memos.api.v1.UserNotification.Status
(UserNotification_Type)(0), // 3: memos.api.v1.UserNotification.Type
(UserNotification_SpaceInvitationPayload_State)(0), // 4: memos.api.v1.UserNotification.SpaceInvitationPayload.State
(*User)(nil), // 5: memos.api.v1.User
(*ListUsersRequest)(nil), // 6: memos.api.v1.ListUsersRequest
(*ListUsersResponse)(nil), // 7: memos.api.v1.ListUsersResponse
(*BatchGetUsersRequest)(nil), // 8: memos.api.v1.BatchGetUsersRequest
(*BatchGetUsersResponse)(nil), // 9: memos.api.v1.BatchGetUsersResponse
(*GetUserRequest)(nil), // 10: memos.api.v1.GetUserRequest
(*CreateUserRequest)(nil), // 11: memos.api.v1.CreateUserRequest
(*UpdateUserRequest)(nil), // 12: memos.api.v1.UpdateUserRequest
(*DeleteUserRequest)(nil), // 13: memos.api.v1.DeleteUserRequest
(*UserStats)(nil), // 14: memos.api.v1.UserStats
(*GetUserStatsRequest)(nil), // 15: memos.api.v1.GetUserStatsRequest
(*ListAllUserStatsRequest)(nil), // 16: memos.api.v1.ListAllUserStatsRequest
(*ListAllUserStatsResponse)(nil), // 17: memos.api.v1.ListAllUserStatsResponse
(*UserSetting)(nil), // 18: memos.api.v1.UserSetting
(*GetUserSettingRequest)(nil), // 19: memos.api.v1.GetUserSettingRequest
(*UpdateUserSettingRequest)(nil), // 20: memos.api.v1.UpdateUserSettingRequest
(*ListUserSettingsRequest)(nil), // 21: memos.api.v1.ListUserSettingsRequest
(*ListUserSettingsResponse)(nil), // 22: memos.api.v1.ListUserSettingsResponse
(*LinkedIdentity)(nil), // 23: memos.api.v1.LinkedIdentity
(*ListLinkedIdentitiesRequest)(nil), // 24: memos.api.v1.ListLinkedIdentitiesRequest
(*ListLinkedIdentitiesResponse)(nil), // 25: memos.api.v1.ListLinkedIdentitiesResponse
(*CreateLinkedIdentityRequest)(nil), // 26: memos.api.v1.CreateLinkedIdentityRequest
(*GetLinkedIdentityRequest)(nil), // 27: memos.api.v1.GetLinkedIdentityRequest
(*DeleteLinkedIdentityRequest)(nil), // 28: memos.api.v1.DeleteLinkedIdentityRequest
(*PersonalAccessToken)(nil), // 29: memos.api.v1.PersonalAccessToken
(*ListPersonalAccessTokensRequest)(nil), // 30: memos.api.v1.ListPersonalAccessTokensRequest
(*ListPersonalAccessTokensResponse)(nil), // 31: memos.api.v1.ListPersonalAccessTokensResponse
(*CreatePersonalAccessTokenRequest)(nil), // 32: memos.api.v1.CreatePersonalAccessTokenRequest
(*CreatePersonalAccessTokenResponse)(nil), // 33: memos.api.v1.CreatePersonalAccessTokenResponse
(*DeletePersonalAccessTokenRequest)(nil), // 34: memos.api.v1.DeletePersonalAccessTokenRequest
(*UserWebhook)(nil), // 35: memos.api.v1.UserWebhook
(*ListUserWebhooksRequest)(nil), // 36: memos.api.v1.ListUserWebhooksRequest
(*ListUserWebhooksResponse)(nil), // 37: memos.api.v1.ListUserWebhooksResponse
(*CreateUserWebhookRequest)(nil), // 38: memos.api.v1.CreateUserWebhookRequest
(*UpdateUserWebhookRequest)(nil), // 39: memos.api.v1.UpdateUserWebhookRequest
(*DeleteUserWebhookRequest)(nil), // 40: memos.api.v1.DeleteUserWebhookRequest
(*GetUserWebhookSigningSecretRequest)(nil), // 41: memos.api.v1.GetUserWebhookSigningSecretRequest
(*GetUserWebhookSigningSecretResponse)(nil), // 42: memos.api.v1.GetUserWebhookSigningSecretResponse
(*UserNotification)(nil), // 43: memos.api.v1.UserNotification
(*ListUserNotificationsRequest)(nil), // 44: memos.api.v1.ListUserNotificationsRequest
(*ListUserNotificationsResponse)(nil), // 45: memos.api.v1.ListUserNotificationsResponse
(*UpdateUserNotificationRequest)(nil), // 46: memos.api.v1.UpdateUserNotificationRequest
(*DeleteUserNotificationRequest)(nil), // 47: memos.api.v1.DeleteUserNotificationRequest
nil, // 48: memos.api.v1.UserStats.TagCountEntry
(*UserStats_MemoTypeStats)(nil), // 49: memos.api.v1.UserStats.MemoTypeStats
(*UserSetting_GeneralSetting)(nil), // 50: memos.api.v1.UserSetting.GeneralSetting
(*UserSetting_TagMetadata)(nil), // 51: memos.api.v1.UserSetting.TagMetadata
(*UserSetting_TagsSetting)(nil), // 52: memos.api.v1.UserSetting.TagsSetting
(*UserSetting_WebhooksSetting)(nil), // 53: memos.api.v1.UserSetting.WebhooksSetting
nil, // 54: memos.api.v1.UserSetting.TagsSetting.TagsEntry
(*UserNotification_MemoCommentPayload)(nil), // 55: memos.api.v1.UserNotification.MemoCommentPayload
(*UserNotification_MemoMentionPayload)(nil), // 56: memos.api.v1.UserNotification.MemoMentionPayload
(*UserNotification_SpaceInvitationPayload)(nil), // 57: memos.api.v1.UserNotification.SpaceInvitationPayload
(State)(0), // 58: memos.api.v1.State
(*timestamppb.Timestamp)(nil), // 59: google.protobuf.Timestamp
(*fieldmaskpb.FieldMask)(nil), // 60: google.protobuf.FieldMask
(*color.Color)(nil), // 61: google.type.Color
(*Space)(nil), // 62: memos.api.v1.Space
(SpaceMember_Role)(0), // 63: memos.api.v1.SpaceMember.Role
(*emptypb.Empty)(nil), // 64: google.protobuf.Empty
}
var file_api_v1_user_service_proto_depIdxs = []int32{
0, // 0: memos.api.v1.User.role:type_name -> memos.api.v1.User.Role
56, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State
57, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp
57, // 3: memos.api.v1.User.update_time:type_name -> google.protobuf.Timestamp
4, // 4: memos.api.v1.ListUsersResponse.users:type_name -> memos.api.v1.User
4, // 5: memos.api.v1.BatchGetUsersResponse.users:type_name -> memos.api.v1.User
58, // 6: memos.api.v1.GetUserRequest.read_mask:type_name -> google.protobuf.FieldMask
4, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User
4, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
58, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
48, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
47, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
57, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
57, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
56, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State
13, // 15: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
49, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
52, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
51, // 18: memos.api.v1.UserSetting.tags_setting:type_name -> memos.api.v1.UserSetting.TagsSetting
17, // 19: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
58, // 20: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
17, // 21: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
22, // 22: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
57, // 23: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
57, // 24: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
57, // 25: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
28, // 26: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
28, // 27: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
57, // 28: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
57, // 29: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
34, // 30: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
34, // 31: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
34, // 32: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
58, // 33: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
4, // 34: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
58, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State
59, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp
59, // 3: memos.api.v1.User.update_time:type_name -> google.protobuf.Timestamp
5, // 4: memos.api.v1.ListUsersResponse.users:type_name -> memos.api.v1.User
5, // 5: memos.api.v1.BatchGetUsersResponse.users:type_name -> memos.api.v1.User
60, // 6: memos.api.v1.GetUserRequest.read_mask:type_name -> google.protobuf.FieldMask
5, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User
5, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
60, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
49, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
48, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
59, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
59, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
58, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State
14, // 15: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
50, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
53, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
52, // 18: memos.api.v1.UserSetting.tags_setting:type_name -> memos.api.v1.UserSetting.TagsSetting
18, // 19: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
60, // 20: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
18, // 21: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
23, // 22: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
59, // 23: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
59, // 24: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
59, // 25: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
29, // 26: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
29, // 27: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
59, // 28: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
59, // 29: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
35, // 30: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
35, // 31: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
35, // 32: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
60, // 33: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
5, // 34: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
2, // 35: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status
57, // 36: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
59, // 36: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
3, // 37: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type
54, // 38: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
55, // 39: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
42, // 40: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
42, // 41: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
58, // 42: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
59, // 43: memos.api.v1.UserSetting.TagMetadata.background_color:type_name -> google.type.Color
53, // 44: memos.api.v1.UserSetting.TagsSetting.tags:type_name -> memos.api.v1.UserSetting.TagsSetting.TagsEntry
34, // 45: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
50, // 46: memos.api.v1.UserSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.UserSetting.TagMetadata
5, // 47: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
7, // 48: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
9, // 49: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
10, // 50: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
11, // 51: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
12, // 52: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
15, // 53: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
14, // 54: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
18, // 55: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
19, // 56: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
20, // 57: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
23, // 58: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
25, // 59: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
26, // 60: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
27, // 61: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
29, // 62: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
31, // 63: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
33, // 64: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
35, // 65: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
37, // 66: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
38, // 67: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
39, // 68: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
40, // 69: memos.api.v1.UserService.GetUserWebhookSigningSecret:input_type -> memos.api.v1.GetUserWebhookSigningSecretRequest
43, // 70: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
45, // 71: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
46, // 72: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
6, // 73: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
8, // 74: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
4, // 75: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
4, // 76: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
4, // 77: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
60, // 78: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
16, // 79: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
13, // 80: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
17, // 81: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
17, // 82: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
21, // 83: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
24, // 84: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
22, // 85: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
22, // 86: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
60, // 87: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
30, // 88: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
32, // 89: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
60, // 90: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
36, // 91: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
34, // 92: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
34, // 93: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
60, // 94: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
41, // 95: memos.api.v1.UserService.GetUserWebhookSigningSecret:output_type -> memos.api.v1.GetUserWebhookSigningSecretResponse
44, // 96: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
42, // 97: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
60, // 98: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
73, // [73:99] is the sub-list for method output_type
47, // [47:73] is the sub-list for method input_type
47, // [47:47] is the sub-list for extension type_name
47, // [47:47] is the sub-list for extension extendee
0, // [0:47] is the sub-list for field type_name
55, // 38: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
56, // 39: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
57, // 40: memos.api.v1.UserNotification.space_invitation:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload
43, // 41: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
43, // 42: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
60, // 43: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
61, // 44: memos.api.v1.UserSetting.TagMetadata.background_color:type_name -> google.type.Color
54, // 45: memos.api.v1.UserSetting.TagsSetting.tags:type_name -> memos.api.v1.UserSetting.TagsSetting.TagsEntry
35, // 46: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
51, // 47: memos.api.v1.UserSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.UserSetting.TagMetadata
62, // 48: memos.api.v1.UserNotification.SpaceInvitationPayload.space:type_name -> memos.api.v1.Space
63, // 49: memos.api.v1.UserNotification.SpaceInvitationPayload.role:type_name -> memos.api.v1.SpaceMember.Role
4, // 50: memos.api.v1.UserNotification.SpaceInvitationPayload.state:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload.State
6, // 51: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
8, // 52: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
10, // 53: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
11, // 54: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
12, // 55: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
13, // 56: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
16, // 57: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
15, // 58: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
19, // 59: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
20, // 60: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
21, // 61: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
24, // 62: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
26, // 63: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
27, // 64: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
28, // 65: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
30, // 66: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
32, // 67: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
34, // 68: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
36, // 69: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
38, // 70: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
39, // 71: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
40, // 72: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
41, // 73: memos.api.v1.UserService.GetUserWebhookSigningSecret:input_type -> memos.api.v1.GetUserWebhookSigningSecretRequest
44, // 74: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
46, // 75: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
47, // 76: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
7, // 77: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
9, // 78: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
5, // 79: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
5, // 80: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
5, // 81: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
64, // 82: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
17, // 83: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
14, // 84: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
18, // 85: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
18, // 86: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
22, // 87: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
25, // 88: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
23, // 89: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
23, // 90: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
64, // 91: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
31, // 92: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
33, // 93: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
64, // 94: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
37, // 95: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
35, // 96: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
35, // 97: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
64, // 98: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
42, // 99: memos.api.v1.UserService.GetUserWebhookSigningSecret:output_type -> memos.api.v1.GetUserWebhookSigningSecretResponse
45, // 100: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
43, // 101: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
64, // 102: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
77, // [77:103] is the sub-list for method output_type
51, // [51:77] is the sub-list for method input_type
51, // [51:51] is the sub-list for extension type_name
51, // [51:51] is the sub-list for extension extendee
0, // [0:51] is the sub-list for field type_name
}
func init() { file_api_v1_user_service_proto_init() }
@ -3861,6 +4024,7 @@ func file_api_v1_user_service_proto_init() {
return
}
file_api_v1_common_proto_init()
file_api_v1_space_service_proto_init()
file_api_v1_user_service_proto_msgTypes[13].OneofWrappers = []any{
(*UserSetting_GeneralSetting_)(nil),
(*UserSetting_WebhooksSetting_)(nil),
@ -3869,14 +4033,15 @@ func file_api_v1_user_service_proto_init() {
file_api_v1_user_service_proto_msgTypes[38].OneofWrappers = []any{
(*UserNotification_MemoComment)(nil),
(*UserNotification_MemoMention)(nil),
(*UserNotification_SpaceInvitation)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_user_service_proto_rawDesc), len(file_api_v1_user_service_proto_rawDesc)),
NumEnums: 4,
NumMessages: 52,
NumEnums: 5,
NumMessages: 53,
NumExtensions: 0,
NumServices: 1,
},

View file

@ -4667,6 +4667,7 @@ components:
- TYPE_UNSPECIFIED
- MEMO_COMMENT
- MEMO_MENTION
- SPACE_INVITATION
type: string
description: The type of the notification.
format: enum
@ -4678,6 +4679,10 @@ components:
readOnly: true
allOf:
- $ref: '#/components/schemas/UserNotification_MemoMentionPayload'
spaceInvitation:
readOnly: true
allOf:
- $ref: '#/components/schemas/UserNotification_SpaceInvitationPayload'
UserNotification_MemoCommentPayload:
type: object
properties:
@ -4716,6 +4721,34 @@ components:
relatedMemoSnippet:
type: string
description: Preview text of the related parent memo.
UserNotification_SpaceInvitationPayload:
type: object
properties:
spaceInvitation:
type: string
description: |-
The invitation resource.
Format: spaces/{space}/invitations/{username}
space:
allOf:
- $ref: '#/components/schemas/Space'
description: The read-only Space summary carried by the invitation.
role:
enum:
- ROLE_UNSPECIFIED
- ADMIN
- USER
type: string
description: The role the invitation offers.
format: enum
state:
enum:
- STATE_UNSPECIFIED
- PENDING
- ACCEPTED
type: string
description: The current state of the invitation for the receiver.
format: enum
UserSetting:
type: object
properties:

View file

@ -29,6 +29,8 @@ const (
InboxMessage_MEMO_COMMENT InboxMessage_Type = 1
// Memo mention notification.
InboxMessage_MEMO_MENTION InboxMessage_Type = 2
// Space invitation notification.
InboxMessage_SPACE_INVITATION InboxMessage_Type = 3
)
// Enum value maps for InboxMessage_Type.
@ -37,11 +39,13 @@ var (
0: "TYPE_UNSPECIFIED",
1: "MEMO_COMMENT",
2: "MEMO_MENTION",
3: "SPACE_INVITATION",
}
InboxMessage_Type_value = map[string]int32{
"TYPE_UNSPECIFIED": 0,
"MEMO_COMMENT": 1,
"MEMO_MENTION": 2,
"SPACE_INVITATION": 3,
}
)
@ -80,6 +84,7 @@ type InboxMessage struct {
//
// *InboxMessage_MemoComment
// *InboxMessage_MemoMention
// *InboxMessage_SpaceInvitation
Payload isInboxMessage_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@ -147,6 +152,15 @@ func (x *InboxMessage) GetMemoMention() *InboxMessage_MemoMentionPayload {
return nil
}
func (x *InboxMessage) GetSpaceInvitation() *InboxMessage_SpaceInvitationPayload {
if x != nil {
if x, ok := x.Payload.(*InboxMessage_SpaceInvitation); ok {
return x.SpaceInvitation
}
}
return nil
}
type isInboxMessage_Payload interface {
isInboxMessage_Payload()
}
@ -159,10 +173,16 @@ type InboxMessage_MemoMention struct {
MemoMention *InboxMessage_MemoMentionPayload `protobuf:"bytes,3,opt,name=memo_mention,json=memoMention,proto3,oneof"`
}
type InboxMessage_SpaceInvitation struct {
SpaceInvitation *InboxMessage_SpaceInvitationPayload `protobuf:"bytes,4,opt,name=space_invitation,json=spaceInvitation,proto3,oneof"`
}
func (*InboxMessage_MemoComment) isInboxMessage_Payload() {}
func (*InboxMessage_MemoMention) isInboxMessage_Payload() {}
func (*InboxMessage_SpaceInvitation) 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"`
@ -267,25 +287,75 @@ func (x *InboxMessage_MemoMentionPayload) GetRelatedMemoId() int32 {
return 0
}
type InboxMessage_SpaceInvitationPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
// The space the receiver was invited to join. The offered role and the
// invitation state are resolved from the space membership at read time.
SpaceId int32 `protobuf:"varint,1,opt,name=space_id,json=spaceId,proto3" json:"space_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InboxMessage_SpaceInvitationPayload) Reset() {
*x = InboxMessage_SpaceInvitationPayload{}
mi := &file_store_inbox_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *InboxMessage_SpaceInvitationPayload) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InboxMessage_SpaceInvitationPayload) ProtoMessage() {}
func (x *InboxMessage_SpaceInvitationPayload) ProtoReflect() protoreflect.Message {
mi := &file_store_inbox_proto_msgTypes[3]
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_SpaceInvitationPayload.ProtoReflect.Descriptor instead.
func (*InboxMessage_SpaceInvitationPayload) Descriptor() ([]byte, []int) {
return file_store_inbox_proto_rawDescGZIP(), []int{0, 2}
}
func (x *InboxMessage_SpaceInvitationPayload) GetSpaceId() int32 {
if x != nil {
return x.SpaceId
}
return 0
}
var File_store_inbox_proto protoreflect.FileDescriptor
const file_store_inbox_proto_rawDesc = "" +
"\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\xe3\x03\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\x8d\x05\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\x12Q\n" +
"\fmemo_mention\x18\x03 \x01(\v2,.memos.store.InboxMessage.MemoMentionPayloadH\x00R\vmemoMention\x1aU\n" +
"\fmemo_mention\x18\x03 \x01(\v2,.memos.store.InboxMessage.MemoMentionPayloadH\x00R\vmemoMention\x12]\n" +
"\x10space_invitation\x18\x04 \x01(\v20.memos.store.InboxMessage.SpaceInvitationPayloadH\x00R\x0fspaceInvitation\x1aU\n" +
"\x12MemoCommentPayload\x12\x17\n" +
"\amemo_id\x18\x01 \x01(\x05R\x06memoId\x12&\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" +
"\x0frelated_memo_id\x18\x02 \x01(\x05R\rrelatedMemoId\x1a3\n" +
"\x16SpaceInvitationPayload\x12\x19\n" +
"\bspace_id\x18\x01 \x01(\x05R\aspaceId\"V\n" +
"\x04Type\x12\x14\n" +
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
"\fMEMO_COMMENT\x10\x01\x12\x10\n" +
"\fMEMO_MENTION\x10\x02B\t\n" +
"\fMEMO_MENTION\x10\x02\x12\x14\n" +
"\x10SPACE_INVITATION\x10\x03B\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"
@ -303,22 +373,24 @@ 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, 3)
var file_store_inbox_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
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
(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
(*InboxMessage_SpaceInvitationPayload)(nil), // 4: memos.store.InboxMessage.SpaceInvitationPayload
}
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
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
4, // 3: memos.store.InboxMessage.space_invitation:type_name -> memos.store.InboxMessage.SpaceInvitationPayload
4, // [4:4] is the sub-list for method output_type
4, // [4:4] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_store_inbox_proto_init() }
@ -329,6 +401,7 @@ func file_store_inbox_proto_init() {
file_store_inbox_proto_msgTypes[0].OneofWrappers = []any{
(*InboxMessage_MemoComment)(nil),
(*InboxMessage_MemoMention)(nil),
(*InboxMessage_SpaceInvitation)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
@ -336,7 +409,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: 3,
NumMessages: 4,
NumExtensions: 0,
NumServices: 0,
},

View file

@ -15,11 +15,18 @@ message InboxMessage {
int32 related_memo_id = 2;
}
message SpaceInvitationPayload {
// The space the receiver was invited to join. The offered role and the
// invitation state are resolved from the space membership at read time.
int32 space_id = 1;
}
// The type of the inbox message.
Type type = 1;
oneof payload {
MemoCommentPayload memo_comment = 2;
MemoMentionPayload memo_mention = 3;
SpaceInvitationPayload space_invitation = 4;
}
enum Type {
@ -28,5 +35,7 @@ message InboxMessage {
MEMO_COMMENT = 1;
// Memo mention notification.
MEMO_MENTION = 2;
// Space invitation notification.
SPACE_INVITATION = 3;
}
}

View file

@ -78,7 +78,7 @@ func (d *EmailDispatcher) DispatchInboxEmail(ctx context.Context, inbox *store.I
return errors.Wrap(err, "failed to get notification memos")
}
message, err := d.buildInboxEmailMessage(inbox, receiver, sender, memosByID)
message, err := d.buildInboxEmailMessage(ctx, inbox, receiver, sender, memosByID)
if err != nil {
return err
}
@ -133,18 +133,73 @@ func SendTestEmail(setting *storepb.InstanceNotificationSetting_EmailSetting, re
return email.Send(EmailConfigFromInstanceSetting(setting), NewTestEmailMessage(recipientEmail, setting.GetReplyTo()))
}
func (d *EmailDispatcher) buildInboxEmailMessage(inbox *store.Inbox, receiver *store.User, sender *store.User, memosByID map[int32]*store.Memo) (*email.Message, error) {
func (d *EmailDispatcher) buildInboxEmailMessage(ctx context.Context, inbox *store.Inbox, receiver *store.User, sender *store.User, memosByID map[int32]*store.Memo) (*email.Message, error) {
senderName := displayNameForEmail(sender)
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
return d.buildMemoCommentEmailMessage(inbox.Message, receiver, senderName, memosByID)
case storepb.InboxMessage_MEMO_MENTION:
return d.buildMemoMentionEmailMessage(inbox.Message, receiver, senderName, memosByID)
case storepb.InboxMessage_SPACE_INVITATION:
return d.buildSpaceInvitationEmailMessage(ctx, inbox.Message, receiver, senderName)
default:
return nil, nil
}
}
// buildSpaceInvitationEmailMessage renders the invitation email only while
// the receiver still holds the pending offer, so a revoked or already handled
// invitation never reaches their mailbox.
func (d *EmailDispatcher) buildSpaceInvitationEmailMessage(ctx context.Context, message *storepb.InboxMessage, receiver *store.User, senderName string) (*email.Message, error) {
payload := message.GetSpaceInvitation()
if payload == nil || payload.SpaceId <= 0 {
return nil, nil
}
space, err := d.store.GetSpace(ctx, &store.FindSpace{ID: &payload.SpaceId})
if err != nil {
return nil, errors.Wrap(err, "failed to get invitation space")
}
if space == nil {
return nil, nil
}
invitation, err := d.store.GetSpaceInvitation(ctx, &store.FindSpaceInvitation{
SpaceID: &space.ID,
UserID: &receiver.ID,
ViewerUserID: &receiver.ID,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get space invitation")
}
if invitation == nil {
return nil, nil
}
url := d.inboxURL()
if url == "" {
return nil, nil
}
roleName := "a member"
if invitation.Role == store.SpaceMemberRoleAdmin {
roleName = "an administrator"
}
body := []string{
fmt.Sprintf("Hi %s,", displayNameForEmail(receiver)),
"",
fmt.Sprintf("%s invited you to join the Space \"%s\" as %s.", senderName, space.Title, roleName),
"",
"Review the invitation in Memos:",
url,
"",
"You are receiving this because an administrator of this Space invited you.",
}
return &email.Message{
To: []string{receiver.Email},
Subject: fmt.Sprintf("[Memos] %s invited you to join \"%s\"", senderName, space.Title),
Body: strings.Join(body, "\n"),
}, nil
}
func (d *EmailDispatcher) buildMemoCommentEmailMessage(message *storepb.InboxMessage, receiver *store.User, senderName string, memosByID map[int32]*store.Memo) (*email.Message, error) {
payload := message.GetMemoComment()
if payload == nil {
@ -298,6 +353,14 @@ func (d *EmailDispatcher) baseURL() string {
return strings.TrimRight(strings.TrimSpace(d.profile.InstanceURL), "/")
}
func (d *EmailDispatcher) inboxURL() string {
baseURL := d.baseURL()
if baseURL == "" {
return ""
}
return baseURL + "/inbox"
}
func (d *EmailDispatcher) memoURL(memo *store.Memo) string {
baseURL := d.baseURL()
if memo == nil || memo.UID == "" || baseURL == "" {

View file

@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/profile"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
teststore "github.com/usememos/memos/store/test"
)
@ -43,3 +45,41 @@ func TestEmailMemoReadCreatorLifecycle(t *testing.T) {
require.NoError(t, err)
require.NotContains(t, readable, danglingMemo.ID, "email rendering must fail closed for a missing memo creator")
}
func TestEmailSpaceInvitationFailsClosedWithoutPendingOffer(t *testing.T) {
ctx := context.Background()
st := teststore.NewTestingStore(ctx, t)
defer st.Close()
dispatcher := NewEmailDispatcher(&profile.Profile{InstanceURL: "https://memos.example.com/"}, st, nil)
inviter, err := st.CreateUser(ctx, &store.User{Username: "email-space-inviter", Nickname: "Ada", Role: store.RoleUser, PasswordHash: "hash"})
require.NoError(t, err)
invitee, err := st.CreateUser(ctx, &store.User{Username: "email-space-invitee", Email: "invitee@example.com", Role: store.RoleUser, PasswordHash: "hash"})
require.NoError(t, err)
space, err := st.CreateSpace(ctx, &store.Space{UID: "email-space", Title: "Design team"}, inviter.ID)
require.NoError(t, err)
_, err = st.CreateSpaceInvitation(ctx, &store.SpaceInvitation{SpaceID: space.ID, UserID: invitee.ID, Role: store.SpaceMemberRoleAdmin}, inviter.ID)
require.NoError(t, err)
inbox := &store.Inbox{
SenderID: inviter.ID,
ReceiverID: invitee.ID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_SPACE_INVITATION,
Payload: &storepb.InboxMessage_SpaceInvitation{SpaceInvitation: &storepb.InboxMessage_SpaceInvitationPayload{SpaceId: space.ID}},
},
}
message, err := dispatcher.buildInboxEmailMessage(ctx, inbox, invitee, inviter, nil)
require.NoError(t, err)
require.NotNil(t, message)
require.Equal(t, []string{"invitee@example.com"}, message.To)
require.Equal(t, `[Memos] Ada invited you to join "Design team"`, message.Subject)
require.Contains(t, message.Body, `Ada invited you to join the Space "Design team" as an administrator.`)
require.Contains(t, message.Body, "https://memos.example.com/inbox")
require.NoError(t, st.DeclineSpaceInvitation(ctx, &store.DeclineSpaceInvitation{SpaceID: space.ID, UserID: invitee.ID}, invitee.ID))
message, err = dispatcher.buildInboxEmailMessage(ctx, inbox, invitee, inviter, nil)
require.NoError(t, err)
require.Nil(t, message, "a declined invitation must not be emailed")
}

View file

@ -288,6 +288,7 @@ func (s *APIV1Service) CreateSpaceInvitation(ctx context.Context, request *v1pb.
if err != nil {
return nil, mapSpaceMutationError(err, "failed to create space invitation")
}
s.createSpaceInvitationNotification(ctx, space, currentUser, targetUser)
s.SSEHub.publishSpaceChanged()
return convertSpaceInvitationFromStore(space, targetUser, created), nil
}
@ -500,6 +501,7 @@ func (s *APIV1Service) DeleteSpaceInvitation(ctx context.Context, request *v1pb.
}, currentUser.ID); err != nil {
return nil, mapSpaceMutationError(err, "failed to revoke space invitation")
}
s.deleteSpaceInvitationNotifications(ctx, targetUser.ID, invitation.SpaceID)
s.SSEHub.publishSpaceChanged()
return &emptypb.Empty{}, nil
}
@ -522,6 +524,7 @@ func (s *APIV1Service) AcceptSpaceInvitation(ctx context.Context, request *v1pb.
if err != nil {
return nil, mapSpaceMutationError(err, "failed to accept space invitation")
}
s.archiveSpaceInvitationNotifications(ctx, currentUser.ID, invitation.SpaceID)
s.SSEHub.publishSpaceChanged()
return convertSpaceMemberFromStore(space, currentUser, member), nil
}
@ -543,6 +546,7 @@ func (s *APIV1Service) DeclineSpaceInvitation(ctx context.Context, request *v1pb
if err := s.Store.DeclineSpaceInvitation(ctx, &store.DeclineSpaceInvitation{SpaceID: invitation.SpaceID, UserID: currentUser.ID}, currentUser.ID); err != nil {
return nil, mapSpaceMutationError(err, "failed to decline space invitation")
}
s.deleteSpaceInvitationNotifications(ctx, currentUser.ID, invitation.SpaceID)
s.SSEHub.publishSpaceChanged()
return &emptypb.Empty{}, nil
}

View file

@ -0,0 +1,184 @@
package v1
import (
"context"
"log/slog"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// createSpaceInvitationNotification records the invitation in the invitee's
// inbox. The invitation itself is already persisted, so a failure here only
// loses the notification and is logged instead of failing the request.
func (s *APIV1Service) createSpaceInvitationNotification(ctx context.Context, space *store.Space, inviter *store.User, invitee *store.User) {
if space == nil || inviter == nil || invitee == nil {
return
}
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
SenderID: inviter.ID,
ReceiverID: invitee.ID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_SPACE_INVITATION,
Payload: &storepb.InboxMessage_SpaceInvitation{
SpaceInvitation: &storepb.InboxMessage_SpaceInvitationPayload{SpaceId: space.ID},
},
},
}); err != nil {
slog.Warn("Failed to create space invitation notification",
slog.Any("err", err),
slog.Int64("space_id", int64(space.ID)),
slog.Int64("receiver_id", int64(invitee.ID)))
}
}
// archiveSpaceInvitationNotifications marks the receiver's notifications for
// the space as handled once the invitation has been accepted.
func (s *APIV1Service) archiveSpaceInvitationNotifications(ctx context.Context, receiverID, spaceID int32) {
s.forEachSpaceInvitationNotification(ctx, receiverID, spaceID, func(inbox *store.Inbox) error {
if inbox.Status == store.ARCHIVED {
return nil
}
_, err := s.Store.UpdateInbox(ctx, &store.UpdateInbox{ID: inbox.ID, Status: store.ARCHIVED})
return err
})
}
// deleteSpaceInvitationNotifications removes the receiver's notifications for
// the space once the invitation has been declined or revoked. A declined or
// revoked offer leaves nothing the receiver can act on.
func (s *APIV1Service) deleteSpaceInvitationNotifications(ctx context.Context, receiverID, spaceID int32) {
s.forEachSpaceInvitationNotification(ctx, receiverID, spaceID, func(inbox *store.Inbox) error {
return s.Store.DeleteInbox(ctx, &store.DeleteInbox{ID: inbox.ID})
})
}
func (s *APIV1Service) forEachSpaceInvitationNotification(ctx context.Context, receiverID, spaceID int32, apply func(*store.Inbox) error) {
messageType := storepb.InboxMessage_SPACE_INVITATION
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{ReceiverID: &receiverID, MessageType: &messageType})
if err != nil {
slog.Warn("Failed to list space invitation notifications",
slog.Any("err", err),
slog.Int64("space_id", int64(spaceID)),
slog.Int64("receiver_id", int64(receiverID)))
return
}
for _, inbox := range inboxes {
if inbox.Message.GetSpaceInvitation().GetSpaceId() != spaceID {
continue
}
if err := apply(inbox); err != nil {
slog.Warn("Failed to resolve space invitation notification",
slog.Any("err", err),
slog.Int64("inbox_id", int64(inbox.ID)),
slog.Int64("space_id", int64(spaceID)),
slog.Int64("receiver_id", int64(receiverID)))
}
}
}
// spaceInvitationNotificationContext carries the viewer's relationship with
// the spaces referenced by a batch of inbox items. An invitation notification
// is only readable while the viewer holds the pending offer or accepted it.
type spaceInvitationNotificationContext struct {
spacesByID map[int32]*store.Space
pendingBySpaceID map[int32]store.SpaceMemberRole
memberBySpaceID map[int32]store.SpaceMemberRole
}
func (c *spaceInvitationNotificationContext) resolve(spaceID int32) (*store.Space, store.SpaceMemberRole, v1pb.UserNotification_SpaceInvitationPayload_State) {
if c == nil {
return nil, "", v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
}
space := c.spacesByID[spaceID]
if space == nil {
return nil, "", v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
}
if role, ok := c.pendingBySpaceID[spaceID]; ok {
return space, role, v1pb.UserNotification_SpaceInvitationPayload_PENDING
}
if role, ok := c.memberBySpaceID[spaceID]; ok {
return space, role, v1pb.UserNotification_SpaceInvitationPayload_ACCEPTED
}
return nil, "", v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED
}
func collectInboxSpaceIDs(inboxes []*store.Inbox) []int32 {
spaceIDs := make([]int32, 0, len(inboxes))
seen := make(map[int32]struct{}, len(inboxes))
for _, inbox := range inboxes {
if inbox == nil || inbox.Message == nil || inbox.Message.Type != storepb.InboxMessage_SPACE_INVITATION {
continue
}
spaceID := inbox.Message.GetSpaceInvitation().GetSpaceId()
if spaceID <= 0 {
continue
}
if _, ok := seen[spaceID]; ok {
continue
}
seen[spaceID] = struct{}{}
spaceIDs = append(spaceIDs, spaceID)
}
return spaceIDs
}
func (s *APIV1Service) loadSpaceInvitationNotificationContext(ctx context.Context, viewer *store.User, inboxes []*store.Inbox) (*spaceInvitationNotificationContext, error) {
result := &spaceInvitationNotificationContext{
spacesByID: map[int32]*store.Space{},
pendingBySpaceID: map[int32]store.SpaceMemberRole{},
memberBySpaceID: map[int32]store.SpaceMemberRole{},
}
spaceIDs := collectInboxSpaceIDs(inboxes)
if viewer == nil || len(spaceIDs) == 0 {
return result, nil
}
spaces, err := s.Store.ListSpaces(ctx, &store.FindSpace{IDList: spaceIDs})
if err != nil {
return nil, err
}
for _, space := range spaces {
result.spacesByID[space.ID] = space
}
invitations, err := s.Store.ListSpaceInvitations(ctx, &store.FindSpaceInvitation{UserID: &viewer.ID, ViewerUserID: &viewer.ID})
if err != nil {
return nil, err
}
for _, invitation := range invitations {
result.pendingBySpaceID[invitation.SpaceID] = invitation.Role
}
members, err := s.Store.ListSpaceMembers(ctx, &store.FindSpaceMember{UserID: &viewer.ID, ViewerUserID: &viewer.ID})
if err != nil {
return nil, err
}
for _, member := range members {
result.memberBySpaceID[member.SpaceID] = member.Role
}
return result, nil
}
// convertSpaceInvitationNotificationPayload returns nil when the viewer no
// longer holds a pending or accepted invitation for the space, so the
// notification fails closed without leaking Space metadata.
func convertSpaceInvitationNotificationPayload(inbox *store.Inbox, viewer *store.User, spaceContext *spaceInvitationNotificationContext) *v1pb.UserNotification_SpaceInvitationPayload {
if inbox == nil || inbox.Message == nil || viewer == nil {
return nil
}
space, role, state := spaceContext.resolve(inbox.Message.GetSpaceInvitation().GetSpaceId())
if space == nil || state == v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED {
return nil
}
convertedRole := convertSpaceMemberRoleFromStore(role)
if convertedRole == v1pb.SpaceMember_ROLE_UNSPECIFIED {
return nil
}
return &v1pb.UserNotification_SpaceInvitationPayload{
SpaceInvitation: buildSpaceInvitationName(space.UID, viewer.Username),
Space: convertSpaceMetadataFromStore(space),
Role: convertedRole,
State: state,
}
}

View file

@ -409,3 +409,93 @@ func TestCreateSpaceInvitationClassifiesTargetUserErrors(t *testing.T) {
})
require.Equal(t, codes.Internal, status.Code(err))
}
func listSpaceInvitationNotifications(ctx context.Context, t *testing.T, service *APIV1Service, user *store.User) []*v1pb.UserNotification {
t.Helper()
response, err := service.ListUserNotifications(userCtx(ctx, user.ID), &v1pb.ListUserNotificationsRequest{Parent: BuildUserName(user.Username)})
require.NoError(t, err)
notifications := []*v1pb.UserNotification{}
for _, notification := range response.Notifications {
if notification.Type == v1pb.UserNotification_SPACE_INVITATION {
notifications = append(notifications, notification)
}
}
return notifications
}
func TestSpaceInvitationNotificationLifecycle(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "notification-owner", store.RoleUser)
invitee := createSpaceTestUser(ctx, t, service, "notification-invitee", store.RoleUser)
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: "notification-lifecycle",
Space: &v1pb.Space{Title: "Notification lifecycle", Description: "Inbox coverage"},
})
require.NoError(t, err)
invitation := inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_USER)
notifications := listSpaceInvitationNotifications(ctx, t, service, invitee)
require.Len(t, notifications, 1, "inviting a user must notify their inbox")
notification := notifications[0]
require.Equal(t, v1pb.UserNotification_UNREAD, notification.Status)
require.Equal(t, BuildUserName(owner.Username), notification.Sender)
payload := notification.GetSpaceInvitation()
require.NotNil(t, payload)
require.Equal(t, invitation.Name, payload.SpaceInvitation)
require.Equal(t, v1pb.UserNotification_SpaceInvitationPayload_PENDING, payload.State)
require.Equal(t, v1pb.SpaceMember_USER, payload.Role)
require.Equal(t, space.Name, payload.Space.GetName())
require.Equal(t, "Notification lifecycle", payload.Space.GetTitle())
require.Equal(t, "Inbox coverage", payload.Space.GetDescription())
require.Equal(t, v1pb.SpaceMember_ROLE_UNSPECIFIED, payload.Space.GetCurrentUserRole(), "an invitation carries a metadata-only Space summary")
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, owner), "the inviter receives no notification")
_, err = service.DeclineSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.DeclineSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "declining removes the notification")
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_ADMIN)
require.Len(t, listSpaceInvitationNotifications(ctx, t, service, invitee), 1, "a later invitation notifies again")
_, err = service.DeleteSpaceInvitation(userCtx(ctx, owner.ID), &v1pb.DeleteSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "revoking removes the notification")
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_ADMIN)
notifications = listSpaceInvitationNotifications(ctx, t, service, invitee)
require.Len(t, notifications, 1)
pending := notifications[0]
archived, err := service.UpdateUserNotification(userCtx(ctx, invitee.ID), &v1pb.UpdateUserNotificationRequest{
Notification: &v1pb.UserNotification{Name: pending.Name, Status: v1pb.UserNotification_ARCHIVED},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"status"}},
})
require.NoError(t, err, "the invitee may archive a pending invitation notification without acting on it")
require.Equal(t, v1pb.UserNotification_SpaceInvitationPayload_PENDING, archived.GetSpaceInvitation().GetState())
_, err = service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
notifications = listSpaceInvitationNotifications(ctx, t, service, invitee)
require.Len(t, notifications, 1, "accepting keeps the notification as history")
accepted := notifications[0]
require.Equal(t, v1pb.UserNotification_ARCHIVED, accepted.Status, "accepting resolves the notification")
require.Equal(t, v1pb.UserNotification_SpaceInvitationPayload_ACCEPTED, accepted.GetSpaceInvitation().GetState())
require.Equal(t, v1pb.SpaceMember_ADMIN, accepted.GetSpaceInvitation().GetRole(), "the accepted state reflects the granted role")
_, err = service.DeleteSpaceMember(userCtx(ctx, invitee.ID), &v1pb.DeleteSpaceMemberRequest{Name: buildSpaceMemberName("notification-lifecycle", invitee.Username)})
require.NoError(t, err)
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "leaving the Space hides the accepted notification")
_, err = service.UpdateUserNotification(userCtx(ctx, invitee.ID), &v1pb.UpdateUserNotificationRequest{
Notification: &v1pb.UserNotification{Name: accepted.Name, Status: v1pb.UserNotification_UNREAD},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"status"}},
})
require.Equal(t, codes.NotFound, status.Code(err), "a hidden notification cannot be updated")
invitation = inviteSpaceTestUser(ctx, t, service, owner, invitee, space, v1pb.SpaceMember_USER)
_, err = service.AcceptSpaceInvitation(userCtx(ctx, invitee.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
require.NoError(t, err)
require.Len(t, listSpaceInvitationNotifications(ctx, t, service, invitee), 2, "re-joining reveals every accepted notification for the Space")
_, err = service.DeleteSpace(userCtx(ctx, owner.ID), &v1pb.DeleteSpaceRequest{Name: space.Name})
require.NoError(t, err)
require.Empty(t, listSpaceInvitationNotifications(ctx, t, service, invitee), "deleting the Space hides its notifications")
}

View file

@ -58,10 +58,14 @@ func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
}
spaceContext, err := s.loadSpaceInvitationNotificationContext(ctx, currentUser, inboxes)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification spaces: %v", err)
}
notifications := []*v1pb.UserNotification{}
for _, inbox := range inboxes {
notification, err := s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, currentUser, usersByID, memosByID)
notification, err := s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, currentUser, usersByID, memosByID, spaceContext)
if err != nil {
if status.Code(err) == codes.NotFound {
slog.Warn("Skipping notification with missing user",
@ -125,7 +129,11 @@ func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
}
accessible, err := s.canAccessNotificationMemos(ctx, currentUser, inbox, memosByID)
spaceContext, err := s.loadSpaceInvitationNotificationContext(ctx, currentUser, []*store.Inbox{inbox})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification spaces: %v", err)
}
accessible, err := s.canAccessNotification(ctx, currentUser, inbox, memosByID, spaceContext)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to authorize notification: %v", err)
}
@ -227,7 +235,11 @@ func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
}
return s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, viewer, usersByID, memosByID)
spaceContext, err := s.loadSpaceInvitationNotificationContext(ctx, viewer, []*store.Inbox{inbox})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification spaces: %v", err)
}
return s.convertInboxToUserNotificationWithUsersAndMemos(ctx, inbox, viewer, usersByID, memosByID, spaceContext)
}
func collectInboxMemoIDs(inboxes []*store.Inbox) []int32 {
@ -264,8 +276,8 @@ func collectInboxMemoIDs(inboxes []*store.Inbox) []int32 {
return memoIDs
}
func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx context.Context, inbox *store.Inbox, viewer *store.User, usersByID map[int32]*store.User, memosByID map[int32]*store.Memo) (*v1pb.UserNotification, error) {
accessible, err := s.canAccessNotificationMemos(ctx, viewer, inbox, memosByID)
func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx context.Context, inbox *store.Inbox, viewer *store.User, usersByID map[int32]*store.User, memosByID map[int32]*store.Memo, spaceContext *spaceInvitationNotificationContext) (*v1pb.UserNotification, error) {
accessible, err := s.canAccessNotification(ctx, viewer, inbox, memosByID, spaceContext)
if err != nil {
return nil, err
}
@ -323,6 +335,18 @@ func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx conte
MemoMention: payload,
}
}
case storepb.InboxMessage_SPACE_INVITATION:
payload := convertSpaceInvitationNotificationPayload(inbox, viewer, spaceContext)
if payload == nil {
// The access check already required a pending or accepted
// invitation; a missing payload means the space disappeared
// in between. Fail closed.
return nil, nil
}
notification.Type = v1pb.UserNotification_SPACE_INVITATION
notification.Payload = &v1pb.UserNotification_SpaceInvitation{
SpaceInvitation: payload,
}
default:
notification.Type = v1pb.UserNotification_TYPE_UNSPECIFIED
}
@ -331,7 +355,11 @@ func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(ctx conte
return notification, nil
}
func (s *APIV1Service) canAccessNotificationMemos(ctx context.Context, viewer *store.User, inbox *store.Inbox, memosByID map[int32]*store.Memo) (bool, error) {
// canAccessNotification reports whether the viewer may still read every
// subject the notification refers to. Memo notifications require read access
// to each memo; a space invitation requires the viewer to hold the pending
// offer or to have accepted it.
func (s *APIV1Service) canAccessNotification(ctx context.Context, viewer *store.User, inbox *store.Inbox, memosByID map[int32]*store.Memo, spaceContext *spaceInvitationNotificationContext) (bool, error) {
if viewer == nil || viewer.RowStatus != store.Normal {
return false, nil
}
@ -377,6 +405,9 @@ func (s *APIV1Service) canAccessNotificationMemos(ctx context.Context, viewer *s
return canReadMemo, err
}
return check(payload.RelatedMemoId)
case storepb.InboxMessage_SPACE_INVITATION:
space, _, state := spaceContext.resolve(inbox.Message.GetSpaceInvitation().GetSpaceId())
return space != nil && state != v1pb.UserNotification_SpaceInvitationPayload_STATE_UNSPECIFIED, nil
default:
return true, nil
}

View file

@ -1,10 +1,10 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { CheckIcon, MessageCircleIcon, 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 { useArchiveNotification, useDeleteNotification } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -15,28 +15,30 @@ interface Props {
function MemoCommentMessage({ notification }: Props) {
const t = useTranslate();
const archiveNotification = useArchiveNotification();
const deleteNotification = useDeleteNotification();
const navigateTo = useNavigateTo();
const commentPayload = notification.payload?.case === "memoComment" ? 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"));
try {
await archiveNotification.mutateAsync(notification.name);
if (!silence) {
toast.success(t("message.archived-successfully"));
}
} catch (error) {
handleError(error, toast.error, { context: "Archive notification" });
}
};
const handleDeleteMessage = async () => {
await userServiceClient.deleteUserNotification({
name: notification.name,
});
toast.success(t("message.deleted-successfully"));
try {
await deleteNotification.mutateAsync(notification.name);
toast.success(t("message.deleted-successfully"));
} catch (error) {
handleError(error, toast.error, { context: "Delete notification" });
}
};
if (!commentPayload) {

View file

@ -1,10 +1,10 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { 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 { useArchiveNotification, useDeleteNotification } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -15,28 +15,30 @@ interface Props {
function MemoMentionMessage({ notification }: Props) {
const t = useTranslate();
const archiveNotification = useArchiveNotification();
const deleteNotification = useDeleteNotification();
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"));
try {
await archiveNotification.mutateAsync(notification.name);
if (!silence) {
toast.success(t("message.archived-successfully"));
}
} catch (error) {
handleError(error, toast.error, { context: "Archive notification" });
}
};
const handleDeleteMessage = async () => {
await userServiceClient.deleteUserNotification({
name: notification.name,
});
toast.success(t("message.deleted-successfully"));
try {
await deleteNotification.mutateAsync(notification.name);
toast.success(t("message.deleted-successfully"));
} catch (error) {
handleError(error, toast.error, { context: "Delete notification" });
}
};
if (!mentionPayload) {

View file

@ -0,0 +1,200 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { CheckIcon, TrashIcon, UsersIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import SpaceMark from "@/components/SpaceMark";
import SpaceRoleBadge from "@/components/SpaceRoleBadge";
import UserAvatar from "@/components/UserAvatar";
import { Button } from "@/components/ui/button";
import { useSpaceContext } from "@/contexts/SpaceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useAcceptSpaceInvitation, useDeclineSpaceInvitation } from "@/hooks/useSpaceQueries";
import { useArchiveNotification, useDeleteNotification } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { extractSpaceUidFromName } from "@/lib/space-display";
import { cn } from "@/lib/utils";
import {
UserNotification,
UserNotification_SpaceInvitationPayload_State,
UserNotification_Status,
} from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
notification: UserNotification;
}
function SpaceInvitationMessage({ notification }: Props) {
const t = useTranslate();
const archiveNotification = useArchiveNotification();
const deleteNotification = useDeleteNotification();
const currentUser = useCurrentUser();
const viewerName = currentUser?.name ?? "";
const { selectSpace } = useSpaceContext();
const acceptInvitation = useAcceptSpaceInvitation(viewerName);
const declineInvitation = useDeclineSpaceInvitation(viewerName);
const payload = notification.payload?.case === "spaceInvitation" ? notification.payload.value : undefined;
const space = payload?.space;
const sender = notification.senderUser;
const handleArchiveMessage = async (silence = false) => {
try {
await archiveNotification.mutateAsync(notification.name);
if (!silence) {
toast.success(t("message.archived-successfully"));
}
} catch (error) {
handleError(error, toast.error, { context: "Archive notification" });
}
};
const handleDeleteMessage = async () => {
try {
await deleteNotification.mutateAsync(notification.name);
toast.success(t("message.deleted-successfully"));
} catch (error) {
handleError(error, toast.error, { context: "Delete notification" });
}
};
if (!payload || !space) {
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 isPending = payload.state === UserNotification_SpaceInvitationPayload_State.PENDING;
const isBusy = acceptInvitation.isPending || declineInvitation.isPending;
const spaceUid = extractSpaceUidFromName(space.name);
const spaceLabel = `${space.title} (${spaceUid})`;
const handleAccept = async () => {
try {
await acceptInvitation.mutateAsync({ name: payload.spaceInvitation });
toast.success(t("setting.spaces.accept-success", { space: spaceLabel }));
} catch (error) {
handleError(error, toast.error, { context: "Accept space invitation" });
}
};
const handleDecline = async () => {
try {
await declineInvitation.mutateAsync({ name: payload.spaceInvitation });
toast.success(t("setting.spaces.decline-success"));
} catch (error) {
handleError(error, toast.error, { context: "Decline space invitation" });
}
};
const handleOpenSpace = () => {
selectSpace(space);
};
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",
)}
>
<UsersIcon 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-3">
<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">{t("inbox.space-invitation")}</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>
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
<SpaceMark size="lg" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium text-foreground/95">{space.title}</span>
<span className="text-xs text-muted-foreground/70">{spaceUid}</span>
<SpaceRoleBadge role={payload.role} />
</div>
<p className="line-clamp-1 text-xs text-muted-foreground/80">
{isPending ? space.description || t("setting.spaces.invited-to-join") : t("inbox.space-invitation-accepted")}
</p>
</div>
</div>
{isPending ? (
<div className="flex shrink-0 items-center gap-1.5">
<Button variant="outline" size="sm" disabled={isBusy} onClick={() => void handleDecline()}>
{t("setting.spaces.decline")}
</Button>
<Button size="sm" disabled={isBusy} onClick={() => void handleAccept()}>
{t("setting.spaces.accept")}
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={handleOpenSpace}>
{t("inbox.space-invitation-open")}
</Button>
)}
</div>
</div>
</div>
</div>
);
}
export default SpaceInvitationMessage;

View file

@ -17,6 +17,7 @@ import CreateSpaceDialog from "@/components/CreateSpaceDialog";
import InviteSpaceMemberDialog from "@/components/Settings/InviteSpaceMemberDialog";
import SettingSection from "@/components/Settings/SettingSection";
import SpaceMark from "@/components/SpaceMark";
import SpaceRoleBadge from "@/components/SpaceRoleBadge";
import UserAvatar from "@/components/UserAvatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -56,16 +57,6 @@ const getSpacesSettingsLocation = (spaceName?: string) => ({
hash: "#spaces",
});
const SpaceRoleBadge = ({ role }: { role: SpaceMember_Role }) => {
const t = useTranslate();
const isAdmin = role === SpaceMember_Role.ADMIN;
return (
<Badge variant={isAdmin ? "secondary" : "outline"} shape="pill" className="h-5 px-2 font-normal">
{isAdmin ? t("setting.spaces.space-admin") : t("setting.spaces.space-user")}
</Badge>
);
};
const MemberIdentity = ({ member, user, isCurrentUser }: { member: SpaceMember; user?: User; isCurrentUser?: boolean }) => {
const t = useTranslate();
const username = user?.username || extractUsernameFromName(member.user);

View file

@ -0,0 +1,15 @@
import { Badge } from "@/components/ui/badge";
import { SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb";
import { useTranslate } from "@/utils/i18n";
const SpaceRoleBadge = ({ role }: { role: SpaceMember_Role }) => {
const t = useTranslate();
const isAdmin = role === SpaceMember_Role.ADMIN;
return (
<Badge variant={isAdmin ? "secondary" : "outline"} shape="pill" className="h-5 px-2 font-normal">
{isAdmin ? t("setting.spaces.space-admin") : t("setting.spaces.space-user")}
</Badge>
);
};
export default SpaceRoleBadge;

View file

@ -472,6 +472,8 @@ function handleSSEEvent(event: SSEChangeEvent, queryClient: ReturnType<typeof us
function invalidateAllLiveQueries(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: spaceKeys.all, refetchType: "active" });
// Space invitations are delivered to the inbox and resolved with the same event.
queryClient.invalidateQueries({ queryKey: userKeys.notifications(), refetchType: "active" });
invalidateLiveMemoQueries(queryClient);
}

View file

@ -299,6 +299,8 @@ export function useAcceptSpaceInvitation(viewerName: string) {
// promote that summary into the member list; refresh the authoritative list.
void queryClient.invalidateQueries({ queryKey: spaceKeys.list(viewerName), exact: true });
invalidateMembershipSensitiveQueries(queryClient);
// The server resolves the matching inbox notification.
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
},
});
}
@ -321,6 +323,8 @@ export function useDeclineSpaceInvitation(viewerName: string) {
removeByName(invitations, name),
);
}
// The server removes the matching inbox notification.
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
},
});
}

View file

@ -1,6 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type QueryClient, queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { memoViewServiceClient, userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { buildUserSettingName, userNamePrefix } from "@/lib/resource-names";
@ -9,6 +9,8 @@ import {
type ListAllUserStatsRequest,
ListAllUserStatsRequestSchema,
User,
UserNotification,
UserNotification_Status,
UserSetting,
UserSetting_GeneralSetting,
UserSetting_Key,
@ -104,6 +106,46 @@ export function useNotifications() {
});
}
const updateCachedNotifications = (queryClient: QueryClient, update: (notifications: UserNotification[]) => UserNotification[]) => {
const notifications = queryClient.getQueryData<UserNotification[]>(userKeys.notifications());
if (notifications !== undefined) {
queryClient.setQueryData<UserNotification[]>(userKeys.notifications(), update(notifications));
}
};
export function useArchiveNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) =>
userServiceClient.updateUserNotification({
notification: { name, status: UserNotification_Status.ARCHIVED },
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
}),
onSuccess: (updated) => {
updateCachedNotifications(queryClient, (notifications) =>
notifications.map((notification) => (notification.name === updated.name ? updated : notification)),
);
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
},
});
}
export function useDeleteNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
await userServiceClient.deleteUserNotification({ name });
return name;
},
onSuccess: (name) => {
updateCachedNotifications(queryClient, (notifications) => notifications.filter((notification) => notification.name !== name));
void queryClient.invalidateQueries({ queryKey: userKeys.notifications() });
},
});
}
export function useTagCounts(forCurrentUser = false) {
const currentUser = useCurrentUser();

View file

@ -322,6 +322,9 @@
"memo-comment": "{{user}} has a comment on your {{memo}}.",
"no-archived": "No archived notifications",
"no-unread": "No unread notifications",
"space-invitation": "invited you to join a space",
"space-invitation-accepted": "You joined this space.",
"space-invitation-open": "Open space",
"unread": "Unread"
},
"live-update": {

View file

@ -3,6 +3,7 @@ import { sortBy } from "lodash-es";
import { BellIcon } from "lucide-react";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
import MemoMentionMessage from "@/components/Inbox/MemoMentionMessage";
import SpaceInvitationMessage from "@/components/Inbox/SpaceInvitationMessage";
import Placeholder from "@/components/Placeholder";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useNotifications } from "@/hooks/useUserQueries";
@ -62,6 +63,9 @@ const Inboxes = () => {
if (notification.type === UserNotification_Type.MEMO_MENTION) {
return <MemoMentionMessage key={notification.name} notification={notification} />;
}
if (notification.type === UserNotification_Type.SPACE_INVITATION) {
return <SpaceInvitationMessage key={notification.name} notification={notification} />;
}
return null;
})}
</div>

File diff suppressed because one or more lines are too long

View file

@ -38,6 +38,7 @@ vi.mock("@/hooks/useSpaceQueries", () => ({
vi.mock("@/hooks/useUserQueries", () => ({
userKeys: {
stats: () => ["users", "stats"],
notifications: () => ["users", "notifications"],
},
}));

View file

@ -0,0 +1,188 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpaceInvitationMessage from "@/components/Inbox/SpaceInvitationMessage";
import { type Space, SpaceMember_Role, SpaceSchema } from "@/types/proto/api/v1/space_service_pb";
import {
type UserNotification,
UserNotification_SpaceInvitationPayload_State,
UserNotification_Status,
UserNotification_Type,
UserNotificationSchema,
UserSchema,
} from "@/types/proto/api/v1/user_service_pb";
const state = vi.hoisted(() => ({
acceptInvitation: vi.fn(),
declineInvitation: vi.fn(),
selectSpace: vi.fn(),
archiveNotification: vi.fn(),
deleteNotification: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: { error: state.toastError, success: state.toastSuccess },
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/alice" }),
}));
vi.mock("@/hooks/useSpaceQueries", () => ({
useAcceptSpaceInvitation: () => ({ isPending: false, mutateAsync: state.acceptInvitation }),
useDeclineSpaceInvitation: () => ({ isPending: false, mutateAsync: state.declineInvitation }),
}));
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => ({ selectSpace: state.selectSpace }),
}));
vi.mock("@/hooks/useUserQueries", () => ({
useArchiveNotification: () => ({ isPending: false, mutateAsync: state.archiveNotification }),
useDeleteNotification: () => ({ isPending: false, mutateAsync: state.deleteNotification }),
}));
vi.mock("@/components/SpaceMark", () => ({
default: () => <span aria-hidden>space-mark</span>,
}));
vi.mock("@/components/UserAvatar", () => ({
default: () => <span aria-hidden data-testid="user-avatar" />,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string, params?: Record<string, string>) => (params ? `${key}:${Object.values(params).join(",")}` : key),
}));
const space: Space = create(SpaceSchema, { name: "spaces/design", title: "Design team", description: "Product design notes" });
const createNotification = (overrides: {
state: UserNotification_SpaceInvitationPayload_State;
status?: UserNotification_Status;
role?: SpaceMember_Role;
}): UserNotification =>
create(UserNotificationSchema, {
name: "users/alice/notifications/7",
sender: "users/bob",
senderUser: create(UserSchema, { name: "users/bob", username: "bob", displayName: "Bob" }),
status: overrides.status ?? UserNotification_Status.UNREAD,
type: UserNotification_Type.SPACE_INVITATION,
payload: {
case: "spaceInvitation",
value: {
spaceInvitation: "spaces/design/invitations/alice",
space,
role: overrides.role ?? SpaceMember_Role.USER,
state: overrides.state,
},
},
});
describe("SpaceInvitationMessage", () => {
beforeEach(() => {
vi.clearAllMocks();
state.acceptInvitation.mockResolvedValue({ name: "spaces/design/members/alice" });
state.declineInvitation.mockResolvedValue("spaces/design/invitations/alice");
});
it("offers accept and decline while the invitation is pending", async () => {
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("inbox.space-invitation")).toBeInTheDocument();
expect(screen.getByText("Design team")).toBeInTheDocument();
expect(screen.getByText("design")).toBeInTheDocument();
expect(screen.getByText("Product design notes")).toBeInTheDocument();
expect(screen.getByText("setting.spaces.space-user")).toBeInTheDocument();
expect(screen.queryByText("inbox.space-invitation-open")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.accept" }));
await waitFor(() => expect(state.acceptInvitation).toHaveBeenCalledWith({ name: "spaces/design/invitations/alice" }));
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("setting.spaces.accept-success:Design team (design)"));
});
it("declines the invitation from the inbox", async () => {
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.decline" }));
await waitFor(() => expect(state.declineInvitation).toHaveBeenCalledWith({ name: "spaces/design/invitations/alice" }));
expect(state.acceptInvitation).not.toHaveBeenCalled();
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("setting.spaces.decline-success"));
});
it("reports a failed accept without pretending it succeeded", async () => {
state.acceptInvitation.mockRejectedValueOnce(new Error("space invitation not found"));
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.accept" }));
await waitFor(() => expect(state.toastError).toHaveBeenCalled());
expect(state.toastSuccess).not.toHaveBeenCalled();
});
it("shows the accepted state with a shortcut into the space", () => {
render(
<SpaceInvitationMessage
notification={createNotification({
state: UserNotification_SpaceInvitationPayload_State.ACCEPTED,
status: UserNotification_Status.ARCHIVED,
role: SpaceMember_Role.ADMIN,
})}
/>,
);
expect(screen.getByText("inbox.space-invitation-accepted")).toBeInTheDocument();
expect(screen.getByText("setting.spaces.space-admin")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "setting.spaces.accept" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "setting.spaces.decline" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "inbox.space-invitation-open" }));
expect(state.selectSpace).toHaveBeenCalledWith(space);
});
it("archives through the shared notification mutation and reports failures", async () => {
state.archiveNotification.mockResolvedValueOnce(undefined);
const { unmount } = render(
<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />,
);
fireEvent.click(screen.getByRole("button", { name: "common.archive" }));
await waitFor(() => expect(state.archiveNotification).toHaveBeenCalledWith("users/alice/notifications/7"));
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("message.archived-successfully"));
unmount();
state.archiveNotification.mockRejectedValueOnce(new Error("offline"));
render(<SpaceInvitationMessage notification={createNotification({ state: UserNotification_SpaceInvitationPayload_State.PENDING })} />);
fireEvent.click(screen.getByRole("button", { name: "common.archive" }));
await waitFor(() => expect(state.toastError).toHaveBeenCalled());
});
it("deletes an archived notification through the shared mutation", async () => {
state.deleteNotification.mockResolvedValueOnce("users/alice/notifications/7");
render(
<SpaceInvitationMessage
notification={createNotification({
state: UserNotification_SpaceInvitationPayload_State.ACCEPTED,
status: UserNotification_Status.ARCHIVED,
})}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "common.delete" }));
await waitFor(() => expect(state.deleteNotification).toHaveBeenCalledWith("users/alice/notifications/7"));
await waitFor(() => expect(state.toastSuccess).toHaveBeenCalledWith("message.deleted-successfully"));
});
it("falls back to the error row when the payload is missing", () => {
const notification = create(UserNotificationSchema, {
name: "users/alice/notifications/8",
type: UserNotification_Type.SPACE_INVITATION,
status: UserNotification_Status.UNREAD,
});
render(<SpaceInvitationMessage notification={notification} />);
expect(screen.getByText("inbox.failed-to-load")).toBeInTheDocument();
expect(screen.queryByText("setting.spaces.accept")).not.toBeInTheDocument();
});
});