feat(stats): support filtered all-user stats
- Add state and filter inputs to ListAllUserStats and reuse it for explore/archive sidebar stats. - Reduce duplicate home initialization requests by sharing stats/settings data paths. - Include memo paragraph regression coverage from the current working tree.
This commit is contained in:
parent
c49e75f91f
commit
88ac3ec31e
15 changed files with 375 additions and 186 deletions
|
|
@ -396,7 +396,12 @@ message GetUserStatsRequest {
|
|||
}
|
||||
|
||||
message ListAllUserStatsRequest {
|
||||
// This endpoint doesn't take any parameters.
|
||||
// Optional. The state of memos to include. Defaults to NORMAL.
|
||||
State state = 1 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. Filter to apply to memo stats.
|
||||
// Uses the same filter syntax as ListMemos.
|
||||
string filter = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
message ListAllUserStatsResponse {
|
||||
|
|
|
|||
|
|
@ -993,7 +993,12 @@ func (x *GetUserStatsRequest) GetName() string {
|
|||
}
|
||||
|
||||
type ListAllUserStatsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Optional. The state of memos to include. Defaults to NORMAL.
|
||||
State State `protobuf:"varint,1,opt,name=state,proto3,enum=memos.api.v1.State" json:"state,omitempty"`
|
||||
// Optional. Filter to apply to memo stats.
|
||||
// Uses the same filter syntax as ListMemos.
|
||||
Filter string `protobuf:"bytes,2,opt,name=filter,proto3" json:"filter,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -1028,6 +1033,20 @@ func (*ListAllUserStatsRequest) Descriptor() ([]byte, []int) {
|
|||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *ListAllUserStatsRequest) GetState() State {
|
||||
if x != nil {
|
||||
return x.State
|
||||
}
|
||||
return State_STATE_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (x *ListAllUserStatsRequest) GetFilter() string {
|
||||
if x != nil {
|
||||
return x.Filter
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ListAllUserStatsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The list of user statistics.
|
||||
|
|
@ -2808,7 +2827,7 @@ type UserStats_MemoTypeStats struct {
|
|||
|
||||
func (x *UserStats_MemoTypeStats) Reset() {
|
||||
*x = UserStats_MemoTypeStats{}
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[42]
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[41]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -2820,7 +2839,7 @@ func (x *UserStats_MemoTypeStats) String() string {
|
|||
func (*UserStats_MemoTypeStats) ProtoMessage() {}
|
||||
|
||||
func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[42]
|
||||
mi := &file_api_v1_user_service_proto_msgTypes[41]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -2833,7 +2852,7 @@ func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use UserStats_MemoTypeStats.ProtoReflect.Descriptor instead.
|
||||
func (*UserStats_MemoTypeStats) Descriptor() ([]byte, []int) {
|
||||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 1}
|
||||
return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 0}
|
||||
}
|
||||
|
||||
func (x *UserStats_MemoTypeStats) GetLinkCount() int32 {
|
||||
|
|
@ -3191,10 +3210,7 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
|||
"\x17memo_created_timestamps\x18\a \x03(\v2\x1a.google.protobuf.TimestampR\x15memoCreatedTimestamps\x12R\n" +
|
||||
"\x17memo_updated_timestamps\x18\b \x03(\v2\x1a.google.protobuf.TimestampR\x15memoUpdatedTimestamps\x12!\n" +
|
||||
"\fpinned_memos\x18\x05 \x03(\tR\vpinnedMemos\x12(\n" +
|
||||
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a;\n" +
|
||||
"\rTagCountEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\x1a\x8b\x01\n" +
|
||||
"\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a\x8b\x01\n" +
|
||||
"\rMemoTypeStats\x12\x1d\n" +
|
||||
"\n" +
|
||||
"link_count\x18\x01 \x01(\x05R\tlinkCount\x12\x1d\n" +
|
||||
|
|
@ -3203,12 +3219,17 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
|||
"\n" +
|
||||
"todo_count\x18\x03 \x01(\x05R\ttodoCount\x12\x1d\n" +
|
||||
"\n" +
|
||||
"undo_count\x18\x04 \x01(\x05R\tundoCount:?\xeaA<\n" +
|
||||
"undo_count\x18\x04 \x01(\x05R\tundoCount\x1a;\n" +
|
||||
"\rTagCountEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01:?\xeaA<\n" +
|
||||
"\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStatsJ\x04\b\x02\x10\x03R\x17memo_display_timestamps\"D\n" +
|
||||
"\x13GetUserStatsRequest\x12-\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/UserR\x04name\"\x19\n" +
|
||||
"\x17ListAllUserStatsRequest\"I\n" +
|
||||
"\x11memos.api.v1/UserR\x04name\"f\n" +
|
||||
"\x17ListAllUserStatsRequest\x12.\n" +
|
||||
"\x05state\x18\x01 \x01(\x0e2\x13.memos.api.v1.StateB\x03\xe0A\x01R\x05state\x12\x1b\n" +
|
||||
"\x06filter\x18\x02 \x01(\tB\x03\xe0A\x01R\x06filter\"I\n" +
|
||||
"\x18ListAllUserStatsResponse\x12-\n" +
|
||||
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xb4\x04\n" +
|
||||
"\vUserSetting\x12\x17\n" +
|
||||
|
|
@ -3465,8 +3486,8 @@ var file_api_v1_user_service_proto_goTypes = []any{
|
|||
(*ListUserNotificationsResponse)(nil), // 42: memos.api.v1.ListUserNotificationsResponse
|
||||
(*UpdateUserNotificationRequest)(nil), // 43: memos.api.v1.UpdateUserNotificationRequest
|
||||
(*DeleteUserNotificationRequest)(nil), // 44: memos.api.v1.DeleteUserNotificationRequest
|
||||
nil, // 45: memos.api.v1.UserStats.TagCountEntry
|
||||
(*UserStats_MemoTypeStats)(nil), // 46: memos.api.v1.UserStats.MemoTypeStats
|
||||
(*UserStats_MemoTypeStats)(nil), // 45: memos.api.v1.UserStats.MemoTypeStats
|
||||
nil, // 46: memos.api.v1.UserStats.TagCountEntry
|
||||
(*UserSetting_GeneralSetting)(nil), // 47: memos.api.v1.UserSetting.GeneralSetting
|
||||
(*UserSetting_WebhooksSetting)(nil), // 48: memos.api.v1.UserSetting.WebhooksSetting
|
||||
(*UserNotification_MemoCommentPayload)(nil), // 49: memos.api.v1.UserNotification.MemoCommentPayload
|
||||
|
|
@ -3487,93 +3508,94 @@ var file_api_v1_user_service_proto_depIdxs = []int32{
|
|||
4, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User
|
||||
4, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User
|
||||
53, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
46, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
|
||||
45, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
|
||||
45, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats
|
||||
46, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry
|
||||
52, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp
|
||||
52, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp
|
||||
13, // 14: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
|
||||
47, // 15: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
|
||||
48, // 16: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
|
||||
17, // 17: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
|
||||
53, // 18: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
17, // 19: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
|
||||
22, // 20: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
|
||||
52, // 21: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
|
||||
52, // 22: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
|
||||
52, // 23: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
|
||||
28, // 24: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
|
||||
28, // 25: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
|
||||
52, // 26: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
|
||||
52, // 27: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
|
||||
34, // 28: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
34, // 29: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
34, // 30: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
53, // 31: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
4, // 32: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
|
||||
2, // 33: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status
|
||||
52, // 34: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
|
||||
3, // 35: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type
|
||||
49, // 36: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
|
||||
50, // 37: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
|
||||
40, // 38: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
|
||||
40, // 39: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
|
||||
53, // 40: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
34, // 41: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
5, // 42: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
|
||||
7, // 43: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
|
||||
9, // 44: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
|
||||
10, // 45: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
|
||||
11, // 46: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
|
||||
12, // 47: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
|
||||
15, // 48: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
|
||||
14, // 49: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
|
||||
18, // 50: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
|
||||
19, // 51: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
|
||||
20, // 52: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
|
||||
23, // 53: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
|
||||
25, // 54: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
|
||||
26, // 55: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
|
||||
27, // 56: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
|
||||
29, // 57: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
|
||||
31, // 58: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
|
||||
33, // 59: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
|
||||
35, // 60: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
|
||||
37, // 61: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
|
||||
38, // 62: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
|
||||
39, // 63: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
|
||||
41, // 64: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
|
||||
43, // 65: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
|
||||
44, // 66: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
|
||||
6, // 67: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
|
||||
8, // 68: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
|
||||
4, // 69: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
|
||||
4, // 70: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
|
||||
4, // 71: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
|
||||
54, // 72: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
|
||||
16, // 73: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
|
||||
13, // 74: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
|
||||
17, // 75: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
17, // 76: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
21, // 77: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
|
||||
24, // 78: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
|
||||
22, // 79: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
22, // 80: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
54, // 81: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
|
||||
30, // 82: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
|
||||
32, // 83: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
|
||||
54, // 84: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
|
||||
36, // 85: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
|
||||
34, // 86: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
34, // 87: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
54, // 88: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
|
||||
42, // 89: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
|
||||
40, // 90: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
|
||||
54, // 91: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
|
||||
67, // [67:92] is the sub-list for method output_type
|
||||
42, // [42:67] is the sub-list for method input_type
|
||||
42, // [42:42] is the sub-list for extension type_name
|
||||
42, // [42:42] is the sub-list for extension extendee
|
||||
0, // [0:42] is the sub-list for field type_name
|
||||
51, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State
|
||||
13, // 15: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats
|
||||
47, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting
|
||||
48, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting
|
||||
17, // 18: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting
|
||||
53, // 19: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
17, // 20: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting
|
||||
22, // 21: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity
|
||||
52, // 22: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp
|
||||
52, // 23: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp
|
||||
52, // 24: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp
|
||||
28, // 25: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken
|
||||
28, // 26: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken
|
||||
52, // 27: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp
|
||||
52, // 28: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp
|
||||
34, // 29: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
34, // 30: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
34, // 31: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook
|
||||
53, // 32: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
4, // 33: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User
|
||||
2, // 34: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status
|
||||
52, // 35: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp
|
||||
3, // 36: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type
|
||||
49, // 37: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload
|
||||
50, // 38: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload
|
||||
40, // 39: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification
|
||||
40, // 40: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification
|
||||
53, // 41: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
34, // 42: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook
|
||||
5, // 43: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest
|
||||
7, // 44: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest
|
||||
9, // 45: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest
|
||||
10, // 46: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest
|
||||
11, // 47: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest
|
||||
12, // 48: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest
|
||||
15, // 49: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest
|
||||
14, // 50: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest
|
||||
18, // 51: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest
|
||||
19, // 52: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest
|
||||
20, // 53: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest
|
||||
23, // 54: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest
|
||||
25, // 55: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest
|
||||
26, // 56: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest
|
||||
27, // 57: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest
|
||||
29, // 58: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest
|
||||
31, // 59: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest
|
||||
33, // 60: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest
|
||||
35, // 61: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest
|
||||
37, // 62: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest
|
||||
38, // 63: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest
|
||||
39, // 64: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest
|
||||
41, // 65: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest
|
||||
43, // 66: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest
|
||||
44, // 67: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest
|
||||
6, // 68: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse
|
||||
8, // 69: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse
|
||||
4, // 70: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User
|
||||
4, // 71: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User
|
||||
4, // 72: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User
|
||||
54, // 73: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty
|
||||
16, // 74: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse
|
||||
13, // 75: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats
|
||||
17, // 76: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
17, // 77: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting
|
||||
21, // 78: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse
|
||||
24, // 79: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse
|
||||
22, // 80: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
22, // 81: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity
|
||||
54, // 82: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty
|
||||
30, // 83: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse
|
||||
32, // 84: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse
|
||||
54, // 85: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty
|
||||
36, // 86: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse
|
||||
34, // 87: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
34, // 88: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook
|
||||
54, // 89: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty
|
||||
42, // 90: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse
|
||||
40, // 91: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification
|
||||
54, // 92: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty
|
||||
68, // [68:93] is the sub-list for method output_type
|
||||
43, // [43:68] is the sub-list for method input_type
|
||||
43, // [43:43] is the sub-list for extension type_name
|
||||
43, // [43:43] is the sub-list for extension extendee
|
||||
0, // [0:43] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_v1_user_service_proto_init() }
|
||||
|
|
|
|||
|
|
@ -325,6 +325,8 @@ func local_request_UserService_DeleteUser_0(ctx context.Context, marshaler runti
|
|||
return msg, metadata, err
|
||||
}
|
||||
|
||||
var filter_UserService_ListAllUserStats_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
|
||||
|
||||
func request_UserService_ListAllUserStats_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ListAllUserStatsRequest
|
||||
|
|
@ -333,6 +335,12 @@ func request_UserService_ListAllUserStats_0(ctx context.Context, marshaler runti
|
|||
if req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
}
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_UserService_ListAllUserStats_0); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.ListAllUserStats(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
|
@ -342,6 +350,12 @@ func local_request_UserService_ListAllUserStats_0(ctx context.Context, marshaler
|
|||
protoReq ListAllUserStatsRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_UserService_ListAllUserStats_0); err != nil {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.ListAllUserStats(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2241,6 +2241,24 @@ paths:
|
|||
- UserService
|
||||
description: ListAllUserStats returns statistics for all users.
|
||||
operationId: UserService_ListAllUserStats
|
||||
parameters:
|
||||
- name: state
|
||||
in: query
|
||||
description: Optional. The state of memos to include. Defaults to NORMAL.
|
||||
schema:
|
||||
enum:
|
||||
- STATE_UNSPECIFIED
|
||||
- NORMAL
|
||||
- ARCHIVED
|
||||
type: string
|
||||
format: enum
|
||||
- name: filter
|
||||
in: query
|
||||
description: |-
|
||||
Optional. Filter to apply to memo stats.
|
||||
Uses the same filter syntax as ListMemos.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
|
|
|
|||
|
|
@ -158,3 +158,45 @@ func TestGetUserStats_MemoUpdatedTimestamps(t *testing.T) {
|
|||
"updated_ts should be after created_ts after an edit",
|
||||
)
|
||||
}
|
||||
|
||||
func TestListAllUserStats_FilterExcludesPrivateMemos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
user, err := ts.CreateHostUser(ctx, "stats-filter-user")
|
||||
require.NoError(t, err)
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
_, err = ts.Store.CreateMemo(ctx, &store.Memo{
|
||||
UID: "stats-filter-public",
|
||||
CreatorID: user.ID,
|
||||
Content: "public memo",
|
||||
Visibility: store.Public,
|
||||
Payload: &storepb.MemoPayload{Tags: []string{"public"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.Store.CreateMemo(ctx, &store.Memo{
|
||||
UID: "stats-filter-private",
|
||||
CreatorID: user.ID,
|
||||
Content: "private memo",
|
||||
Visibility: store.Private,
|
||||
Payload: &storepb.MemoPayload{Tags: []string{"private"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
unfilteredResp, err := ts.Service.ListAllUserStats(userCtx, &v1pb.ListAllUserStatsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, unfilteredResp.Stats, 1)
|
||||
require.Equal(t, int32(1), unfilteredResp.Stats[0].TagCount["public"])
|
||||
require.Equal(t, int32(1), unfilteredResp.Stats[0].TagCount["private"])
|
||||
|
||||
filteredResp, err := ts.Service.ListAllUserStats(userCtx, &v1pb.ListAllUserStatsRequest{
|
||||
Filter: `visibility in ["PUBLIC", "PROTECTED"]`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, filteredResp.Stats, 1)
|
||||
require.Equal(t, int32(1), filteredResp.Stats[0].TagCount["public"])
|
||||
require.NotContains(t, filteredResp.Stats[0].TagCount, "private")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,20 +53,34 @@ func (s *APIV1Service) listUsernamesByID(ctx context.Context, userIDs []int32) (
|
|||
return usernamesByID, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) {
|
||||
normalStatus := store.Normal
|
||||
func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) {
|
||||
rowStatus := convertStateToStore(request.State)
|
||||
memoFind := &store.FindMemo{
|
||||
// Exclude comments by default.
|
||||
ExcludeComments: true,
|
||||
ExcludeContent: true,
|
||||
RowStatus: &normalStatus,
|
||||
RowStatus: &rowStatus,
|
||||
}
|
||||
|
||||
currentUser, err := s.fetchCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if currentUser == nil {
|
||||
|
||||
if request.Filter != "" {
|
||||
if err := s.validateFilter(ctx, request.Filter); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
|
||||
}
|
||||
memoFind.Filters = append(memoFind.Filters, request.Filter)
|
||||
}
|
||||
|
||||
if request.State == v1pb.State_ARCHIVED {
|
||||
// Archived memos are only visible to their creator.
|
||||
if currentUser == nil {
|
||||
return &v1pb.ListAllUserStatsResponse{}, nil
|
||||
}
|
||||
memoFind.CreatorID = ¤tUser.ID
|
||||
} else if currentUser == nil {
|
||||
memoFind.VisibilityList = []store.Visibility{store.Public}
|
||||
} else {
|
||||
if memoFind.CreatorID == nil {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, Rea
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function getSingleLinkHref(node?: Element): string | undefined {
|
||||
export function getSingleLinkHref(node?: Element): string | undefined {
|
||||
if (!node || node.tagName !== "p") {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -27,7 +27,20 @@ function getSingleLinkHref(node?: Element): string | undefined {
|
|||
}
|
||||
|
||||
const href = onlyChild.properties?.href;
|
||||
return typeof href === "string" ? href : undefined;
|
||||
if (typeof href !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const meaningfulLinkChildren = onlyChild.children.filter((child) => {
|
||||
return !(child.type === "text" && child.value.trim() === "");
|
||||
});
|
||||
|
||||
if (meaningfulLinkChildren.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onlyLinkChild = meaningfulLinkChildren[0];
|
||||
return onlyLinkChild.type === "text" && onlyLinkChild.value === href ? href : undefined;
|
||||
}
|
||||
|
||||
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
|
@ -29,10 +29,6 @@ function ShortcutsSection() {
|
|||
const { shortcut: selectedShortcut, setShortcut } = useMemoFilterContext();
|
||||
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
refetchSettings();
|
||||
}, [refetchSettings]);
|
||||
|
||||
const handleDeleteShortcut = async (shortcut: Shortcut) => {
|
||||
setDeleteTarget(shortcut);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -142,18 +142,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
}, [queryClient]);
|
||||
|
||||
const refetchSettings = useCallback(async () => {
|
||||
// Use functional setState to get current user without including state in dependencies
|
||||
const currentUserName = state.currentUser?.name;
|
||||
if (!currentUserName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await fetchUserSettings(currentUserName);
|
||||
setState((prev) => {
|
||||
if (!prev.currentUser) return prev;
|
||||
|
||||
// Fetch settings asynchronously
|
||||
fetchUserSettings(prev.currentUser.name).then((settings) => {
|
||||
setState((current) => ({ ...current, ...settings }));
|
||||
});
|
||||
|
||||
return prev;
|
||||
if (prev.currentUser?.name !== currentUserName) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, ...settings };
|
||||
});
|
||||
}, [fetchUserSettings]);
|
||||
}, [fetchUserSettings, state.currentUser?.name]);
|
||||
|
||||
// Sync the updated user to AuthContext and React Query cache after profile changes
|
||||
const setCurrentUser = useCallback(
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { useMemo } from "react";
|
|||
import type { MemoExplorerContext } from "@/components/MemoExplorer";
|
||||
import { type MemoTimeBasis, useView } from "@/contexts/ViewContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useMemos } from "@/hooks/useMemoQueries";
|
||||
import { useUserStats } from "@/hooks/useUserQueries";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { UserStats } from "@/types/proto/api/v1/user_service_pb";
|
||||
import type { StatisticsData } from "@/types/statistics";
|
||||
|
||||
export interface FilteredMemoStats {
|
||||
|
|
@ -23,9 +23,15 @@ export interface UseFilteredMemoStatsOptions {
|
|||
|
||||
const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD");
|
||||
|
||||
const memoTimestampForBasis = (memo: Memo, basis: MemoTimeBasis): Date | undefined => {
|
||||
const ts = basis === "update_time" ? memo.updateTime : memo.createTime;
|
||||
return ts ? timestampDate(ts) : undefined;
|
||||
const timestampsForBasis = (stats: UserStats, basis: MemoTimeBasis) => {
|
||||
const createdArray = stats.memoCreatedTimestamps ?? [];
|
||||
const updatedArray = stats.memoUpdatedTimestamps ?? [];
|
||||
const wantUpdated = basis === "update_time";
|
||||
const oldServerFallback = wantUpdated && updatedArray.length === 0 && createdArray.length > 0;
|
||||
if (oldServerFallback) {
|
||||
console.warn("UserStats.memo_updated_timestamps not present; falling back to memo_created_timestamps");
|
||||
}
|
||||
return wantUpdated && !oldServerFallback ? updatedArray : createdArray;
|
||||
};
|
||||
|
||||
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
|
||||
|
|
@ -35,49 +41,43 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
|
|||
|
||||
// home/profile: use backend per-user stats (full tag set, not page-limited)
|
||||
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName);
|
||||
|
||||
// explore: fetch memos with visibility filter to exclude private content.
|
||||
// ListMemos AND's the request filter with the server's auth filter, so private
|
||||
// memos are always excluded regardless of backend version.
|
||||
// other contexts: fetch with default params for the fallback memo-based path.
|
||||
// explore/archived: fetch backend grouped stats and aggregate them locally.
|
||||
// ListAllUserStats AND's the request filter with the server's auth filter, so
|
||||
// private memos are not included unless explicitly visible to the current user.
|
||||
const exploreVisibilityFilter = currentUser != null ? 'visibility in ["PUBLIC", "PROTECTED"]' : 'visibility in ["PUBLIC"]';
|
||||
const memoQueryParams = context === "explore" ? { filter: exploreVisibilityFilter, pageSize: 1000 } : {};
|
||||
const { data: memosResponse, isLoading: isLoadingMemos } = useMemos(memoQueryParams);
|
||||
const allUserStatsRequest =
|
||||
context === "explore"
|
||||
? { state: State.NORMAL, filter: exploreVisibilityFilter }
|
||||
: context === "archived"
|
||||
? { state: State.ARCHIVED }
|
||||
: {};
|
||||
const shouldFetchAllUserStats = context === "explore" || (context === "archived" && !!currentUser?.name);
|
||||
const { data: allUserStats = [], isLoading: isLoadingAllUserStats } = useAllUserStats(allUserStatsRequest, {
|
||||
enabled: shouldFetchAllUserStats,
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
const loading = isLoadingUserStats || isLoadingMemos;
|
||||
const loading = isLoadingUserStats || isLoadingAllUserStats;
|
||||
let activityStats: Record<string, number> = {};
|
||||
let tagCount: Record<string, number> = {};
|
||||
|
||||
if (context === "explore") {
|
||||
// Tags and activity stats from visibility-filtered memos (no private content).
|
||||
for (const memo of memosResponse?.memos ?? []) {
|
||||
for (const tag of memo.tags ?? []) {
|
||||
tagCount[tag] = (tagCount[tag] ?? 0) + 1;
|
||||
if (context === "explore" || context === "archived") {
|
||||
const displayDates: string[] = [];
|
||||
for (const stats of allUserStats) {
|
||||
for (const [tag, count] of Object.entries(stats.tagCount ?? {})) {
|
||||
tagCount[tag] = (tagCount[tag] ?? 0) + count;
|
||||
}
|
||||
displayDates.push(
|
||||
...timestampsForBasis(stats, timeBasis)
|
||||
.map((ts) => (ts ? timestampDate(ts) : undefined))
|
||||
.filter((date): date is Date => date !== undefined)
|
||||
.map(toDateString),
|
||||
);
|
||||
}
|
||||
const displayDates = (memosResponse?.memos ?? [])
|
||||
.map((memo) => memoTimestampForBasis(memo, timeBasis))
|
||||
.filter((date): date is Date => date !== undefined)
|
||||
.map(toDateString);
|
||||
activityStats = countBy(displayDates);
|
||||
} else if (userName && userStats) {
|
||||
// home/profile: use backend per-user stats.
|
||||
//
|
||||
// protobuf-es generates repeated fields as non-optional T[], so an old
|
||||
// server that doesn't know the new field deserializes it as []. Since
|
||||
// memo.updated_ts is initialized to created_ts at row creation, the two
|
||||
// arrays are always the same length when there are memos. Length
|
||||
// divergence (created non-empty AND updated empty) therefore reliably
|
||||
// signals "old server" and is the only case where we fall back.
|
||||
const createdArray = userStats.memoCreatedTimestamps ?? [];
|
||||
const updatedArray = userStats.memoUpdatedTimestamps ?? [];
|
||||
const wantUpdated = timeBasis === "update_time";
|
||||
const oldServerFallback = wantUpdated && updatedArray.length === 0 && createdArray.length > 0;
|
||||
if (oldServerFallback) {
|
||||
console.warn("UserStats.memo_updated_timestamps not present; falling back to memo_created_timestamps");
|
||||
}
|
||||
const sourceArray = wantUpdated && !oldServerFallback ? updatedArray : createdArray;
|
||||
const sourceArray = timestampsForBasis(userStats, timeBasis);
|
||||
if (sourceArray.length > 0) {
|
||||
activityStats = countBy(
|
||||
sourceArray
|
||||
|
|
@ -89,22 +89,10 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
|
|||
if (userStats.tagCount) {
|
||||
tagCount = userStats.tagCount;
|
||||
}
|
||||
} else if (memosResponse?.memos) {
|
||||
// archived/fallback: compute from cached memos
|
||||
const displayDates = memosResponse.memos
|
||||
.map((memo) => memoTimestampForBasis(memo, timeBasis))
|
||||
.filter((date): date is Date => date !== undefined)
|
||||
.map(toDateString);
|
||||
activityStats = countBy(displayDates);
|
||||
for (const memo of memosResponse.memos) {
|
||||
for (const tag of memo.tags ?? []) {
|
||||
tagCount[tag] = (tagCount[tag] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { statistics: { activityStats, timeBasis }, tags: tagCount, loading };
|
||||
}, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos, timeBasis]);
|
||||
}, [context, userName, userStats, allUserStats, isLoadingUserStats, isLoadingAllUserStats, timeBasis]);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -84,10 +84,15 @@ export function useLiveMemoRefresh() {
|
|||
const connect = async () => {
|
||||
if (!mounted) return;
|
||||
|
||||
if (!currentUserName) {
|
||||
setSSEStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
setSSEStatus("disconnected");
|
||||
// Not logged in; do not retry. Effect will re-run when currentUser is set (login).
|
||||
// Not logged in; do not retry. Effect will re-run when currentUser is set.
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { shortcutServiceClient, userServiceClient } from "@/connect";
|
||||
import { buildUserSettingName } from "@/helpers/resource-names";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
import {
|
||||
type ListAllUserStatsRequest,
|
||||
ListAllUserStatsRequestSchema,
|
||||
User,
|
||||
UserSetting,
|
||||
UserSetting_GeneralSetting,
|
||||
UserSetting_Key,
|
||||
UserSettingSchema,
|
||||
UserStats,
|
||||
} from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
const BATCH_GET_USERS_LIMIT = 100;
|
||||
type ListAllUserStatsQuery = Pick<ListAllUserStatsRequest, "state" | "filter">;
|
||||
|
||||
// Query keys factory
|
||||
export const userKeys = {
|
||||
|
|
@ -15,6 +25,7 @@ export const userKeys = {
|
|||
detail: (name: string) => [...userKeys.details(), name] as const,
|
||||
stats: () => [...userKeys.all, "stats"] as const,
|
||||
userStats: (name: string) => [...userKeys.stats(), name] as const,
|
||||
allUserStats: (request: Partial<ListAllUserStatsQuery>) => [...userKeys.stats(), "all", request] as const,
|
||||
currentUser: () => [...userKeys.all, "current"] as const,
|
||||
shortcuts: () => [...userKeys.all, "shortcuts"] as const,
|
||||
notifications: () => [...userKeys.all, "notifications"] as const,
|
||||
|
|
@ -48,6 +59,17 @@ export function useUserStats(username?: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useAllUserStats(request: Partial<ListAllUserStatsQuery> = {}, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.allUserStats(request),
|
||||
queryFn: async () => {
|
||||
const { stats } = await userServiceClient.listAllUserStats(create(ListAllUserStatsRequestSchema, request));
|
||||
return stats;
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useShortcuts() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.shortcuts(),
|
||||
|
|
@ -78,16 +100,17 @@ export function useNotifications() {
|
|||
export function useTagCounts(forCurrentUser = false) {
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: forCurrentUser ? [...userKeys.stats(), "tagCounts", "current"] : [...userKeys.stats(), "tagCounts", "all"],
|
||||
queryFn: async () => {
|
||||
return useQuery<UserStats | Record<string, number>, Error, Record<string, number>>({
|
||||
queryKey:
|
||||
forCurrentUser && currentUser?.name
|
||||
? userKeys.userStats(currentUser.name)
|
||||
: [...userKeys.stats(), "tagCounts", forCurrentUser ? "current" : "all"],
|
||||
queryFn: async (): Promise<UserStats | Record<string, number>> => {
|
||||
if (forCurrentUser) {
|
||||
// Fetch current user stats only
|
||||
if (!currentUser?.name) {
|
||||
return {};
|
||||
}
|
||||
const stats = await userServiceClient.getUserStats({ name: currentUser.name });
|
||||
return stats.tagCount || {};
|
||||
return userServiceClient.getUserStats({ name: currentUser.name });
|
||||
} else {
|
||||
// Fetch all user stats
|
||||
const { stats } = await userServiceClient.listAllUserStats({});
|
||||
|
|
@ -104,6 +127,12 @@ export function useTagCounts(forCurrentUser = false) {
|
|||
return tagCount;
|
||||
}
|
||||
},
|
||||
select: (data) => {
|
||||
if (forCurrentUser) {
|
||||
return (data as UserStats).tagCount || {};
|
||||
}
|
||||
return data as Record<string, number>;
|
||||
},
|
||||
enabled: !forCurrentUser || !!currentUser?.name,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes - tags don't change frequently
|
||||
});
|
||||
|
|
|
|||
|
|
@ -178,10 +178,6 @@ const Shortcuts = () => {
|
|||
const isEditing = draft.name !== "";
|
||||
const isSaving = createState.isLoading || updateState.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
refetchSettings();
|
||||
}, [refetchSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as ShortcutsRouteState | null;
|
||||
if (!state) return;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
34
web/tests/memo-content-paragraph.test.tsx
Normal file
34
web/tests/memo-content-paragraph.test.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSingleLinkHref } from "@/components/MemoContent/markdown/Paragraph";
|
||||
|
||||
const collectSingleLinkHrefs = (content: string): Array<string | undefined> => {
|
||||
const hrefs: Array<string | undefined> = [];
|
||||
|
||||
renderToStaticMarkup(
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children, node }) => {
|
||||
hrefs.push(getSingleLinkHref(node));
|
||||
return <p>{children}</p>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>,
|
||||
);
|
||||
|
||||
return hrefs;
|
||||
};
|
||||
|
||||
describe("memo content paragraph links", () => {
|
||||
it("treats only bare single-link paragraphs as single link hrefs", () => {
|
||||
expect(collectSingleLinkHrefs("https://www.bilibili.com/\n\n[bilibili](https://www.bilibili.com/)")).toEqual([
|
||||
"https://www.bilibili.com/",
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue