Commit bc1550e9 authored by Steven's avatar Steven

refactor(api): migrate inbox functionality to user notifications

- Remove standalone InboxService and move functionality to UserService
- Rename inbox to user notifications for better API consistency
- Add ListUserNotifications, UpdateUserNotification, DeleteUserNotification methods
- Update frontend components to use new notification endpoints
- Update store layer to support new notification model

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
parent e915e3a4
......@@ -61,8 +61,6 @@ message Activity {
TYPE_UNSPECIFIED = 0;
// Memo comment activity.
MEMO_COMMENT = 1;
// Version update activity.
VERSION_UPDATE = 2;
}
// Activity levels.
......
syntax = "proto3";
package memos.api.v1;
import "google/api/annotations.proto";
import "google/api/client.proto";
import "google/api/field_behavior.proto";
import "google/api/resource.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/timestamp.proto";
option go_package = "gen/api/v1";
service InboxService {
// ListInboxes lists inboxes for a user.
rpc ListInboxes(ListInboxesRequest) returns (ListInboxesResponse) {
option (google.api.http) = {get: "/api/v1/{parent=users/*}/inboxes"};
option (google.api.method_signature) = "parent";
}
// UpdateInbox updates an inbox.
rpc UpdateInbox(UpdateInboxRequest) returns (Inbox) {
option (google.api.http) = {
patch: "/api/v1/{inbox.name=inboxes/*}"
body: "inbox"
};
option (google.api.method_signature) = "inbox,update_mask";
}
// DeleteInbox deletes an inbox.
rpc DeleteInbox(DeleteInboxRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {delete: "/api/v1/{name=inboxes/*}"};
option (google.api.method_signature) = "name";
}
}
message Inbox {
option (google.api.resource) = {
type: "memos.api.v1/Inbox"
pattern: "inboxes/{inbox}"
name_field: "name"
singular: "inbox"
plural: "inboxes"
};
// The resource name of the inbox.
// Format: inboxes/{inbox}
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// The sender of the inbox notification.
// Format: users/{user}
string sender = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
// The receiver of the inbox notification.
// Format: users/{user}
string receiver = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
// The status of the inbox notification.
Status status = 4 [(google.api.field_behavior) = OPTIONAL];
// Output only. The creation timestamp.
google.protobuf.Timestamp create_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
// The type of the inbox notification.
Type type = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
// Optional. The activity ID associated with this inbox notification.
optional int32 activity_id = 7 [(google.api.field_behavior) = OPTIONAL];
// Status enumeration for inbox notifications.
enum Status {
// Unspecified status.
STATUS_UNSPECIFIED = 0;
// The notification is unread.
UNREAD = 1;
// The notification is archived.
ARCHIVED = 2;
}
// Type enumeration for inbox notifications.
enum Type {
// Unspecified type.
TYPE_UNSPECIFIED = 0;
// Memo comment notification.
MEMO_COMMENT = 1;
// Version update notification.
VERSION_UPDATE = 2;
}
}
message ListInboxesRequest {
// Required. The parent resource whose inboxes will be listed.
// Format: users/{user}
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/User"}
];
// Optional. The maximum number of inboxes to return.
// The service may return fewer than this value.
// If unspecified, at most 50 inboxes will be returned.
// The maximum value is 1000; values above 1000 will be coerced to 1000.
int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL];
// Optional. A page token, received from a previous `ListInboxes` call.
// Provide this to retrieve the subsequent page.
string page_token = 3 [(google.api.field_behavior) = OPTIONAL];
// Optional. Filter to apply to the list results.
// Example: "status=UNREAD" or "type=MEMO_COMMENT"
// Supported operators: =, !=
// Supported fields: status, type, sender, create_time
string filter = 4 [(google.api.field_behavior) = OPTIONAL];
// Optional. The order to sort results by.
// Example: "create_time desc" or "status asc"
string order_by = 5 [(google.api.field_behavior) = OPTIONAL];
}
message ListInboxesResponse {
// The list of inboxes.
repeated Inbox inboxes = 1;
// A token that can be sent as `page_token` to retrieve the next page.
// If this field is omitted, there are no subsequent pages.
string next_page_token = 2;
// The total count of inboxes (may be approximate).
int32 total_size = 3;
}
message UpdateInboxRequest {
// Required. The inbox to update.
Inbox inbox = 1 [(google.api.field_behavior) = REQUIRED];
// Required. The list of fields to update.
google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = REQUIRED];
// Optional. If set to true, allows updating missing fields.
bool allow_missing = 3 [(google.api.field_behavior) = OPTIONAL];
}
message DeleteInboxRequest {
// Required. The resource name of the inbox to delete.
// Format: inboxes/{inbox}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Inbox"}
];
}
......@@ -153,6 +153,27 @@ service UserService {
option (google.api.http) = {delete: "/api/v1/{name=users/*/webhooks/*}"};
option (google.api.method_signature) = "name";
}
// ListUserNotifications lists notifications for a user.
rpc ListUserNotifications(ListUserNotificationsRequest) returns (ListUserNotificationsResponse) {
option (google.api.http) = {get: "/api/v1/{parent=users/*}/notifications"};
option (google.api.method_signature) = "parent";
}
// UpdateUserNotification updates a notification.
rpc UpdateUserNotification(UpdateUserNotificationRequest) returns (UserNotification) {
option (google.api.http) = {
patch: "/api/v1/{notification.name=users/*/notifications/*}"
body: "notification"
};
option (google.api.method_signature) = "notification,update_mask";
}
// DeleteUserNotification deletes a notification.
rpc DeleteUserNotification(DeleteUserNotificationRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {delete: "/api/v1/{name=users/*/notifications/*}"};
option (google.api.method_signature) = "name";
}
}
message User {
......@@ -672,3 +693,81 @@ message DeleteUserWebhookRequest {
// Format: users/{user}/webhooks/{webhook}
string name = 1 [(google.api.field_behavior) = REQUIRED];
}
message UserNotification {
option (google.api.resource) = {
type: "memos.api.v1/UserNotification"
pattern: "users/{user}/notifications/{notification}"
name_field: "name"
singular: "notification"
plural: "notifications"
};
// The resource name of the notification.
// Format: users/{user}/notifications/{notification}
string name = 1 [
(google.api.field_behavior) = OUTPUT_ONLY,
(google.api.field_behavior) = IDENTIFIER
];
// The sender of the notification.
// Format: users/{user}
string sender = 2 [
(google.api.field_behavior) = OUTPUT_ONLY,
(google.api.resource_reference) = {type: "memos.api.v1/User"}
];
// The status of the notification.
Status status = 3 [(google.api.field_behavior) = OPTIONAL];
// The creation timestamp.
google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
// The type of the notification.
Type type = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
// The activity ID associated with this notification.
optional int32 activity_id = 6 [(google.api.field_behavior) = OPTIONAL];
enum Status {
STATUS_UNSPECIFIED = 0;
UNREAD = 1;
ARCHIVED = 2;
}
enum Type {
TYPE_UNSPECIFIED = 0;
MEMO_COMMENT = 1;
}
}
message ListUserNotificationsRequest {
// The parent user resource.
// Format: users/{user}
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/User"}
];
int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL];
string page_token = 3 [(google.api.field_behavior) = OPTIONAL];
string filter = 4 [(google.api.field_behavior) = OPTIONAL];
}
message ListUserNotificationsResponse {
repeated UserNotification notifications = 1;
string next_page_token = 2;
}
message UpdateUserNotificationRequest {
UserNotification notification = 1 [(google.api.field_behavior) = REQUIRED];
google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = REQUIRED];
}
message DeleteUserNotificationRequest {
// Format: users/{user}/notifications/{notification}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/UserNotification"}
];
}
......@@ -31,8 +31,6 @@ const (
Activity_TYPE_UNSPECIFIED Activity_Type = 0
// Memo comment activity.
Activity_MEMO_COMMENT Activity_Type = 1
// Version update activity.
Activity_VERSION_UPDATE Activity_Type = 2
)
// Enum value maps for Activity_Type.
......@@ -40,12 +38,10 @@ var (
Activity_Type_name = map[int32]string{
0: "TYPE_UNSPECIFIED",
1: "MEMO_COMMENT",
2: "VERSION_UPDATE",
}
Activity_Type_value = map[string]int32{
"TYPE_UNSPECIFIED": 0,
"MEMO_COMMENT": 1,
"VERSION_UPDATE": 2,
}
)
......@@ -513,7 +509,7 @@ var File_api_v1_activity_service_proto protoreflect.FileDescriptor
const file_api_v1_activity_service_proto_rawDesc = "" +
"\n" +
"\x1dapi/v1/activity_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x86\x04\n" +
"\x1dapi/v1/activity_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf2\x03\n" +
"\bActivity\x12\x1a\n" +
"\x04name\x18\x01 \x01(\tB\x06\xe0A\x03\xe0A\bR\x04name\x12\x1d\n" +
"\acreator\x18\x02 \x01(\tB\x03\xe0A\x03R\acreator\x124\n" +
......@@ -521,11 +517,10 @@ const file_api_v1_activity_service_proto_rawDesc = "" +
"\x05level\x18\x04 \x01(\x0e2\x1c.memos.api.v1.Activity.LevelB\x03\xe0A\x03R\x05level\x12@\n" +
"\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
"createTime\x12<\n" +
"\apayload\x18\x06 \x01(\v2\x1d.memos.api.v1.ActivityPayloadB\x03\xe0A\x03R\apayload\"B\n" +
"\apayload\x18\x06 \x01(\v2\x1d.memos.api.v1.ActivityPayloadB\x03\xe0A\x03R\apayload\".\n" +
"\x04Type\x12\x14\n" +
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
"\fMEMO_COMMENT\x10\x01\x12\x12\n" +
"\x0eVERSION_UPDATE\x10\x02\"=\n" +
"\fMEMO_COMMENT\x10\x01\"=\n" +
"\x05Level\x12\x15\n" +
"\x11LEVEL_UNSPECIFIED\x10\x00\x12\b\n" +
"\x04INFO\x10\x01\x12\b\n" +
......
......@@ -86,8 +86,6 @@ const (
Inbox_TYPE_UNSPECIFIED Inbox_Type = 0
// Memo comment notification.
Inbox_MEMO_COMMENT Inbox_Type = 1
// Version update notification.
Inbox_VERSION_UPDATE Inbox_Type = 2
)
// Enum value maps for Inbox_Type.
......@@ -95,12 +93,10 @@ var (
Inbox_Type_name = map[int32]string{
0: "TYPE_UNSPECIFIED",
1: "MEMO_COMMENT",
2: "VERSION_UPDATE",
}
Inbox_Type_value = map[string]int32{
"TYPE_UNSPECIFIED": 0,
"MEMO_COMMENT": 1,
"VERSION_UPDATE": 2,
}
)
......@@ -500,7 +496,7 @@ var File_api_v1_inbox_service_proto protoreflect.FileDescriptor
const file_api_v1_inbox_service_proto_rawDesc = "" +
"\n" +
"\x1aapi/v1/inbox_service.proto\x12\fmemos.api.v1\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\"\x87\x04\n" +
"\x1aapi/v1/inbox_service.proto\x12\fmemos.api.v1\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\"\xf3\x03\n" +
"\x05Inbox\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x1b\n" +
"\x06sender\x18\x02 \x01(\tB\x03\xe0A\x03R\x06sender\x12\x1f\n" +
......@@ -515,11 +511,10 @@ const file_api_v1_inbox_service_proto_rawDesc = "" +
"\x12STATUS_UNSPECIFIED\x10\x00\x12\n" +
"\n" +
"\x06UNREAD\x10\x01\x12\f\n" +
"\bARCHIVED\x10\x02\"B\n" +
"\bARCHIVED\x10\x02\".\n" +
"\x04Type\x12\x14\n" +
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
"\fMEMO_COMMENT\x10\x01\x12\x12\n" +
"\x0eVERSION_UPDATE\x10\x02:>\xeaA;\n" +
"\fMEMO_COMMENT\x10\x01:>\xeaA;\n" +
"\x12memos.api.v1/Inbox\x12\x0finboxes/{inbox}\x1a\x04name*\ainboxes2\x05inboxB\x0e\n" +
"\f_activity_id\"\xca\x01\n" +
"\x12ListInboxesRequest\x121\n" +
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -25,8 +25,8 @@ type InboxMessage_Type int32
const (
InboxMessage_TYPE_UNSPECIFIED InboxMessage_Type = 0
InboxMessage_MEMO_COMMENT InboxMessage_Type = 1
InboxMessage_VERSION_UPDATE InboxMessage_Type = 2
// Memo comment notification.
InboxMessage_MEMO_COMMENT InboxMessage_Type = 1
)
// Enum value maps for InboxMessage_Type.
......@@ -34,12 +34,10 @@ var (
InboxMessage_Type_name = map[int32]string{
0: "TYPE_UNSPECIFIED",
1: "MEMO_COMMENT",
2: "VERSION_UPDATE",
}
InboxMessage_Type_value = map[string]int32{
"TYPE_UNSPECIFIED": 0,
"MEMO_COMMENT": 1,
"VERSION_UPDATE": 2,
}
)
......@@ -71,9 +69,11 @@ func (InboxMessage_Type) EnumDescriptor() ([]byte, []int) {
}
type InboxMessage struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type InboxMessage_Type `protobuf:"varint,1,opt,name=type,proto3,enum=memos.store.InboxMessage_Type" json:"type,omitempty"`
ActivityId *int32 `protobuf:"varint,2,opt,name=activity_id,json=activityId,proto3,oneof" json:"activity_id,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
// The type of the inbox message.
Type InboxMessage_Type `protobuf:"varint,1,opt,name=type,proto3,enum=memos.store.InboxMessage_Type" json:"type,omitempty"`
// The system-generated unique ID of related activity.
ActivityId *int32 `protobuf:"varint,2,opt,name=activity_id,json=activityId,proto3,oneof" json:"activity_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
......@@ -126,15 +126,14 @@ var File_store_inbox_proto protoreflect.FileDescriptor
const file_store_inbox_proto_rawDesc = "" +
"\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\xbc\x01\n" +
"\x11store/inbox.proto\x12\vmemos.store\"\xa8\x01\n" +
"\fInboxMessage\x122\n" +
"\x04type\x18\x01 \x01(\x0e2\x1e.memos.store.InboxMessage.TypeR\x04type\x12$\n" +
"\vactivity_id\x18\x02 \x01(\x05H\x00R\n" +
"activityId\x88\x01\x01\"B\n" +
"activityId\x88\x01\x01\".\n" +
"\x04Type\x12\x14\n" +
"\x10TYPE_UNSPECIFIED\x10\x00\x12\x10\n" +
"\fMEMO_COMMENT\x10\x01\x12\x12\n" +
"\x0eVERSION_UPDATE\x10\x02B\x0e\n" +
"\fMEMO_COMMENT\x10\x01B\x0e\n" +
"\f_activity_idB\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"
......
......@@ -5,11 +5,14 @@ package memos.store;
option go_package = "gen/store";
message InboxMessage {
// The type of the inbox message.
Type type = 1;
// The system-generated unique ID of related activity.
optional int32 activity_id = 2;
enum Type {
TYPE_UNSPECIFIED = 0;
// Memo comment notification.
MEMO_COMMENT = 1;
VERSION_UPDATE = 2;
}
Type type = 1;
optional int32 activity_id = 2;
}
......@@ -64,6 +64,9 @@ func (s *APIV1Service) GetActivity(ctx context.Context, request *v1pb.GetActivit
return activityMessage, nil
}
// convertActivityFromStore converts a storage-layer activity to an API activity.
// This handles the mapping between internal activity representation and the public API,
// including proper type and level conversions.
func (s *APIV1Service) convertActivityFromStore(ctx context.Context, activity *store.Activity) (*v1pb.Activity, error) {
payload, err := s.convertActivityPayloadFromStore(ctx, activity.Payload)
if err != nil {
......@@ -98,9 +101,12 @@ func (s *APIV1Service) convertActivityFromStore(ctx context.Context, activity *s
}, nil
}
// convertActivityPayloadFromStore converts a storage-layer activity payload to an API payload.
// This resolves references (e.g., memo IDs) to resource names for the API.
func (s *APIV1Service) convertActivityPayloadFromStore(ctx context.Context, payload *storepb.ActivityPayload) (*v1pb.ActivityPayload, error) {
v2Payload := &v1pb.ActivityPayload{}
if payload.MemoComment != nil {
// Fetch the comment memo
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &payload.MemoComment.MemoId,
ExcludeContent: true,
......@@ -111,6 +117,8 @@ func (s *APIV1Service) convertActivityPayloadFromStore(ctx context.Context, payl
if memo == nil {
return nil, status.Errorf(codes.NotFound, "memo does not exist")
}
// Fetch the related memo (the one being commented on)
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: &payload.MemoComment.RelatedMemoId,
ExcludeContent: true,
......@@ -118,6 +126,7 @@ func (s *APIV1Service) convertActivityPayloadFromStore(ctx context.Context, payl
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get related memo: %v", err)
}
v2Payload.Payload = &v1pb.ActivityPayload_MemoComment{
MemoComment: &v1pb.ActivityMemoCommentPayload{
Memo: fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID),
......
package v1
import (
"context"
"fmt"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) ListInboxes(ctx context.Context, request *v1pb.ListInboxesRequest) (*v1pb.ListInboxesResponse, error) {
// Extract user ID from parent resource name
userID, err := ExtractUserIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent name %q: %v", request.Parent, err)
}
// Get current user for authorization
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Check if current user can access the requested user's inboxes
if currentUser.ID != userID {
// Only allow hosts and admins to access other users' inboxes
if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "cannot access inboxes for user %q", request.Parent)
}
}
var limit, offset int
if request.PageToken != "" {
var pageToken v1pb.PageToken
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
}
limit = int(pageToken.Limit)
offset = int(pageToken.Offset)
} else {
limit = int(request.PageSize)
}
if limit <= 0 {
limit = DefaultPageSize
}
if limit > MaxPageSize {
limit = MaxPageSize
}
limitPlusOne := limit + 1
findInbox := &store.FindInbox{
ReceiverID: &userID,
Limit: &limitPlusOne,
Offset: &offset,
}
inboxes, err := s.Store.ListInboxes(ctx, findInbox)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
}
inboxMessages := []*v1pb.Inbox{}
nextPageToken := ""
if len(inboxes) == limitPlusOne {
inboxes = inboxes[:limit]
nextPageToken, err = getPageToken(limit, offset+limit)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get next page token: %v", err)
}
}
for _, inbox := range inboxes {
inboxMessage := convertInboxFromStore(inbox)
if inboxMessage.Type == v1pb.Inbox_TYPE_UNSPECIFIED {
continue
}
inboxMessages = append(inboxMessages, inboxMessage)
}
response := &v1pb.ListInboxesResponse{
Inboxes: inboxMessages,
NextPageToken: nextPageToken,
TotalSize: int32(len(inboxMessages)), // For now, use actual returned count
}
return response, nil
}
func (s *APIV1Service) UpdateInbox(ctx context.Context, request *v1pb.UpdateInboxRequest) (*v1pb.Inbox, error) {
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
}
inboxID, err := ExtractInboxIDFromName(request.Inbox.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name %q: %v", request.Inbox.Name, err)
}
// Get current user for authorization
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Get the existing inbox to verify ownership
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &inboxID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "inbox %q not found", request.Inbox.Name)
}
existingInbox := inboxes[0]
// Check if current user can update this inbox (must be the receiver)
if currentUser.ID != existingInbox.ReceiverID {
return nil, status.Errorf(codes.PermissionDenied, "cannot update inbox for another user")
}
update := &store.UpdateInbox{
ID: inboxID,
}
for _, field := range request.UpdateMask.Paths {
if field == "status" {
if request.Inbox.Status == v1pb.Inbox_STATUS_UNSPECIFIED {
return nil, status.Errorf(codes.InvalidArgument, "status cannot be unspecified")
}
update.Status = convertInboxStatusToStore(request.Inbox.Status)
} else {
return nil, status.Errorf(codes.InvalidArgument, "unsupported field in update mask: %q", field)
}
}
inbox, err := s.Store.UpdateInbox(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
}
return convertInboxFromStore(inbox), nil
}
func (s *APIV1Service) DeleteInbox(ctx context.Context, request *v1pb.DeleteInboxRequest) (*emptypb.Empty, error) {
inboxID, err := ExtractInboxIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name %q: %v", request.Name, err)
}
// Get current user for authorization
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Get the existing inbox to verify ownership
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &inboxID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "inbox %q not found", request.Name)
}
existingInbox := inboxes[0]
// Check if current user can delete this inbox (must be the receiver)
if currentUser.ID != existingInbox.ReceiverID {
return nil, status.Errorf(codes.PermissionDenied, "cannot delete inbox for another user")
}
if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{
ID: inboxID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete inbox: %v", err)
}
return &emptypb.Empty{}, nil
}
func convertInboxFromStore(inbox *store.Inbox) *v1pb.Inbox {
return &v1pb.Inbox{
Name: fmt.Sprintf("%s%d", InboxNamePrefix, inbox.ID),
Sender: fmt.Sprintf("%s%d", UserNamePrefix, inbox.SenderID),
Receiver: fmt.Sprintf("%s%d", UserNamePrefix, inbox.ReceiverID),
Status: convertInboxStatusFromStore(inbox.Status),
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
Type: v1pb.Inbox_Type(inbox.Message.Type),
ActivityId: inbox.Message.ActivityId,
}
}
func convertInboxStatusFromStore(status store.InboxStatus) v1pb.Inbox_Status {
switch status {
case store.UNREAD:
return v1pb.Inbox_UNREAD
case store.ARCHIVED:
return v1pb.Inbox_ARCHIVED
default:
return v1pb.Inbox_STATUS_UNSPECIFIED
}
}
func convertInboxStatusToStore(status v1pb.Inbox_Status) store.InboxStatus {
switch status {
case v1pb.Inbox_ARCHIVED:
return store.ARCHIVED
default:
return store.UNREAD
}
}
This diff is collapsed.
......@@ -1552,3 +1552,201 @@ func extractUsernameFromComparison(left, right ast.Expr) (string, bool) {
return str, true
}
// ListUserNotifications lists all notifications for a user.
// Notifications are backed by the inbox storage layer and represent activities
// that require user attention (e.g., memo comments).
func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.ListUserNotificationsRequest) (*v1pb.ListUserNotificationsResponse, error) {
userID, err := ExtractUserIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
// Verify the requesting user has permission to view these notifications
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Fetch inbox items from storage
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ReceiverID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
}
// Convert storage layer inboxes to API notifications
notifications := []*v1pb.UserNotification{}
for _, inbox := range inboxes {
notification, err := s.convertInboxToUserNotification(ctx, inbox)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
notifications = append(notifications, notification)
}
return &v1pb.ListUserNotificationsResponse{
Notifications: notifications,
}, nil
}
// UpdateUserNotification updates a notification's status (e.g., marking as read/archived).
// Only the notification owner can update their notifications.
func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb.UpdateUserNotificationRequest) (*v1pb.UserNotification, error) {
if request.Notification == nil {
return nil, status.Errorf(codes.InvalidArgument, "notification is required")
}
notificationID, err := ExtractNotificationIDFromName(request.Notification.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
}
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
// Verify ownership before updating
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "notification not found")
}
inbox := inboxes[0]
if inbox.ReceiverID != currentUser.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Build update request based on field mask
update := &store.UpdateInbox{
ID: notificationID,
}
for _, path := range request.UpdateMask.Paths {
switch path {
case "status":
// Convert API status enum to storage enum
var inboxStatus store.InboxStatus
switch request.Notification.Status {
case v1pb.UserNotification_UNREAD:
inboxStatus = store.UNREAD
case v1pb.UserNotification_ARCHIVED:
inboxStatus = store.ARCHIVED
default:
return nil, status.Errorf(codes.InvalidArgument, "invalid status")
}
update.Status = inboxStatus
}
}
updatedInbox, err := s.Store.UpdateInbox(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
}
notification, err := s.convertInboxToUserNotification(ctx, updatedInbox)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
return notification, nil
}
// DeleteUserNotification permanently deletes a notification.
// Only the notification owner can delete their notifications.
func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb.DeleteUserNotificationRequest) (*emptypb.Empty, error) {
notificationID, err := ExtractNotificationIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
}
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
// Verify ownership before deletion
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "notification not found")
}
inbox := inboxes[0]
if inbox.ReceiverID != currentUser.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{
ID: notificationID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete inbox: %v", err)
}
return &emptypb.Empty{}, nil
}
// convertInboxToUserNotification converts a storage-layer inbox to an API notification.
// This handles the mapping between the internal inbox representation and the public API.
func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox *store.Inbox) (*v1pb.UserNotification, error) {
notification := &v1pb.UserNotification{
Name: fmt.Sprintf("users/%d/notifications/%d", inbox.ReceiverID, inbox.ID),
Sender: fmt.Sprintf("%s%d", UserNamePrefix, inbox.SenderID),
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
}
// Convert status from storage enum to API enum
switch inbox.Status {
case store.UNREAD:
notification.Status = v1pb.UserNotification_UNREAD
case store.ARCHIVED:
notification.Status = v1pb.UserNotification_ARCHIVED
default:
notification.Status = v1pb.UserNotification_STATUS_UNSPECIFIED
}
// Extract notification type and activity ID from inbox message
if inbox.Message != nil {
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
notification.Type = v1pb.UserNotification_MEMO_COMMENT
default:
notification.Type = v1pb.UserNotification_TYPE_UNSPECIFIED
}
if inbox.Message.ActivityId != nil {
notification.ActivityId = inbox.Message.ActivityId
}
}
return notification, nil
}
// ExtractNotificationIDFromName extracts the notification ID from a resource name.
// Expected format: users/{user_id}/notifications/{notification_id}
func ExtractNotificationIDFromName(name string) (int32, error) {
pattern := regexp.MustCompile(`^users/(\d+)/notifications/(\d+)$`)
matches := pattern.FindStringSubmatch(name)
if len(matches) != 3 {
return 0, errors.Errorf("invalid notification name: %s", name)
}
id, err := strconv.Atoi(matches[2])
if err != nil {
return 0, errors.Errorf("invalid notification id: %s", matches[2])
}
return int32(id), nil
}
......@@ -29,7 +29,6 @@ type APIV1Service struct {
v1pb.UnimplementedMemoServiceServer
v1pb.UnimplementedAttachmentServiceServer
v1pb.UnimplementedShortcutServiceServer
v1pb.UnimplementedInboxServiceServer
v1pb.UnimplementedActivityServiceServer
v1pb.UnimplementedIdentityProviderServiceServer
......@@ -60,7 +59,6 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
v1pb.RegisterMemoServiceServer(grpcServer, apiv1Service)
v1pb.RegisterAttachmentServiceServer(grpcServer, apiv1Service)
v1pb.RegisterShortcutServiceServer(grpcServer, apiv1Service)
v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service)
v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service)
v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service)
reflection.Register(grpcServer)
......@@ -107,9 +105,6 @@ func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Ech
if err := v1pb.RegisterShortcutServiceHandler(ctx, gwMux, conn); err != nil {
return err
}
if err := v1pb.RegisterInboxServiceHandler(ctx, gwMux, conn); err != nil {
return err
}
if err := v1pb.RegisterActivityServiceHandler(ctx, gwMux, conn); err != nil {
return err
}
......
......@@ -6,11 +6,13 @@ import (
storepb "github.com/usememos/memos/proto/gen/store"
)
// InboxStatus is the status for an inbox.
// InboxStatus represents the status of an inbox notification.
type InboxStatus string
const (
UNREAD InboxStatus = "UNREAD"
// UNREAD indicates the notification has not been read by the user.
UNREAD InboxStatus = "UNREAD"
// ARCHIVED indicates the notification has been archived/dismissed by the user.
ARCHIVED InboxStatus = "ARCHIVED"
)
......@@ -18,20 +20,24 @@ func (s InboxStatus) String() string {
return string(s)
}
// Inbox represents a notification in a user's inbox.
// It connects activities to users who should be notified.
type Inbox struct {
ID int32
CreatedTs int64
SenderID int32
ReceiverID int32
Status InboxStatus
Message *storepb.InboxMessage
SenderID int32 // The user who triggered the notification
ReceiverID int32 // The user who receives the notification
Status InboxStatus // Current status (unread/archived)
Message *storepb.InboxMessage // The notification message content
}
// UpdateInbox contains fields that can be updated for an inbox item.
type UpdateInbox struct {
ID int32
Status InboxStatus
}
// FindInbox specifies filter criteria for querying inbox items.
type FindInbox struct {
ID *int32
SenderID *int32
......@@ -43,22 +49,27 @@ type FindInbox struct {
Offset *int
}
// DeleteInbox specifies which inbox item to delete.
type DeleteInbox struct {
ID int32
}
// CreateInbox creates a new inbox notification.
func (s *Store) CreateInbox(ctx context.Context, create *Inbox) (*Inbox, error) {
return s.driver.CreateInbox(ctx, create)
}
// ListInboxes retrieves inbox items matching the filter criteria.
func (s *Store) ListInboxes(ctx context.Context, find *FindInbox) ([]*Inbox, error) {
return s.driver.ListInboxes(ctx, find)
}
// UpdateInbox updates an existing inbox item.
func (s *Store) UpdateInbox(ctx context.Context, update *UpdateInbox) (*Inbox, error) {
return s.driver.UpdateInbox(ctx, update)
}
// DeleteInbox permanently removes an inbox item.
func (s *Store) DeleteInbox(ctx context.Context, delete *DeleteInbox) error {
return s.driver.DeleteInbox(ctx, delete)
}
import { EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { NavLink } from "react-router-dom";
......@@ -33,7 +33,7 @@ const Navigation = observer((props: Props) => {
return;
}
userStore.fetchInboxes();
userStore.fetchNotifications();
}, []);
const homeNavLink: NavLinkItem = {
......@@ -54,6 +54,22 @@ const Navigation = observer((props: Props) => {
title: t("common.attachments"),
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />,
};
const unreadCount = userStore.state.notifications.filter((n) => n.status === "UNREAD").length;
const inboxNavLink: NavLinkItem = {
id: "header-inbox",
path: Routes.INBOX,
title: t("common.inbox"),
icon: (
<div className="relative">
<BellIcon className="w-6 h-auto shrink-0" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-primary text-primary-foreground text-[10px] font-semibold rounded-full border-2 border-background">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
),
};
const signInNavLink: NavLinkItem = {
id: "header-auth",
path: Routes.AUTH,
......@@ -61,7 +77,9 @@ const Navigation = observer((props: Props) => {
icon: <UserCircleIcon className="w-6 h-auto shrink-0" />,
};
const navLinks: NavLinkItem[] = currentUser ? [homeNavLink, exploreNavLink, attachmentsNavLink] : [exploreNavLink, signInNavLink];
const navLinks: NavLinkItem[] = currentUser
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
: [exploreNavLink, signInNavLink];
return (
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar", className)}>
......
import {
ArchiveIcon,
LogOutIcon,
User2Icon,
SquareUserIcon,
SettingsIcon,
BellIcon,
GlobeIcon,
PaletteIcon,
CheckIcon,
} from "lucide-react";
import { ArchiveIcon, LogOutIcon, User2Icon, SquareUserIcon, SettingsIcon, GlobeIcon, PaletteIcon, CheckIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { authServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
......@@ -99,10 +89,6 @@ const UserMenu = observer((props: Props) => {
<ArchiveIcon className="size-4 text-muted-foreground" />
{t("common.archived")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigateTo(Routes.INBOX)}>
<BellIcon className="size-4 text-muted-foreground" />
{t("common.inbox")}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<GlobeIcon className="size-4 text-muted-foreground" />
......
......@@ -3,7 +3,6 @@ import { ActivityServiceDefinition } from "./types/proto/api/v1/activity_service
import { AttachmentServiceDefinition } from "./types/proto/api/v1/attachment_service";
import { AuthServiceDefinition } from "./types/proto/api/v1/auth_service";
import { IdentityProviderServiceDefinition } from "./types/proto/api/v1/idp_service";
import { InboxServiceDefinition } from "./types/proto/api/v1/inbox_service";
import { MemoServiceDefinition } from "./types/proto/api/v1/memo_service";
import { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_service";
import { UserServiceDefinition } from "./types/proto/api/v1/user_service";
......@@ -30,8 +29,6 @@ export const attachmentServiceClient = clientFactory.create(AttachmentServiceDef
export const shortcutServiceClient = clientFactory.create(ShortcutServiceDefinition, channel);
export const inboxServiceClient = clientFactory.create(InboxServiceDefinition, channel);
export const activityServiceClient = clientFactory.create(ActivityServiceDefinition, channel);
export const identityProviderServiceClient = clientFactory.create(IdentityProviderServiceDefinition, channel);
......@@ -18,6 +18,7 @@
"about": "About",
"add": "Add",
"admin": "Admin",
"all": "All",
"archive": "Archive",
"archived": "Archived",
"attachments": "Attachments",
......@@ -125,8 +126,10 @@
},
"inbox": {
"memo-comment": "{{user}} has a comment on your {{memo}}.",
"version-update": "New version {{version}} is available now!",
"failed-to-load": "Failed to load inbox item"
"failed-to-load": "Failed to load inbox item",
"unread": "Unread",
"no-unread": "No unread notifications",
"no-archived": "No archived notifications"
},
"markdown": {
"checkbox": "Checkbox",
......
import { sortBy } from "lodash-es";
import { BellIcon } from "lucide-react";
import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import Empty from "@/components/Empty";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
import MobileHeader from "@/components/MobileHeader";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import { Inbox, Inbox_Status, Inbox_Type } from "@/types/proto/api/v1/inbox_service";
import { UserNotification, UserNotification_Status, UserNotification_Type } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
const Inboxes = observer(() => {
const t = useTranslate();
const { md } = useResponsiveWidth();
const [filter, setFilter] = useState<"all" | "unread" | "archived">("all");
const inboxes = sortBy(userStore.state.inboxes, (inbox: Inbox) => {
if (inbox.status === Inbox_Status.UNREAD) return 0;
if (inbox.status === Inbox_Status.ARCHIVED) return 1;
return 2;
const allNotifications = sortBy(userStore.state.notifications, (notification: UserNotification) => {
return -(notification.createTime?.getTime() || 0);
});
const fetchInboxes = async () => {
const notifications = allNotifications.filter((notification) => {
if (filter === "unread") return notification.status === UserNotification_Status.UNREAD;
if (filter === "archived") return notification.status === UserNotification_Status.ARCHIVED;
return true;
});
const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const archivedCount = allNotifications.filter((n) => n.status === UserNotification_Status.ARCHIVED).length;
const fetchNotifications = async () => {
try {
await userStore.fetchInboxes();
await userStore.fetchNotifications();
} catch (error) {
console.error("Failed to fetch inboxes:", error);
console.error("Failed to fetch notifications:", error);
}
};
useEffect(() => {
fetchInboxes();
fetchNotifications();
}, []);
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
{!md && <MobileHeader />}
<div className="w-full px-4 sm:px-6">
<div className="w-full border border-border flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-background text-foreground">
<div className="relative w-full flex flex-row justify-between items-center">
<p className="py-1 flex flex-row justify-start items-center select-none opacity-80">
<BellIcon className="w-6 h-auto mr-1 opacity-80" />
<span className="text-lg">{t("common.inbox")}</span>
</p>
<div className="w-full border border-border flex flex-col justify-start items-start rounded-xl bg-background text-foreground overflow-hidden">
{/* Header */}
<div className="w-full px-4 py-4 border-b border-border">
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row items-center gap-2">
<BellIcon className="w-5 h-auto text-muted-foreground" />
<h1 className="text-xl font-semibold">{t("common.inbox")}</h1>
{unreadCount > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium rounded-full bg-primary text-primary-foreground">
{unreadCount}
</span>
)}
</div>
</div>
</div>
<div className="w-full h-auto flex flex-col justify-start items-start px-2 pb-4">
{inboxes.length === 0 && (
<div className="w-full mt-4 mb-8 flex flex-col justify-center items-center italic">
{/* Filter Tabs */}
<div className="w-full px-4 py-2 border-b border-border bg-muted/30">
<div className="flex flex-row gap-1">
<button
onClick={() => setFilter("all")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
filter === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
{t("common.all")} ({allNotifications.length})
</button>
<button
onClick={() => setFilter("unread")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5",
filter === "unread"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
<InboxIcon className="w-3.5 h-auto" />
{t("inbox.unread")} ({unreadCount})
</button>
<button
onClick={() => setFilter("archived")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5",
filter === "archived"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
<ArchiveIcon className="w-3.5 h-auto" />
{t("common.archived")} ({archivedCount})
</button>
</div>
</div>
{/* Notifications List */}
<div className="w-full">
{notifications.length === 0 ? (
<div className="w-full py-16 flex flex-col justify-center items-center">
<Empty />
<p className="mt-4 text-muted-foreground">{t("message.no-data")}</p>
<p className="mt-4 text-sm text-muted-foreground">
{filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")}
</p>
</div>
) : (
<div className="flex flex-col">
{notifications.map((notification: UserNotification) => {
if (notification.type === UserNotification_Type.MEMO_COMMENT) {
return <MemoCommentMessage key={notification.name} notification={notification} />;
}
return null;
})}
</div>
)}
<div className="flex flex-col justify-start items-start w-full mt-4 gap-4">
{inboxes.map((inbox: Inbox) => {
if (inbox.type === Inbox_Type.MEMO_COMMENT) {
return <MemoCommentMessage key={`${inbox.name}-${inbox.status}`} inbox={inbox} />;
}
return undefined;
})}
</div>
</div>
</div>
</div>
......
import { uniqueId } from "lodash-es";
import { makeAutoObservable, computed } from "mobx";
import { authServiceClient, inboxServiceClient, userServiceClient, shortcutServiceClient } from "@/grpcweb";
import { Inbox } from "@/types/proto/api/v1/inbox_service";
import { authServiceClient, userServiceClient, shortcutServiceClient } from "@/grpcweb";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import {
User,
UserNotification,
UserSetting,
UserSetting_Key,
UserSetting_GeneralSetting,
......@@ -24,7 +24,7 @@ class LocalState {
userAccessTokensSetting?: UserSetting_AccessTokensSetting;
userWebhooksSetting?: UserSetting_WebhooksSetting;
shortcuts: Shortcut[] = [];
inboxes: Inbox[] = [];
notifications: UserNotification[] = [];
userMapByName: Record<string, User> = {};
userStatsByName: Record<string, UserStats> = {};
......@@ -218,40 +218,40 @@ const userStore = (() => {
// Note: fetchShortcuts is now handled by fetchUserSettings
// The shortcuts are extracted from the user shortcuts setting
const fetchInboxes = async () => {
const fetchNotifications = async () => {
if (!state.currentUser) {
throw new Error("No current user available");
}
const { inboxes } = await inboxServiceClient.listInboxes({
const { notifications } = await userServiceClient.listUserNotifications({
parent: state.currentUser,
});
state.setPartial({
inboxes,
notifications,
});
};
const updateInbox = async (inbox: Partial<Inbox>, updateMask: string[]) => {
const updatedInbox = await inboxServiceClient.updateInbox({
inbox,
const updateNotification = async (notification: Partial<UserNotification>, updateMask: string[]) => {
const updatedNotification = await userServiceClient.updateUserNotification({
notification,
updateMask,
});
state.setPartial({
inboxes: state.inboxes.map((i) => {
if (i.name === updatedInbox.name) {
return updatedInbox;
notifications: state.notifications.map((n) => {
if (n.name === updatedNotification.name) {
return updatedNotification;
}
return i;
return n;
}),
});
return updatedInbox;
return updatedNotification;
};
const deleteInbox = async (name: string) => {
await inboxServiceClient.deleteInbox({ name });
const deleteNotification = async (name: string) => {
await userServiceClient.deleteUserNotification({ name });
state.setPartial({
inboxes: state.inboxes.filter((i) => i.name !== name),
notifications: state.notifications.filter((n) => n.name !== name),
});
};
......@@ -296,9 +296,9 @@ const userStore = (() => {
updateUserGeneralSetting,
getUserGeneralSetting,
fetchUserSettings,
fetchInboxes,
updateInbox,
deleteInbox,
fetchNotifications,
updateNotification,
deleteNotification,
fetchUserStats,
setStatsStateId,
};
......
......@@ -39,8 +39,6 @@ export enum Activity_Type {
TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED",
/** MEMO_COMMENT - Memo comment activity. */
MEMO_COMMENT = "MEMO_COMMENT",
/** VERSION_UPDATE - Version update activity. */
VERSION_UPDATE = "VERSION_UPDATE",
UNRECOGNIZED = "UNRECOGNIZED",
}
......@@ -52,9 +50,6 @@ export function activity_TypeFromJSON(object: any): Activity_Type {
case 1:
case "MEMO_COMMENT":
return Activity_Type.MEMO_COMMENT;
case 2:
case "VERSION_UPDATE":
return Activity_Type.VERSION_UPDATE;
case -1:
case "UNRECOGNIZED":
default:
......@@ -68,8 +63,6 @@ export function activity_TypeToNumber(object: Activity_Type): number {
return 0;
case Activity_Type.MEMO_COMMENT:
return 1;
case Activity_Type.VERSION_UPDATE:
return 2;
case Activity_Type.UNRECOGNIZED:
default:
return -1;
......
......@@ -89,8 +89,6 @@ export enum Inbox_Type {
TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED",
/** MEMO_COMMENT - Memo comment notification. */
MEMO_COMMENT = "MEMO_COMMENT",
/** VERSION_UPDATE - Version update notification. */
VERSION_UPDATE = "VERSION_UPDATE",
UNRECOGNIZED = "UNRECOGNIZED",
}
......@@ -102,9 +100,6 @@ export function inbox_TypeFromJSON(object: any): Inbox_Type {
case 1:
case "MEMO_COMMENT":
return Inbox_Type.MEMO_COMMENT;
case 2:
case "VERSION_UPDATE":
return Inbox_Type.VERSION_UPDATE;
case -1:
case "UNRECOGNIZED":
default:
......@@ -118,8 +113,6 @@ export function inbox_TypeToNumber(object: Inbox_Type): number {
return 0;
case Inbox_Type.MEMO_COMMENT:
return 1;
case Inbox_Type.VERSION_UPDATE:
return 2;
case Inbox_Type.UNRECOGNIZED:
default:
return -1;
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment