feat(views): support custom icons for saved memo views (#6305)

This commit is contained in:
Johnny 2026-09-08 22:10:02 +08:00 committed by GitHub
parent e02899e514
commit 51b434d3b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1082 additions and 424 deletions

View file

@ -598,6 +598,19 @@ message MemoView {
// The CEL filter expression for the memo view, using the same grammar as the // The CEL filter expression for the memo view, using the same grammar as the
// ListMemos `filter` argument. Reuse it by passing this value to ListMemos. // ListMemos `filter` argument. Reuse it by passing this value to ListMemos.
string filter = 3 [(google.api.field_behavior) = REQUIRED]; string filter = 3 [(google.api.field_behavior) = REQUIRED];
// Optional. Display icon. Omit when updating with the "icon" mask to reset
// to the default view icon.
Icon icon = 4 [(google.api.field_behavior) = OPTIONAL];
message Icon {
oneof value {
// A Unicode emoji sequence.
string emoji = 1;
// A canonical Lucide icon name, such as "leaf".
string lucide = 2;
}
}
} }
message ListMemoViewsRequest { message ListMemoViewsRequest {

View file

@ -1497,7 +1497,10 @@ type MemoView struct {
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
// The CEL filter expression for the memo view, using the same grammar as the // The CEL filter expression for the memo view, using the same grammar as the
// ListMemos `filter` argument. Reuse it by passing this value to ListMemos. // ListMemos `filter` argument. Reuse it by passing this value to ListMemos.
Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"` Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"`
// Optional. Display icon. Omit when updating with the "icon" mask to reset
// to the default view icon.
Icon *MemoView_Icon `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1553,6 +1556,13 @@ func (x *MemoView) GetFilter() string {
return "" return ""
} }
func (x *MemoView) GetIcon() *MemoView_Icon {
if x != nil {
return x.Icon
}
return nil
}
type ListMemoViewsRequest struct { type ListMemoViewsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The parent resource where memo views are listed. // Required. The parent resource where memo views are listed.
@ -3658,6 +3668,90 @@ func (x *UserSetting_WebhooksSetting) GetWebhooks() []*UserWebhook {
return nil return nil
} }
type MemoView_Icon struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Value:
//
// *MemoView_Icon_Emoji
// *MemoView_Icon_Lucide
Value isMemoView_Icon_Value `protobuf_oneof:"value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MemoView_Icon) Reset() {
*x = MemoView_Icon{}
mi := &file_api_v1_user_service_proto_msgTypes[57]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MemoView_Icon) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MemoView_Icon) ProtoMessage() {}
func (x *MemoView_Icon) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_user_service_proto_msgTypes[57]
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 MemoView_Icon.ProtoReflect.Descriptor instead.
func (*MemoView_Icon) Descriptor() ([]byte, []int) {
return file_api_v1_user_service_proto_rawDescGZIP(), []int{18, 0}
}
func (x *MemoView_Icon) GetValue() isMemoView_Icon_Value {
if x != nil {
return x.Value
}
return nil
}
func (x *MemoView_Icon) GetEmoji() string {
if x != nil {
if x, ok := x.Value.(*MemoView_Icon_Emoji); ok {
return x.Emoji
}
}
return ""
}
func (x *MemoView_Icon) GetLucide() string {
if x != nil {
if x, ok := x.Value.(*MemoView_Icon_Lucide); ok {
return x.Lucide
}
}
return ""
}
type isMemoView_Icon_Value interface {
isMemoView_Icon_Value()
}
type MemoView_Icon_Emoji struct {
// A Unicode emoji sequence.
Emoji string `protobuf:"bytes,1,opt,name=emoji,proto3,oneof"`
}
type MemoView_Icon_Lucide struct {
// A canonical Lucide icon name, such as "leaf".
Lucide string `protobuf:"bytes,2,opt,name=lucide,proto3,oneof"`
}
func (*MemoView_Icon_Emoji) isMemoView_Icon_Value() {}
func (*MemoView_Icon_Lucide) isMemoView_Icon_Value() {}
type UserNotification_MemoCommentPayload struct { type UserNotification_MemoCommentPayload struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// The memo name of comment. // The memo name of comment.
@ -3676,7 +3770,7 @@ type UserNotification_MemoCommentPayload struct {
func (x *UserNotification_MemoCommentPayload) Reset() { func (x *UserNotification_MemoCommentPayload) Reset() {
*x = UserNotification_MemoCommentPayload{} *x = UserNotification_MemoCommentPayload{}
mi := &file_api_v1_user_service_proto_msgTypes[57] mi := &file_api_v1_user_service_proto_msgTypes[58]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -3688,7 +3782,7 @@ func (x *UserNotification_MemoCommentPayload) String() string {
func (*UserNotification_MemoCommentPayload) ProtoMessage() {} func (*UserNotification_MemoCommentPayload) ProtoMessage() {}
func (x *UserNotification_MemoCommentPayload) ProtoReflect() protoreflect.Message { func (x *UserNotification_MemoCommentPayload) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_user_service_proto_msgTypes[57] mi := &file_api_v1_user_service_proto_msgTypes[58]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -3750,7 +3844,7 @@ type UserNotification_MemoMentionPayload struct {
func (x *UserNotification_MemoMentionPayload) Reset() { func (x *UserNotification_MemoMentionPayload) Reset() {
*x = UserNotification_MemoMentionPayload{} *x = UserNotification_MemoMentionPayload{}
mi := &file_api_v1_user_service_proto_msgTypes[58] mi := &file_api_v1_user_service_proto_msgTypes[59]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -3762,7 +3856,7 @@ func (x *UserNotification_MemoMentionPayload) String() string {
func (*UserNotification_MemoMentionPayload) ProtoMessage() {} func (*UserNotification_MemoMentionPayload) ProtoMessage() {}
func (x *UserNotification_MemoMentionPayload) ProtoReflect() protoreflect.Message { func (x *UserNotification_MemoMentionPayload) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_user_service_proto_msgTypes[58] mi := &file_api_v1_user_service_proto_msgTypes[59]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -3823,7 +3917,7 @@ type UserNotification_SpaceInvitationPayload struct {
func (x *UserNotification_SpaceInvitationPayload) Reset() { func (x *UserNotification_SpaceInvitationPayload) Reset() {
*x = UserNotification_SpaceInvitationPayload{} *x = UserNotification_SpaceInvitationPayload{}
mi := &file_api_v1_user_service_proto_msgTypes[59] mi := &file_api_v1_user_service_proto_msgTypes[60]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -3835,7 +3929,7 @@ func (x *UserNotification_SpaceInvitationPayload) String() string {
func (*UserNotification_SpaceInvitationPayload) ProtoMessage() {} func (*UserNotification_SpaceInvitationPayload) ProtoMessage() {}
func (x *UserNotification_SpaceInvitationPayload) ProtoReflect() protoreflect.Message { func (x *UserNotification_SpaceInvitationPayload) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_user_service_proto_msgTypes[59] mi := &file_api_v1_user_service_proto_msgTypes[60]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4012,11 +4106,16 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"y\n" + "page_token\x18\x03 \x01(\tB\x03\xe0A\x01R\tpageToken\"y\n" +
"\x18ListUserSettingsResponse\x125\n" + "\x18ListUserSettingsResponse\x125\n" +
"\bsettings\x18\x01 \x03(\v2\x19.memos.api.v1.UserSettingR\bsettings\x12&\n" + "\bsettings\x18\x01 \x03(\v2\x19.memos.api.v1.UserSettingR\bsettings\x12&\n" +
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x9f\x01\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"\x98\x02\n" +
"\bMemoView\x12\x17\n" + "\bMemoView\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" +
"\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12\x1b\n" + "\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12\x1b\n" +
"\x06filter\x18\x03 \x01(\tB\x03\xe0A\x02R\x06filter:B\xeaA?\n" + "\x06filter\x18\x03 \x01(\tB\x03\xe0A\x02R\x06filter\x124\n" +
"\x04icon\x18\x04 \x01(\v2\x1b.memos.api.v1.MemoView.IconB\x03\xe0A\x01R\x04icon\x1aA\n" +
"\x04Icon\x12\x16\n" +
"\x05emoji\x18\x01 \x01(\tH\x00R\x05emoji\x12\x18\n" +
"\x06lucide\x18\x02 \x01(\tH\x00R\x06lucideB\a\n" +
"\x05value:B\xeaA?\n" +
"\x15memos.api.v1/MemoView\x12\x19users/{user}/views/{view}*\x05views2\x04view\"M\n" + "\x15memos.api.v1/MemoView\x12\x19users/{user}/views/{view}*\x05views2\x04view\"M\n" +
"\x14ListMemoViewsRequest\x125\n" + "\x14ListMemoViewsRequest\x125\n" +
"\x06parent\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\x12\x15memos.api.v1/MemoViewR\x06parent\"N\n" + "\x06parent\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\x12\x15memos.api.v1/MemoViewR\x06parent\"N\n" +
@ -4235,7 +4334,7 @@ func file_api_v1_user_service_proto_rawDescGZIP() []byte {
} }
var file_api_v1_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5) var file_api_v1_user_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 60) var file_api_v1_user_service_proto_msgTypes = make([]protoimpl.MessageInfo, 61)
var file_api_v1_user_service_proto_goTypes = []any{ var file_api_v1_user_service_proto_goTypes = []any{
(User_Role)(0), // 0: memos.api.v1.User.Role (User_Role)(0), // 0: memos.api.v1.User.Role
(UserSetting_Key)(0), // 1: memos.api.v1.UserSetting.Key (UserSetting_Key)(0), // 1: memos.api.v1.UserSetting.Key
@ -4299,140 +4398,142 @@ var file_api_v1_user_service_proto_goTypes = []any{
(*UserSetting_TagsSetting)(nil), // 59: memos.api.v1.UserSetting.TagsSetting (*UserSetting_TagsSetting)(nil), // 59: memos.api.v1.UserSetting.TagsSetting
(*UserSetting_WebhooksSetting)(nil), // 60: memos.api.v1.UserSetting.WebhooksSetting (*UserSetting_WebhooksSetting)(nil), // 60: memos.api.v1.UserSetting.WebhooksSetting
nil, // 61: memos.api.v1.UserSetting.TagsSetting.TagsEntry nil, // 61: memos.api.v1.UserSetting.TagsSetting.TagsEntry
(*UserNotification_MemoCommentPayload)(nil), // 62: memos.api.v1.UserNotification.MemoCommentPayload (*MemoView_Icon)(nil), // 62: memos.api.v1.MemoView.Icon
(*UserNotification_MemoMentionPayload)(nil), // 63: memos.api.v1.UserNotification.MemoMentionPayload (*UserNotification_MemoCommentPayload)(nil), // 63: memos.api.v1.UserNotification.MemoCommentPayload
(*UserNotification_SpaceInvitationPayload)(nil), // 64: memos.api.v1.UserNotification.SpaceInvitationPayload (*UserNotification_MemoMentionPayload)(nil), // 64: memos.api.v1.UserNotification.MemoMentionPayload
(State)(0), // 65: memos.api.v1.State (*UserNotification_SpaceInvitationPayload)(nil), // 65: memos.api.v1.UserNotification.SpaceInvitationPayload
(*timestamppb.Timestamp)(nil), // 66: google.protobuf.Timestamp (State)(0), // 66: memos.api.v1.State
(*fieldmaskpb.FieldMask)(nil), // 67: google.protobuf.FieldMask (*timestamppb.Timestamp)(nil), // 67: google.protobuf.Timestamp
(*color.Color)(nil), // 68: google.type.Color (*fieldmaskpb.FieldMask)(nil), // 68: google.protobuf.FieldMask
(*Space)(nil), // 69: memos.api.v1.Space (*color.Color)(nil), // 69: google.type.Color
(SpaceMember_Role)(0), // 70: memos.api.v1.SpaceMember.Role (*Space)(nil), // 70: memos.api.v1.Space
(*emptypb.Empty)(nil), // 71: google.protobuf.Empty (SpaceMember_Role)(0), // 71: memos.api.v1.SpaceMember.Role
(*emptypb.Empty)(nil), // 72: google.protobuf.Empty
} }
var file_api_v1_user_service_proto_depIdxs = []int32{ var file_api_v1_user_service_proto_depIdxs = []int32{
0, // 0: memos.api.v1.User.role:type_name -> memos.api.v1.User.Role 0, // 0: memos.api.v1.User.role:type_name -> memos.api.v1.User.Role
65, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State 66, // 1: memos.api.v1.User.state:type_name -> memos.api.v1.State
66, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp 67, // 2: memos.api.v1.User.create_time:type_name -> google.protobuf.Timestamp
66, // 3: memos.api.v1.User.update_time:type_name -> google.protobuf.Timestamp 67, // 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, // 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 5, // 5: memos.api.v1.BatchGetUsersResponse.users:type_name -> memos.api.v1.User
67, // 6: memos.api.v1.GetUserRequest.read_mask:type_name -> google.protobuf.FieldMask 68, // 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, // 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 5, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
67, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask 68, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
56, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats 56, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
55, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry 55, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
66, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp 67, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
66, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp 67, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
65, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State 66, // 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 14, // 15: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
57, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting 57, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
60, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting 60, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
59, // 18: memos.api.v1.UserSetting.tags_setting:type_name -> memos.api.v1.UserSetting.TagsSetting 59, // 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 18, // 19: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
67, // 20: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask 68, // 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 18, // 21: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
23, // 22: memos.api.v1.ListMemoViewsResponse.memo_views:type_name -> memos.api.v1.MemoView 62, // 22: memos.api.v1.MemoView.icon:type_name -> memos.api.v1.MemoView.Icon
23, // 23: memos.api.v1.CreateMemoViewRequest.memo_view:type_name -> memos.api.v1.MemoView 23, // 23: memos.api.v1.ListMemoViewsResponse.memo_views:type_name -> memos.api.v1.MemoView
23, // 24: memos.api.v1.UpdateMemoViewRequest.memo_view:type_name -> memos.api.v1.MemoView 23, // 24: memos.api.v1.CreateMemoViewRequest.memo_view:type_name -> memos.api.v1.MemoView
67, // 25: memos.api.v1.UpdateMemoViewRequest.update_mask:type_name -> google.protobuf.FieldMask 23, // 25: memos.api.v1.UpdateMemoViewRequest.memo_view:type_name -> memos.api.v1.MemoView
30, // 26: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity 68, // 26: memos.api.v1.UpdateMemoViewRequest.update_mask:type_name -> google.protobuf.FieldMask
66, // 27: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp 30, // 27: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
66, // 28: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp 67, // 28: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
66, // 29: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp 67, // 29: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
36, // 30: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken 67, // 30: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
36, // 31: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken 36, // 31: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
66, // 32: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp 36, // 32: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
66, // 33: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp 67, // 33: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
42, // 34: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook 67, // 34: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
42, // 35: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook 42, // 35: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
42, // 36: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook 42, // 36: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
67, // 37: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask 42, // 37: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
5, // 38: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User 68, // 38: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
2, // 39: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status 5, // 39: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
66, // 40: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp 2, // 40: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status
3, // 41: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type 67, // 41: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
62, // 42: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload 3, // 42: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type
63, // 43: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload 63, // 43: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
64, // 44: memos.api.v1.UserNotification.space_invitation:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload 64, // 44: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
50, // 45: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification 65, // 45: memos.api.v1.UserNotification.space_invitation:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload
50, // 46: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification 50, // 46: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
67, // 47: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask 50, // 47: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
68, // 48: memos.api.v1.UserSetting.TagMetadata.background_color:type_name -> google.type.Color 68, // 48: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
61, // 49: memos.api.v1.UserSetting.TagsSetting.tags:type_name -> memos.api.v1.UserSetting.TagsSetting.TagsEntry 69, // 49: memos.api.v1.UserSetting.TagMetadata.background_color:type_name -> google.type.Color
42, // 50: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook 61, // 50: memos.api.v1.UserSetting.TagsSetting.tags:type_name -> memos.api.v1.UserSetting.TagsSetting.TagsEntry
58, // 51: memos.api.v1.UserSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.UserSetting.TagMetadata 42, // 51: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
69, // 52: memos.api.v1.UserNotification.SpaceInvitationPayload.space:type_name -> memos.api.v1.Space 58, // 52: memos.api.v1.UserSetting.TagsSetting.TagsEntry.value:type_name -> memos.api.v1.UserSetting.TagMetadata
70, // 53: memos.api.v1.UserNotification.SpaceInvitationPayload.role:type_name -> memos.api.v1.SpaceMember.Role 70, // 53: memos.api.v1.UserNotification.SpaceInvitationPayload.space:type_name -> memos.api.v1.Space
4, // 54: memos.api.v1.UserNotification.SpaceInvitationPayload.state:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload.State 71, // 54: memos.api.v1.UserNotification.SpaceInvitationPayload.role:type_name -> memos.api.v1.SpaceMember.Role
6, // 55: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest 4, // 55: memos.api.v1.UserNotification.SpaceInvitationPayload.state:type_name -> memos.api.v1.UserNotification.SpaceInvitationPayload.State
8, // 56: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest 6, // 56: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
10, // 57: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest 8, // 57: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
11, // 58: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest 10, // 58: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
12, // 59: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest 11, // 59: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
13, // 60: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest 12, // 60: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
16, // 61: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest 13, // 61: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
15, // 62: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest 16, // 62: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
19, // 63: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest 15, // 63: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
20, // 64: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest 19, // 64: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
21, // 65: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest 20, // 65: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
24, // 66: memos.api.v1.UserService.ListMemoViews:input_type -> memos.api.v1.ListMemoViewsRequest 21, // 66: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
26, // 67: memos.api.v1.UserService.GetMemoView:input_type -> memos.api.v1.GetMemoViewRequest 24, // 67: memos.api.v1.UserService.ListMemoViews:input_type -> memos.api.v1.ListMemoViewsRequest
27, // 68: memos.api.v1.UserService.CreateMemoView:input_type -> memos.api.v1.CreateMemoViewRequest 26, // 68: memos.api.v1.UserService.GetMemoView:input_type -> memos.api.v1.GetMemoViewRequest
28, // 69: memos.api.v1.UserService.UpdateMemoView:input_type -> memos.api.v1.UpdateMemoViewRequest 27, // 69: memos.api.v1.UserService.CreateMemoView:input_type -> memos.api.v1.CreateMemoViewRequest
29, // 70: memos.api.v1.UserService.DeleteMemoView:input_type -> memos.api.v1.DeleteMemoViewRequest 28, // 70: memos.api.v1.UserService.UpdateMemoView:input_type -> memos.api.v1.UpdateMemoViewRequest
31, // 71: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest 29, // 71: memos.api.v1.UserService.DeleteMemoView:input_type -> memos.api.v1.DeleteMemoViewRequest
33, // 72: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest 31, // 72: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
34, // 73: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest 33, // 73: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
35, // 74: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest 34, // 74: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
37, // 75: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest 35, // 75: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
39, // 76: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest 37, // 76: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
41, // 77: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest 39, // 77: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
43, // 78: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest 41, // 78: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
45, // 79: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest 43, // 79: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
46, // 80: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest 45, // 80: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
47, // 81: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest 46, // 81: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
48, // 82: memos.api.v1.UserService.GetUserWebhookSigningSecret:input_type -> memos.api.v1.GetUserWebhookSigningSecretRequest 47, // 82: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
51, // 83: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest 48, // 83: memos.api.v1.UserService.GetUserWebhookSigningSecret:input_type -> memos.api.v1.GetUserWebhookSigningSecretRequest
53, // 84: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest 51, // 84: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
54, // 85: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest 53, // 85: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
7, // 86: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse 54, // 86: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
9, // 87: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse 7, // 87: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
5, // 88: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User 9, // 88: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
5, // 89: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User 5, // 89: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
5, // 90: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User 5, // 90: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
71, // 91: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty 5, // 91: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
17, // 92: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse 72, // 92: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
14, // 93: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats 17, // 93: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
18, // 94: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting 14, // 94: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
18, // 95: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting 18, // 95: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
22, // 96: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse 18, // 96: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
25, // 97: memos.api.v1.UserService.ListMemoViews:output_type -> memos.api.v1.ListMemoViewsResponse 22, // 97: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
23, // 98: memos.api.v1.UserService.GetMemoView:output_type -> memos.api.v1.MemoView 25, // 98: memos.api.v1.UserService.ListMemoViews:output_type -> memos.api.v1.ListMemoViewsResponse
23, // 99: memos.api.v1.UserService.CreateMemoView:output_type -> memos.api.v1.MemoView 23, // 99: memos.api.v1.UserService.GetMemoView:output_type -> memos.api.v1.MemoView
23, // 100: memos.api.v1.UserService.UpdateMemoView:output_type -> memos.api.v1.MemoView 23, // 100: memos.api.v1.UserService.CreateMemoView:output_type -> memos.api.v1.MemoView
71, // 101: memos.api.v1.UserService.DeleteMemoView:output_type -> google.protobuf.Empty 23, // 101: memos.api.v1.UserService.UpdateMemoView:output_type -> memos.api.v1.MemoView
32, // 102: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse 72, // 102: memos.api.v1.UserService.DeleteMemoView:output_type -> google.protobuf.Empty
30, // 103: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity 32, // 103: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
30, // 104: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity 30, // 104: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
71, // 105: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty 30, // 105: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
38, // 106: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse 72, // 106: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
40, // 107: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse 38, // 107: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
71, // 108: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty 40, // 108: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
44, // 109: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse 72, // 109: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
42, // 110: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook 44, // 110: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
42, // 111: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook 42, // 111: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
71, // 112: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty 42, // 112: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
49, // 113: memos.api.v1.UserService.GetUserWebhookSigningSecret:output_type -> memos.api.v1.GetUserWebhookSigningSecretResponse 72, // 113: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
52, // 114: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse 49, // 114: memos.api.v1.UserService.GetUserWebhookSigningSecret:output_type -> memos.api.v1.GetUserWebhookSigningSecretResponse
50, // 115: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification 52, // 115: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
71, // 116: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty 50, // 116: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
86, // [86:117] is the sub-list for method output_type 72, // 117: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
55, // [55:86] is the sub-list for method input_type 87, // [87:118] is the sub-list for method output_type
55, // [55:55] is the sub-list for extension type_name 56, // [56:87] is the sub-list for method input_type
55, // [55:55] is the sub-list for extension extendee 56, // [56:56] is the sub-list for extension type_name
0, // [0:55] is the sub-list for field type_name 56, // [56:56] is the sub-list for extension extendee
0, // [0:56] is the sub-list for field type_name
} }
func init() { file_api_v1_user_service_proto_init() } func init() { file_api_v1_user_service_proto_init() }
@ -4452,13 +4553,17 @@ func file_api_v1_user_service_proto_init() {
(*UserNotification_MemoMention)(nil), (*UserNotification_MemoMention)(nil),
(*UserNotification_SpaceInvitation)(nil), (*UserNotification_SpaceInvitation)(nil),
} }
file_api_v1_user_service_proto_msgTypes[57].OneofWrappers = []any{
(*MemoView_Icon_Emoji)(nil),
(*MemoView_Icon_Lucide)(nil),
}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 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)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_user_service_proto_rawDesc), len(file_api_v1_user_service_proto_rawDesc)),
NumEnums: 5, NumEnums: 5,
NumMessages: 60, NumMessages: 61,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View file

@ -4119,7 +4119,22 @@ components:
description: |- description: |-
The CEL filter expression for the memo view, using the same grammar as the The CEL filter expression for the memo view, using the same grammar as the
ListMemos `filter` argument. Reuse it by passing this value to ListMemos. ListMemos `filter` argument. Reuse it by passing this value to ListMemos.
icon:
allOf:
- $ref: '#/components/schemas/MemoView_Icon'
description: |-
Optional. Display icon. Omit when updating with the "icon" mask to reset
to the default view icon.
description: Saved memo view messages description: Saved memo view messages
MemoView_Icon:
type: object
properties:
emoji:
type: string
description: A Unicode emoji sequence.
lucide:
type: string
description: A canonical Lucide icon name, such as "leaf".
Memo_Property: Memo_Property:
type: object type: object
properties: properties:

View file

@ -857,10 +857,11 @@ func (x *PersonalAccessTokensUserSetting_PersonalAccessToken) GetLastUsedAt() *t
} }
type MemoViewsUserSetting_MemoView struct { type MemoViewsUserSetting_MemoView struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"` Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"`
Icon *MemoViewsUserSetting_MemoView_Icon `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -916,6 +917,97 @@ func (x *MemoViewsUserSetting_MemoView) GetFilter() string {
return "" return ""
} }
func (x *MemoViewsUserSetting_MemoView) GetIcon() *MemoViewsUserSetting_MemoView_Icon {
if x != nil {
return x.Icon
}
return nil
}
type MemoViewsUserSetting_MemoView_Icon struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Value:
//
// *MemoViewsUserSetting_MemoView_Icon_Emoji
// *MemoViewsUserSetting_MemoView_Icon_Lucide
Value isMemoViewsUserSetting_MemoView_Icon_Value `protobuf_oneof:"value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MemoViewsUserSetting_MemoView_Icon) Reset() {
*x = MemoViewsUserSetting_MemoView_Icon{}
mi := &file_store_user_setting_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MemoViewsUserSetting_MemoView_Icon) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MemoViewsUserSetting_MemoView_Icon) ProtoMessage() {}
func (x *MemoViewsUserSetting_MemoView_Icon) ProtoReflect() protoreflect.Message {
mi := &file_store_user_setting_proto_msgTypes[13]
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 MemoViewsUserSetting_MemoView_Icon.ProtoReflect.Descriptor instead.
func (*MemoViewsUserSetting_MemoView_Icon) Descriptor() ([]byte, []int) {
return file_store_user_setting_proto_rawDescGZIP(), []int{6, 0, 0}
}
func (x *MemoViewsUserSetting_MemoView_Icon) GetValue() isMemoViewsUserSetting_MemoView_Icon_Value {
if x != nil {
return x.Value
}
return nil
}
func (x *MemoViewsUserSetting_MemoView_Icon) GetEmoji() string {
if x != nil {
if x, ok := x.Value.(*MemoViewsUserSetting_MemoView_Icon_Emoji); ok {
return x.Emoji
}
}
return ""
}
func (x *MemoViewsUserSetting_MemoView_Icon) GetLucide() string {
if x != nil {
if x, ok := x.Value.(*MemoViewsUserSetting_MemoView_Icon_Lucide); ok {
return x.Lucide
}
}
return ""
}
type isMemoViewsUserSetting_MemoView_Icon_Value interface {
isMemoViewsUserSetting_MemoView_Icon_Value()
}
type MemoViewsUserSetting_MemoView_Icon_Emoji struct {
// A Unicode emoji sequence.
Emoji string `protobuf:"bytes,1,opt,name=emoji,proto3,oneof"`
}
type MemoViewsUserSetting_MemoView_Icon_Lucide struct {
// A canonical Lucide icon name, such as "leaf".
Lucide string `protobuf:"bytes,2,opt,name=lucide,proto3,oneof"`
}
func (*MemoViewsUserSetting_MemoView_Icon_Emoji) isMemoViewsUserSetting_MemoView_Icon_Value() {}
func (*MemoViewsUserSetting_MemoView_Icon_Lucide) isMemoViewsUserSetting_MemoView_Icon_Value() {}
type WebhooksUserSetting_Webhook struct { type WebhooksUserSetting_Webhook struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Unique identifier for the webhook // Unique identifier for the webhook
@ -932,7 +1024,7 @@ type WebhooksUserSetting_Webhook struct {
func (x *WebhooksUserSetting_Webhook) Reset() { func (x *WebhooksUserSetting_Webhook) Reset() {
*x = WebhooksUserSetting_Webhook{} *x = WebhooksUserSetting_Webhook{}
mi := &file_store_user_setting_proto_msgTypes[13] mi := &file_store_user_setting_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -944,7 +1036,7 @@ func (x *WebhooksUserSetting_Webhook) String() string {
func (*WebhooksUserSetting_Webhook) ProtoMessage() {} func (*WebhooksUserSetting_Webhook) ProtoMessage() {}
func (x *WebhooksUserSetting_Webhook) ProtoReflect() protoreflect.Message { func (x *WebhooksUserSetting_Webhook) ProtoReflect() protoreflect.Message {
mi := &file_store_user_setting_proto_msgTypes[13] mi := &file_store_user_setting_proto_msgTypes[14]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -1060,14 +1152,19 @@ const file_store_user_setting_proto_rawDesc = "" +
"\n" + "\n" +
"created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" + "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
"\flast_used_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\n" + "\flast_used_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"lastUsedAt\"\xab\x01\n" + "lastUsedAt\"\xb4\x02\n" +
"\x14MemoViewsUserSetting\x12I\n" + "\x14MemoViewsUserSetting\x12I\n" +
"\n" + "\n" +
"memo_views\x18\x01 \x03(\v2*.memos.store.MemoViewsUserSetting.MemoViewR\tmemoViews\x1aH\n" + "memo_views\x18\x01 \x03(\v2*.memos.store.MemoViewsUserSetting.MemoViewR\tmemoViews\x1a\xd0\x01\n" +
"\bMemoView\x12\x0e\n" + "\bMemoView\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\x16\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12\x16\n" +
"\x06filter\x18\x03 \x01(\tR\x06filter\"\xc5\x01\n" + "\x06filter\x18\x03 \x01(\tR\x06filter\x12C\n" +
"\x04icon\x18\x04 \x01(\v2/.memos.store.MemoViewsUserSetting.MemoView.IconR\x04icon\x1aA\n" +
"\x04Icon\x12\x16\n" +
"\x05emoji\x18\x01 \x01(\tH\x00R\x05emoji\x12\x18\n" +
"\x06lucide\x18\x02 \x01(\tH\x00R\x06lucideB\a\n" +
"\x05value\"\xc5\x01\n" +
"\x13WebhooksUserSetting\x12D\n" + "\x13WebhooksUserSetting\x12D\n" +
"\bwebhooks\x18\x01 \x03(\v2(.memos.store.WebhooksUserSetting.WebhookR\bwebhooks\x1ah\n" + "\bwebhooks\x18\x01 \x03(\v2(.memos.store.WebhooksUserSetting.WebhookR\bwebhooks\x1ah\n" +
"\aWebhook\x12\x0e\n" + "\aWebhook\x12\x0e\n" +
@ -1090,7 +1187,7 @@ func file_store_user_setting_proto_rawDescGZIP() []byte {
} }
var file_store_user_setting_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_store_user_setting_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_store_user_setting_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_store_user_setting_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_store_user_setting_proto_goTypes = []any{ var file_store_user_setting_proto_goTypes = []any{
(UserSetting_Key)(0), // 0: memos.store.UserSetting.Key (UserSetting_Key)(0), // 0: memos.store.UserSetting.Key
(*UserSetting)(nil), // 1: memos.store.UserSetting (*UserSetting)(nil), // 1: memos.store.UserSetting
@ -1106,9 +1203,10 @@ var file_store_user_setting_proto_goTypes = []any{
(*RefreshTokensUserSetting_ClientInfo)(nil), // 11: memos.store.RefreshTokensUserSetting.ClientInfo (*RefreshTokensUserSetting_ClientInfo)(nil), // 11: memos.store.RefreshTokensUserSetting.ClientInfo
(*PersonalAccessTokensUserSetting_PersonalAccessToken)(nil), // 12: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken (*PersonalAccessTokensUserSetting_PersonalAccessToken)(nil), // 12: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken
(*MemoViewsUserSetting_MemoView)(nil), // 13: memos.store.MemoViewsUserSetting.MemoView (*MemoViewsUserSetting_MemoView)(nil), // 13: memos.store.MemoViewsUserSetting.MemoView
(*WebhooksUserSetting_Webhook)(nil), // 14: memos.store.WebhooksUserSetting.Webhook (*MemoViewsUserSetting_MemoView_Icon)(nil), // 14: memos.store.MemoViewsUserSetting.MemoView.Icon
(*color.Color)(nil), // 15: google.type.Color (*WebhooksUserSetting_Webhook)(nil), // 15: memos.store.WebhooksUserSetting.Webhook
(*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp (*color.Color)(nil), // 16: google.type.Color
(*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp
} }
var file_store_user_setting_proto_depIdxs = []int32{ var file_store_user_setting_proto_depIdxs = []int32{
0, // 0: memos.store.UserSetting.key:type_name -> memos.store.UserSetting.Key 0, // 0: memos.store.UserSetting.key:type_name -> memos.store.UserSetting.Key
@ -1118,24 +1216,25 @@ var file_store_user_setting_proto_depIdxs = []int32{
5, // 4: memos.store.UserSetting.refresh_tokens:type_name -> memos.store.RefreshTokensUserSetting 5, // 4: memos.store.UserSetting.refresh_tokens:type_name -> memos.store.RefreshTokensUserSetting
6, // 5: memos.store.UserSetting.personal_access_tokens:type_name -> memos.store.PersonalAccessTokensUserSetting 6, // 5: memos.store.UserSetting.personal_access_tokens:type_name -> memos.store.PersonalAccessTokensUserSetting
4, // 6: memos.store.UserSetting.tags:type_name -> memos.store.TagsUserSetting 4, // 6: memos.store.UserSetting.tags:type_name -> memos.store.TagsUserSetting
15, // 7: memos.store.UserTagMetadata.background_color:type_name -> google.type.Color 16, // 7: memos.store.UserTagMetadata.background_color:type_name -> google.type.Color
9, // 8: memos.store.TagsUserSetting.tags:type_name -> memos.store.TagsUserSetting.TagsEntry 9, // 8: memos.store.TagsUserSetting.tags:type_name -> memos.store.TagsUserSetting.TagsEntry
10, // 9: memos.store.RefreshTokensUserSetting.refresh_tokens:type_name -> memos.store.RefreshTokensUserSetting.RefreshToken 10, // 9: memos.store.RefreshTokensUserSetting.refresh_tokens:type_name -> memos.store.RefreshTokensUserSetting.RefreshToken
12, // 10: memos.store.PersonalAccessTokensUserSetting.tokens:type_name -> memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken 12, // 10: memos.store.PersonalAccessTokensUserSetting.tokens:type_name -> memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken
13, // 11: memos.store.MemoViewsUserSetting.memo_views:type_name -> memos.store.MemoViewsUserSetting.MemoView 13, // 11: memos.store.MemoViewsUserSetting.memo_views:type_name -> memos.store.MemoViewsUserSetting.MemoView
14, // 12: memos.store.WebhooksUserSetting.webhooks:type_name -> memos.store.WebhooksUserSetting.Webhook 15, // 12: memos.store.WebhooksUserSetting.webhooks:type_name -> memos.store.WebhooksUserSetting.Webhook
3, // 13: memos.store.TagsUserSetting.TagsEntry.value:type_name -> memos.store.UserTagMetadata 3, // 13: memos.store.TagsUserSetting.TagsEntry.value:type_name -> memos.store.UserTagMetadata
16, // 14: memos.store.RefreshTokensUserSetting.RefreshToken.expires_at:type_name -> google.protobuf.Timestamp 17, // 14: memos.store.RefreshTokensUserSetting.RefreshToken.expires_at:type_name -> google.protobuf.Timestamp
16, // 15: memos.store.RefreshTokensUserSetting.RefreshToken.created_at:type_name -> google.protobuf.Timestamp 17, // 15: memos.store.RefreshTokensUserSetting.RefreshToken.created_at:type_name -> google.protobuf.Timestamp
11, // 16: memos.store.RefreshTokensUserSetting.RefreshToken.client_info:type_name -> memos.store.RefreshTokensUserSetting.ClientInfo 11, // 16: memos.store.RefreshTokensUserSetting.RefreshToken.client_info:type_name -> memos.store.RefreshTokensUserSetting.ClientInfo
16, // 17: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp 17, // 17: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
16, // 18: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp 17, // 18: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
16, // 19: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp 17, // 19: memos.store.PersonalAccessTokensUserSetting.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
20, // [20:20] is the sub-list for method output_type 14, // 20: memos.store.MemoViewsUserSetting.MemoView.icon:type_name -> memos.store.MemoViewsUserSetting.MemoView.Icon
20, // [20:20] is the sub-list for method input_type 21, // [21:21] is the sub-list for method output_type
20, // [20:20] is the sub-list for extension type_name 21, // [21:21] is the sub-list for method input_type
20, // [20:20] is the sub-list for extension extendee 21, // [21:21] is the sub-list for extension type_name
0, // [0:20] is the sub-list for field type_name 21, // [21:21] is the sub-list for extension extendee
0, // [0:21] is the sub-list for field type_name
} }
func init() { file_store_user_setting_proto_init() } func init() { file_store_user_setting_proto_init() }
@ -1151,13 +1250,17 @@ func file_store_user_setting_proto_init() {
(*UserSetting_PersonalAccessTokens)(nil), (*UserSetting_PersonalAccessTokens)(nil),
(*UserSetting_Tags)(nil), (*UserSetting_Tags)(nil),
} }
file_store_user_setting_proto_msgTypes[13].OneofWrappers = []any{
(*MemoViewsUserSetting_MemoView_Icon_Emoji)(nil),
(*MemoViewsUserSetting_MemoView_Icon_Lucide)(nil),
}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_user_setting_proto_rawDesc), len(file_store_user_setting_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_user_setting_proto_rawDesc), len(file_store_user_setting_proto_rawDesc)),
NumEnums: 1, NumEnums: 1,
NumMessages: 14, NumMessages: 15,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

View file

@ -118,6 +118,16 @@ message MemoViewsUserSetting {
string id = 1; string id = 1;
string title = 2; string title = 2;
string filter = 3; string filter = 3;
Icon icon = 4;
message Icon {
oneof value {
// A Unicode emoji sequence.
string emoji = 1;
// A canonical Lucide icon name, such as "leaf".
string lucide = 2;
}
}
} }
repeated MemoView memo_views = 1; repeated MemoView memo_views = 1;
} }

View file

@ -0,0 +1,35 @@
package v1
import (
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
)
func convertMemoViewIconFromStore(icon *storepb.MemoViewsUserSetting_MemoView_Icon) *v1pb.MemoView_Icon {
if icon == nil {
return nil
}
switch value := icon.Value.(type) {
case *storepb.MemoViewsUserSetting_MemoView_Icon_Emoji:
return &v1pb.MemoView_Icon{Value: &v1pb.MemoView_Icon_Emoji{Emoji: value.Emoji}}
case *storepb.MemoViewsUserSetting_MemoView_Icon_Lucide:
return &v1pb.MemoView_Icon{Value: &v1pb.MemoView_Icon_Lucide{Lucide: value.Lucide}}
default:
return nil
}
}
func convertMemoViewIconToStore(icon *v1pb.MemoView_Icon) *storepb.MemoViewsUserSetting_MemoView_Icon {
if icon == nil {
return nil
}
switch value := icon.Value.(type) {
case *v1pb.MemoView_Icon_Emoji:
return &storepb.MemoViewsUserSetting_MemoView_Icon{Value: &storepb.MemoViewsUserSetting_MemoView_Icon_Emoji{Emoji: value.Emoji}}
case *v1pb.MemoView_Icon_Lucide:
return &storepb.MemoViewsUserSetting_MemoView_Icon{Value: &storepb.MemoViewsUserSetting_MemoView_Icon_Lucide{Lucide: value.Lucide}}
default:
// Preserve an empty icon so validation rejects it instead of treating it as a reset.
return &storepb.MemoViewsUserSetting_MemoView_Icon{}
}
}

View file

@ -0,0 +1,99 @@
package test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/fieldmaskpb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
func TestMemoViewIconLifecycle(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
owner, err := ts.CreateRegularUser(ctx, "icon-owner")
require.NoError(t, err)
other, err := ts.CreateRegularUser(ctx, "icon-other")
require.NoError(t, err)
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
parent := "users/" + owner.Username
emoji := &v1pb.MemoView_Icon{Value: &v1pb.MemoView_Icon_Emoji{Emoji: "👩🏽‍🌾"}}
request := &v1pb.CreateMemoViewRequest{
Parent: parent,
MemoView: &v1pb.MemoView{Title: "Garden", Filter: "pinned", Icon: emoji},
ValidateOnly: true,
}
validated, err := ts.Service.CreateMemoView(ownerCtx, request)
require.NoError(t, err)
require.True(t, proto.Equal(emoji, validated.Icon))
listed, err := ts.Service.ListMemoViews(ownerCtx, &v1pb.ListMemoViewsRequest{Parent: parent})
require.NoError(t, err)
require.Empty(t, listed.MemoViews)
request.ValidateOnly = false
view, err := ts.Service.CreateMemoView(ownerCtx, request)
require.NoError(t, err)
require.True(t, proto.Equal(emoji, view.Icon))
view, err = ts.Service.UpdateMemoView(ownerCtx, &v1pb.UpdateMemoViewRequest{
MemoView: &v1pb.MemoView{Name: view.Name, Title: "Renamed", Filter: "has_link"},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title", "filter"}},
})
require.NoError(t, err)
require.True(t, proto.Equal(emoji, view.Icon), "other field updates preserve the icon")
lucide := &v1pb.MemoView_Icon{Value: &v1pb.MemoView_Icon_Lucide{Lucide: "future-icon"}}
update := &v1pb.UpdateMemoViewRequest{
MemoView: &v1pb.MemoView{Name: view.Name, Icon: lucide},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"icon"}},
}
_, err = ts.Service.UpdateMemoView(ts.CreateUserContext(ctx, other.ID), update)
require.Equal(t, codes.PermissionDenied, status.Code(err))
view, err = ts.Service.UpdateMemoView(ownerCtx, update)
require.NoError(t, err)
require.True(t, proto.Equal(lucide, view.Icon))
require.Equal(t, "Renamed", view.Title)
require.Equal(t, "has_link", view.Filter)
fetched, err := ts.Service.GetMemoView(ownerCtx, &v1pb.GetMemoViewRequest{Name: view.Name})
require.NoError(t, err)
require.True(t, proto.Equal(lucide, fetched.Icon))
listed, err = ts.Service.ListMemoViews(ownerCtx, &v1pb.ListMemoViewsRequest{Parent: parent})
require.NoError(t, err)
require.Len(t, listed.MemoViews, 1)
require.True(t, proto.Equal(lucide, listed.MemoViews[0].Icon))
for _, invalid := range []*v1pb.MemoView_Icon{
{},
{Value: &v1pb.MemoView_Icon_Emoji{Emoji: "🌱🌱"}},
{Value: &v1pb.MemoView_Icon_Emoji{Emoji: ""}},
{Value: &v1pb.MemoView_Icon_Lucide{Lucide: "BookOpen"}},
{Value: &v1pb.MemoView_Icon_Lucide{Lucide: ""}},
} {
request.MemoView.Icon = invalid
request.ValidateOnly = true
_, err = ts.Service.CreateMemoView(ownerCtx, request)
require.Equal(t, codes.InvalidArgument, status.Code(err))
request.ValidateOnly = false
_, err = ts.Service.CreateMemoView(ownerCtx, request)
require.Equal(t, codes.InvalidArgument, status.Code(err))
update.MemoView.Icon = invalid
_, err = ts.Service.UpdateMemoView(ownerCtx, update)
require.Equal(t, codes.InvalidArgument, status.Code(err))
}
fetched, err = ts.Service.GetMemoView(ownerCtx, &v1pb.GetMemoViewRequest{Name: view.Name})
require.NoError(t, err)
require.True(t, proto.Equal(lucide, fetched.Icon), "invalid updates leave storage unchanged")
update.MemoView.Icon = nil
view, err = ts.Service.UpdateMemoView(ownerCtx, update)
require.NoError(t, err)
require.Nil(t, view.Icon)
fetched, err = ts.Service.GetMemoView(ownerCtx, &v1pb.GetMemoViewRequest{Name: view.Name})
require.NoError(t, err)
require.Nil(t, fetched.Icon)
}

View file

@ -51,6 +51,7 @@ func convertMemoViewFromStore(username string, memoView *storepb.MemoViewsUserSe
Name: constructMemoViewName(username, memoView.GetId()), Name: constructMemoViewName(username, memoView.GetId()),
Title: memoView.GetTitle(), Title: memoView.GetTitle(),
Filter: memoView.GetFilter(), Filter: memoView.GetFilter(),
Icon: convertMemoViewIconFromStore(memoView.GetIcon()),
} }
} }
@ -138,6 +139,7 @@ func (s *APIV1Service) CreateMemoView(ctx context.Context, request *v1pb.CreateM
Id: util.GenUUID(), Id: util.GenUUID(),
Title: request.GetMemoView().GetTitle(), Title: request.GetMemoView().GetTitle(),
Filter: request.GetMemoView().GetFilter(), Filter: request.GetMemoView().GetFilter(),
Icon: convertMemoViewIconToStore(request.GetMemoView().GetIcon()),
} }
if newMemoView.Title == "" { if newMemoView.Title == "" {
return nil, status.Errorf(codes.InvalidArgument, "title is required") return nil, status.Errorf(codes.InvalidArgument, "title is required")
@ -145,6 +147,9 @@ func (s *APIV1Service) CreateMemoView(ctx context.Context, request *v1pb.CreateM
if err := s.validateFilter(ctx, newMemoView.Filter); err != nil { if err := s.validateFilter(ctx, newMemoView.Filter); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err) return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
} }
if err := store.ValidateMemoViewIcon(newMemoView.Icon); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid icon: %v", err)
}
if request.ValidateOnly { if request.ValidateOnly {
return convertMemoViewFromStore(user.Username, newMemoView), nil return convertMemoViewFromStore(user.Username, newMemoView), nil
} }
@ -170,6 +175,7 @@ func (s *APIV1Service) UpdateMemoView(ctx context.Context, request *v1pb.UpdateM
} }
var title, filterValue *string var title, filterValue *string
var icon **storepb.MemoViewsUserSetting_MemoView_Icon
for _, field := range request.UpdateMask.Paths { for _, field := range request.UpdateMask.Paths {
switch field { switch field {
case "title": case "title":
@ -184,12 +190,18 @@ func (s *APIV1Service) UpdateMemoView(ctx context.Context, request *v1pb.UpdateM
} }
value := request.GetMemoView().GetFilter() value := request.GetMemoView().GetFilter()
filterValue = &value filterValue = &value
case "icon":
value := convertMemoViewIconToStore(request.GetMemoView().GetIcon())
if err := store.ValidateMemoViewIcon(value); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid icon: %v", err)
}
icon = &value
default: default:
return nil, status.Errorf(codes.InvalidArgument, "unsupported update mask path: %s", field) return nil, status.Errorf(codes.InvalidArgument, "unsupported update mask path: %s", field)
} }
} }
updatedMemoView, err := s.Store.UpdateUserMemoView(ctx, user.ID, memoViewID, title, filterValue) updatedMemoView, err := s.Store.UpdateUserMemoView(ctx, user.ID, memoViewID, title, filterValue, icon)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update memo view: %v", err) return nil, status.Errorf(codes.Internal, "failed to update memo view: %v", err)
} }

