feat(spaces): support custom icons and emoji (#6287)

This commit is contained in:
ABird 2026-09-07 23:12:18 +08:00 committed by GitHub
parent 92a95a262f
commit 34dbb83948
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1542 additions and 130 deletions

View file

@ -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
}

View file

@ -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.

View file

@ -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,
},

View file

@ -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:

219
proto/gen/store/space.pb.go Normal file
View file

@ -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
}

19
proto/store/space.proto Normal file
View file

@ -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;
}
}
}

View file

@ -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: "<svg>"}},
} {
_, 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)
}

View file

@ -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)
}

View file

@ -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{}
}
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,3 @@
ALTER TABLE space ADD COLUMN payload JSON;
UPDATE space SET payload = '{}';
ALTER TABLE space MODIFY COLUMN payload JSON NOT NULL;

View file

@ -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

View file

@ -0,0 +1 @@
ALTER TABLE space ADD COLUMN payload JSONB NOT NULL DEFAULT '{}';

View file

@ -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

View file

@ -0,0 +1 @@
ALTER TABLE space ADD COLUMN payload TEXT NOT NULL DEFAULT '{}';

View file

@ -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

View file

@ -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)

41
store/space_icon.go Normal file
View file

@ -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
}

27
store/space_icon_test.go Normal file
View file

