fix(members): show mobile badges and fresh profiles in lists (#3370)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 17:00:30 +02:00
1 parent 0f0ff5f3df
commit c14b7d4cb3
13 files changed
+171 -10

No files matched your search

@@ -7,6 +7,7 @@ import styles from '@app/features/channel/components/MemberListItem.module.css';
import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout'; import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout';
import Guilds from '@app/features/guild/state/Guilds'; import Guilds from '@app/features/guild/state/Guilds';
import {useMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus'; 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 {useMemberListPresence} from '@app/features/member/hooks/useMemberListPresence';
import type {GuildMember} from '@app/features/member/models/GuildMember'; import type {GuildMember} from '@app/features/member/models/GuildMember';
import TypingIndicator from '@app/features/typing/state/TypingIndicator'; import TypingIndicator from '@app/features/typing/state/TypingIndicator';
@@ -51,6 +52,7 @@ interface MemberListItemProps {
guildMember?: GuildMember; guildMember?: GuildMember;
status?: StatusType; status?: StatusType;
customStatus?: CustomStatus | null; customStatus?: CustomStatus | null;
isMobile?: boolean;
isOwner?: boolean; isOwner?: boolean;
roleColor?: string; roleColor?: string;
displayName?: string; displayName?: string;
@@ -67,6 +69,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
guildMember, guildMember,
status: providedStatus, status: providedStatus,
customStatus: providedCustomStatus, customStatus: providedCustomStatus,
isMobile: providedIsMobile,
isOwner = false, isOwner = false,
roleColor, roleColor,
displayName, displayName,
@@ -91,6 +94,13 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
const [contextMenuOpen, setContextMenuOpen] = useState(false); const [contextMenuOpen, setContextMenuOpen] = useState(false);
const contextMenuTicketRef = useRef(0); const contextMenuTicketRef = useRef(0);
const isCurrentUser = user.id === Authentication.currentUserId; 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 isTyping = TypingIndicator.isMemberListTyping(channelId, user.id, Authentication.currentUserId);
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(event: React.MouseEvent) => { (event: React.MouseEvent) => {
@@ -205,6 +215,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
showOffline={isCurrentUser || isTyping} showOffline={isCurrentUser || isTyping}
guildId={guildId} guildId={guildId}
status={status} status={status}
isMobile={isMobile}
avatarUrl={memberAvatarUrl} avatarUrl={memberAvatarUrl}
hoverAvatarUrl={memberHoverAvatarUrl} hoverAvatarUrl={memberHoverAvatarUrl}
mediaSize={avatarMediaSize} mediaSize={avatarMediaSize}
@@ -11,6 +11,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
import type {Guild} from '@app/features/guild/models/Guild'; import type {Guild} from '@app/features/guild/models/Guild';
import {OFFLINE_DESCRIPTOR, ONLINE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {OFFLINE_DESCRIPTOR, ONLINE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {resolveMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus'; 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 {resolveMemberListPresence} from '@app/features/member/hooks/useMemberListPresence';
import {useMemberListSubscription} from '@app/features/member/hooks/useMemberListSubscription'; import {useMemberListSubscription} from '@app/features/member/hooks/useMemberListSubscription';
import type {GuildMember} from '@app/features/member/models/GuildMember'; import type {GuildMember} from '@app/features/member/models/GuildMember';
@@ -118,6 +119,9 @@ const MobileMemberListItem = observer(
const {i18n} = useLingui(); const {i18n} = useLingui();
const isTyping = TypingIndicator.isMemberListTyping(channelId, member.user.id, Authentication.currentUserId); const isTyping = TypingIndicator.isMemberListTyping(channelId, member.user.id, Authentication.currentUserId);
const status = resolveMemberListPresence({guildId: guild.id, channelId, userId: member.user.id}); 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({ const memberListCustomStatus = resolveMemberListCustomStatus({
guildId: guild.id, guildId: guild.id,
channelId, channelId,
@@ -175,6 +179,7 @@ const MobileMemberListItem = observer(
showOffline={member.user.id === Authentication.currentUserId || isTyping} showOffline={member.user.id === Authentication.currentUserId || isTyping}
guildId={guild.id} guildId={guild.id}
status={status} status={status}
isMobile={isMobile}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl} hoverAvatarUrl={hoverAvatarUrl}
mediaSize={MEDIA_PROXY_AVATAR_SIZE_DEFAULT} mediaSize={MEDIA_PROXY_AVATAR_SIZE_DEFAULT}
@@ -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;
}
@@ -61,6 +61,25 @@ export class GuildMember {
this.mentionFlags = (guildMember.mention_flags ?? 0) as MentionReplyPreference; 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 { isAvatarUnset(): boolean {
return (this.profileFlags & GuildMemberProfileFlags.AVATAR_UNSET) !== 0; return (this.profileFlags & GuildMemberProfileFlags.AVATAR_UNSET) !== 0;
} }
@@ -7,6 +7,7 @@ import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
import {Logger} from '@app/features/platform/utils/AppLogger'; import {Logger} from '@app/features/platform/utils/AppLogger';
import Users from '@app/features/user/state/Users'; import Users from '@app/features/user/state/Users';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; 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'; import {makeAutoObservable} from 'mobx';
type Members = Record<string, GuildMember>; type Members = Record<string, GuildMember>;
@@ -215,6 +216,19 @@ class GuildMembers {
this.nonMembers[guildId]?.delete(member.user.id); 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 { handleMemberRemove(guildId: string, userId: string): void {
this.markAsNonMember(guildId, userId); this.markAsNonMember(guildId, userId);
const existingMembers = this.members[guildId]; const existingMembers = this.members[guildId];
@@ -58,6 +58,7 @@ interface MemberListState {
requestedRanges: NormalizedMemberListRanges; requestedRanges: NormalizedMemberListRanges;
subscribedRanges: NormalizedMemberListRanges; subscribedRanges: NormalizedMemberListRanges;
presences: Map<string, StatusType>; presences: Map<string, StatusType>;
mobileUserIds: Set<string>;
customStatuses: Map<string, CustomStatus | null>; customStatuses: Map<string, CustomStatus | null>;
knownCustomStatuses: Map<string, CustomStatus | null>; knownCustomStatuses: Map<string, CustomStatus | null>;
} }
@@ -69,6 +70,7 @@ interface MemberListRow {
member?: GuildMemberData; member?: GuildMemberData;
presence?: { presence?: {
status?: string; status?: string;
mobile?: boolean;
custom_status?: GatewayCustomStatusPayload | null; custom_status?: GatewayCustomStatusPayload | null;
} | null; } | null;
} }
@@ -76,6 +78,7 @@ interface MemberListRow {
type MemberListOperationMember = GuildMemberData & { type MemberListOperationMember = GuildMemberData & {
presence?: { presence?: {
status?: string; status?: string;
mobile?: boolean;
custom_status?: GatewayCustomStatusPayload | null; custom_status?: GatewayCustomStatusPayload | null;
} | null; } | null;
}; };
@@ -655,6 +658,7 @@ class MemberSidebar {
const newRows = new Map<number, MemberListRow>(); const newRows = new Map<number, MemberListRow>();
const newItems = new Map<number, MemberListItem>(); const newItems = new Map<number, MemberListItem>();
const newPresences = new Map<string, StatusType>(); const newPresences = new Map<string, StatusType>();
const newMobileUserIds = new Set<string>();
const newCustomStatuses = new Map<string, CustomStatus | null>(); const newCustomStatuses = new Map<string, CustomStatus | null>();
const newMembersByUserId = new Map<string, GuildMemberData>(); const newMembersByUserId = new Map<string, GuildMemberData>();
const nextKnownCustomStatuses = new Map(listState.knownCustomStatuses); const nextKnownCustomStatuses = new Map(listState.knownCustomStatuses);
@@ -704,6 +708,9 @@ class MemberSidebar {
const presenceStatus = this.extractPresenceFromRow(row); const presenceStatus = this.extractPresenceFromRow(row);
if (presenceStatus) { if (presenceStatus) {
newPresences.set(userId, 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')) { if (row.presence && Object.hasOwn(row.presence, 'custom_status')) {
const customStatus = fromGatewayCustomStatus(row.presence.custom_status ?? null); const customStatus = fromGatewayCustomStatus(row.presence.custom_status ?? null);
@@ -757,6 +764,7 @@ class MemberSidebar {
listState.items = newItems; listState.items = newItems;
listState.membersByUserId = newMembersByUserId; listState.membersByUserId = newMembersByUserId;
listState.presences = newPresences; listState.presences = newPresences;
listState.mobileUserIds = newMobileUserIds;
listState.customStatuses = newCustomStatuses; listState.customStatuses = newCustomStatuses;
listState.knownCustomStatuses = nextKnownCustomStatuses; listState.knownCustomStatuses = nextKnownCustomStatuses;
listState.subscribedRanges = getHydratedMemberListRangesFromNormalized( listState.subscribedRanges = getHydratedMemberListRangesFromNormalized(
@@ -1183,6 +1191,14 @@ class MemberSidebar {
return listState.presences.get(userId) ?? null; 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 { getCustomStatus(guildId: string, listId: string, userId: string): CustomStatus | null | undefined {
const listState = this.getList(guildId, listId); const listState = this.getList(guildId, listId);
if (!listState) { if (!listState) {
@@ -1262,6 +1278,7 @@ class MemberSidebar {
items: new Map(), items: new Map(),
membersByUserId: new Map(), membersByUserId: new Map(),
presences: new Map(), presences: new Map(),
mobileUserIds: new Set(),
customStatuses: new Map(), customStatuses: new Map(),
}; };
} }
@@ -1274,10 +1291,14 @@ class MemberSidebar {
prunedMembersByUserId.set(item.data.userId, item.data.member); prunedMembersByUserId.set(item.data.userId, item.data.member);
} }
const prunedPresences = new Map<string, StatusType>(); const prunedPresences = new Map<string, StatusType>();
const prunedMobileUserIds = new Set<string>();
const prunedCustomStatuses = new Map<string, CustomStatus | null>(); const prunedCustomStatuses = new Map<string, CustomStatus | null>();
for (const [userId, status] of listState.presences) { for (const [userId, status] of listState.presences) {
if (retainedUserIds.has(userId)) { if (retainedUserIds.has(userId)) {
prunedPresences.set(userId, status); prunedPresences.set(userId, status);
if (listState.mobileUserIds.has(userId)) {
prunedMobileUserIds.add(userId);
}
} }
} }
for (const [userId, customStatus] of listState.customStatuses) { for (const [userId, customStatus] of listState.customStatuses) {
@@ -1302,6 +1323,7 @@ class MemberSidebar {
items: prunedItems, items: prunedItems,
membersByUserId: prunedMembersByUserId, membersByUserId: prunedMembersByUserId,
presences: prunedPresences, presences: prunedPresences,
mobileUserIds: prunedMobileUserIds,
customStatuses: prunedCustomStatuses, customStatuses: prunedCustomStatuses,
}; };
} }
@@ -1350,6 +1372,7 @@ class MemberSidebar {
requestedRanges: normalizeMemberListRanges(requestedRanges), requestedRanges: normalizeMemberListRanges(requestedRanges),
subscribedRanges: EMPTY_MEMBER_LIST_RANGES, subscribedRanges: EMPTY_MEMBER_LIST_RANGES,
presences: new Map(), presences: new Map(),
mobileUserIds: new Set(),
customStatuses: new Map(), customStatuses: new Map(),
knownCustomStatuses: new Map(), knownCustomStatuses: new Map(),
}; };
@@ -34,6 +34,7 @@ export function useThreadRosterSubscription(thread: Channel | undefined): void {
interface RosterRow { interface RosterRow {
user: User; user: User;
status: StatusType; status: StatusType;
mobile?: boolean;
customStatus: CustomStatus | null; customStatus: CustomStatus | null;
} }
@@ -66,7 +67,7 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
</span> </span>
</div> </div>
<div className={styles.membersList} data-flx="threads.thread-members-panel.roster-group.members-list"> <div className={styles.membersList} data-flx="threads.thread-members-panel.roster-group.members-list">
{members.map(({user, status, customStatus}) => ( {members.map(({user, status, mobile, customStatus}) => (
<MemberListItem <MemberListItem
key={user.id} key={user.id}
user={user} user={user}
@@ -74,6 +75,7 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
guildId={thread.guildId} guildId={thread.guildId}
guildMember={thread.guildId ? (GuildMembers.getMember(thread.guildId, user.id) ?? undefined) : undefined} guildMember={thread.guildId ? (GuildMembers.getMember(thread.guildId, user.id) ?? undefined) : undefined}
status={status} status={status}
isMobile={mobile}
customStatus={customStatus} customStatus={customStatus}
isOwner={user.id === ownerId} isOwner={user.id === ownerId}
disableBackdrop={true} disableBackdrop={true}
@@ -95,7 +97,7 @@ function resolveMembers(guildId: string | undefined, roster: ReadonlyArray<Threa
const status = LocalPresence.getStatus(); const status = LocalPresence.getStatus();
members.push({user, status, customStatus: isOfflineStatus(status) ? null : LocalPresence.customStatus}); members.push({user, status, customStatus: isOfflineStatus(status) ? null : LocalPresence.customStatus});
} else { } else {
members.push({user, status: entry.status, customStatus: entry.customStatus}); members.push({user, status: entry.status, mobile: entry.mobile, customStatus: entry.customStatus});
} }
} }
members.sort((a, b) => members.sort((a, b) =>
@@ -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/ui/commands/PopoutCommands', () => ({closeAll: vi.fn()}));
vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => ({ownerId: 'owner', mfaLevel: 0})}})); vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => ({ownerId: 'owner', mfaLevel: 0})}}));
vi.mock('@app/features/member/state/GuildMembers', () => ({ vi.mock('@app/features/member/state/GuildMembers', () => ({
default: {getMember: () => null, hydrateIfMissing: () => {}}, default: {getMember: () => null, hydrateOrRefresh: () => {}},
})); }));
vi.mock('@app/features/permissions/state/Permission', () => ({ vi.mock('@app/features/permissions/state/Permission', () => ({
default: { default: {
@@ -616,7 +616,7 @@ describe('thread roster', () => {
const release = ThreadRoster.subscribe(GUILD, THREAD_A); const release = ThreadRoster.subscribe(GUILD, THREAD_A);
ThreadRoster.handleListUpdate( ThreadRoster.handleListUpdate(
listUpdate([ 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, user_id: IDLE,
presence: { presence: {
@@ -630,14 +630,15 @@ describe('thread roster', () => {
]), ]),
); );
expect(ThreadRoster.getMembers(THREAD_A)).toEqual([ 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, userId: IDLE,
joinTimestamp: null, joinTimestamp: null,
status: 'idle', status: 'idle',
mobile: false,
customStatus: expect.objectContaining({text: 'im spooky goop', emojiName: 'nahua_smug'}), 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(); release();
}); });
@@ -652,7 +653,7 @@ describe('thread roster', () => {
); );
ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'dnd'}}])); ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'dnd'}}]));
expect(ThreadRoster.getMembers(THREAD_A)).toEqual([ 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(); release();
ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'online'}}])); ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'online'}}]));
@@ -20,7 +20,11 @@ export interface ThreadMemberListPayload {
join_timestamp: string | null; join_timestamp: string | null;
flags: number; flags: number;
member: GuildMemberData | null; 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 userId: string;
readonly joinTimestamp: string | null; readonly joinTimestamp: string | null;
readonly status: StatusType; readonly status: StatusType;
readonly mobile: boolean;
readonly customStatus: CustomStatus | null; readonly customStatus: CustomStatus | null;
} }
@@ -78,13 +83,14 @@ class ThreadRoster {
const members: Array<ThreadRosterMember> = []; const members: Array<ThreadRosterMember> = [];
for (const entry of payload.members) { for (const entry of payload.members) {
if (entry.member) { if (entry.member) {
GuildMembers.hydrateIfMissing(payload.guild_id, entry.member); GuildMembers.hydrateOrRefresh(payload.guild_id, entry.member);
} }
const status = normalizeStatus(entry.presence?.status); const status = normalizeStatus(entry.presence?.status);
members.push({ members.push({
userId: entry.user_id, userId: entry.user_id,
joinTimestamp: entry.join_timestamp, joinTimestamp: entry.join_timestamp,
status, status,
mobile: !isOfflineStatus(status) && entry.presence?.mobile === true,
customStatus: isOfflineStatus(status) ? null : fromGatewayCustomStatus(entry.presence?.custom_status), customStatus: isOfflineStatus(status) ? null : fromGatewayCustomStatus(entry.presence?.custom_status),
}); });
} }
@@ -26,6 +26,7 @@ export interface StatusAwareAvatarProps {
guildId?: string | null; guildId?: string | null;
mediaSize?: MediaProxyImageSize; mediaSize?: MediaProxyImageSize;
status?: string | null; status?: string | null;
isMobile?: boolean;
animateStatusCutout?: boolean; animateStatusCutout?: boolean;
} }
@@ -54,6 +55,7 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
guildId, guildId,
mediaSize, mediaSize,
status: externalStatus, status: externalStatus,
isMobile: externalIsMobile,
animateStatusCutout, animateStatusCutout,
}) => { }) => {
if (!user) { if (!user) {
@@ -66,7 +68,7 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
} else if (externalStatus == null) { } else if (externalStatus == null) {
status = getStatusWithTransientFallback(user.id); status = getStatusWithTransientFallback(user.id);
} }
const isMobile = shouldDisablePresence ? false : Presence.isMobile(user.id); const isMobile = shouldDisablePresence ? false : (externalIsMobile ?? Presence.isMobile(user.id));
return ( return (
<Avatar <Avatar
user={user} user={user}
@@ -299,6 +299,13 @@ after_store_event(guild_member_remove, Data, Tab, State) ->
_ -> [] _ -> []
end, end,
State; 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) -> after_store_event(_Event, _Data, _Tab, State) ->
State. State.
@@ -12,6 +12,7 @@
member_lists_changed/2, member_lists_changed/2,
resync_after_load/1, resync_after_load/1,
presence_changed/2, presence_changed/2,
member_changed/2,
handle_list_flush/1, handle_list_flush/1,
refresh_presence_users/1, refresh_presence_users/1,
export_handoff/1, export_handoff/1,
@@ -306,6 +307,10 @@ presence_changed(UserId, #{thread_presence_users := Users} = State) ->
presence_changed(_UserId, State) -> presence_changed(_UserId, State) ->
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(). -spec mark_dirty([integer()], guild_state()) -> guild_state().
mark_dirty([], State) -> mark_dirty([], State) ->
State; State;
@@ -1486,6 +1486,35 @@ thread_member_list_follows_presence_changes_test() ->
?assertEqual(State, guild_thread_subscriptions:presence_changed(?A, State)) ?assertEqual(State, guild_thread_subscriptions:presence_changed(?A, State))
end). 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() -> handoff_through_an_old_node_round_trips_test() ->
with_guild(fun(State) -> with_guild(fun(State) ->
Exported = guild_handoff:export_handoff_state(State), Exported = guild_handoff:export_handoff_state(State),