33
store/custom_icon.go Normal file
View file

@ -0,0 +1,33 @@
package store
import (
"strings"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/markdown/parser"
)
func validateEmojiIcon(emoji string) error {
if !parser.IsEmoji(emoji) {
return errors.New("icon must contain one fully qualified emoji")
}
return nil
}
func validateLucideIcon(name string) error {
if len(name) == 0 || len(name) > 128 {
return errors.New("icon name must contain 1 to 128 bytes")
}
for _, part := range strings.Split(name, "-") {
if part == "" {
return errors.New("icon name must use lowercase kebab-case")
}
for _, char := range part {
if (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return errors.New("icon name must use lowercase kebab-case")
}
}
}
return nil
}

22
store/memo_view_icon.go Normal file
View file

@ -0,0 +1,22 @@
package store
import (
"github.com/pkg/errors"
storepb "github.com/usememos/memos/proto/gen/store"
)
// ValidateMemoViewIcon validates optional display metadata for a saved view.
func ValidateMemoViewIcon(icon *storepb.MemoViewsUserSetting_MemoView_Icon) error {
if icon == nil {
return nil
}
switch value := icon.Value.(type) {
case *storepb.MemoViewsUserSetting_MemoView_Icon_Emoji:
return validateEmojiIcon(value.Emoji)
case *storepb.MemoViewsUserSetting_MemoView_Icon_Lucide:
return validateLucideIcon(value.Lucide)
default:
return errors.New("view icon must specify emoji or lucide")
}
}