@ -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", "<svg>", "🌱", strings.Repeat("a", 129)} {
require.Error(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: name}}), name)
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -102,7 +102,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
<span className={cn("flex min-w-0 items-center overflow-hidden", size === "header" ? "gap-2" : "gap-1.5")}>
{selectedSpaceName ? (
<>
<SpaceMark size={spaceMarkSize} />
<SpaceMark icon={selectedSpace?.icon} size={spaceMarkSize} />
<span data-sidebar-label className="flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
<span
className={cn(
@ -160,7 +160,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
to={getSpaceSwitchPath(location, space.name)}
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
>
<SpaceMark size="sm" />
<SpaceMark icon={space.icon} size="sm" />
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block max-w-full truncate font-medium">{space.title}</span>
{showUid && uid ? (

View file

@ -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<Space_Icon>();
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) {
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="space-title">{t("common.name")}</Label>
<Input
id="space-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={t("space.name-placeholder")}
autoComplete="off"
autoFocus
/>
<div className="flex items-center gap-2">
<SpaceIconPicker value={icon} onChange={setIcon} disabled={createSpace.isPending} />
<Input
id="space-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={t("space.name-placeholder")}
autoComplete="off"
autoFocus
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="space-description">{t("common.description")}</Label>

View file

@ -164,7 +164,7 @@ function SpaceInvitationMessage({ notification }: Props) {
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
<SpaceMark size="lg" />
<SpaceMark icon={space.icon} size="lg" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium text-foreground/95">{space.title}</span>

View file

@ -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",
)}
>
<AstroidIcon aria-hidden="true" className="size-3 shrink-0" strokeWidth={1.8} />
<SpaceIcon icon={knownSpace?.icon} className="size-3 text-xs" />
<span className="flex min-w-0 items-baseline overflow-hidden">
<span className="min-w-0 flex-1 truncate">
{knownTitle && <span className="sr-only">{spaceLabel}: </span>}

View file

@ -16,6 +16,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import CreateSpaceDialog from "@/components/CreateSpaceDialog";
import InviteSpaceMemberDialog from "@/components/Settings/InviteSpaceMemberDialog";
import SettingSection from "@/components/Settings/SettingSection";
import SpaceIconPicker from "@/components/SpaceIconPicker";
import SpaceMark from "@/components/SpaceMark";
import SpaceRoleBadge from "@/components/SpaceRoleBadge";
import UserAvatar from "@/components/UserAvatar";
@ -44,6 +45,7 @@ import { useUsersByUsernames } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { extractUsernameFromName } from "@/lib/resource-names";
import { extractSpaceUidFromName } from "@/lib/space-display";
import { spaceIconsEqual } from "@/lib/space-icons";
import { ROUTES } from "@/router/routes";
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";
@ -189,7 +191,7 @@ const SpacesSection = () => {
return (
<div key={invitation.name} className="flex min-w-0 flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
<SpaceMark size="lg" />
<SpaceMark icon={invitation.space?.icon} size="lg" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{title}</p>
@ -279,7 +281,7 @@ const SpacesSection = () => {
onClick={() => handleOpenSpace(space.name)}
className="group flex w-full min-w-0 items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/45"
>
<SpaceMark size="lg" />
<SpaceMark icon={space.icon} size="lg" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{space.title}</p>
@ -349,6 +351,7 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
const [tab, setTab] = useState<DetailTab>("general");
const [title, setTitle] = useState(space.title);
const [description, setDescription] = useState(space.description);
const [icon, setIcon] = useState(space.icon);
const [inviteOpen, setInviteOpen] = useState(false);
const [leaveOpen, setLeaveOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -367,22 +370,24 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
{ value: String(SpaceMember_Role.USER), label: t("setting.spaces.space-user") },
{ value: String(SpaceMember_Role.ADMIN), label: t("setting.spaces.space-admin") },
];
const detailsChanged = title.trim() !== space.title || description.trim() !== space.description;
const iconChanged = !spaceIconsEqual(icon, space.icon);
const detailsChanged = title.trim() !== space.title || description.trim() !== space.description || iconChanged;
const spaceUid = extractSpaceUidFromName(space.name);
const disambiguatedSpaceTitle = `${space.title} (${spaceUid})`;
useEffect(() => {
setTitle(space.title);
setDescription(space.description);
}, [space.description, space.title]);
setIcon(space.icon);
}, [space.description, space.title, space.icon]);
const handleSave = async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle || !isAdmin || updateSpace.isPending) return;
try {
await updateSpace.mutateAsync({
space: { name: space.name, title: trimmedTitle, description: description.trim() },
updateMask: ["title", "description"],
space: { name: space.name, title: trimmedTitle, description: description.trim(), ...(iconChanged ? { icon } : {}) },
updateMask: ["title", "description", ...(iconChanged ? ["icon"] : [])],
});
toast.success(t("setting.spaces.save-success"));
} catch (error) {
@ -459,7 +464,7 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
</button>
<header className="flex min-w-0 flex-col gap-3 border-b border-border/70 pb-4 sm:flex-row sm:items-center">
<SpaceMark size="xl" />
<SpaceMark icon={space.icon} size="xl" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-lg font-semibold tracking-tight">{space.title}</h3>
@ -503,13 +508,20 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
</div>
<div className="grid gap-2 border-b border-border px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-center">
<Label htmlFor="space-settings-title">{t("common.name")}</Label>
<Input
id="space-settings-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
readOnly={!isAdmin}
className={!isAdmin ? "bg-muted/25" : undefined}
/>
<div className="flex items-center gap-2">
{isAdmin ? (
<SpaceIconPicker value={icon} onChange={setIcon} disabled={updateSpace.isPending} />
) : (
<SpaceMark icon={icon} size="lg" className="size-8" />
)}
<Input
id="space-settings-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
readOnly={!isAdmin}
className={!isAdmin ? "bg-muted/25" : undefined}
/>
</div>
</div>
<div className="grid gap-2 px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-start">
<div>

View file

@ -0,0 +1,22 @@
import { getSpaceSymbol } from "@/lib/space-icons";
import { cn } from "@/lib/utils";
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb";
interface Props {
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;

View file

@ -0,0 +1,205 @@
import { create } from "@bufbuild/protobuf";
import { RotateCcwIcon, SearchIcon } from "lucide-react";
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 { 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 {
value?: Space_Icon;
onChange: (icon: Space_Icon | undefined) => void;
disabled?: boolean;
}
function SpaceIconPicker({ value, onChange, disabled }: 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 = (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 (
<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={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;

View file

@ -1,21 +1,23 @@
import { AstroidIcon } from "lucide-react";
import SpaceIcon from "@/components/SpaceIcon";
import { cn } from "@/lib/utils";
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb";
const MARK_SCALE = {
xl: { mark: "size-11 rounded-lg", icon: "size-6" },
lg: { mark: "size-9 rounded-[7px]", icon: "size-5" },
md: { mark: "size-7 rounded-[7px]", icon: "size-4" },
xl: { mark: "size-11 rounded-lg", icon: "size-6", emoji: "text-2xl" },
lg: { mark: "size-9 rounded-[7px]", icon: "size-5", emoji: "text-xl" },
md: { mark: "size-7 rounded-[7px]", icon: "size-4", emoji: "text-base" },
/** Primary chrome: kept in step with MemosLogo's header scale. */
header: { mark: "size-6 rounded-[6px]", icon: "size-3.5" },
sm: { mark: "size-5 rounded-[5px]", icon: "size-3" },
header: { mark: "size-6 rounded-[6px]", icon: "size-3.5", emoji: "text-base" },
sm: { mark: "size-5 rounded-[5px]", icon: "size-3", emoji: "text-sm" },
} as const;
interface Props {
icon?: Space_Icon;
size?: keyof typeof MARK_SCALE;
className?: string;
}
const SpaceMark = ({ size = "md", className }: Props) => {
const SpaceMark = ({ icon, size = "md", className }: Props) => {
const scale = MARK_SCALE[size];
return (
@ -23,7 +25,7 @@ const SpaceMark = ({ size = "md", className }: Props) => {
aria-hidden
className={cn("flex shrink-0 items-center justify-center bg-sidebar-accent text-sidebar-accent-foreground", scale.mark, className)}
>
<AstroidIcon className={scale.icon} strokeWidth={1.8} />
<SpaceIcon icon={icon} className={cn(scale.icon, icon?.value.case === "emoji" && scale.emoji)} />
</span>
);
};

View file

@ -216,9 +216,9 @@ export function useCreateSpace(viewerName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ title, description, spaceId }: { title: string; description?: string; spaceId: string }) =>
mutationFn: ({ title, description, spaceId, icon }: { title: string; description?: string; spaceId: string; icon?: Space["icon"] }) =>
spaceServiceClient.createSpace({
space: create(SpaceSchema, { title, description }),
space: create(SpaceSchema, { title, description, icon }),
spaceId,
}),
onSuccess: (space) => {

View file

@ -0,0 +1,67 @@
// Common Space identities. Any supported Unicode emoji can also be pasted into the picker.
export const SPACE_EMOJI = [
["🌱", "Seedling plant garden growth"],
["📚", "Books reading library"],
["💼", "Briefcase work business"],
["🏠", "House home family"],
["💡", "Light bulb ideas"],
["🚀", "Rocket launch project"],
["🎨", "Artist palette design art"],
["✍️", "Writing hand journal"],
["📝", "Memo notes writing"],
["🧠", "Brain learning knowledge"],
["🎯", "Target goals"],
["⭐", "Star favorites"],
["❤️", "Heart love"],
["🔥", "Fire energy"],
["⚡", "Lightning bolt power"],
["☀️", "Sun sunshine"],
["🌙", "Moon night"],
["🌈", "Rainbow colors"],
["🌊", "Wave ocean water"],
["🍀", "Four leaf clover luck"],
["🌸", "Cherry blossom flower"],
["🌻", "Sunflower"],
["🌳", "Tree forest nature"],
["🍄", "Mushroom"],
["🐈", "Cat pet"],
["🐕", "Dog pet"],
["🦊", "Fox"],
["🐼", "Panda"],
["🦋", "Butterfly"],
["🐝", "Bee honey"],
["🐙", "Octopus"],
["🐳", "Whale"],
["☕", "Coffee drink cafe"],
["🍵", "Tea cup"],
["🍎", "Apple fruit food"],
["🍋", "Lemon fruit"],
["🥑", "Avocado"],
["🍕", "Pizza cooking food"],
["🍰", "Cake dessert baking"],
["🍷", "Wine glass"],
["🎵", "Music note"],
["🎸", "Guitar music"],
["🎬", "Clapper board movies film"],
["📷", "Camera photography"],
["🎮", "Game controller gaming"],
["🎲", "Dice games"],
["⚽", "Soccer football sport"],
["🏀", "Basketball sport"],
["🚲", "Bicycle cycling"],
["🏆", "Trophy achievement"],
["✈️", "Airplane travel"],
["🏕️", "Camping tent outdoors"],
["🏔️", "Mountain hiking"],
["🏖️", "Beach holiday"],
["🌍", "Earth world globe"],
["🧭", "Compass exploration"],
["🔬", "Microscope research science"],
["🧪", "Test tube experiment"],
["💻", "Laptop code technology"],
["🛠️", "Tools building"],
["🧩", "Puzzle piece"],
["💎", "Gem diamond"],
["🎓", "Graduation education study"],
["🎉", "Party celebration"],
] as const;

View file

@ -0,0 +1,88 @@
import {
AstroidIcon,
BookOpenIcon,
BriefcaseIcon,
CameraIcon,
ChartNoAxesCombinedIcon,
CheckCheckIcon,
CodeIcon,
CoffeeIcon,
CompassIcon,
EarthIcon,
FeatherIcon,
FlaskConicalIcon,
Flower2Icon,
FolderIcon,
GemIcon,
GraduationCapIcon,
HeartIcon,
HouseIcon,
LeafIcon,
LightbulbIcon,
MapPinIcon,
MountainIcon,
MusicIcon,
NotebookPenIcon,
PaletteIcon,
PencilIcon,
PlaneIcon,
RocketIcon,
SproutIcon,
StarIcon,
SunIcon,
TargetIcon,
TentIcon,
TrophyIcon,
UsersIcon,
UtensilsIcon,
WrenchIcon,
ZapIcon,
} from "lucide-react";
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb";
// Keep the catalog explicit so adding a picker does not bundle the entire icon library.
export const SPACE_SYMBOLS = {
astroid: AstroidIcon,
"book-open": BookOpenIcon,
briefcase: BriefcaseIcon,
camera: CameraIcon,
"chart-no-axes-combined": ChartNoAxesCombinedIcon,
"check-check": CheckCheckIcon,
code: CodeIcon,
coffee: CoffeeIcon,
compass: CompassIcon,
earth: EarthIcon,
feather: FeatherIcon,
"flask-conical": FlaskConicalIcon,
"flower-2": Flower2Icon,
folder: FolderIcon,
gem: GemIcon,
"graduation-cap": GraduationCapIcon,
heart: HeartIcon,
house: HouseIcon,
leaf: LeafIcon,
lightbulb: LightbulbIcon,
"map-pin": MapPinIcon,
mountain: MountainIcon,
music: MusicIcon,
"notebook-pen": NotebookPenIcon,
palette: PaletteIcon,
pencil: PencilIcon,
plane: PlaneIcon,
rocket: RocketIcon,
sprout: SproutIcon,
star: StarIcon,
sun: SunIcon,
target: TargetIcon,
tent: TentIcon,
trophy: TrophyIcon,
users: UsersIcon,
utensils: UtensilsIcon,
wrench: WrenchIcon,
zap: ZapIcon,
};
export const getSpaceSymbol = (name: string) =>
Object.hasOwn(SPACE_SYMBOLS, name) ? SPACE_SYMBOLS[name as keyof typeof SPACE_SYMBOLS] : AstroidIcon;
export const spaceIconsEqual = (a?: Space_Icon, b?: Space_Icon) => a?.value.case === b?.value.case && a?.value.value === b?.value.value;

View file

@ -1083,6 +1083,18 @@
}
},
"space": {
"icon": {
"change": "Sahə ikonunu dəyiş",
"kind": "İkon növü",
"icons": "İkonlar",
"emoji": "Emoji",
"search-icons": "İkon axtar…",
"search-emoji": "Emoji axtar və ya yapışdır…",
"no-results": "Uyğun nəticə yoxdur",
"emoji-help": "Emoji seçin və ya öz emojinizi yapışdırın.",
"reset": "Standarta qaytar",
"use-emoji": "{{emoji}} istifadə et"
},
"unavailable": "Bu sahə mövcud deyil və ya ona giriş icazəniz yoxdur.",
"back-to-memos": "Memos-a qayıt",
"create": "Sahə yarat",

View file

@ -1083,6 +1083,18 @@
}
},
"space": {
"icon": {
"change": "Change space icon",
"kind": "Icon type",
"icons": "Icons",
"emoji": "Emoji",
"search-icons": "Search icons…",
"search-emoji": "Search or paste emoji…",
"no-results": "No matches",
"emoji-help": "Choose a common emoji or paste your own.",
"reset": "Reset to default",
"use-emoji": "Use {{emoji}}"
},
"unavailable": "This space does not exist or you don’t have access to it.",
"back-to-memos": "Back to Memos",
"create": "Create space",

View file

@ -968,6 +968,18 @@
}
},
"space": {
"icon": {
"change": "更改空间图标",
"kind": "图标类型",
"icons": "图标",
"emoji": "表情",
"search-icons": "搜索图标…",
"search-emoji": "搜索或粘贴表情…",
"no-results": "没有匹配项",
"emoji-help": "选择常用表情,或粘贴你喜欢的表情。",
"reset": "恢复默认",
"use-emoji": "使用 {{emoji}}"
},
"unavailable": "空间不存在或你没有访问权限。",
"back-to-memos": "返回 Memos"
}

File diff suppressed because one or more lines are too long

View file

@ -59,6 +59,26 @@ describe("CreateSpaceDialog", () => {
expect(mocks.mutateAsync).toHaveBeenCalledWith({ title: "Product", description: "Plans", spaceId: FIRST_SPACE_UID });
});
it("keeps the icon in the draft until creating and clears it when the dialog closes", async () => {
const view = render(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
fireEvent.change(screen.getByLabelText("common.name"), { target: { value: "Garden" } });
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
fireEvent.click(await screen.findByRole("button", { name: "Leaf" }));
expect(mocks.mutateAsync).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
await waitFor(() =>
expect(mocks.mutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
icon: expect.objectContaining({ value: { case: "lucide", value: "leaf" } }),
}),
),
);
view.rerender(<CreateSpaceDialog open={false} onOpenChange={mocks.onOpenChange} />);
view.rerender(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
expect(await screen.findByRole("button", { name: "space.icon.reset" })).toBeDisabled();
});
it("allows a valid custom Space UID and explains invalid values", async () => {
render(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);

View file

@ -0,0 +1,103 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import SpaceIcon from "@/components/SpaceIcon";
import SpaceIconPicker from "@/components/SpaceIconPicker";
import { SPACE_EMOJI } from "@/lib/space-emoji";
import { type Space_Icon, Space_IconSchema } from "@/types/proto/api/v1/space_service_pb";
import { FULLY_QUALIFIED_EMOJI } from "@/utils/tag-unicode-data";
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
const icon = (value: string, kind: "emoji" | "lucide" = "lucide") => create(Space_IconSchema, { value: { case: kind, value } });
function Harness({ initial, onChange = vi.fn() }: { initial?: Space_Icon; onChange?: (value?: Space_Icon) => void }) {
const [value, setValue] = useState(initial);
return (
<SpaceIconPicker
value={value}
onChange={(next) => {
setValue(next);
onChange(next);
}}
/>
);
}
describe("SpaceIconPicker", () => {
it("searches and selects a symbol with keyboard navigation and restores trigger focus", async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
const trigger = screen.getByRole("button", { name: "space.icon.change" });
fireEvent.click(trigger);
const search = await screen.findByRole("textbox", { name: "space.icon.search-icons" });
fireEvent.change(search, { target: { value: "book" } });
fireEvent.keyDown(search, { key: "ArrowDown" });
expect(screen.getByRole("button", { name: "Book" })).toHaveFocus();
fireEvent.keyDown(screen.getByRole("button", { name: "Book" }), { key: "ArrowRight" });
expect(screen.getByRole("button", { name: "Notebook" })).toHaveFocus();
fireEvent.click(screen.getByRole("button", { name: "Notebook" }));
expect(onChange).toHaveBeenCalledWith(icon("notebook-pen"));
await waitFor(() => expect(trigger).toHaveFocus());
});
it("accepts a pasted joined emoji and can reset it", async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
fireEvent.click(await screen.findByRole("tab", { name: "space.icon.emoji" }));
const search = screen.getByRole("textbox", { name: "space.icon.search-emoji" });
fireEvent.change(search, { target: { value: "👩🏽‍🌾" } });
fireEvent.keyDown(search, { key: "Enter" });
expect(onChange).toHaveBeenLastCalledWith(icon("👩🏽‍🌾", "emoji"));
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
expect(await screen.findByRole("tab", { name: "space.icon.emoji" })).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByRole("button", { name: "space.icon.reset" }));
expect(onChange).toHaveBeenLastCalledWith(undefined);
});
it("keeps the selection unchanged when dismissed or when Enter completes IME composition", async () => {
const onChange = vi.fn();
render(<Harness initial={icon("leaf")} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
const search = await screen.findByRole("textbox", { name: "space.icon.search-icons" });
expect(screen.getByRole("button", { name: "Leaf" })).toHaveAttribute("aria-pressed", "true");
fireEvent.keyDown(search, { key: "Enter", isComposing: true });
expect(onChange).not.toHaveBeenCalled();
fireEvent.keyDown(search, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("textbox")).not.toBeInTheDocument());
expect(onChange).not.toHaveBeenCalled();
});
it("shows empty search results without accepting invalid emoji text", async () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
fireEvent.click(await screen.findByRole("tab", { name: "space.icon.emoji" }));
const search = screen.getByRole("textbox", { name: "space.icon.search-emoji" });
fireEvent.change(search, { target: { value: "🌱🌱" } });
expect(screen.getByRole("status")).toHaveTextContent("space.icon.no-results");
fireEvent.keyDown(search, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("disables editing while a form is saving", () => {
render(<SpaceIconPicker disabled onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "space.icon.change" })).toBeDisabled();
});
it("offers only emoji accepted by the shared Unicode catalog", () => {
for (const [emoji] of SPACE_EMOJI) expect(FULLY_QUALIFIED_EMOJI).toContain(emoji);
});
});
describe("SpaceIcon", () => {
it("renders symbols and emoji, with a fallback for unknown names", () => {
const view = render(<SpaceIcon icon={icon("leaf")} />);
expect(view.container.querySelector(".lucide-leaf")).not.toBeNull();
view.rerender(<SpaceIcon icon={icon("🌱", "emoji")} />);
expect(view.container).toHaveTextContent("🌱");
view.rerender(<SpaceIcon icon={icon("future-symbol")} />);
expect(view.container.querySelector(".lucide-astroid")).not.toBeNull();
});
});

View file

@ -255,6 +255,40 @@ describe("SpacesSection", () => {
expect(within(joinedSection!).getByTitle("joined-product")).toHaveTextContent("joined-product");
});
it("saves the icon through the icon update mask", async () => {
state.spaces = [adminSpace];
state.members = [adminMember];
renderSection("/setting?space=spaces%2Fproduct#spaces");
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
fireEvent.click(await screen.findByRole("button", { name: "Leaf" }));
expect(state.updateSpace).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.save-changes" }));
await waitFor(() =>
expect(state.updateSpace).toHaveBeenCalledWith({
space: expect.objectContaining({
name: adminSpace.name,
icon: expect.objectContaining({ value: { case: "lucide", value: "leaf" } }),
}),
updateMask: ["title", "description", "icon"],
}),
);
});
it("clears a saved icon with an explicit update mask", async () => {
state.spaces = [{ ...adminSpace, icon: { $typeName: "memos.api.v1.Space.Icon", value: { case: "emoji", value: "🌱" } } }];
state.members = [adminMember];
renderSection("/setting?space=spaces%2Fproduct#spaces");
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
fireEvent.click(await screen.findByRole("button", { name: "space.icon.reset" }));
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.save-changes" }));
await waitFor(() =>
expect(state.updateSpace).toHaveBeenCalledWith({
space: expect.objectContaining({ name: adminSpace.name, icon: undefined }),
updateMask: ["title", "description", "icon"],
}),
);
});
it("shows governance controls to Space admins and cancels the exact pending invitation", async () => {
state.spaces = [adminSpace];
state.members = [adminMember, ordinaryMember];
@ -301,6 +335,7 @@ describe("SpacesSection", () => {
renderSection("/setting?space=spaces%2Fproduct#spaces");
expect(screen.getByLabelText("common.name")).toHaveAttribute("readonly");
expect(screen.queryByRole("button", { name: "space.icon.change" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "setting.spaces.save-changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "setting.spaces.delete-space" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "setting.spaces.leave-space" })).toBeInTheDocument();