diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 402a39c..b055a57 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -3340,8 +3340,11 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt // Fetch all subscribed users. The number of users is not large. q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, - s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private + s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private, + p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, + p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id + LEFT JOIN im_user_profiles AS p ON p.user_id=s.userid WHERE s.topic=?` args := []any{topic} if !keepDeleted { @@ -3394,18 +3397,21 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt var lastSeen sql.NullTime var userAgent string var public, trusted any + var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString for rows.Next() { if err = rows.Scan( &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven, - &public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil { + &public, &trusted, &lastSeen, &userAgent, &sub.Private, + &profileNickname, &profileAvatarFileID, &profileSignature, + &profileGender, &profileRegion, &profilePublicAccount); err != nil { break } sub.User = common.EncodeUidString(sub.User).String() sub.Private = common.FromJSON(sub.Private) - sub.SetPublic(common.FromJSON(public)) + sub.SetPublic(profilePublicFromFields(common.FromJSON(public), profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount)) sub.SetTrusted(common.FromJSON(trusted)) sub.SetLastSeenAndUA(&lastSeen.Time, userAgent) subs = append(subs, sub) diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 39e85af..4a729ae 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -1077,8 +1077,11 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b } tcat := t.GetTopicCat(topic) q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, - s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private + s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private, + p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, + p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id + LEFT JOIN im_user_profiles AS p ON p.tenant_id=s.tenant_id AND p.user_id=s.userid WHERE s.tenant_id=? AND s.topic=?` args := []any{id, topic} if !keepDeleted { @@ -1119,14 +1122,17 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b var sub t.Subscription var lastSeen sql.NullTime var public, trusted any + var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven, - &public, &trusted, &lastSeen, &userAgent, &sub.Private); err != nil { + &public, &trusted, &lastSeen, &userAgent, &sub.Private, + &profileNickname, &profileAvatarFileID, &profileSignature, + &profileGender, &profileRegion, &profilePublicAccount); err != nil { return nil, err } sub.User = common.EncodeUidString(sub.User).String() sub.Private = common.FromJSON(sub.Private) - sub.SetPublic(common.FromJSON(public)) + sub.SetPublic(profilePublicFromFields(common.FromJSON(public), profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount)) sub.SetTrusted(common.FromJSON(trusted)) sub.SetLastSeenAndUA(&lastSeen.Time, userAgent) subs = append(subs, sub) diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index c9d7ccc..659b175 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -248,30 +248,49 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou if opts.Limit <= 0 || opts.Limit > 100 { opts.Limit = 30 } - where := []string{"topic=?", "member_status=?"} + memberIndex := "idx_im_group_members_topic_status" + where := []string{"gm.topic=?", "gm.member_status=?"} args := []any{topic, t.GroupMemberStatusActive} if opts.Role != "" { - where = append(where, "role=?") + memberIndex = "idx_im_group_members_topic_role" + where = append(where, "gm.role=?") args = append(args, opts.Role) } if opts.Query != "" { - where = append(where, "nickname LIKE ?") - args = append(args, "%"+opts.Query+"%") + like := "%" + opts.Query + "%" + where = append(where, `(gm.nickname LIKE ? OR p.nickname LIKE ? OR p.public_account LIKE ? + OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.fn')) LIKE ? + OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.name')) LIKE ? + OR JSON_UNQUOTE(JSON_EXTRACT(u.public,'$.display_name')) LIKE ? + OR u.username LIKE ?)`) + args = append(args, like, like, like, like, like, like, like) } if opts.Cursor != "" { parsed, err := strconv.ParseInt(opts.Cursor, 10, 64) - if err != nil || parsed <= 0 { + if err != nil || parsed < 0 { return nil, "", t.ErrMalformed } - where = append(where, "id>?") - args = append(args, parsed) + if parsed > 0 { + where = append(where, "gm.id>?") + args = append(args, parsed) + } + } + if opts.Mentionable { + where = append(where, "INSTR(s.modewant,'J')>0 AND INSTR(s.modegiven,'J')>0") } args = append(args, opts.Limit+1) ctx, cancel := a.getContext() if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_group_members WHERE "+strings.Join(where, " AND ")+" ORDER BY id LIMIT ?", args...) + rows, err := a.db.QueryxContext(ctx, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,u.public,u.trusted, + p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, + p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account + FROM im_group_members AS gm FORCE INDEX (`+memberIndex+`) + JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL + JOIN im_users AS u ON u.id=gm.user_id AND u.state!=? + LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id + WHERE `+strings.Join(where, " AND ")+` ORDER BY gm.id LIMIT ?`, append([]any{t.StateDeleted}, args...)...) if err != nil { return nil, "", err } @@ -279,15 +298,30 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou items := make([]t.GroupMember, 0, opts.Limit) next := "" for rows.Next() { - var item t.GroupMember - if err = rows.StructScan(&item); err != nil { + row := struct { + t.GroupMember + Public any + Trusted any + ProfileNickname sql.NullString `db:"profile_nickname"` + ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"` + ProfileSignature sql.NullString `db:"profile_signature"` + ProfileGender sql.NullString `db:"profile_gender"` + ProfileRegion sql.NullString `db:"profile_region"` + ProfilePublicAccount sql.NullString `db:"profile_public_account"` + }{} + if err = rows.StructScan(&row); err != nil { return nil, "", err } + item := row.GroupMember scanGroupMember(&item) if len(items) == opts.Limit { - next = strconv.FormatInt(item.ID, 10) + if len(items) > 0 { + next = strconv.FormatInt(items[len(items)-1].ID, 10) + } continue } + item.Public = profilePublicFromFields(common.FromJSON(row.Public), row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount) + item.Trusted = common.FromJSON(row.Trusted) items = append(items, item) } return items, next, rows.Err() diff --git a/server/im_http.go b/server/im_http.go index f31ccb5..8b024fe 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -4807,8 +4807,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin return } limit := parseIMLimit(req.URL.Query().Get("limit"), 30, 100) - cursor, err := parseIMNonNegativeCursor(req.URL.Query().Get("cursor")) - if err != nil { + cursor := strings.TrimSpace(req.URL.Query().Get("cursor")) + if cursor != "" { + if _, err := strconv.ParseInt(cursor, 10, 64); err != nil { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) + return + } + } + if parsed, err := parseIMNonNegativeCursor(cursor); err != nil || parsed < 0 { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) return } @@ -4822,25 +4828,22 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin writeIMSuccess(wrt, req, cached) return } - members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) + members, nextCursor, err := store.GroupBusiness.ListMembers(principal.TenantID, topic, types.GroupMemberListOptions{ + Role: roleFilter, + EligibleRole: eligibleRole, + Query: query, + Cursor: cursor, + Limit: limit, + Mentionable: mentionableOnly, + }) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } items := make([]map[string]any, 0, limit) - seen := 0 - nextCursor := "" for _, member := range members { - access := member.ModeGiven & member.ModeWant - mentionable := access.IsJoiner() && access.IsReader() - role := groupMemberRole(access) - if roleFilter != "" && role != roleFilter { - continue - } - if mentionableOnly && !mentionable { - continue - } - item := groupMemberPayload(member, mentionable) + access := groupMemberAccess(member) + item := groupBusinessMemberPayload(member) if eligibleRole != "" { eligible, reason := groupRoleEligibility(selfAccess, access, eligibleRole) item["eligible"] = eligible @@ -4855,19 +4858,7 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin item["ineligible_reason"] = reason } } - if query != "" && !groupMemberMatches(item, query) { - continue - } - if seen < cursor { - seen++ - continue - } - if len(items) == limit { - nextCursor = strconv.Itoa(seen) - break - } items = append(items, item) - seen++ } permissions := map[string]any{ "can_mention_all": selfAccess.IsAdmin(), @@ -7320,6 +7311,38 @@ func groupMemberPayload(sub types.Subscription, mentionable bool) map[string]any return data } +func groupBusinessMemberPayload(member types.GroupMember) map[string]any { + access := groupMemberAccess(member) + user := &types.User{ObjHeader: types.ObjHeader{Id: member.UserID.String()}, Public: member.Public, Trusted: member.Trusted} + data := userSummaryPayload(user, "") + if nickname := strings.TrimSpace(member.Nickname); nickname != "" { + data["group_nickname"] = nickname + } + data["role"] = groupMemberRole(access) + data["mentionable"] = access.IsJoiner() && access.IsReader() + data["acs"] = map[string]any{ + "mode": access.String(), + "want": member.ModeWant.String(), + "given": member.ModeGiven.String(), + } + return data +} + +func groupMemberAccess(member types.GroupMember) types.AccessMode { + access := member.ModeGiven & member.ModeWant + if access != types.ModeNone { + return access + } + switch member.Role { + case types.GroupMemberRoleOwner: + return types.ModeCFull + case types.GroupMemberRoleAdmin: + return types.ModeCPublic | types.ModeApprove + default: + return types.ModeCPublic + } +} + func groupMemberProfilePayload(topic string, sub *types.Subscription, user *types.User, ctx *types.RelationshipContext) map[string]any { data := groupMemberPayload(*sub, true) data["topic"] = topic @@ -7771,16 +7794,17 @@ func groupMemberMatches(item map[string]any, query string) bool { } func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string, - limit, cursor int, mentionableOnly, muteEligible bool) string { + limit int, cursor string, mentionableOnly, muteEligible bool) string { return strings.Join([]string{ topic, + "v3", uid.UserId(), access.String(), strings.ToLower(strings.TrimSpace(query)), strings.TrimSpace(roleFilter), strings.TrimSpace(eligibleRole), strconv.Itoa(limit), - strconv.Itoa(cursor), + strings.TrimSpace(cursor), strconv.FormatBool(mentionableOnly), strconv.FormatBool(muteEligible), }, "|") diff --git a/server/im_http_test.go b/server/im_http_test.go index 40e9ace..44d8f37 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -345,6 +345,9 @@ type testGroupBusinessStore struct { mutes []types.GroupMute files []types.GroupFile invitations []types.GroupInvitation + listMembers []types.GroupMember + listMembersOpts types.GroupMemberListOptions + listMembersNext string updatedSettings types.GroupSettingsPatch updatedMembers []types.GroupMemberPatch updatedRoleMode types.AccessMode @@ -402,8 +405,9 @@ func (s *testGroupBusinessStore) GetMember(_ types.TenantID, topic string, uid t return &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, Version: 1}, nil } -func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, _ types.GroupMemberListOptions) ([]types.GroupMember, string, error) { - return nil, "", nil +func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) { + s.listMembersOpts = opts + return s.listMembers, s.listMembersNext, nil } func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) { @@ -3877,14 +3881,14 @@ func TestServeIMGroupMembers(t *testing.T) { defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs - oldTopics := store.Topics + oldGroupBusiness := store.GroupBusiness subs := mock_store.NewMockSubsPersistenceInterface(ctrl) - topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) store.Subs = subs - store.Topics = topics + groupStore := &testGroupBusinessStore{listMembersNext: "101"} + store.GroupBusiness = groupStore defer func() { store.Subs = oldSubs - store.Topics = oldTopics + store.GroupBusiness = oldGroupBusiness }() subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ @@ -3894,13 +3898,18 @@ func TestServeIMGroupMembers(t *testing.T) { ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) - alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} - alice.SetPublic(map[string]any{"display_name": "Alice"}) - bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin} - bob.SetPublic(map[string]any{"display_name": "Bob"}) - topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil) + groupStore.listMembers = []types.GroupMember{{ + ID: 101, + Topic: "grpMention", + UserID: types.Uid(101), + Role: types.GroupMemberRoleMember, + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + Public: map[string]any{"display_name": "Alice", "avatar_file_id": "alice.jpg"}, + MemberStatus: types.GroupMemberStatusActive, + }} - req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true", nil) + req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true&limit=20&cursor=99", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() @@ -3911,12 +3920,21 @@ func TestServeIMGroupMembers(t *testing.T) { } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) - if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || items[0].(map[string]any)["mentionable"] != true { + if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || + items[0].(map[string]any)["avatar_file_id"] != "alice.jpg" || + items[0].(map[string]any)["mentionable"] != true { t.Fatalf("unexpected members response: %#v", data) } + if data["next_cursor"] != "101" { + t.Fatalf("unexpected cursor: %#v", data) + } if data["permissions"].(map[string]any)["can_mention_all"] != true { t.Fatalf("expected can_mention_all permission: %#v", data) } + if groupStore.listMembersOpts.Query != "ali" || !groupStore.listMembersOpts.Mentionable || + groupStore.listMembersOpts.Limit != 20 || groupStore.listMembersOpts.Cursor != "99" { + t.Fatalf("members options were not forwarded: %#v", groupStore.listMembersOpts) + } } func TestGroupMemberPayloadUserIDFormats(t *testing.T) { diff --git a/server/store/store.go b/server/store/store.go index c2fa2f7..7a3c9f6 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2904,7 +2904,7 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi items, nextCursor, err := Relations.ListFriends(tenantID, uid, "", "", cursor, 100) if err != nil { logs.Warn.Printf("profile %s: refresh friend subscription projections failed: %v", uid, err) - return + break } for _, item := range items { if item.Friendship.Topic == "" || item.Friendship.PeerID.IsZero() { @@ -2915,10 +2915,23 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi } } if nextCursor == "" { - return + break } cursor = nextCursor } + if topics, err := Users.GetTopics(tenantID, uid, &types.QueryOpt{Limit: 1000}); err != nil { + logs.Warn.Printf("profile %s: refresh group member projections failed: %v", uid, err) + } else { + for _, sub := range topics { + if types.GetTopicCat(sub.Topic) != types.TopicCatGrp { + continue + } + if err := Subs.Update(tenantID, sub.Topic, uid, map[string]any{"Public": public}); err != nil { + logs.Warn.Printf("profile %s: refresh group subscription %s public failed: %v", uid, sub.Topic, err) + } + invalidateGroupMemberPages(tenantID, sub.Topic) + } + } } func userProfilePublicProjection(profile *types.UserProfile) map[string]any { diff --git a/server/store/types/groups.go b/server/store/types/groups.go index 3bf137a..144939f 100644 --- a/server/store/types/groups.go +++ b/server/store/types/groups.go @@ -73,6 +73,10 @@ type GroupMember struct { Role string `db:"role"` MemberStatus string `db:"member_status"` Nickname string `db:"nickname"` + ModeWant AccessMode `db:"modewant"` + ModeGiven AccessMode `db:"modegiven"` + Public any `db:"public"` + Trusted any `db:"trusted"` Pinned bool `db:"pinned"` MuteNotifications bool `db:"mute_notifications"` JoinSource string `db:"join_source"` @@ -105,6 +109,7 @@ type GroupMemberListOptions struct { Query string Cursor string Limit int + Mentionable bool } type GroupInvitation struct {