View file

@ -1,11 +1,8 @@
package store package store
import ( import (
"strings"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/usememos/memos/internal/markdown/parser"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
) )
@ -17,25 +14,10 @@ func ValidateSpaceIcon(icon *storepb.SpacePayload_Icon) error {
} }
switch value := icon.Value.(type) { switch value := icon.Value.(type) {
case *storepb.SpacePayload_Icon_Emoji: case *storepb.SpacePayload_Icon_Emoji:
if !parser.IsEmoji(value.Emoji) { return validateEmojiIcon(value.Emoji)
return errors.New("space icon must contain one fully qualified emoji")
}
case *storepb.SpacePayload_Icon_Lucide: case *storepb.SpacePayload_Icon_Lucide:
if len(value.Lucide) == 0 || len(value.Lucide) > 128 { return validateLucideIcon(value.Lucide)
return errors.New("space icon name must contain 1 to 128 bytes")
}
for _, part := range strings.Split(value.Lucide, "-") {
if part == "" {
return errors.New("space icon name must use lowercase kebab-case")
}
for _, char := range part {
if (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return errors.New("space icon name must use lowercase kebab-case")
}
}
}
default: default:
return errors.New("space icon must specify emoji or lucide") return errors.New("space icon must specify emoji or lucide")
} }
return nil
} }

