diff --git a/fluxer_app/src/features/channel/components/MemberListItem.tsx b/fluxer_app/src/features/channel/components/MemberListItem.tsx index 14b45f8f3..30927796c 100644 --- a/fluxer_app/src/features/channel/components/MemberListItem.tsx +++ b/fluxer_app/src/features/channel/components/MemberListItem.tsx @@ -7,6 +7,7 @@ import styles from '@app/features/channel/components/MemberListItem.module.css'; import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout'; import Guilds from '@app/features/guild/state/Guilds'; import {useMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus'; +import {useMemberListMobile} from '@app/features/member/hooks/useMemberListMobile'; import {useMemberListPresence} from '@app/features/member/hooks/useMemberListPresence'; import type {GuildMember} from '@app/features/member/models/GuildMember'; import TypingIndicator from '@app/features/typing/state/TypingIndicator'; @@ -51,6 +52,7 @@ interface MemberListItemProps { guildMember?: GuildMember; status?: StatusType; customStatus?: CustomStatus | null; + isMobile?: boolean; isOwner?: boolean; roleColor?: string; displayName?: string; @@ -67,6 +69,7 @@ export const MemberListItem: React.FC = observer((props) => guildMember, status: providedStatus, customStatus: providedCustomStatus, + isMobile: providedIsMobile, isOwner = false, roleColor, displayName, @@ -91,6 +94,13 @@ export const MemberListItem: React.FC = observer((props) => const [contextMenuOpen, setContextMenuOpen] = useState(false); const contextMenuTicketRef = useRef(0); const isCurrentUser = user.id === Authentication.currentUserId; + const hookIsMobile = useMemberListMobile({ + guildId: guildId ?? '', + channelId, + userId: user.id, + enabled: guildId !== undefined && providedIsMobile === undefined && !isCurrentUser, + }); + const isMobile = isCurrentUser ? undefined : (providedIsMobile ?? hookIsMobile); const isTyping = TypingIndicator.isMemberListTyping(channelId, user.id, Authentication.currentUserId); const handleContextMenu = useCallback( (event: React.MouseEvent) => { @@ -205,6 +215,7 @@ export const MemberListItem: React.FC = observer((props) => showOffline={isCurrentUser || isTyping} guildId={guildId} status={status} + isMobile={isMobile} avatarUrl={memberAvatarUrl} hoverAvatarUrl={memberHoverAvatarUrl} mediaSize={avatarMediaSize} diff --git a/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx b/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx index 300c10bc8..51f142d86 100644 --- a/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx +++ b/fluxer_app/src/features/channel/components/bottomsheets/ChannelDetailsBottomSheetMemberList.tsx @@ -11,6 +11,7 @@ import type {Channel} from '@app/features/channel/models/Channel'; import type {Guild} from '@app/features/guild/models/Guild'; import {OFFLINE_DESCRIPTOR, ONLINE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {resolveMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus'; +import {resolveMemberListMobile} from '@app/features/member/hooks/useMemberListMobile'; import {resolveMemberListPresence} from '@app/features/member/hooks/useMemberListPresence'; import {useMemberListSubscription} from '@app/features/member/hooks/useMemberListSubscription'; import type {GuildMember} from '@app/features/member/models/GuildMember'; @@ -118,6 +119,9 @@ const MobileMemberListItem = observer( const {i18n} = useLingui(); const isTyping = TypingIndicator.isMemberListTyping(channelId, member.user.id, Authentication.currentUserId); const status = resolveMemberListPresence({guildId: guild.id, channelId, userId: member.user.id}); + const isMobile = member.isCurrentUser() + ? undefined + : resolveMemberListMobile({guildId: guild.id, channelId, userId: member.user.id}); const memberListCustomStatus = resolveMemberListCustomStatus({ guildId: guild.id, channelId, @@ -175,6 +179,7 @@ const MobileMemberListItem = observer( showOffline={member.user.id === Authentication.currentUserId || isTyping} guildId={guild.id} status={status} + isMobile={isMobile} avatarUrl={avatarUrl} hoverAvatarUrl={hoverAvatarUrl} mediaSize={MEDIA_PROXY_AVATAR_SIZE_DEFAULT} diff --git a/fluxer_app/src/features/member/hooks/useMemberListMobile.ts b/fluxer_app/src/features/member/hooks/useMemberListMobile.ts new file mode 100644 index 000000000..e3d78e5d4 --- /dev/null +++ b/fluxer_app/src/features/member/hooks/useMemberListMobile.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import MemberSidebar from '@app/features/member/state/MemberSidebar'; +import {reaction} from 'mobx'; +import {useEffect, useState} from 'react'; + +interface UseMemberListMobileOptions { + guildId: string; + channelId: string; + userId: string; + enabled?: boolean; +} + +export function resolveMemberListMobile({ + guildId, + channelId, + userId, + enabled = true, +}: UseMemberListMobileOptions): boolean | undefined { + if (!enabled) return undefined; + return MemberSidebar.isMobile(guildId, channelId, userId) ?? undefined; +} + +export function useMemberListMobile({ + guildId, + channelId, + userId, + enabled = true, +}: UseMemberListMobileOptions): boolean | undefined { + const [isMobile, setIsMobile] = useState(() => resolveMemberListMobile({guildId, channelId, userId, enabled})); + useEffect(() => { + return reaction(() => resolveMemberListMobile({guildId, channelId, userId, enabled}), setIsMobile, { + fireImmediately: true, + }); + }, [guildId, channelId, userId, enabled]); + return isMobile; +} diff --git a/fluxer_app/src/features/member/models/GuildMember.ts b/fluxer_app/src/features/member/models/GuildMember.ts index 15feea6c9..89ef19778 100644 --- a/fluxer_app/src/features/member/models/GuildMember.ts +++ b/fluxer_app/src/features/member/models/GuildMember.ts @@ -61,6 +61,25 @@ export class GuildMember { this.mentionFlags = (guildMember.mention_flags ?? 0) as MentionReplyPreference; } + matches(guildMember: GuildMemberData): boolean { + const until = guildMember.communication_disabled_until + ? new Date(guildMember.communication_disabled_until).getTime() + : null; + return ( + this.nick === (guildMember.nick ?? null) && + this.avatar === (guildMember.avatar ?? null) && + this.banner === (guildMember.banner ?? null) && + this.accentColor === (guildMember.accent_color ?? null) && + this.mute === (guildMember.mute ?? false) && + this.deaf === (guildMember.deaf ?? false) && + this.profileFlags === (guildMember.profile_flags ?? 0) && + this.mentionFlags === (guildMember.mention_flags ?? 0) && + (this.communicationDisabledUntil?.getTime() ?? null) === until && + this.roles.size === guildMember.roles.length && + guildMember.roles.every((roleId) => this.roles.has(roleId)) + ); + } + isAvatarUnset(): boolean { return (this.profileFlags & GuildMemberProfileFlags.AVATAR_UNSET) !== 0; } diff --git a/fluxer_app/src/features/member/state/GuildMembers.ts b/fluxer_app/src/features/member/state/GuildMembers.ts index d70869ffd..9e12a438d 100644 --- a/fluxer_app/src/features/member/state/GuildMembers.ts +++ b/fluxer_app/src/features/member/state/GuildMembers.ts @@ -7,6 +7,7 @@ import SelectedGuild from '@app/features/navigation/state/SelectedGuild'; import {Logger} from '@app/features/platform/utils/AppLogger'; import Users from '@app/features/user/state/Users'; import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; +import type {User as WireUser} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {makeAutoObservable} from 'mobx'; type Members = Record; @@ -215,6 +216,19 @@ class GuildMembers { this.nonMembers[guildId]?.delete(member.user.id); } + hydrateOrRefresh(guildId: string, member: GuildMemberData): void { + const existing = this.members[guildId]?.[member.user.id]; + if (!existing) { + this.hydrateIfMissing(guildId, member); + return; + } + Users.handleUserUpdate(member.user as WireUser); + if (existing.user === Users.getUser(member.user.id) && existing.matches(member)) { + return; + } + this.members[guildId][member.user.id] = new GuildMember(guildId, member); + } + handleMemberRemove(guildId: string, userId: string): void { this.markAsNonMember(guildId, userId); const existingMembers = this.members[guildId]; diff --git a/fluxer_app/src/features/member/state/MemberSidebar.ts b/fluxer_app/src/features/member/state/MemberSidebar.ts index cd916c7d2..ee01349a4 100644 --- a/fluxer_app/src/features/member/state/MemberSidebar.ts +++ b/fluxer_app/src/features/member/state/MemberSidebar.ts @@ -58,6 +58,7 @@ interface MemberListState { requestedRanges: NormalizedMemberListRanges; subscribedRanges: NormalizedMemberListRanges; presences: Map; + mobileUserIds: Set; customStatuses: Map; knownCustomStatuses: Map; } @@ -69,6 +70,7 @@ interface MemberListRow { member?: GuildMemberData; presence?: { status?: string; + mobile?: boolean; custom_status?: GatewayCustomStatusPayload | null; } | null; } @@ -76,6 +78,7 @@ interface MemberListRow { type MemberListOperationMember = GuildMemberData & { presence?: { status?: string; + mobile?: boolean; custom_status?: GatewayCustomStatusPayload | null; } | null; }; @@ -655,6 +658,7 @@ class MemberSidebar { const newRows = new Map(); const newItems = new Map(); const newPresences = new Map(); + const newMobileUserIds = new Set(); const newCustomStatuses = new Map(); const newMembersByUserId = new Map(); const nextKnownCustomStatuses = new Map(listState.knownCustomStatuses); @@ -704,6 +708,9 @@ class MemberSidebar { const presenceStatus = this.extractPresenceFromRow(row); if (presenceStatus) { newPresences.set(userId, presenceStatus); + if (presenceStatus !== StatusTypes.OFFLINE && row.presence?.mobile === true) { + newMobileUserIds.add(userId); + } } if (row.presence && Object.hasOwn(row.presence, 'custom_status')) { const customStatus = fromGatewayCustomStatus(row.presence.custom_status ?? null); @@ -757,6 +764,7 @@ class MemberSidebar { listState.items = newItems; listState.membersByUserId = newMembersByUserId; listState.presences = newPresences; + listState.mobileUserIds = newMobileUserIds; listState.customStatuses = newCustomStatuses; listState.knownCustomStatuses = nextKnownCustomStatuses; listState.subscribedRanges = getHydratedMemberListRangesFromNormalized( @@ -1183,6 +1191,14 @@ class MemberSidebar { return listState.presences.get(userId) ?? null; } + isMobile(guildId: string, listId: string, userId: string): boolean | null { + const listState = this.getList(guildId, listId); + if (!listState?.presences.has(userId)) { + return null; + } + return listState.mobileUserIds.has(userId); + } + getCustomStatus(guildId: string, listId: string, userId: string): CustomStatus | null | undefined { const listState = this.getList(guildId, listId); if (!listState) { @@ -1262,6 +1278,7 @@ class MemberSidebar { items: new Map(), membersByUserId: new Map(), presences: new Map(), + mobileUserIds: new Set(), customStatuses: new Map(), }; } @@ -1274,10 +1291,14 @@ class MemberSidebar { prunedMembersByUserId.set(item.data.userId, item.data.member); } const prunedPresences = new Map(); + const prunedMobileUserIds = new Set(); const prunedCustomStatuses = new Map(); for (const [userId, status] of listState.presences) { if (retainedUserIds.has(userId)) { prunedPresences.set(userId, status); + if (listState.mobileUserIds.has(userId)) { + prunedMobileUserIds.add(userId); + } } } for (const [userId, customStatus] of listState.customStatuses) { @@ -1302,6 +1323,7 @@ class MemberSidebar { items: prunedItems, membersByUserId: prunedMembersByUserId, presences: prunedPresences, + mobileUserIds: prunedMobileUserIds, customStatuses: prunedCustomStatuses, }; } @@ -1350,6 +1372,7 @@ class MemberSidebar { requestedRanges: normalizeMemberListRanges(requestedRanges), subscribedRanges: EMPTY_MEMBER_LIST_RANGES, presences: new Map(), + mobileUserIds: new Set(), customStatuses: new Map(), knownCustomStatuses: new Map(), }; diff --git a/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx b/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx index 7550bc4f1..4f398f019 100644 --- a/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx +++ b/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx @@ -34,6 +34,7 @@ export function useThreadRosterSubscription(thread: Channel | undefined): void { interface RosterRow { user: User; status: StatusType; + mobile?: boolean; customStatus: CustomStatus | null; } @@ -66,7 +67,7 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
- {members.map(({user, status, customStatus}) => ( + {members.map(({user, status, mobile, customStatus}) => ( diff --git a/fluxer_app/src/features/threads/state/ChannelThreads.test.ts b/fluxer_app/src/features/threads/state/ChannelThreads.test.ts index 2932c0c54..facd22ac2 100644 --- a/fluxer_app/src/features/threads/state/ChannelThreads.test.ts +++ b/fluxer_app/src/features/threads/state/ChannelThreads.test.ts @@ -53,7 +53,7 @@ vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({close: vi.fn()} vi.mock('@app/features/ui/commands/PopoutCommands', () => ({closeAll: vi.fn()})); vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => ({ownerId: 'owner', mfaLevel: 0})}})); vi.mock('@app/features/member/state/GuildMembers', () => ({ - default: {getMember: () => null, hydrateIfMissing: () => {}}, + default: {getMember: () => null, hydrateOrRefresh: () => {}}, })); vi.mock('@app/features/permissions/state/Permission', () => ({ default: { @@ -616,7 +616,7 @@ describe('thread roster', () => { const release = ThreadRoster.subscribe(GUILD, THREAD_A); ThreadRoster.handleListUpdate( listUpdate([ - {user_id: ONLINE, presence: {status: 'online', afk: false, mobile: false, custom_status: null}}, + {user_id: ONLINE, presence: {status: 'online', afk: false, mobile: true, custom_status: null}}, { user_id: IDLE, presence: { @@ -630,14 +630,15 @@ describe('thread roster', () => { ]), ); expect(ThreadRoster.getMembers(THREAD_A)).toEqual([ - {userId: ONLINE, joinTimestamp: null, status: 'online', customStatus: null}, + {userId: ONLINE, joinTimestamp: null, status: 'online', mobile: true, customStatus: null}, { userId: IDLE, joinTimestamp: null, status: 'idle', + mobile: false, customStatus: expect.objectContaining({text: 'im spooky goop', emojiName: 'nahua_smug'}), }, - {userId: OFFLINE, joinTimestamp: null, status: 'offline', customStatus: null}, + {userId: OFFLINE, joinTimestamp: null, status: 'offline', mobile: false, customStatus: null}, ]); release(); }); @@ -652,7 +653,7 @@ describe('thread roster', () => { ); ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'dnd'}}])); expect(ThreadRoster.getMembers(THREAD_A)).toEqual([ - {userId: ONLINE, joinTimestamp: null, status: 'dnd', customStatus: null}, + {userId: ONLINE, joinTimestamp: null, status: 'dnd', mobile: false, customStatus: null}, ]); release(); ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'online'}}])); diff --git a/fluxer_app/src/features/threads/state/ThreadRoster.ts b/fluxer_app/src/features/threads/state/ThreadRoster.ts index 68dd0db7f..29fcd43b9 100644 --- a/fluxer_app/src/features/threads/state/ThreadRoster.ts +++ b/fluxer_app/src/features/threads/state/ThreadRoster.ts @@ -20,7 +20,11 @@ export interface ThreadMemberListPayload { join_timestamp: string | null; flags: number; member: GuildMemberData | null; - presence?: {status?: string | null; custom_status?: GatewayCustomStatusPayload | null} | null; + presence?: { + status?: string | null; + mobile?: boolean; + custom_status?: GatewayCustomStatusPayload | null; + } | null; }>; } @@ -28,6 +32,7 @@ export interface ThreadRosterMember { readonly userId: string; readonly joinTimestamp: string | null; readonly status: StatusType; + readonly mobile: boolean; readonly customStatus: CustomStatus | null; } @@ -78,13 +83,14 @@ class ThreadRoster { const members: Array = []; for (const entry of payload.members) { if (entry.member) { - GuildMembers.hydrateIfMissing(payload.guild_id, entry.member); + GuildMembers.hydrateOrRefresh(payload.guild_id, entry.member); } const status = normalizeStatus(entry.presence?.status); members.push({ userId: entry.user_id, joinTimestamp: entry.join_timestamp, status, + mobile: !isOfflineStatus(status) && entry.presence?.mobile === true, customStatus: isOfflineStatus(status) ? null : fromGatewayCustomStatus(entry.presence?.custom_status), }); } diff --git a/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx b/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx index 69bc535e4..3255717c3 100644 --- a/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx +++ b/fluxer_app/src/features/ui/components/StatusAwareAvatar.tsx @@ -26,6 +26,7 @@ export interface StatusAwareAvatarProps { guildId?: string | null; mediaSize?: MediaProxyImageSize; status?: string | null; + isMobile?: boolean; animateStatusCutout?: boolean; } @@ -54,6 +55,7 @@ export const StatusAwareAvatar: React.FC = observer( guildId, mediaSize, status: externalStatus, + isMobile: externalIsMobile, animateStatusCutout, }) => { if (!user) { @@ -66,7 +68,7 @@ export const StatusAwareAvatar: React.FC = observer( } else if (externalStatus == null) { status = getStatusWithTransientFallback(user.id); } - const isMobile = shouldDisablePresence ? false : Presence.isMobile(user.id); + const isMobile = shouldDisablePresence ? false : (externalIsMobile ?? Presence.isMobile(user.id)); return ( _ -> [] end, State; +after_store_event(guild_member_update, Data, _Tab, State) -> + case guild_state_member:extract_user_id(Data) of + UserId when is_integer(UserId) -> + guild_thread_subscriptions:member_changed(UserId, State); + _ -> + State + end; after_store_event(_Event, _Data, _Tab, State) -> State. diff --git a/fluxer_gateway/src/guild/guild_thread_subscriptions.erl b/fluxer_gateway/src/guild/guild_thread_subscriptions.erl index 5f40d133f..47894e82f 100644 --- a/fluxer_gateway/src/guild/guild_thread_subscriptions.erl +++ b/fluxer_gateway/src/guild/guild_thread_subscriptions.erl @@ -12,6 +12,7 @@ member_lists_changed/2, resync_after_load/1, presence_changed/2, + member_changed/2, handle_list_flush/1, refresh_presence_users/1, export_handoff/1, @@ -306,6 +307,10 @@ presence_changed(UserId, #{thread_presence_users := Users} = State) -> presence_changed(_UserId, State) -> State. +-spec member_changed(integer(), guild_state()) -> guild_state(). +member_changed(UserId, State) -> + presence_changed(UserId, State). + -spec mark_dirty([integer()], guild_state()) -> guild_state(). mark_dirty([], State) -> State; diff --git a/fluxer_gateway/test/guild_threads_tests.erl b/fluxer_gateway/test/guild_threads_tests.erl index 667f0e05b..ac7d1ddbf 100644 --- a/fluxer_gateway/test/guild_threads_tests.erl +++ b/fluxer_gateway/test/guild_threads_tests.erl @@ -1486,6 +1486,35 @@ thread_member_list_follows_presence_changes_test() -> ?assertEqual(State, guild_thread_subscriptions:presence_changed(?A, State)) end). +thread_member_list_follows_member_updates_test() -> + with_guild(fun(State) -> + Subscribed = guild_thread_subscriptions:update( + <<"a">>, #{member_lists => [?T1]}, State + ), + [_] = received(a, thread_member_list_update, drain()), + Update = fun(UserId, Nick) -> + #{ + <<"user">> => #{<<"id">> => integer_to_binary(UserId)}, + <<"roles">> => [], + <<"nick">> => Nick + } + end, + Changed = dispatch(guild_member_update, Update(?A, <<"renamed">>), Subscribed), + ?assertEqual([], received(a, thread_member_list_update, drain())), + _ = guild_thread_subscriptions:handle_list_flush(Changed), + [#{<<"members">> := Members}] = received(a, thread_member_list_update, drain()), + ?assertEqual( + [<<"renamed">>], + [ + maps:get(<<"nick">>, Member) + || #{<<"user_id">> := Id, <<"member">> := Member} <- Members, + Id =:= integer_to_binary(?A) + ] + ), + Unlisted = dispatch(guild_member_update, Update(?C, <<"other">>), Subscribed), + ?assertNot(maps:is_key(thread_list_dirty, Unlisted)) + end). + handoff_through_an_old_node_round_trips_test() -> with_guild(fun(State) -> Exported = guild_handoff:export_handoff_state(State),