diff --git a/internal/markdown/parser/emoji.go b/internal/markdown/parser/emoji.go new file mode 100644 index 00000000..f96437ee --- /dev/null +++ b/internal/markdown/parser/emoji.go @@ -0,0 +1,7 @@ +package parser + +// IsEmoji reports whether value is one fully qualified Unicode emoji sequence. +func IsEmoji(value string) bool { + _, ok := emoji17FullyQualified[value] + return ok +} diff --git a/proto/api/v1/space_service.proto b/proto/api/v1/space_service.proto index aea232dc..7cb0a73b 100644 --- a/proto/api/v1/space_service.proto +++ b/proto/api/v1/space_service.proto @@ -157,6 +157,19 @@ message Space { // Output only. The number of accepted members in this space. Pending // invitations are excluded. Zero when this Space is exposed as metadata-only. int32 member_count = 5 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Optional. Display icon. Omit when updating with the "icon" mask to reset + // to the default Space mark. + Icon icon = 6 [(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; + } + } } // SpaceMember is a user's membership and governance role in a space. diff --git a/proto/gen/api/v1/space_service.pb.go b/proto/gen/api/v1/space_service.pb.go index 802a116c..fc210374 100644 --- a/proto/gen/api/v1/space_service.pb.go +++ b/proto/gen/api/v1/space_service.pb.go @@ -87,7 +87,10 @@ type Space struct { CurrentUserRole SpaceMember_Role `protobuf:"varint,4,opt,name=current_user_role,json=currentUserRole,proto3,enum=memos.api.v1.SpaceMember_Role" json:"current_user_role,omitempty"` // Output only. The number of accepted members in this space. Pending // invitations are excluded. Zero when this Space is exposed as metadata-only. - MemberCount int32 `protobuf:"varint,5,opt,name=member_count,json=memberCount,proto3" json:"member_count,omitempty"` + MemberCount int32 `protobuf:"varint,5,opt,name=member_count,json=memberCount,proto3" json:"member_count,omitempty"` + // Optional. Display icon. Omit when updating with the "icon" mask to reset + // to the default Space mark. + Icon *Space_Icon `protobuf:"bytes,6,opt,name=icon,proto3" json:"icon,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -157,6 +160,13 @@ func (x *Space) GetMemberCount() int32 { return 0 } +func (x *Space) GetIcon() *Space_Icon { + if x != nil { + return x.Icon + } + return nil +} + // SpaceMember is a user's membership and governance role in a space. type SpaceMember struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1324,17 +1334,106 @@ func (x *DeleteSpaceMemberRequest) GetName() string { return "" } +type Space_Icon struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *Space_Icon_Emoji + // *Space_Icon_Lucide + Value isSpace_Icon_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Space_Icon) Reset() { + *x = Space_Icon{} + mi := &file_api_v1_space_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Space_Icon) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Space_Icon) ProtoMessage() {} + +func (x *Space_Icon) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_space_service_proto_msgTypes[23] + 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 Space_Icon.ProtoReflect.Descriptor instead. +func (*Space_Icon) Descriptor() ([]byte, []int) { + return file_api_v1_space_service_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *Space_Icon) GetValue() isSpace_Icon_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *Space_Icon) GetEmoji() string { + if x != nil { + if x, ok := x.Value.(*Space_Icon_Emoji); ok { + return x.Emoji + } + } + return "" +} + +func (x *Space_Icon) GetLucide() string { + if x != nil { + if x, ok := x.Value.(*Space_Icon_Lucide); ok { + return x.Lucide + } + } + return "" +} + +type isSpace_Icon_Value interface { + isSpace_Icon_Value() +} + +type Space_Icon_Emoji struct { + // A Unicode emoji sequence. + Emoji string `protobuf:"bytes,1,opt,name=emoji,proto3,oneof"` +} + +type Space_Icon_Lucide struct { + // A canonical Lucide icon name, such as "leaf". + Lucide string `protobuf:"bytes,2,opt,name=lucide,proto3,oneof"` +} + +func (*Space_Icon_Emoji) isSpace_Icon_Value() {} + +func (*Space_Icon_Lucide) isSpace_Icon_Value() {} + var File_api_v1_space_service_proto protoreflect.FileDescriptor const file_api_v1_space_service_proto_rawDesc = "" + "\n" + - "\x1aapi/v1/space_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\x99\x02\n" + + "\x1aapi/v1/space_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\x8f\x03\n" + "\x05Space\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" + "\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12%\n" + "\vdescription\x18\x03 \x01(\tB\x03\xe0A\x01R\vdescription\x12O\n" + "\x11current_user_role\x18\x04 \x01(\x0e2\x1e.memos.api.v1.SpaceMember.RoleB\x03\xe0A\x03R\x0fcurrentUserRole\x12&\n" + - "\fmember_count\x18\x05 \x01(\x05B\x03\xe0A\x03R\vmemberCount:<\xeaA9\n" + + "\fmember_count\x18\x05 \x01(\x05B\x03\xe0A\x03R\vmemberCount\x121\n" + + "\x04icon\x18\x06 \x01(\v2\x18.memos.api.v1.Space.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:<\xeaA9\n" + "\x12memos.api.v1/Space\x12\x0espaces/{space}\x1a\x04name*\x06spaces2\x05space\"\xa2\x02\n" + "\vSpaceMember\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12-\n" + @@ -1456,7 +1555,7 @@ func file_api_v1_space_service_proto_rawDescGZIP() []byte { } var file_api_v1_space_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_api_v1_space_service_proto_msgTypes = make([]protoimpl.MessageInfo, 23) +var file_api_v1_space_service_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_api_v1_space_service_proto_goTypes = []any{ (SpaceMember_Role)(0), // 0: memos.api.v1.SpaceMember.Role (*Space)(nil), // 1: memos.api.v1.Space @@ -1482,61 +1581,63 @@ var file_api_v1_space_service_proto_goTypes = []any{ (*GetSpaceMemberRequest)(nil), // 21: memos.api.v1.GetSpaceMemberRequest (*UpdateSpaceMemberRequest)(nil), // 22: memos.api.v1.UpdateSpaceMemberRequest (*DeleteSpaceMemberRequest)(nil), // 23: memos.api.v1.DeleteSpaceMemberRequest - (*fieldmaskpb.FieldMask)(nil), // 24: google.protobuf.FieldMask - (*emptypb.Empty)(nil), // 25: google.protobuf.Empty + (*Space_Icon)(nil), // 24: memos.api.v1.Space.Icon + (*fieldmaskpb.FieldMask)(nil), // 25: google.protobuf.FieldMask + (*emptypb.Empty)(nil), // 26: google.protobuf.Empty } var file_api_v1_space_service_proto_depIdxs = []int32{ 0, // 0: memos.api.v1.Space.current_user_role:type_name -> memos.api.v1.SpaceMember.Role - 0, // 1: memos.api.v1.SpaceMember.role:type_name -> memos.api.v1.SpaceMember.Role - 0, // 2: memos.api.v1.SpaceInvitation.role:type_name -> memos.api.v1.SpaceMember.Role - 1, // 3: memos.api.v1.SpaceInvitation.space:type_name -> memos.api.v1.Space - 1, // 4: memos.api.v1.CreateSpaceRequest.space:type_name -> memos.api.v1.Space - 1, // 5: memos.api.v1.ListSpacesResponse.spaces:type_name -> memos.api.v1.Space - 1, // 6: memos.api.v1.UpdateSpaceRequest.space:type_name -> memos.api.v1.Space - 24, // 7: memos.api.v1.UpdateSpaceRequest.update_mask:type_name -> google.protobuf.FieldMask - 3, // 8: memos.api.v1.CreateSpaceInvitationRequest.space_invitation:type_name -> memos.api.v1.SpaceInvitation - 3, // 9: memos.api.v1.ListSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation - 3, // 10: memos.api.v1.ListUserSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation - 2, // 11: memos.api.v1.ListSpaceMembersResponse.space_members:type_name -> memos.api.v1.SpaceMember - 2, // 12: memos.api.v1.UpdateSpaceMemberRequest.space_member:type_name -> memos.api.v1.SpaceMember - 24, // 13: memos.api.v1.UpdateSpaceMemberRequest.update_mask:type_name -> google.protobuf.FieldMask - 4, // 14: memos.api.v1.SpaceService.CreateSpace:input_type -> memos.api.v1.CreateSpaceRequest - 5, // 15: memos.api.v1.SpaceService.ListSpaces:input_type -> memos.api.v1.ListSpacesRequest - 7, // 16: memos.api.v1.SpaceService.GetSpace:input_type -> memos.api.v1.GetSpaceRequest - 8, // 17: memos.api.v1.SpaceService.UpdateSpace:input_type -> memos.api.v1.UpdateSpaceRequest - 9, // 18: memos.api.v1.SpaceService.DeleteSpace:input_type -> memos.api.v1.DeleteSpaceRequest - 10, // 19: memos.api.v1.SpaceService.CreateSpaceInvitation:input_type -> memos.api.v1.CreateSpaceInvitationRequest - 11, // 20: memos.api.v1.SpaceService.ListSpaceInvitations:input_type -> memos.api.v1.ListSpaceInvitationsRequest - 13, // 21: memos.api.v1.SpaceService.ListUserSpaceInvitations:input_type -> memos.api.v1.ListUserSpaceInvitationsRequest - 15, // 22: memos.api.v1.SpaceService.GetSpaceInvitation:input_type -> memos.api.v1.GetSpaceInvitationRequest - 16, // 23: memos.api.v1.SpaceService.DeleteSpaceInvitation:input_type -> memos.api.v1.DeleteSpaceInvitationRequest - 17, // 24: memos.api.v1.SpaceService.AcceptSpaceInvitation:input_type -> memos.api.v1.AcceptSpaceInvitationRequest - 18, // 25: memos.api.v1.SpaceService.DeclineSpaceInvitation:input_type -> memos.api.v1.DeclineSpaceInvitationRequest - 19, // 26: memos.api.v1.SpaceService.ListSpaceMembers:input_type -> memos.api.v1.ListSpaceMembersRequest - 21, // 27: memos.api.v1.SpaceService.GetSpaceMember:input_type -> memos.api.v1.GetSpaceMemberRequest - 22, // 28: memos.api.v1.SpaceService.UpdateSpaceMember:input_type -> memos.api.v1.UpdateSpaceMemberRequest - 23, // 29: memos.api.v1.SpaceService.DeleteSpaceMember:input_type -> memos.api.v1.DeleteSpaceMemberRequest - 1, // 30: memos.api.v1.SpaceService.CreateSpace:output_type -> memos.api.v1.Space - 6, // 31: memos.api.v1.SpaceService.ListSpaces:output_type -> memos.api.v1.ListSpacesResponse - 1, // 32: memos.api.v1.SpaceService.GetSpace:output_type -> memos.api.v1.Space - 1, // 33: memos.api.v1.SpaceService.UpdateSpace:output_type -> memos.api.v1.Space - 25, // 34: memos.api.v1.SpaceService.DeleteSpace:output_type -> google.protobuf.Empty - 3, // 35: memos.api.v1.SpaceService.CreateSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation - 12, // 36: memos.api.v1.SpaceService.ListSpaceInvitations:output_type -> memos.api.v1.ListSpaceInvitationsResponse - 14, // 37: memos.api.v1.SpaceService.ListUserSpaceInvitations:output_type -> memos.api.v1.ListUserSpaceInvitationsResponse - 3, // 38: memos.api.v1.SpaceService.GetSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation - 25, // 39: memos.api.v1.SpaceService.DeleteSpaceInvitation:output_type -> google.protobuf.Empty - 2, // 40: memos.api.v1.SpaceService.AcceptSpaceInvitation:output_type -> memos.api.v1.SpaceMember - 25, // 41: memos.api.v1.SpaceService.DeclineSpaceInvitation:output_type -> google.protobuf.Empty - 20, // 42: memos.api.v1.SpaceService.ListSpaceMembers:output_type -> memos.api.v1.ListSpaceMembersResponse - 2, // 43: memos.api.v1.SpaceService.GetSpaceMember:output_type -> memos.api.v1.SpaceMember - 2, // 44: memos.api.v1.SpaceService.UpdateSpaceMember:output_type -> memos.api.v1.SpaceMember - 25, // 45: memos.api.v1.SpaceService.DeleteSpaceMember:output_type -> google.protobuf.Empty - 30, // [30:46] is the sub-list for method output_type - 14, // [14:30] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 24, // 1: memos.api.v1.Space.icon:type_name -> memos.api.v1.Space.Icon + 0, // 2: memos.api.v1.SpaceMember.role:type_name -> memos.api.v1.SpaceMember.Role + 0, // 3: memos.api.v1.SpaceInvitation.role:type_name -> memos.api.v1.SpaceMember.Role + 1, // 4: memos.api.v1.SpaceInvitation.space:type_name -> memos.api.v1.Space + 1, // 5: memos.api.v1.CreateSpaceRequest.space:type_name -> memos.api.v1.Space + 1, // 6: memos.api.v1.ListSpacesResponse.spaces:type_name -> memos.api.v1.Space + 1, // 7: memos.api.v1.UpdateSpaceRequest.space:type_name -> memos.api.v1.Space + 25, // 8: memos.api.v1.UpdateSpaceRequest.update_mask:type_name -> google.protobuf.FieldMask + 3, // 9: memos.api.v1.CreateSpaceInvitationRequest.space_invitation:type_name -> memos.api.v1.SpaceInvitation + 3, // 10: memos.api.v1.ListSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation + 3, // 11: memos.api.v1.ListUserSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation + 2, // 12: memos.api.v1.ListSpaceMembersResponse.space_members:type_name -> memos.api.v1.SpaceMember + 2, // 13: memos.api.v1.UpdateSpaceMemberRequest.space_member:type_name -> memos.api.v1.SpaceMember + 25, // 14: memos.api.v1.UpdateSpaceMemberRequest.update_mask:type_name -> google.protobuf.FieldMask + 4, // 15: memos.api.v1.SpaceService.CreateSpace:input_type -> memos.api.v1.CreateSpaceRequest + 5, // 16: memos.api.v1.SpaceService.ListSpaces:input_type -> memos.api.v1.ListSpacesRequest + 7, // 17: memos.api.v1.SpaceService.GetSpace:input_type -> memos.api.v1.GetSpaceRequest + 8, // 18: memos.api.v1.SpaceService.UpdateSpace:input_type -> memos.api.v1.UpdateSpaceRequest + 9, // 19: memos.api.v1.SpaceService.DeleteSpace:input_type -> memos.api.v1.DeleteSpaceRequest + 10, // 20: memos.api.v1.SpaceService.CreateSpaceInvitation:input_type -> memos.api.v1.CreateSpaceInvitationRequest + 11, // 21: memos.api.v1.SpaceService.ListSpaceInvitations:input_type -> memos.api.v1.ListSpaceInvitationsRequest + 13, // 22: memos.api.v1.SpaceService.ListUserSpaceInvitations:input_type -> memos.api.v1.ListUserSpaceInvitationsRequest + 15, // 23: memos.api.v1.SpaceService.GetSpaceInvitation:input_type -> memos.api.v1.GetSpaceInvitationRequest + 16, // 24: memos.api.v1.SpaceService.DeleteSpaceInvitation:input_type -> memos.api.v1.DeleteSpaceInvitationRequest + 17, // 25: memos.api.v1.SpaceService.AcceptSpaceInvitation:input_type -> memos.api.v1.AcceptSpaceInvitationRequest + 18, // 26: memos.api.v1.SpaceService.DeclineSpaceInvitation:input_type -> memos.api.v1.DeclineSpaceInvitationRequest + 19, // 27: memos.api.v1.SpaceService.ListSpaceMembers:input_type -> memos.api.v1.ListSpaceMembersRequest + 21, // 28: memos.api.v1.SpaceService.GetSpaceMember:input_type -> memos.api.v1.GetSpaceMemberRequest + 22, // 29: memos.api.v1.SpaceService.UpdateSpaceMember:input_type -> memos.api.v1.UpdateSpaceMemberRequest + 23, // 30: memos.api.v1.SpaceService.DeleteSpaceMember:input_type -> memos.api.v1.DeleteSpaceMemberRequest + 1, // 31: memos.api.v1.SpaceService.CreateSpace:output_type -> memos.api.v1.Space + 6, // 32: memos.api.v1.SpaceService.ListSpaces:output_type -> memos.api.v1.ListSpacesResponse + 1, // 33: memos.api.v1.SpaceService.GetSpace:output_type -> memos.api.v1.Space + 1, // 34: memos.api.v1.SpaceService.UpdateSpace:output_type -> memos.api.v1.Space + 26, // 35: memos.api.v1.SpaceService.DeleteSpace:output_type -> google.protobuf.Empty + 3, // 36: memos.api.v1.SpaceService.CreateSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation + 12, // 37: memos.api.v1.SpaceService.ListSpaceInvitations:output_type -> memos.api.v1.ListSpaceInvitationsResponse + 14, // 38: memos.api.v1.SpaceService.ListUserSpaceInvitations:output_type -> memos.api.v1.ListUserSpaceInvitationsResponse + 3, // 39: memos.api.v1.SpaceService.GetSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation + 26, // 40: memos.api.v1.SpaceService.DeleteSpaceInvitation:output_type -> google.protobuf.Empty + 2, // 41: memos.api.v1.SpaceService.AcceptSpaceInvitation:output_type -> memos.api.v1.SpaceMember + 26, // 42: memos.api.v1.SpaceService.DeclineSpaceInvitation:output_type -> google.protobuf.Empty + 20, // 43: memos.api.v1.SpaceService.ListSpaceMembers:output_type -> memos.api.v1.ListSpaceMembersResponse + 2, // 44: memos.api.v1.SpaceService.GetSpaceMember:output_type -> memos.api.v1.SpaceMember + 2, // 45: memos.api.v1.SpaceService.UpdateSpaceMember:output_type -> memos.api.v1.SpaceMember + 26, // 46: memos.api.v1.SpaceService.DeleteSpaceMember:output_type -> google.protobuf.Empty + 31, // [31:47] is the sub-list for method output_type + 15, // [15:31] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_api_v1_space_service_proto_init() } @@ -1544,13 +1645,17 @@ func file_api_v1_space_service_proto_init() { if File_api_v1_space_service_proto != nil { return } + file_api_v1_space_service_proto_msgTypes[23].OneofWrappers = []any{ + (*Space_Icon_Emoji)(nil), + (*Space_Icon_Lucide)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_space_service_proto_rawDesc), len(file_api_v1_space_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 23, + NumMessages: 24, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index baadf66e..3368e245 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -4399,6 +4399,12 @@ components: Output only. The number of accepted members in this space. Pending invitations are excluded. Zero when this Space is exposed as metadata-only. format: int32 + icon: + allOf: + - $ref: '#/components/schemas/Space_Icon' + description: |- + Optional. Display icon. Omit when updating with the "icon" mask to reset + to the default Space mark. description: Space is a collaboration boundary for placed memos. SpaceInvitation: required: @@ -4449,6 +4455,15 @@ components: description: Required. The member's role within this space. format: enum description: SpaceMember is a user's membership and governance role in a space. + Space_Icon: + type: object + properties: + emoji: + type: string + description: A Unicode emoji sequence. + lucide: + type: string + description: A canonical Lucide icon name, such as "leaf". Status: type: object properties: diff --git a/proto/gen/store/space.pb.go b/proto/gen/store/space.pb.go new file mode 100644 index 00000000..e44a4b29 --- /dev/null +++ b/proto/gen/store/space.pb.go @@ -0,0 +1,219 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: store/space.proto + +package store + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// SpacePayload stores optional Space display metadata. +type SpacePayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + Icon *SpacePayload_Icon `protobuf:"bytes,1,opt,name=icon,proto3" json:"icon,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SpacePayload) Reset() { + *x = SpacePayload{} + mi := &file_store_space_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SpacePayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SpacePayload) ProtoMessage() {} + +func (x *SpacePayload) ProtoReflect() protoreflect.Message { + mi := &file_store_space_proto_msgTypes[0] + 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 SpacePayload.ProtoReflect.Descriptor instead. +func (*SpacePayload) Descriptor() ([]byte, []int) { + return file_store_space_proto_rawDescGZIP(), []int{0} +} + +func (x *SpacePayload) GetIcon() *SpacePayload_Icon { + if x != nil { + return x.Icon + } + return nil +} + +type SpacePayload_Icon struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *SpacePayload_Icon_Emoji + // *SpacePayload_Icon_Lucide + Value isSpacePayload_Icon_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SpacePayload_Icon) Reset() { + *x = SpacePayload_Icon{} + mi := &file_store_space_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SpacePayload_Icon) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SpacePayload_Icon) ProtoMessage() {} + +func (x *SpacePayload_Icon) ProtoReflect() protoreflect.Message { + mi := &file_store_space_proto_msgTypes[1] + 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 SpacePayload_Icon.ProtoReflect.Descriptor instead. +func (*SpacePayload_Icon) Descriptor() ([]byte, []int) { + return file_store_space_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *SpacePayload_Icon) GetValue() isSpacePayload_Icon_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *SpacePayload_Icon) GetEmoji() string { + if x != nil { + if x, ok := x.Value.(*SpacePayload_Icon_Emoji); ok { + return x.Emoji + } + } + return "" +} + +func (x *SpacePayload_Icon) GetLucide() string { + if x != nil { + if x, ok := x.Value.(*SpacePayload_Icon_Lucide); ok { + return x.Lucide + } + } + return "" +} + +type isSpacePayload_Icon_Value interface { + isSpacePayload_Icon_Value() +} + +type SpacePayload_Icon_Emoji struct { + // A Unicode emoji sequence. + Emoji string `protobuf:"bytes,1,opt,name=emoji,proto3,oneof"` +} + +type SpacePayload_Icon_Lucide struct { + // A canonical Lucide icon name, such as "leaf". + Lucide string `protobuf:"bytes,2,opt,name=lucide,proto3,oneof"` +} + +func (*SpacePayload_Icon_Emoji) isSpacePayload_Icon_Value() {} + +func (*SpacePayload_Icon_Lucide) isSpacePayload_Icon_Value() {} + +var File_store_space_proto protoreflect.FileDescriptor + +const file_store_space_proto_rawDesc = "" + + "\n" + + "\x11store/space.proto\x12\vmemos.store\"\x85\x01\n" + + "\fSpacePayload\x122\n" + + "\x04icon\x18\x01 \x01(\v2\x1e.memos.store.SpacePayload.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" + + "\x05valueB\x95\x01\n" + + "\x0fcom.memos.storeB\n" + + "SpaceProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3" + +var ( + file_store_space_proto_rawDescOnce sync.Once + file_store_space_proto_rawDescData []byte +) + +func file_store_space_proto_rawDescGZIP() []byte { + file_store_space_proto_rawDescOnce.Do(func() { + file_store_space_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_store_space_proto_rawDesc), len(file_store_space_proto_rawDesc))) + }) + return file_store_space_proto_rawDescData +} + +var file_store_space_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_store_space_proto_goTypes = []any{ + (*SpacePayload)(nil), // 0: memos.store.SpacePayload + (*SpacePayload_Icon)(nil), // 1: memos.store.SpacePayload.Icon +} +var file_store_space_proto_depIdxs = []int32{ + 1, // 0: memos.store.SpacePayload.icon:type_name -> memos.store.SpacePayload.Icon + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_store_space_proto_init() } +func file_store_space_proto_init() { + if File_store_space_proto != nil { + return + } + file_store_space_proto_msgTypes[1].OneofWrappers = []any{ + (*SpacePayload_Icon_Emoji)(nil), + (*SpacePayload_Icon_Lucide)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_space_proto_rawDesc), len(file_store_space_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_store_space_proto_goTypes, + DependencyIndexes: file_store_space_proto_depIdxs, + MessageInfos: file_store_space_proto_msgTypes, + }.Build() + File_store_space_proto = out.File + file_store_space_proto_goTypes = nil + file_store_space_proto_depIdxs = nil +} diff --git a/proto/store/space.proto b/proto/store/space.proto new file mode 100644 index 00000000..98f22ee0 --- /dev/null +++ b/proto/store/space.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package memos.store; + +option go_package = "gen/store"; + +// SpacePayload stores optional Space display metadata. +message SpacePayload { + Icon icon = 1; + + message Icon { + oneof value { + // A Unicode emoji sequence. + string emoji = 1; + // A canonical Lucide icon name, such as "leaf". + string lucide = 2; + } + } +} diff --git a/server/router/api/v1/space_icon_test.go b/server/router/api/v1/space_icon_test.go new file mode 100644 index 00000000..be72ad55 --- /dev/null +++ b/server/router/api/v1/space_icon_test.go @@ -0,0 +1,89 @@ +package v1 + +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" + "github.com/usememos/memos/store" +) + +func TestSpaceIconLifecycle(t *testing.T) { + ctx := context.Background() + service := newIntegrationService(t) + owner := createSpaceTestUser(ctx, t, service, "icon-owner", store.RoleUser) + member := createSpaceTestUser(ctx, t, service, "icon-member", store.RoleUser) + ownerCtx := userCtx(ctx, owner.ID) + emoji := &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Emoji{Emoji: "🌱"}} + space, err := service.CreateSpace(ownerCtx, &v1pb.CreateSpaceRequest{ + SpaceId: "icon-space", Space: &v1pb.Space{Title: "Garden", Icon: emoji}, + }) + require.NoError(t, err) + require.True(t, proto.Equal(emoji, space.Icon)) + + invitation := inviteSpaceTestUser(ctx, t, service, owner, member, space, v1pb.SpaceMember_USER) + require.True(t, proto.Equal(emoji, invitation.Space.Icon), "invitation metadata includes the icon") + _, err = service.AcceptSpaceInvitation(userCtx(ctx, member.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name}) + require.NoError(t, err) + + space, err = service.UpdateSpace(ownerCtx, &v1pb.UpdateSpaceRequest{ + Space: &v1pb.Space{Name: space.Name, Title: "Renamed"}, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title"}}, + }) + require.NoError(t, err) + require.True(t, proto.Equal(emoji, space.Icon), "unmasked icon remains unchanged") + lucide := &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Lucide{Lucide: "leaf"}} + update := &v1pb.UpdateSpaceRequest{ + Space: &v1pb.Space{Name: space.Name, Icon: lucide}, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"icon"}}, + } + _, err = service.UpdateSpace(userCtx(ctx, member.ID), update) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + space, err = service.UpdateSpace(ownerCtx, update) + require.NoError(t, err) + require.True(t, proto.Equal(lucide, space.Icon)) + require.Equal(t, "Renamed", space.Title) + fetched, err := service.GetSpace(ownerCtx, &v1pb.GetSpaceRequest{Name: space.Name}) + require.NoError(t, err) + require.True(t, proto.Equal(lucide, fetched.Icon)) + listed, err := service.ListSpaces(ownerCtx, &v1pb.ListSpacesRequest{}) + require.NoError(t, err) + require.Len(t, listed.Spaces, 1) + require.True(t, proto.Equal(lucide, listed.Spaces[0].Icon)) + + update.Space.Icon = &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Emoji{Emoji: "not an emoji"}} + _, err = service.UpdateSpace(ownerCtx, update) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + fetched, err = service.GetSpace(ownerCtx, &v1pb.GetSpaceRequest{Name: space.Name}) + require.NoError(t, err) + require.True(t, proto.Equal(lucide, fetched.Icon), "invalid updates do not change the stored icon") + + update.Space.Icon = nil + space, err = service.UpdateSpace(ownerCtx, update) + require.NoError(t, err) + require.Nil(t, space.Icon) + fetched, err = service.GetSpace(ownerCtx, &v1pb.GetSpaceRequest{Name: space.Name}) + require.NoError(t, err) + require.Nil(t, fetched.Icon) +} + +func TestCreateSpaceRejectsInvalidIcon(t *testing.T) { + ctx := context.Background() + service := newIntegrationService(t) + owner := createSpaceTestUser(ctx, t, service, "invalid-icon-owner", store.RoleUser) + for _, icon := range []*v1pb.Space_Icon{ + {}, + {Value: &v1pb.Space_Icon_Emoji{Emoji: "🌱🌱"}}, + {Value: &v1pb.Space_Icon_Lucide{Lucide: ""}}, + } { + _, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{Space: &v1pb.Space{Title: "Invalid", Icon: icon}}) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + } + spaces, err := service.ListSpaces(userCtx(ctx, owner.ID), &v1pb.ListSpacesRequest{}) + require.NoError(t, err) + require.Empty(t, spaces.Spaces) +} diff --git a/server/router/api/v1/space_service.go b/server/router/api/v1/space_service.go index 8e1de110..c012997e 100644 --- a/server/router/api/v1/space_service.go +++ b/server/router/api/v1/space_service.go @@ -8,9 +8,11 @@ import ( "github.com/pkg/errors" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/emptypb" v1pb "github.com/usememos/memos/proto/gen/api/v1" + storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" ) @@ -98,10 +100,15 @@ func (s *APIV1Service) CreateSpace(ctx context.Context, request *v1pb.CreateSpac if err != nil { return nil, err } + icon := convertSpaceIconToStore(request.Space.Icon) + if err := store.ValidateSpaceIcon(icon); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "%v", err) + } created, err := s.Store.CreateSpace(ctx, &store.Space{ UID: uid, Title: title, Description: strings.TrimSpace(request.Space.Description), + Payload: &storepb.SpacePayload{Icon: icon}, }, currentUser.ID) if err != nil { return nil, mapSpaceMutationError(err, "failed to create space") @@ -201,6 +208,16 @@ func (s *APIV1Service) UpdateSpace(ctx context.Context, request *v1pb.UpdateSpac case "description": description := strings.TrimSpace(request.Space.Description) update.Description = &description + case "icon": + icon := convertSpaceIconToStore(request.Space.Icon) + if err := store.ValidateSpaceIcon(icon); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "%v", err) + } + update.Payload = &storepb.SpacePayload{} + if space.Payload != nil { + update.Payload = proto.CloneOf(space.Payload) + } + update.Payload.Icon = icon default: return nil, status.Errorf(codes.InvalidArgument, "unsupported update mask path: %s", path) } diff --git a/server/router/api/v1/space_service_converters.go b/server/router/api/v1/space_service_converters.go index 7bda669f..3964f0f4 100644 --- a/server/router/api/v1/space_service_converters.go +++ b/server/router/api/v1/space_service_converters.go @@ -2,6 +2,7 @@ package v1 import ( v1pb "github.com/usememos/memos/proto/gen/api/v1" + storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" ) @@ -35,6 +36,36 @@ func convertSpaceMetadataFromStore(space *store.Space) *v1pb.Space { Name: buildSpaceName(space.UID), Title: space.Title, Description: space.Description, + Icon: convertSpaceIconFromStore(space.Payload.GetIcon()), + } +} + +func convertSpaceIconFromStore(icon *storepb.SpacePayload_Icon) *v1pb.Space_Icon { + if icon == nil { + return nil + } + switch value := icon.Value.(type) { + case *storepb.SpacePayload_Icon_Emoji: + return &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Emoji{Emoji: value.Emoji}} + case *storepb.SpacePayload_Icon_Lucide: + return &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Lucide{Lucide: value.Lucide}} + default: + return nil + } +} + +func convertSpaceIconToStore(icon *v1pb.Space_Icon) *storepb.SpacePayload_Icon { + if icon == nil { + return nil + } + switch value := icon.Value.(type) { + case *v1pb.Space_Icon_Emoji: + return &storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Emoji{Emoji: value.Emoji}} + case *v1pb.Space_Icon_Lucide: + return &storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: value.Lucide}} + default: + // Preserve an empty icon so validation rejects it instead of treating it as a reset. + return &storepb.SpacePayload_Icon{} } } diff --git a/store/db/mysql/space.go b/store/db/mysql/space.go index 139f4a03..aba2202f 100644 --- a/store/db/mysql/space.go +++ b/store/db/mysql/space.go @@ -8,7 +8,9 @@ import ( mysqldriver "github.com/go-sql-driver/mysql" "github.com/pkg/errors" + "google.golang.org/protobuf/encoding/protojson" + storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" ) @@ -23,9 +25,17 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int if err := lockMySQLActiveUser(ctx, tx, creatorID); err != nil { return nil, err } - fields := []string{"uid", "title", "description"} - values := []string{"?", "?", "?"} - args := []any{create.UID, create.Title, create.Description} + payload := "{}" + if create.Payload != nil { + data, err := protojson.Marshal(create.Payload) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal space payload") + } + payload = string(data) + } + fields := []string{"uid", "title", "description", "payload"} + values := []string{"?", "?", "?", "?"} + args := []any{create.UID, create.Title, create.Description, payload} result, err := tx.ExecContext(ctx, "INSERT INTO space ("+strings.Join(fields, ", ")+") VALUES ("+strings.Join(values, ", ")+")", args...) if err != nil { if isMySQLUniqueViolation(err) { @@ -55,7 +65,7 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Space, error) { where, args := []string{"1 = 1"}, []any{} - selectFields := "space.id, space.uid, space.title, space.description" + selectFields := "space.id, space.uid, space.title, space.description, space.payload" joins := "" groupBy := "" if find.ID != nil { @@ -119,6 +129,13 @@ func (d *DB) UpdateSpace(ctx context.Context, update *store.UpdateSpace, actorUs if update.Description != nil { sets, args = append(sets, "description = ?"), append(args, *update.Description) } + if update.Payload != nil { + data, err := protojson.Marshal(update.Payload) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal space payload") + } + sets, args = append(sets, "payload = ?"), append(args, string(data)) + } args = append(args, update.ID) if _, err := tx.ExecContext(ctx, "UPDATE space SET "+strings.Join(sets, ", ")+" WHERE id = ?", args...); err != nil { return nil, err @@ -434,14 +451,20 @@ func (d *DB) DeleteSpaceMember(ctx context.Context, delete *store.DeleteSpaceMem type mysqlRowScanner interface{ Scan(...any) error } func scanMySQLSpace(row mysqlRowScanner, space *store.Space) error { - return row.Scan(&space.ID, &space.UID, &space.Title, &space.Description) + return scanMySQLSpaceWithSummary(row, space, false) } func scanMySQLSpaceWithSummary(row mysqlRowScanner, space *store.Space, withSummary bool) error { - if !withSummary { - return scanMySQLSpace(row, space) + var payloadBytes []byte + targets := []any{&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes} + if withSummary { + targets = append(targets, &space.CurrentUserRole, &space.MemberCount) } - return row.Scan(&space.ID, &space.UID, &space.Title, &space.Description, &space.CurrentUserRole, &space.MemberCount) + if err := row.Scan(targets...); err != nil { + return err + } + space.Payload = &storepb.SpacePayload{} + return errors.Wrap(protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload), "failed to unmarshal space payload") } func scanMySQLSpaceSummary(row mysqlRowScanner, space *store.Space) error { @@ -462,7 +485,7 @@ func populateMySQLSpaceSummary(ctx context.Context, tx *sql.Tx, space *store.Spa func getMySQLSpace(ctx context.Context, tx *sql.Tx, id int32) (*store.Space, error) { space := &store.Space{} - return space, scanMySQLSpace(tx.QueryRowContext(ctx, `SELECT id, uid, title, description FROM space WHERE id = ?`, id), space) + return space, scanMySQLSpace(tx.QueryRowContext(ctx, `SELECT id, uid, title, description, payload FROM space WHERE id = ?`, id), space) } func getMySQLSpaceMember(ctx context.Context, tx *sql.Tx, spaceID, userID int32) (*store.SpaceMember, error) { diff --git a/store/db/postgres/space.go b/store/db/postgres/space.go index 98143d69..a5fd1029 100644 --- a/store/db/postgres/space.go +++ b/store/db/postgres/space.go @@ -8,7 +8,9 @@ import ( "github.com/lib/pq" "github.com/pkg/errors" + "google.golang.org/protobuf/encoding/protojson" + storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" ) @@ -23,16 +25,29 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int if err := lockPostgresActiveUser(ctx, tx, creatorID); err != nil { return nil, err } - fields := []string{"uid", "title", "description"} - args := []any{create.UID, create.Title, create.Description} + payload := "{}" + if create.Payload != nil { + data, err := protojson.Marshal(create.Payload) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal space payload") + } + payload = string(data) + } + fields := []string{"uid", "title", "description", "payload"} + args := []any{create.UID, create.Title, create.Description, payload} space := &store.Space{} - query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, uid, title, description" - if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil { + var payloadBytes []byte + query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, uid, title, description, payload" + if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil { if isPostgresUniqueViolation(err) { return nil, store.ErrSpaceAlreadyExists } return nil, err } + space.Payload = &storepb.SpacePayload{} + if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal space payload") + } if _, err := tx.ExecContext(ctx, "INSERT INTO space_member (space_id, user_id, status, role) VALUES ($1, $2, $3, $4)", space.ID, creatorID, store.SpaceMemberStatusActive, store.SpaceMemberRoleAdmin); err != nil { return nil, err } @@ -46,7 +61,7 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Space, error) { where, args := []string{"1 = 1"}, []any{} - selectFields := "space.id, space.uid, space.title, space.description" + selectFields := "space.id, space.uid, space.title, space.description, space.payload" joins := "" groupBy := "" add := func(condition string, value any) { @@ -75,7 +90,7 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp JOIN "user" active_user ON active_user.id = active_member.user_id AND active_user.row_status = 'NORMAL'` add("viewer_member.user_id = %s", *find.MemberUserID) where = append(where, "viewer_member.status = 'ACTIVE'", "viewer_member.role IN ('ADMIN', 'USER')", "viewer_user.row_status = 'NORMAL'") - groupBy = " GROUP BY space.id, space.uid, space.title, space.description, viewer_member.role" + groupBy = " GROUP BY space.id, space.uid, space.title, space.description, space.payload, viewer_member.role" } query := "SELECT " + selectFields + " FROM space" + joins + " WHERE " + strings.Join(where, " AND ") + groupBy + " ORDER BY space.id DESC" query = appendPostgresLimit(query, find.Limit, find.Offset) @@ -87,13 +102,18 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp spaces := []*store.Space{} for rows.Next() { space := &store.Space{} - scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description} + var payloadBytes []byte + scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes} if find.MemberUserID != nil { scanTargets = append(scanTargets, &space.CurrentUserRole, &space.MemberCount) } if err := rows.Scan(scanTargets...); err != nil { return nil, err } + space.Payload = &storepb.SpacePayload{} + if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal space payload") + } spaces = append(spaces, space) } return spaces, rows.Err() @@ -122,12 +142,24 @@ func (d *DB) UpdateSpace(ctx context.Context, update *store.UpdateSpace, actorUs if update.Description != nil { add("description", *update.Description) } + if update.Payload != nil { + data, err := protojson.Marshal(update.Payload) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal space payload") + } + add("payload", string(data)) + } args = append(args, update.ID) space := &store.Space{} - query := "UPDATE space SET " + strings.Join(sets, ", ") + " WHERE id = " + placeholder(len(args)) + " RETURNING id, uid, title, description" - if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil { + var payloadBytes []byte + query := "UPDATE space SET " + strings.Join(sets, ", ") + " WHERE id = " + placeholder(len(args)) + " RETURNING id, uid, title, description, payload" + if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil { return nil, err } + space.Payload = &storepb.SpacePayload{} + if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal space payload") + } if err := populatePostgresSpaceSummary(ctx, tx, space, actorUserID); err != nil { return nil, err } diff --git a/store/db/sqlite/space.go b/store/db/sqlite/space.go index ee6d0c2c..b59d22bc 100644 --- a/store/db/sqlite/space.go +++ b/store/db/sqlite/space.go @@ -7,9 +7,11 @@ import ( "strings" "github.com/pkg/errors" + "google.golang.org/protobuf/encoding/protojson" msqlite "modernc.org/sqlite" sqlite3 "modernc.org/sqlite/lib" + storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" ) @@ -23,17 +25,30 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int return nil, err } - fields := []string{"uid", "title", "description"} - values := []string{"?", "?", "?"} - args := []any{create.UID, create.Title, create.Description} - query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(values, ", ") + ") RETURNING id, uid, title, description" + payload := "{}" + if create.Payload != nil { + data, err := protojson.Marshal(create.Payload) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal space payload") + } + payload = string(data) + } + fields := []string{"uid", "title", "description", "payload"} + values := []string{"?", "?", "?", "?"} + args := []any{create.UID, create.Title, create.Description, payload} + query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(values, ", ") + ") RETURNING id, uid, title, description, payload" space := &store.Space{} - if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil { + var payloadBytes []byte + if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil { if isSQLiteUniqueViolation(err) { return nil, store.ErrSpaceAlreadyExists } return nil, errors.Wrap(err, "failed to create space") } + space.Payload = &storepb.SpacePayload{} + if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal space payload") + } if _, err := tx.ExecContext(ctx, "INSERT INTO space_member (space_id, user_id, status, role) VALUES (?, ?, ?, ?)", space.ID, creatorID, store.SpaceMemberStatusActive, store.SpaceMemberRoleAdmin); err != nil { return nil, errors.Wrap(err, "failed to create initial space admin") } @@ -47,7 +62,7 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Space, error) { where, args := []string{"1 = 1"}, []any{} - selectFields := "space.id, space.uid, space.title, space.description" + selectFields := "space.id, space.uid, space.title, space.description, space.payload" joins := "" groupBy := "" if find.ID != nil { @@ -72,7 +87,7 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp JOIN user active_user ON active_user.id = active_member.user_id AND active_user.row_status = 'NORMAL'` where = append(where, "viewer_member.user_id = ?", "viewer_member.status = 'ACTIVE'", "viewer_member.role IN ('ADMIN', 'USER')", "viewer_user.row_status = 'NORMAL'") args = append(args, *find.MemberUserID) - groupBy = " GROUP BY space.id, space.uid, space.title, space.description, viewer_member.role" + groupBy = " GROUP BY space.id, space.uid, space.title, space.description, space.payload, viewer_member.role" } query := "SELECT " + selectFields + " FROM space" + joins + " WHERE " + strings.Join(where, " AND ") + groupBy + " ORDER BY space.id DESC" query = appendSQLiteLimit(query, find.Limit, find.Offset) @@ -84,13 +99,18 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp spaces := []*store.Space{} for rows.Next() { space := &store.Space{} - scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description} + var payloadBytes []byte + scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes} if find.MemberUserID != nil { scanTargets = append(scanTargets, &space.CurrentUserRole, &space.MemberCount) } if err := rows.Scan(scanTargets...); err != nil { return nil, err } + space.Payload = &storepb.SpacePayload{} + if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal space payload") + } spaces = append(spaces, space) } return spaces, rows.Err() @@ -112,13 +132,25 @@ func (d *DB) UpdateSpace(ctx context.Context, update *store.UpdateSpace, actorUs if update.Description != nil { sets, args = append(sets, "description = ?"), append(args, *update.Description) } + if update.Payload != nil { + data, err := protojson.Marshal(update.Payload) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal space payload") + } + sets, args = append(sets, "payload = ?"), append(args, string(data)) + } args = append(args, update.ID) query := `UPDATE space SET ` + strings.Join(sets, ", ") + ` WHERE id = ? - RETURNING id, uid, title, description` + RETURNING id, uid, title, description, payload` space := &store.Space{} - if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil { + var payloadBytes []byte + if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil { return nil, err } + space.Payload = &storepb.SpacePayload{} + if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal space payload") + } if err := populateSQLiteSpaceSummary(ctx, tx, space, actorUserID); err != nil { return nil, err } diff --git a/store/migration/mysql/0.31/06__space_payload.sql b/store/migration/mysql/0.31/06__space_payload.sql new file mode 100644 index 00000000..3241d068 --- /dev/null +++ b/store/migration/mysql/0.31/06__space_payload.sql @@ -0,0 +1,3 @@ +ALTER TABLE space ADD COLUMN payload JSON; +UPDATE space SET payload = '{}'; +ALTER TABLE space MODIFY COLUMN payload JSON NOT NULL; diff --git a/store/migration/mysql/LATEST.sql b/store/migration/mysql/LATEST.sql index 1f49cb52..e3595447 100644 --- a/store/migration/mysql/LATEST.sql +++ b/store/migration/mysql/LATEST.sql @@ -33,7 +33,8 @@ CREATE TABLE `space` ( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `uid` VARCHAR(256) NOT NULL UNIQUE, `title` TEXT NOT NULL, - `description` TEXT NOT NULL + `description` TEXT NOT NULL, + `payload` JSON NOT NULL ); -- space membership diff --git a/store/migration/postgres/0.31/06__space_payload.sql b/store/migration/postgres/0.31/06__space_payload.sql new file mode 100644 index 00000000..b02c713a --- /dev/null +++ b/store/migration/postgres/0.31/06__space_payload.sql @@ -0,0 +1 @@ +ALTER TABLE space ADD COLUMN payload JSONB NOT NULL DEFAULT '{}'; diff --git a/store/migration/postgres/LATEST.sql b/store/migration/postgres/LATEST.sql index dbf1777f..49ed1805 100644 --- a/store/migration/postgres/LATEST.sql +++ b/store/migration/postgres/LATEST.sql @@ -33,7 +33,8 @@ CREATE TABLE space ( id SERIAL PRIMARY KEY, uid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '' + description TEXT NOT NULL DEFAULT '', + payload JSONB NOT NULL DEFAULT '{}' ); -- space membership diff --git a/store/migration/sqlite/0.31/06__space_payload.sql b/store/migration/sqlite/0.31/06__space_payload.sql new file mode 100644 index 00000000..825efe82 --- /dev/null +++ b/store/migration/sqlite/0.31/06__space_payload.sql @@ -0,0 +1 @@ +ALTER TABLE space ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'; diff --git a/store/migration/sqlite/LATEST.sql b/store/migration/sqlite/LATEST.sql index 6d3fede2..fbfff5a6 100644 --- a/store/migration/sqlite/LATEST.sql +++ b/store/migration/sqlite/LATEST.sql @@ -34,7 +34,8 @@ CREATE TABLE space ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '' + description TEXT NOT NULL DEFAULT '', + payload TEXT NOT NULL DEFAULT '{}' ); -- space membership diff --git a/store/space.go b/store/space.go index e194fea4..c509251d 100644 --- a/store/space.go +++ b/store/space.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/usememos/memos/internal/base" + storepb "github.com/usememos/memos/proto/gen/store" ) // ErrLastSpaceAdmin indicates that a membership mutation would leave an active @@ -71,6 +72,7 @@ type Space struct { UID string Title string Description string + Payload *storepb.SpacePayload CurrentUserRole SpaceMemberRole MemberCount int32 } @@ -92,6 +94,7 @@ type UpdateSpace struct { ID int32 Title *string Description *string + Payload *storepb.SpacePayload } // DeleteSpace identifies a Space to hard-delete. @@ -181,6 +184,9 @@ func (s *Store) CreateSpace(ctx context.Context, create *Space, creatorID int32) if strings.TrimSpace(create.Title) == "" { return nil, errors.New("space title is required") } + if err := ValidateSpaceIcon(create.Payload.GetIcon()); err != nil { + return nil, err + } return s.driver.CreateSpace(ctx, create, creatorID) } @@ -209,7 +215,10 @@ func (s *Store) UpdateSpace(ctx context.Context, update *UpdateSpace, actorUserI if update.Title != nil && strings.TrimSpace(*update.Title) == "" { return nil, errors.New("space title is required") } - if update.Title == nil && update.Description == nil { + if err := ValidateSpaceIcon(update.Payload.GetIcon()); err != nil { + return nil, err + } + if update.Title == nil && update.Description == nil && update.Payload == nil { return nil, errors.New("space update requires at least one field") } return s.driver.UpdateSpace(ctx, update, actorUserID) diff --git a/store/space_icon.go b/store/space_icon.go new file mode 100644 index 00000000..0b30a5e6 --- /dev/null +++ b/store/space_icon.go @@ -0,0 +1,41 @@ +package store + +import ( + "strings" + + "github.com/pkg/errors" + + "github.com/usememos/memos/internal/markdown/parser" + storepb "github.com/usememos/memos/proto/gen/store" +) + +// ValidateSpaceIcon validates display metadata without depending on a client's +// installed Lucide catalog. A nil icon requests the default Space mark. +func ValidateSpaceIcon(icon *storepb.SpacePayload_Icon) error { + if icon == nil { + return nil + } + switch value := icon.Value.(type) { + case *storepb.SpacePayload_Icon_Emoji: + if !parser.IsEmoji(value.Emoji) { + return errors.New("space icon must contain one fully qualified emoji") + } + case *storepb.SpacePayload_Icon_Lucide: + if len(value.Lucide) == 0 || len(value.Lucide) > 128 { + 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: + return errors.New("space icon must specify emoji or lucide") + } + return nil +} diff --git a/store/space_icon_test.go b/store/space_icon_test.go new file mode 100644 index 00000000..d6ba7420 --- /dev/null +++ b/store/space_icon_test.go @@ -0,0 +1,27 @@ +package store + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + storepb "github.com/usememos/memos/proto/gen/store" +) + +func TestValidateSpaceIcon(t *testing.T) { + require.NoError(t, ValidateSpaceIcon(nil)) + require.Error(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{})) + for _, emoji := range []string{"🌱", "👩🏽‍🌾", "🇸🇬", "❤️", "1️⃣"} { + require.NoError(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Emoji{Emoji: emoji}}), emoji) + } + for _, emoji := range []string{"", "leaf", "🌱🌱", " 🌱", "🏻", "\xff"} { + require.Error(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Emoji{Emoji: emoji}}), emoji) + } + for _, name := range []string{"leaf", "book-open", "house-plus", "circle-1", "future-icon"} { + require.NoError(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: name}}), name) + } + for _, name := range []string{"", "LeafIcon", "book_open", "-leaf", "leaf-", "book--open", "", "🌱", strings.Repeat("a", 129)} { + require.Error(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: name}}), name) + } +} diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index 90d77673..c146ee9f 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -62,11 +62,11 @@ func TestFreshInstall(t *testing.T) { // The fresh schema supports memo-local Space placement without adding a // canonical thread shape. COMMENT remains an ordinary relation row. driver := getDriverFromEnv() - insertSpace := "INSERT INTO space (id, uid, title, description) VALUES (?, ?, ?, ?)" + insertSpace := "INSERT INTO space (id, uid, title, description, payload) VALUES (?, ?, ?, ?, '{}')" insertMemo := "INSERT INTO memo (id, uid, creator_id, content, visibility, payload, space_id) VALUES (?, ?, ?, ?, ?, ?, ?)" insertRelation := "INSERT INTO memo_relation (memo_id, related_memo_id, type) VALUES (?, ?, ?)" if driver == "postgres" { - insertSpace = "INSERT INTO space (id, uid, title, description) VALUES ($1, $2, $3, $4)" + insertSpace = "INSERT INTO space (id, uid, title, description, payload) VALUES ($1, $2, $3, $4, '{}')" insertMemo = "INSERT INTO memo (id, uid, creator_id, content, visibility, payload, space_id) VALUES ($1, $2, $3, $4, $5, $6, $7)" insertRelation = "INSERT INTO memo_relation (memo_id, related_memo_id, type) VALUES ($1, $2, $3)" } @@ -307,6 +307,8 @@ func TestMigrationSpaceMemberStatusBackfillsActive(t *testing.T) { db := ts.GetDriver().GetDB() _, err = db.ExecContext(ctx, "ALTER TABLE space_member DROP COLUMN status") require.NoError(t, err) + _, err = db.ExecContext(ctx, "ALTER TABLE space DROP COLUMN payload") + require.NoError(t, err) basicSetting, err := ts.GetInstanceBasicSetting(ctx) require.NoError(t, err) diff --git a/store/test/space_payload_test.go b/store/test/space_payload_test.go new file mode 100644 index 00000000..1444dfdb --- /dev/null +++ b/store/test/space_payload_test.go @@ -0,0 +1,85 @@ +package test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/store" +) + +func TestSpacePayloadRoundTrip(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + defer ts.Close() + owner, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + space, err := ts.CreateSpace(ctx, &store.Space{ + UID: "icon-space", Title: "Garden", + Payload: &storepb.SpacePayload{Icon: &storepb.SpacePayload_Icon{ + Value: &storepb.SpacePayload_Icon_Emoji{Emoji: "👩🏽‍🌾"}, + }}, + }, owner.ID) + require.NoError(t, err) + require.Equal(t, "👩🏽‍🌾", space.Payload.GetIcon().GetEmoji()) + + for _, find := range []*store.FindSpace{{ID: &space.ID}, {MemberUserID: &owner.ID}} { + spaces, err := ts.ListSpaces(ctx, find) + require.NoError(t, err) + require.Len(t, spaces, 1) + require.Equal(t, "👩🏽‍🌾", spaces[0].Payload.GetIcon().GetEmoji()) + } + title := "Renamed garden" + space, err = ts.UpdateSpace(ctx, &store.UpdateSpace{ID: space.ID, Title: &title}, owner.ID) + require.NoError(t, err) + require.Equal(t, "👩🏽‍🌾", space.Payload.GetIcon().GetEmoji()) + + space, err = ts.UpdateSpace(ctx, &store.UpdateSpace{ID: space.ID, Payload: &storepb.SpacePayload{ + Icon: &storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: "leaf"}}, + }}, owner.ID) + require.NoError(t, err) + require.Equal(t, "leaf", space.Payload.GetIcon().GetLucide()) + require.Equal(t, title, space.Title) + var raw string + require.NoError(t, ts.GetDriver().GetDB().QueryRowContext(ctx, "SELECT payload FROM space WHERE uid = 'icon-space'").Scan(&raw)) + require.JSONEq(t, `{"icon":{"lucide":"leaf"}}`, raw) + + space, err = ts.UpdateSpace(ctx, &store.UpdateSpace{ID: space.ID, Payload: &storepb.SpacePayload{}}, owner.ID) + require.NoError(t, err) + require.Nil(t, space.Payload.GetIcon()) + require.NoError(t, ts.GetDriver().GetDB().QueryRowContext(ctx, "SELECT payload FROM space WHERE uid = 'icon-space'").Scan(&raw)) + require.JSONEq(t, `{}`, raw) +} + +func TestMigrationSpacePayloadBackfillsDefault(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + defer ts.Close() + owner, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + space, err := ts.CreateSpace(ctx, &store.Space{UID: "legacy-space", Title: "Legacy"}, owner.ID) + require.NoError(t, err) + require.NotNil(t, space.Payload) + require.Nil(t, space.Payload.Icon) + _, err = ts.GetDriver().GetDB().ExecContext(ctx, "ALTER TABLE space DROP COLUMN payload") + require.NoError(t, err) + setting, err := ts.GetInstanceBasicSetting(ctx) + require.NoError(t, err) + setting.SchemaVersion = "0.31.6" + _, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_BASIC, + Value: &storepb.InstanceSetting_BasicSetting{BasicSetting: setting}, + }) + require.NoError(t, err) + require.NoError(t, ts.Migrate(ctx)) + space, err = ts.GetSpace(ctx, &store.FindSpace{ID: &space.ID}) + require.NoError(t, err) + require.Equal(t, "Legacy", space.Title) + require.NotNil(t, space.Payload) + require.Nil(t, space.Payload.Icon) + var raw string + require.NoError(t, ts.GetDriver().GetDB().QueryRowContext(ctx, "SELECT payload FROM space WHERE uid = 'legacy-space'").Scan(&raw)) + require.JSONEq(t, `{}`, raw) +} diff --git a/web/src/components/AppSidebar/SpaceSwitcher.tsx b/web/src/components/AppSidebar/SpaceSwitcher.tsx index 52cb7815..620e1167 100644 --- a/web/src/components/AppSidebar/SpaceSwitcher.tsx +++ b/web/src/components/AppSidebar/SpaceSwitcher.tsx @@ -102,7 +102,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?: {selectedSpaceName ? ( <> - + - + {space.title} {showUid && uid ? ( diff --git a/web/src/components/CreateSpaceDialog.tsx b/web/src/components/CreateSpaceDialog.tsx index b7acd3e3..0d003778 100644 --- a/web/src/components/CreateSpaceDialog.tsx +++ b/web/src/components/CreateSpaceDialog.tsx @@ -3,6 +3,7 @@ import { ChevronDownIcon } from "lucide-react"; import { type FormEvent, useEffect, useState } from "react"; import { toast } from "react-hot-toast"; import { v4 as uuidv4 } from "uuid"; +import SpaceIconPicker from "@/components/SpaceIconPicker"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -12,7 +13,7 @@ import useCurrentUser from "@/hooks/useCurrentUser"; import { useCreateSpace } from "@/hooks/useSpaceQueries"; import { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; -import type { Space } from "@/types/proto/api/v1/space_service_pb"; +import type { Space, Space_Icon } from "@/types/proto/api/v1/space_service_pb"; import { useTranslate } from "@/utils/i18n"; const SPACE_UID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$/; @@ -30,6 +31,7 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) { const createSpace = useCreateSpace(currentUserName); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); + const [icon, setIcon] = useState(); const [spaceUid, setSpaceUid] = useState(() => uuidv4()); const [showCustomId, setShowCustomId] = useState(false); const [spaceUidConflict, setSpaceUidConflict] = useState(false); @@ -40,6 +42,7 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) { if (!open) { setTitle(""); setDescription(""); + setIcon(undefined); setSpaceUid(uuidv4()); setShowCustomId(false); setSpaceUidConflict(false); @@ -60,6 +63,7 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) { title: trimmedTitle, description: description.trim() || undefined, spaceId: spaceUid, + ...(icon ? { icon } : {}), }); } catch (error) { if (error instanceof ConnectError && error.code === Code.AlreadyExists) { @@ -92,14 +96,17 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) {
- setTitle(event.target.value)} - placeholder={t("space.name-placeholder")} - autoComplete="off" - autoFocus - /> +
+ + setTitle(event.target.value)} + placeholder={t("space.name-placeholder")} + autoComplete="off" + autoFocus + /> +
diff --git a/web/src/components/Inbox/SpaceInvitationMessage.tsx b/web/src/components/Inbox/SpaceInvitationMessage.tsx index 04fa081d..d42879de 100644 --- a/web/src/components/Inbox/SpaceInvitationMessage.tsx +++ b/web/src/components/Inbox/SpaceInvitationMessage.tsx @@ -164,7 +164,7 @@ function SpaceInvitationMessage({ notification }: Props) {
- +
{space.title} diff --git a/web/src/components/MemoView/components/MemoSpaceBadge.tsx b/web/src/components/MemoView/components/MemoSpaceBadge.tsx index 9848efec..3277bcd1 100644 --- a/web/src/components/MemoView/components/MemoSpaceBadge.tsx +++ b/web/src/components/MemoView/components/MemoSpaceBadge.tsx @@ -1,4 +1,4 @@ -import { AstroidIcon } from "lucide-react"; +import SpaceIcon from "@/components/SpaceIcon"; import { Badge } from "@/components/ui/badge"; import { useSpaceContext } from "@/contexts/SpaceContext"; import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display"; @@ -34,7 +34,7 @@ const MemoSpaceBadge = ({ spaceName }: MemoSpaceBadgeProps) => { showUid && uid ? "max-w-52 sm:max-w-64" : "max-w-36 sm:max-w-48", )} > -