View file

@ -939,21 +939,27 @@ func TestUserMemoViewConcurrentPartialUpdates(t *testing.T) {
Id: "work", Id: "work",
Title: "Original title", Title: "Original title",
Filter: `tag in ["original"]`, Filter: `tag in ["original"]`,
Icon: &storepb.MemoViewsUserSetting_MemoView_Icon{Value: &storepb.MemoViewsUserSetting_MemoView_Icon_Emoji{Emoji: "🌱"}},
})) }))
updatedTitle := "Updated title" updatedTitle := "Updated title"
updatedFilter := `tag in ["updated"]` updatedFilter := `tag in ["updated"]`
var wg sync.WaitGroup var wg sync.WaitGroup
errs := make([]error, 2) errs := make([]error, 3)
start := make(chan struct{}) start := make(chan struct{})
wg.Go(func() { wg.Go(func() {
<-start <-start
_, errs[0] = ts.UpdateUserMemoView(ctx, user.ID, "work", &updatedTitle, nil) _, errs[0] = ts.UpdateUserMemoView(ctx, user.ID, "work", &updatedTitle, nil, nil)
}) })
wg.Go(func() { wg.Go(func() {
<-start <-start
_, errs[1] = ts.UpdateUserMemoView(ctx, user.ID, "work", nil, &updatedFilter) _, errs[1] = ts.UpdateUserMemoView(ctx, user.ID, "work", nil, &updatedFilter, nil)
})
wg.Go(func() {
<-start
icon := &storepb.MemoViewsUserSetting_MemoView_Icon{Value: &storepb.MemoViewsUserSetting_MemoView_Icon_Lucide{Lucide: "leaf"}}
_, errs[2] = ts.UpdateUserMemoView(ctx, user.ID, "work", nil, nil, &icon)
}) })
close(start) close(start)
wg.Wait() wg.Wait()
@ -961,11 +967,14 @@ func TestUserMemoViewConcurrentPartialUpdates(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
} }
_, err = ts.ListUserSettings(ctx, &store.FindUserSetting{UserID: &user.ID, Key: storepb.UserSetting_MEMO_VIEWS})
require.NoError(t, err)
memoViews, err := ts.GetUserMemoViews(ctx, user.ID) memoViews, err := ts.GetUserMemoViews(ctx, user.ID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, memoViews, 1) require.Len(t, memoViews, 1)
require.Equal(t, updatedTitle, memoViews[0].GetTitle()) require.Equal(t, updatedTitle, memoViews[0].GetTitle())
require.Equal(t, updatedFilter, memoViews[0].GetFilter()) require.Equal(t, updatedFilter, memoViews[0].GetFilter())
require.Equal(t, "leaf", memoViews[0].GetIcon().GetLucide())
} }
func TestGetUserMemoViewsReturnsCopies(t *testing.T) { func TestGetUserMemoViewsReturnsCopies(t *testing.T) {
@ -980,17 +989,20 @@ func TestGetUserMemoViewsReturnsCopies(t *testing.T) {
Id: "work", Id: "work",
Title: "Original title", Title: "Original title",
Filter: `tag in ["original"]`, Filter: `tag in ["original"]`,
Icon: &storepb.MemoViewsUserSetting_MemoView_Icon{Value: &storepb.MemoViewsUserSetting_MemoView_Icon_Emoji{Emoji: "🌱"}},
})) }))
firstRead, err := ts.GetUserMemoViews(ctx, user.ID) firstRead, err := ts.GetUserMemoViews(ctx, user.ID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, firstRead, 1) require.Len(t, firstRead, 1)
firstRead[0].Title = "Mutated title" firstRead[0].Title = "Mutated title"
firstRead[0].Icon.Value = &storepb.MemoViewsUserSetting_MemoView_Icon_Lucide{Lucide: "leaf"}
secondRead, err := ts.GetUserMemoViews(ctx, user.ID) secondRead, err := ts.GetUserMemoViews(ctx, user.ID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, secondRead, 1) require.Len(t, secondRead, 1)
require.Equal(t, "Original title", secondRead[0].GetTitle()) require.Equal(t, "Original title", secondRead[0].GetTitle())
require.Equal(t, "🌱", secondRead[0].GetIcon().GetEmoji())
} }
func TestUserSettingMemoViewsPartialUpdate(t *testing.T) { func TestUserSettingMemoViewsPartialUpdate(t *testing.T) {

View file

@ -495,6 +495,7 @@ func (s *Store) AddUserMemoView(ctx context.Context, userID int32, memoView *sto
} }
// UpdateUserMemoView applies the non-nil field updates to the memo view carrying the same ID. // UpdateUserMemoView applies the non-nil field updates to the memo view carrying the same ID.
// A non-nil icon update containing nil resets the icon.
// It returns nil when no matching memo view is found. // It returns nil when no matching memo view is found.
func (s *Store) UpdateUserMemoView( func (s *Store) UpdateUserMemoView(
ctx context.Context, ctx context.Context,
@ -502,6 +503,7 @@ func (s *Store) UpdateUserMemoView(
memoViewID string, memoViewID string,
title *string, title *string,
filter *string, filter *string,
icon **storepb.MemoViewsUserSetting_MemoView_Icon,
) (*storepb.MemoViewsUserSetting_MemoView, error) { ) (*storepb.MemoViewsUserSetting_MemoView, error) {
s.memoViewMu.Lock() s.memoViewMu.Lock()
defer s.memoViewMu.Unlock() defer s.memoViewMu.Unlock()
@ -519,17 +521,16 @@ func (s *Store) UpdateUserMemoView(
continue continue
} }
updatedMemoView = &storepb.MemoViewsUserSetting_MemoView{ updatedMemoView = proto.CloneOf(item)
Id: item.GetId(),
Title: item.GetTitle(),
Filter: item.GetFilter(),
}
if title != nil { if title != nil {
updatedMemoView.Title = *title updatedMemoView.Title = *title
} }
if filter != nil { if filter != nil {
updatedMemoView.Filter = *filter updatedMemoView.Filter = *filter
} }
if icon != nil {
updatedMemoView.Icon = proto.CloneOf(*icon)
}
memoViews = append(memoViews, updatedMemoView) memoViews = append(memoViews, updatedMemoView)
} }
if updatedMemoView == nil { if updatedMemoView == nil {

View file

@ -1,10 +1,11 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { MoreHorizontalIcon, ParenthesesIcon, PlusIcon, SquareCheckIcon } from "lucide-react"; import { MoreHorizontalIcon, PlusIcon, SquareCheckIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu"; import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu";
import MemoViewIcon from "@/components/MemoViewIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
@ -20,9 +21,10 @@ import type { MemoView } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import SidebarRow, { import SidebarRow, {
SIDEBAR_ROW_BOX_CLASSES, SIDEBAR_ROW_BOX_CLASSES,
SIDEBAR_ROW_ICON_CLASSES,
SIDEBAR_ROW_LABEL_CLASSES, SIDEBAR_ROW_LABEL_CLASSES,
SIDEBAR_ROW_SLOT_BUTTON_CLASSES, SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
SidebarRowIconSlot, SIDEBAR_ROW_SLOT_CLASSES,
sidebarRowStateAttributes, sidebarRowStateAttributes,
sidebarRowStateClasses, sidebarRowStateClasses,
} from "./SidebarRow"; } from "./SidebarRow";
@ -102,7 +104,9 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
className={cn(SIDEBAR_ROW_BOX_CLASSES, "group/view", sidebarRowStateClasses(state))} className={cn(SIDEBAR_ROW_BOX_CLASSES, "group/view", sidebarRowStateClasses(state))}
> >
<button type="button" onClick={() => handleView(id)} aria-pressed={active || undefined} className={SIDEBAR_ROW_LABEL_CLASSES}> <button type="button" onClick={() => handleView(id)} aria-pressed={active || undefined} className={SIDEBAR_ROW_LABEL_CLASSES}>
<SidebarRowIconSlot icon={ParenthesesIcon} /> <span className={SIDEBAR_ROW_SLOT_CLASSES} aria-hidden="true">
<MemoViewIcon icon={memoView.icon} className={cn(SIDEBAR_ROW_ICON_CLASSES, "text-base")} />
</span>
<span className="min-w-0 flex-1 truncate">{memoView.title}</span> <span className="min-w-0 flex-1 truncate">{memoView.title}</span>
</button> </button>
<DropdownMenu> <DropdownMenu>

View file

@ -0,0 +1,24 @@
import { AstroidIcon, type LucideIcon } from "lucide-react";
import type { CustomIconValue } from "@/lib/custom-icons";
import { getCustomSymbol } from "@/lib/custom-icons";
import { cn } from "@/lib/utils";
interface Props {
icon?: CustomIconValue;
className?: string;
fallback?: LucideIcon;
}
function CustomIcon({ icon, className, fallback = AstroidIcon }: Props) {
if (icon?.value.case === "emoji" && icon.value.value) {
return (
<span aria-hidden className={cn("inline-flex shrink-0 items-center justify-center leading-none", className)}>
{icon.value.value}
</span>
);
}
const IconComponent = getCustomSymbol(icon?.value.case === "lucide" ? icon.value.value : "", fallback);
return <IconComponent aria-hidden className={cn("shrink-0", className)} strokeWidth={1.8} />;
}
export default CustomIcon;

View file

@ -0,0 +1,192 @@
import { AstroidIcon, type LucideIcon, RotateCcwIcon, SearchIcon } from "lucide-react";
import { type KeyboardEvent, useId, useRef, useState } from "react";
import CustomIcon from "@/components/CustomIcon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { CustomIconValue } from "@/lib/custom-icons";
import { CUSTOM_SYMBOLS, customIconsEqual } from "@/lib/custom-icons";
import { SPACE_EMOJI } from "@/lib/space-emoji";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { FULLY_QUALIFIED_EMOJI } from "@/utils/tag-unicode-data";
const emojiSet = new Set(FULLY_QUALIFIED_EMOJI);
const symbolLabels: Record<string, string> = {
astroid: "Space",
"book-open": "Book",
"chart-no-axes-combined": "Chart",
"check-check": "Checklist",
"flask-conical": "Science",
"flower-2": "Flower",
"graduation-cap": "Education",
"map-pin": "Location",
"notebook-pen": "Notebook",
};
const symbols = Object.keys(CUSTOM_SYMBOLS).map((name) => ({
label: symbolLabels[name] ?? name.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase()),
icon: { value: { case: "lucide", value: name } } satisfies CustomIconValue,
}));
const emojis = SPACE_EMOJI.map(([emoji, label]) => ({ label, icon: { value: { case: "emoji", value: emoji } } satisfies CustomIconValue }));
interface Props {
value?: CustomIconValue;
onChange: (icon: CustomIconValue | undefined) => void;
disabled?: boolean;
label: string;
fallback?: LucideIcon;
}
function CustomIconPicker({ value, onChange, disabled, label, fallback = AstroidIcon }: Props) {
const t = useTranslate();
const id = useId();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState("icons");
const [query, setQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(0);
const searchRef = useRef<HTMLInputElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const search = query.trim().toLowerCase();
const options: { label: string; icon: CustomIconValue }[] = (tab === "icons" ? symbols : emojis).filter(
(option) => option.label.toLowerCase().includes(search) || option.icon.value.value?.includes(search),
);
const customEmoji = query.trim() || (value?.value.case === "emoji" ? value.value.value : "");
if (tab === "emoji" && emojiSet.has(customEmoji) && !options.some((option) => option.icon.value.value === customEmoji)) {
options.unshift({
label: t("space.icon.use-emoji", { emoji: customEmoji }),
icon: { value: { case: "emoji", value: customEmoji } },
});
}
const select = (icon?: CustomIconValue) => {
onChange(icon);
setOpen(false);
};
const changeTab = (next: string) => {
setTab(next);
setQuery("");
setFocusedIndex(0);
};
const moveFocus = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const offsets: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -8, ArrowDown: 8 };
let next = index;
if (event.key in offsets) next = Math.max(0, Math.min(options.length - 1, index + offsets[event.key]));
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = options.length - 1;
else return;
event.preventDefault();
setFocusedIndex(next);
gridRef.current?.querySelectorAll<HTMLButtonElement>("button")[next]?.focus();
};
return (
<Popover
open={open}
onOpenChange={(next) => {
if (next) {
changeTab(value?.value.case === "emoji" ? "emoji" : "icons");
}
setOpen(next);
}}
>
<PopoverTrigger
render={
<Button type="button" variant="outline" size="icon" className="size-8 p-0" disabled={disabled} aria-label={label} title={label} />
}
>
<CustomIcon icon={value} fallback={fallback} className="size-5 text-xl" />
</PopoverTrigger>
<PopoverContent align="start" className="w-[290px] max-w-[calc(100vw-2rem)] p-2" aria-label={label} initialFocus={searchRef}>
<Tabs value={tab} onValueChange={changeTab}>
<TabsList className="mb-2 rounded-md bg-muted/60 p-0.5" aria-label={t("space.icon.kind")}>
{(["icons", "emoji"] as const).map((name) => (
<TabsTrigger
key={name}
id={`${id}-${name}`}
value={name}
aria-controls={`${id}-panel`}
tabIndex={tab === name ? 0 : -1}
className="flex-1 py-1 text-xs"
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const next = name === "icons" ? "emoji" : "icons";
changeTab(next);
document.getElementById(`${id}-${next}`)?.focus();
}
}}
>
{t(`space.icon.${name}`)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${tab}`}>
<div className="relative mb-2">
<SearchIcon aria-hidden className="pointer-events-none absolute start-2 top-2 size-4 text-muted-foreground" />
<Input
ref={searchRef}
value={query}
className="ps-8 text-sm"
aria-label={t(tab === "icons" ? "space.icon.search-icons" : "space.icon.search-emoji")}
placeholder={t(tab === "icons" ? "space.icon.search-icons" : "space.icon.search-emoji")}
onChange={(event) => {
setQuery(event.target.value);
setFocusedIndex(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter") {
event.preventDefault();
if (options[0]) select(options[0].icon);
} else if (event.key === "ArrowDown" && options.length) {
event.preventDefault();
setFocusedIndex(0);
gridRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
}
}}
/>
</div>
<div className="max-h-56 overflow-y-auto overscroll-contain" ref={gridRef}>
{options.length ? (
<div className="grid grid-cols-8 gap-0.5">
{options.map((option, index) => (
<Button
key={`${option.icon.value.case}:${option.icon.value.value}`}
type="button"
variant="ghost"
size="icon"
aria-label={option.label}
title={option.label}
aria-pressed={customIconsEqual(value, option.icon)}
tabIndex={index === Math.min(focusedIndex, options.length - 1) ? 0 : -1}
onFocus={() => setFocusedIndex(index)}
onKeyDown={(event) => moveFocus(event, index)}
onClick={() => select(option.icon)}
className={cn("rounded-md", customIconsEqual(value, option.icon) && "bg-accent ring-1 ring-inset ring-primary/40")}
>
<CustomIcon icon={option.icon} className={tab === "emoji" ? "size-5 text-xl" : "size-4"} />
</Button>
))}
</div>
) : (
<p role="status" className="py-8 text-center text-xs text-muted-foreground">
{t("space.icon.no-results")}
</p>
)}
</div>
{tab === "emoji" && <p className="px-1 pt-2 text-xs leading-4 text-muted-foreground">{t("space.icon.emoji-help")}</p>}
</div>
<div className="mt-2 border-t pt-1.5">
<Button type="button" variant="quiet" size="sm" className="w-full justify-start" disabled={!value} onClick={() => select()}>
<RotateCcwIcon aria-hidden className="size-3.5" />
{t("space.icon.reset")}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
export default CustomIconPicker;

View file

@ -10,12 +10,13 @@ import {
LinkIcon, LinkIcon,
type LucideIcon, type LucideIcon,
MapPinIcon, MapPinIcon,
ParenthesesIcon,
SearchIcon, SearchIcon,
SquareCheckIcon, SquareCheckIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import MemoViewIcon from "@/components/MemoViewIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAppSidebar } from "@/contexts/AppSidebarContext"; import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { type FilterFactor, getMemoFilterKey, type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { type FilterFactor, getMemoFilterKey, type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
@ -83,6 +84,7 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
interface FilterChipProps { interface FilterChipProps {
icon?: LucideIcon; icon?: LucideIcon;
customIcon?: ReactNode;
label: string; label: string;
onRemove: () => void; onRemove: () => void;
/** When set, the label is a button that edits the filter; the label is also shown in full as a tooltip. */ /** When set, the label is a button that edits the filter; the label is also shown in full as a tooltip. */
@ -90,10 +92,10 @@ interface FilterChipProps {
} }
/** One chip for anything narrowing the list, so a view, a tag and a day are announced the same way. */ /** One chip for anything narrowing the list, so a view, a tag and a day are announced the same way. */
const FilterChip = ({ icon: Icon, label, onRemove, onEdit }: FilterChipProps) => { const FilterChip = ({ icon: Icon, customIcon, label, onRemove, onEdit }: FilterChipProps) => {
const body = ( const body = (
<> <>
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />} {customIcon ?? (Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />)}
<span className={cn("text-foreground/80 truncate", onEdit ? "font-mono text-xs max-w-64" : "font-medium max-w-32")}>{label}</span> <span className={cn("text-foreground/80 truncate", onEdit ? "font-mono text-xs max-w-64" : "font-medium max-w-32")}>{label}</span>
</> </>
); );
@ -151,8 +153,13 @@ const MemoFilters = ({ className }: { className?: string }) => {
const viewChip = (() => { const viewChip = (() => {
if (!viewApplies) return null; if (!viewApplies) return null;
if (memoView === BUILTIN_TASKS_VIEW_ID) return { icon: SquareCheckIcon, label: t("common.tasks") }; if (memoView === BUILTIN_TASKS_VIEW_ID) return { icon: SquareCheckIcon, label: t("common.tasks") };
const title = memoViews.find((item) => getMemoViewId(item.name) === memoView)?.title; const view = memoViews.find((item) => getMemoViewId(item.name) === memoView);
return title ? { icon: ParenthesesIcon, label: title } : null; return view?.title
? {
customIcon: <MemoViewIcon icon={view.icon} className="size-3.5 text-sm text-muted-foreground" />,
label: view.title,
}
: null;
})(); })();
if (filters.length === 0 && !viewChip) { if (filters.length === 0 && !viewChip) {
@ -161,7 +168,9 @@ const MemoFilters = ({ className }: { className?: string }) => {
return ( return (
<div className={cn("w-full flex flex-row justify-start items-center flex-wrap gap-2", className)}> <div className={cn("w-full flex flex-row justify-start items-center flex-wrap gap-2", className)}>
{viewChip && <FilterChip icon={viewChip.icon} label={viewChip.label} onRemove={() => setMemoView(undefined)} />} {viewChip && (
<FilterChip icon={viewChip.icon} customIcon={viewChip.customIcon} label={viewChip.label} onRemove={() => setMemoView(undefined)} />
)}
{filters.map((filter) => ( {filters.map((filter) => (
<FilterChip <FilterChip
key={getMemoFilterKey(filter)} key={getMemoFilterKey(filter)}

View file

@ -0,0 +1,9 @@
import { ParenthesesIcon } from "lucide-react";
import CustomIcon from "@/components/CustomIcon";
import type { MemoView_Icon } from "@/types/proto/api/v1/user_service_pb";
const MemoViewIcon = ({ icon, className }: { icon?: MemoView_Icon; className?: string }) => (
<CustomIcon icon={icon} className={className} fallback={ParenthesesIcon} />
);
export default MemoViewIcon;

View file

@ -42,10 +42,10 @@ import {
useUserSpaceInvitations, useUserSpaceInvitations,
} from "@/hooks/useSpaceQueries"; } from "@/hooks/useSpaceQueries";
import { useUsersByUsernames } from "@/hooks/useUserQueries"; import { useUsersByUsernames } from "@/hooks/useUserQueries";
import { customIconsEqual } from "@/lib/custom-icons";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
import { extractUsernameFromName } from "@/lib/resource-names"; import { extractUsernameFromName } from "@/lib/resource-names";
import { extractSpaceUidFromName } from "@/lib/space-display"; import { extractSpaceUidFromName } from "@/lib/space-display";
import { spaceIconsEqual } from "@/lib/space-icons";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { type Space, type SpaceInvitation, type SpaceMember, SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb"; import { type Space, type SpaceInvitation, type SpaceMember, SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb";
@ -370,7 +370,7 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
{ value: String(SpaceMember_Role.USER), label: t("setting.spaces.space-user") }, { value: String(SpaceMember_Role.USER), label: t("setting.spaces.space-user") },
{ value: String(SpaceMember_Role.ADMIN), label: t("setting.spaces.space-admin") }, { value: String(SpaceMember_Role.ADMIN), label: t("setting.spaces.space-admin") },
]; ];
const iconChanged = !spaceIconsEqual(icon, space.icon); const iconChanged = !customIconsEqual(icon, space.icon);
const detailsChanged = title.trim() !== space.title || description.trim() !== space.description || iconChanged; const detailsChanged = title.trim() !== space.title || description.trim() !== space.description || iconChanged;
const spaceUid = extractSpaceUidFromName(space.name); const spaceUid = extractSpaceUidFromName(space.name);
const disambiguatedSpaceTitle = `${space.title} (${spaceUid})`; const disambiguatedSpaceTitle = `${space.title} (${spaceUid})`;

View file

@ -1,22 +1,6 @@
import { getSpaceSymbol } from "@/lib/space-icons"; import CustomIcon from "@/components/CustomIcon";
import { cn } from "@/lib/utils";
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb"; import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb";
interface Props { const SpaceIcon = ({ icon, className }: { icon?: Space_Icon; className?: string }) => <CustomIcon icon={icon} className={className} />;
icon?: Space_Icon;
className?: string;
}
function SpaceIcon({ icon, className }: Props) {
if (icon?.value.case === "emoji" && icon.value.value) {
return (
<span aria-hidden className={cn("inline-flex shrink-0 items-center justify-center leading-none", className)}>
{icon.value.value}
</span>
);
}
const IconComponent = getSpaceSymbol(icon?.value.case === "lucide" ? icon.value.value : "astroid");
return <IconComponent aria-hidden className={cn("shrink-0", className)} strokeWidth={1.8} />;
}
export default SpaceIcon; export default SpaceIcon;

View file

@ -1,36 +1,7 @@
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { RotateCcwIcon, SearchIcon } from "lucide-react"; import CustomIconPicker from "@/components/CustomIconPicker";
import { type KeyboardEvent, useId, useRef, useState } from "react";
import SpaceIcon from "@/components/SpaceIcon";
import SpaceMark from "@/components/SpaceMark";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SPACE_EMOJI } from "@/lib/space-emoji";
import { SPACE_SYMBOLS, spaceIconsEqual } from "@/lib/space-icons";
import { cn } from "@/lib/utils";
import { type Space_Icon, Space_IconSchema } from "@/types/proto/api/v1/space_service_pb"; import { type Space_Icon, Space_IconSchema } from "@/types/proto/api/v1/space_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { FULLY_QUALIFIED_EMOJI } from "@/utils/tag-unicode-data";
const emojiSet = new Set(FULLY_QUALIFIED_EMOJI);
const symbolLabels: Record<string, string> = {
astroid: "Space",
"book-open": "Book",
"chart-no-axes-combined": "Chart",
"check-check": "Checklist",
"flask-conical": "Science",
"flower-2": "Flower",
"graduation-cap": "Education",
"map-pin": "Location",
"notebook-pen": "Notebook",
};
const symbols = Object.keys(SPACE_SYMBOLS).map((name) => ({
label: symbolLabels[name] ?? name.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase()),
icon: create(Space_IconSchema, { value: { case: "lucide", value: name } }),
}));
const emojis = SPACE_EMOJI.map(([emoji, label]) => ({ label, icon: create(Space_IconSchema, { value: { case: "emoji", value: emoji } }) }));
interface Props { interface Props {
value?: Space_Icon; value?: Space_Icon;
@ -38,168 +9,16 @@ interface Props {
disabled?: boolean; disabled?: boolean;
} }
function SpaceIconPicker({ value, onChange, disabled }: Props) { const SpaceIconPicker = ({ value, onChange, disabled }: Props) => {
const t = useTranslate(); const t = useTranslate();
const id = useId();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState("icons");
const [query, setQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(0);
const searchRef = useRef<HTMLInputElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const search = query.trim().toLowerCase();
const options = (tab === "icons" ? symbols : emojis).filter(
(option) => option.label.toLowerCase().includes(search) || option.icon.value.value?.includes(search),
);
const customEmoji = query.trim() || (value?.value.case === "emoji" ? value.value.value : "");
if (tab === "emoji" && emojiSet.has(customEmoji) && !options.some((option) => option.icon.value.value === customEmoji)) {
options.unshift({
label: t("space.icon.use-emoji", { emoji: customEmoji }),
icon: create(Space_IconSchema, { value: { case: "emoji", value: customEmoji } }),
});
}
const select = (icon?: Space_Icon) => {
onChange(icon);
setOpen(false);
};
const changeTab = (next: string) => {
setTab(next);
setQuery("");
setFocusedIndex(0);
};
const moveFocus = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const offsets: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -8, ArrowDown: 8 };
let next = index;
if (event.key in offsets) next = Math.max(0, Math.min(options.length - 1, index + offsets[event.key]));
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = options.length - 1;
else return;
event.preventDefault();
setFocusedIndex(next);
gridRef.current?.querySelectorAll<HTMLButtonElement>("button")[next]?.focus();
};
return ( return (
<Popover <CustomIconPicker
open={open} value={value}
onOpenChange={(next) => { onChange={(icon) => onChange(icon ? create(Space_IconSchema, icon) : undefined)}
if (next) { disabled={disabled}
changeTab(value?.value.case === "emoji" ? "emoji" : "icons"); label={t("space.icon.change")}
} />
setOpen(next);
}}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="icon"
className="size-8 p-0"
disabled={disabled}
aria-label={t("space.icon.change")}
title={t("space.icon.change")}
/>
}
>
<SpaceMark icon={value} size="lg" className="size-full bg-transparent" />
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[290px] max-w-[calc(100vw-2rem)] p-2"
aria-label={t("space.icon.change")}
initialFocus={searchRef}
>
<Tabs value={tab} onValueChange={changeTab}>
<TabsList className="mb-2 rounded-md bg-muted/60 p-0.5" aria-label={t("space.icon.kind")}>
{(["icons", "emoji"] as const).map((name) => (
<TabsTrigger
key={name}
id={`${id}-${name}`}
value={name}
aria-controls={`${id}-panel`}
tabIndex={tab === name ? 0 : -1}
className="flex-1 py-1 text-xs"
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
const next = name === "icons" ? "emoji" : "icons";
changeTab(next);
document.getElementById(`${id}-${next}`)?.focus();
}
}}
>
{t(`space.icon.${name}`)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${tab}`}>
<div className="relative mb-2">
<SearchIcon aria-hidden className="pointer-events-none absolute start-2 top-2 size-4 text-muted-foreground" />
<Input
ref={searchRef}
value={query}
className="ps-8 text-sm"
aria-label={t(tab === "icons" ? "space.icon.search-icons" : "space.icon.search-emoji")}
placeholder={t(tab === "icons" ? "space.icon.search-icons" : "space.icon.search-emoji")}
onChange={(event) => {
setQuery(event.target.value);
setFocusedIndex(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter") {
event.preventDefault();
if (options[0]) select(options[0].icon);
} else if (event.key === "ArrowDown" && options.length) {
event.preventDefault();
setFocusedIndex(0);
gridRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
}
}}
/>
</div>
<div className="max-h-56 overflow-y-auto overscroll-contain" ref={gridRef}>
{options.length ? (
<div className="grid grid-cols-8 gap-0.5">
{options.map((option, index) => (
<Button
key={`${option.icon.value.case}:${option.icon.value.value}`}
type="button"
variant="ghost"
size="icon"
aria-label={option.label}
title={option.label}
aria-pressed={spaceIconsEqual(value, option.icon)}
tabIndex={index === Math.min(focusedIndex, options.length - 1) ? 0 : -1}
onFocus={() => setFocusedIndex(index)}
onKeyDown={(event) => moveFocus(event, index)}
onClick={() => select(option.icon)}
className={cn("rounded-md", spaceIconsEqual(value, option.icon) && "bg-accent ring-1 ring-inset ring-primary/40")}
>
<SpaceIcon icon={option.icon} className={tab === "emoji" ? "size-5 text-xl" : "size-4"} />
</Button>
))}
</div>
) : (
<p role="status" className="py-8 text-center text-xs text-muted-foreground">
{t("space.icon.no-results")}
</p>
)}
</div>
{tab === "emoji" && <p className="px-1 pt-2 text-xs leading-4 text-muted-foreground">{t("space.icon.emoji-help")}</p>}
</div>
<div className="mt-2 border-t pt-1.5">
<Button type="button" variant="quiet" size="sm" className="w-full justify-start" disabled={!value} onClick={() => select()}>
<RotateCcwIcon aria-hidden className="size-3.5" />
{t("space.icon.reset")}
</Button>
</div>
</PopoverContent>
</Popover>
); );
} };
export default SpaceIconPicker; export default SpaceIconPicker;

View file

@ -19,6 +19,7 @@ import {
HouseIcon, HouseIcon,
LeafIcon, LeafIcon,
LightbulbIcon, LightbulbIcon,
type LucideIcon,
MapPinIcon, MapPinIcon,
MountainIcon, MountainIcon,
MusicIcon, MusicIcon,
@ -38,10 +39,13 @@ import {
WrenchIcon, WrenchIcon,
ZapIcon, ZapIcon,
} from "lucide-react"; } from "lucide-react";
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb"; // Shared display shape; each resource retains its own protobuf message type.
export interface CustomIconValue {
value: { case: "emoji" | "lucide"; value: string } | { case: undefined; value?: undefined };
}
// Keep the catalog explicit so adding a picker does not bundle the entire icon library. // Keep the catalog explicit so adding a picker does not bundle the entire icon library.
export const SPACE_SYMBOLS = { export const CUSTOM_SYMBOLS = {
astroid: AstroidIcon, astroid: AstroidIcon,
"book-open": BookOpenIcon, "book-open": BookOpenIcon,
briefcase: BriefcaseIcon, briefcase: BriefcaseIcon,
@ -82,7 +86,8 @@ export const SPACE_SYMBOLS = {
zap: ZapIcon, zap: ZapIcon,
}; };
export const getSpaceSymbol = (name: string) => export const getCustomSymbol = (name: string, fallback: LucideIcon = AstroidIcon) =>
Object.hasOwn(SPACE_SYMBOLS, name) ? SPACE_SYMBOLS[name as keyof typeof SPACE_SYMBOLS] : AstroidIcon; Object.hasOwn(CUSTOM_SYMBOLS, name) ? CUSTOM_SYMBOLS[name as keyof typeof CUSTOM_SYMBOLS] : fallback;
export const spaceIconsEqual = (a?: Space_Icon, b?: Space_Icon) => a?.value.case === b?.value.case && a?.value.value === b?.value.value; export const customIconsEqual = (a?: CustomIconValue, b?: CustomIconValue) =>
a?.value.case === b?.value.case && a?.value.value === b?.value.value;

View file

@ -854,6 +854,7 @@
"uploads-privacy-title": "Yükləmələr və məxfilik" "uploads-privacy-title": "Yükləmələr və məxfilik"
}, },
"memo-view": { "memo-view": {
"change-icon": "Görünüş ikonunu dəyiş",
"delete-confirm": "`{{title}}` görünüşünü silmək istədiyinizə əminsiniz?", "delete-confirm": "`{{title}}` görünüşünü silmək istədiyinizə əminsiniz?",
"delete-success": "`{{title}}` görünüşü silindi" "delete-success": "`{{title}}` görünüşü silindi"
}, },

View file

@ -854,6 +854,7 @@
"uploads-privacy-title": "Uploads & privacy" "uploads-privacy-title": "Uploads & privacy"
}, },
"memo-view": { "memo-view": {
"change-icon": "Change view icon",
"delete-confirm": "Are you sure you want to delete view `{{title}}`?", "delete-confirm": "Are you sure you want to delete view `{{title}}`?",
"delete-success": "View `{{title}}` deleted successfully" "delete-success": "View `{{title}}` deleted successfully"
}, },

View file

@ -10,6 +10,7 @@ import {
FilterIcon, FilterIcon,
MapPinIcon, MapPinIcon,
MoreVerticalIcon, MoreVerticalIcon,
ParenthesesIcon,
PencilIcon, PencilIcon,
PinIcon, PinIcon,
PlusIcon, PlusIcon,
@ -24,6 +25,8 @@ import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import CustomIconPicker from "@/components/CustomIconPicker";
import MemoViewIcon from "@/components/MemoViewIcon";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@ -38,7 +41,7 @@ import { useMemoViews, userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
import { getMemoViewId } from "@/lib/memo-views"; import { getMemoViewId } from "@/lib/memo-views";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MemoView, MemoViewSchema } from "@/types/proto/api/v1/user_service_pb"; import { MemoView, MemoView_IconSchema, MemoViewSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
const memoViewExamples = [ const memoViewExamples = [
@ -287,6 +290,7 @@ const MemoViews = () => {
name: state.memoView.name, name: state.memoView.name,
title: state.memoView.title, title: state.memoView.title,
filter: state.memoView.filter, filter: state.memoView.filter,
icon: state.memoView.icon,
}), }),
); );
setIsCreateFormOpen(true); setIsCreateFormOpen(true);
@ -308,6 +312,7 @@ const MemoViews = () => {
name: draft.name, name: draft.name,
title: draft.title || example.title, title: draft.title || example.title,
filter: example.filter, filter: example.filter,
icon: draft.icon,
}), }),
); );
setIsCreateFormOpen(true); setIsCreateFormOpen(true);
@ -329,6 +334,7 @@ const MemoViews = () => {
name: memoView.name, name: memoView.name,
title: memoView.title, title: memoView.title,
filter: memoView.filter, filter: memoView.filter,
icon: memoView.icon,
}), }),
); );
setIsCreateFormOpen(true); setIsCreateFormOpen(true);
@ -348,7 +354,7 @@ const MemoViews = () => {
validateState.setLoading(); validateState.setLoading();
await userServiceClient.createMemoView({ await userServiceClient.createMemoView({
parent: user.name, parent: user.name,
memoView: { name: "", title: draft.title, filter: draft.filter }, memoView: { name: "", title: draft.title, filter: draft.filter, icon: draft.icon },
validateOnly: true, validateOnly: true,
}); });
validateState.setFinish(); validateState.setFinish();
@ -377,7 +383,7 @@ const MemoViews = () => {
createState.setLoading(); createState.setLoading();
await userServiceClient.createMemoView({ await userServiceClient.createMemoView({
parent: user.name, parent: user.name,
memoView: { name: "", title: draft.title, filter: draft.filter }, memoView: { name: "", title: draft.title, filter: draft.filter, icon: draft.icon },
}); });
await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(user.name) }); await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(user.name) });
createState.setFinish(); createState.setFinish();
@ -402,7 +408,7 @@ const MemoViews = () => {
updateState.setLoading(); updateState.setLoading();
await userServiceClient.updateMemoView({ await userServiceClient.updateMemoView({
memoView: draft, memoView: draft,
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }), updateMask: create(FieldMaskSchema, { paths: ["title", "filter", "icon"] }),
}); });
await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(user?.name) }); await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(user?.name) });
updateState.setFinish(); updateState.setFinish();
@ -491,15 +497,21 @@ const MemoViews = () => {
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="view-title">{t("common.title")}</Label> <Label htmlFor="view-title">{t("common.title")}</Label>
<Input <div className="flex items-center gap-2">
id="view-title" <CustomIconPicker
value={draft.title} value={draft.icon}
placeholder="Pinned, Recent notes, Work" onChange={(icon) => setDraftState({ icon: icon ? create(MemoView_IconSchema, icon) : undefined })}
onChange={(event) => setDraftState({ title: event.target.value })} label={t("setting.memo-view.change-icon")}
/> fallback={ParenthesesIcon}
<p className="text-xs leading-5 text-muted-foreground"> disabled={isSaving}
Prefix the title with an emoji if you want it to appear in the sidebar. />
</p> <Input
id="view-title"
value={draft.title}
placeholder="Pinned, Recent notes, Work"
onChange={(event) => setDraftState({ title: event.target.value })}
/>
</div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="view-filter">{t("common.filter")}</Label> <Label htmlFor="view-filter">{t("common.filter")}</Label>
@ -554,7 +566,10 @@ const MemoViews = () => {
className="grid gap-3 bg-background px-4 py-3 sm:grid-cols-[minmax(10rem,14rem)_minmax(0,1fr)_2rem]" className="grid gap-3 bg-background px-4 py-3 sm:grid-cols-[minmax(10rem,14rem)_minmax(0,1fr)_2rem]"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{memoView.title}</div> <div className="flex items-center gap-2 text-sm font-medium text-foreground">
<MemoViewIcon icon={memoView.icon} className="size-4 text-base" />
<span className="truncate">{memoView.title}</span>
</div>
<div className="mt-1 font-mono text-xs text-muted-foreground">{getMemoViewId(memoView.name)}</div> <div className="mt-1 font-mono text-xs text-muted-foreground">{getMemoViewId(memoView.name)}</div>
</div> </div>
<pre className="min-w-0 overflow-x-auto rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground"> <pre className="min-w-0 overflow-x-auto rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,112 @@
import { create } from "@bufbuild/protobuf";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MemoViewIcon from "@/components/MemoViewIcon";
import MemoViews from "@/pages/MemoViews";
import { MemoView_IconSchema, MemoViewSchema } from "@/types/proto/api/v1/user_service_pb";
const mocks = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn() }));
vi.mock("@/connect", () => ({ userServiceClient: { createMemoView: mocks.create, updateMemoView: mocks.update } }));
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/steven" }) }));
vi.mock("@/hooks/useUserQueries", () => ({ useMemoViews: () => ({ data: [] }), userKeys: { memoViews: () => ["views"] } }));
vi.mock("@/contexts/MemoFilterContext", () => ({ useMemoFilterContext: () => ({ setMemoView: vi.fn() }) }));
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
const emoji = create(MemoView_IconSchema, { value: { case: "emoji", value: "🌱" } });
const leaf = create(MemoView_IconSchema, { value: { case: "lucide", value: "leaf" } });
const existingView = create(MemoViewSchema, { name: "users/steven/views/garden", title: "Garden", filter: "pinned", icon: emoji });
const iconMask = expect.objectContaining({ paths: ["title", "filter", "icon"] });
function renderPage(edit = false) {
return render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MemoryRouter initialEntries={[{ pathname: "/views", state: edit ? { memoView: existingView } : { openCreate: true } }]}>
<MemoViews />
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.create.mockResolvedValue({});
mocks.update.mockResolvedValue({});
});
describe("memo view icons", () => {
it("renders emoji and symbols with a view-specific fallback", () => {
const view = render(<MemoViewIcon />);
expect(view.container.querySelector(".lucide-parentheses")).not.toBeNull();
view.rerender(<MemoViewIcon icon={emoji} />);
expect(view.container).toHaveTextContent("🌱");
view.rerender(<MemoViewIcon icon={leaf} />);
expect(view.container.querySelector(".lucide-leaf")).not.toBeNull();
view.rerender(<MemoViewIcon icon={create(MemoView_IconSchema, { value: { case: "lucide", value: "future-icon" } })} />);
expect(view.container.querySelector(".lucide-parentheses")).not.toBeNull();
});
it("creates and validates a view with a selected icon", async () => {
renderPage();
fireEvent.change(screen.getByLabelText("common.title"), { target: { value: "Garden" } });
fireEvent.change(screen.getByLabelText("common.filter"), { target: { value: "pinned" } });
fireEvent.click(screen.getByRole("button", { name: "setting.memo-view.change-icon" }));
const search = await screen.findByRole("textbox", { name: "space.icon.search-icons" });
fireEvent.change(search, { target: { value: "leaf" } });
fireEvent.keyDown(search, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Validate" }));
await waitFor(() =>
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({
validateOnly: true,
memoView: expect.objectContaining({ icon: leaf }),
}),
),
);
await waitFor(() => expect(screen.getByRole("button", { name: "common.save" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() =>
expect(mocks.create).toHaveBeenLastCalledWith({
parent: "users/steven",
memoView: { name: "", title: "Garden", filter: "pinned", icon: leaf },
}),
);
});
it("preserves the icon when editing from the sidebar and applying an example", async () => {
renderPage(true);
expect(screen.getByRole("button", { name: "setting.memo-view.change-icon" })).toHaveTextContent("🌱");
fireEvent.click(screen.getByRole("button", { name: /Recent notes/ }));
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() =>
expect(mocks.update).toHaveBeenCalledWith(
expect.objectContaining({
memoView: expect.objectContaining({
name: existingView.name,
title: "Garden",
icon: emoji,
filter: 'created_ts >= now - duration("1h")',
}),
updateMask: iconMask,
}),
),
);
});
it("resets an existing icon using the icon update mask", async () => {
renderPage(true);
fireEvent.click(screen.getByRole("button", { name: "setting.memo-view.change-icon" }));
fireEvent.click(await screen.findByRole("button", { name: "space.icon.reset" }));
expect(screen.getByRole("button", { name: "setting.memo-view.change-icon" }).querySelector(".lucide-parentheses")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() =>
expect(mocks.update).toHaveBeenCalledWith(
expect.objectContaining({
memoView: expect.objectContaining({ icon: undefined }),
updateMask: iconMask,
}),
),
);
});
});