Skip to content

Commit 2d580d4

Browse files
committed
implement lazy guild loading
1 parent de79517 commit 2d580d4

13 files changed

Lines changed: 405 additions & 114 deletions

File tree

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ function DirectMessageButton({ channelId }: { channelId: bigint }) {
256256
}}>
257257
<span class="truncate min-w-0">
258258
{group
259-
? t('dms.group_members_other', { count: channel().recipient_ids.length })
259+
? t('generic.members', { count: channel().recipient_ids.length })
260260
: (
261261
lastMessage() ? (
262262
(deltaMs()! > 30_000 ? t('time.past', { delta: humanizeTimeDeltaShort(deltaMs()!) }) : t('time.recently'))

src/Entrypoint.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ const Entrypoint: Component = () => {
115115
ws.connect().then(() => {
116116
setWs(ws)
117117
api?.pushNotifications.subscribe()
118+
119+
// ts workaround bypasses the params context
120+
const match = window.location.pathname.match(/^\/guilds\/(\d+)/)
121+
const focusedGuildId = match ? BigInt(match[1]) : undefined
122+
ws.startBackgroundGuildLoading(focusedGuildId)
118123
})
119124
api.ws = ws
120125
}
@@ -126,8 +131,8 @@ const Entrypoint: Component = () => {
126131
if (contextMenu.menu() == null || contextMenuRef == null) return
127132

128133
contextMenu.setPos(({ x, y }) => ({
129-
x: contextMenuAdjustment(x, contextMenuRef!.offsetWidth, window.innerWidth),
130-
y: contextMenuAdjustment(y, contextMenuRef!.offsetHeight, window.innerHeight),
134+
x: contextMenuAdjustment(x, (contextMenuRef as any)!.offsetWidth, window.innerWidth),
135+
y: contextMenuAdjustment(y, (contextMenuRef as any)!.offsetHeight, window.innerHeight),
131136
}))
132137
})
133138

@@ -136,7 +141,7 @@ const Entrypoint: Component = () => {
136141
class="relative font-sans m-0 w-[100vw] h-[100vh] text-fg"
137142
onClick={(event) => contextMenu.setMenu(prev => {
138143
if (prev != null && contextMenuRef != null) {
139-
if (contextMenuRef.contains(event.target)) return prev
144+
if ((contextMenuRef as any).contains(event.target)) return prev
140145
}
141146
})}
142147
>

src/api/ApiCache.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export default class ApiCache {
6464
guildMentions: ReactiveMap<bigint, ReactiveMap<bigint, bigint[]>>
6565
dmMentions: ReactiveMap<bigint, bigint[]>
6666
customEmojis: ReactiveMap<bigint, CustomEmoji>
67+
guildLoadingStates: ReactiveMap<bigint, boolean>
6768

6869
constructor(private readonly api: Api) {
6970
this.clientUserReactor = null as any // lazy
@@ -88,6 +89,7 @@ export default class ApiCache {
8889
this.guildMentions = new ReactiveMap()
8990
this.dmMentions = new ReactiveMap()
9091
this.customEmojis = new ReactiveMap()
92+
this.guildLoadingStates = new ReactiveMap()
9193
}
9294

9395
static fromReadyEvent(api: Api, ready: ReadyEvent): ApiCache {
@@ -99,7 +101,7 @@ export default class ApiCache {
99101
cache.updateRelationship(relationship)
100102

101103
for (const guild of ready.guilds)
102-
cache.updateGuild(guild)
104+
cache.updatePartialGuild(guild)
103105

104106
for (const presence of ready.presences)
105107
cache.updatePresence(presence)
@@ -178,6 +180,8 @@ export default class ApiCache {
178180
this.updateEmoji(emoji)
179181

180182
this.guildListReactor[1](prev => {
183+
// If already in the list (e.g. was added as partial guild), don't add again
184+
if (prev.includes(guild.id)) return prev
181185
// TODO: sort by true order, this is just creation date
182186
const index = sortedIndex(prev, guild.id)
183187
const next = [...prev]
@@ -186,6 +190,22 @@ export default class ApiCache {
186190
})
187191
}
188192

193+
updatePartialGuild(partialGuild: PartialGuild) {
194+
const existing = this.guilds.get(partialGuild.id)
195+
if (existing) {
196+
this.guilds.set(partialGuild.id, { ...existing, ...partialGuild })
197+
return
198+
}
199+
200+
this.guilds.set(partialGuild.id, partialGuild as Guild)
201+
this.guildListReactor[1](prev => {
202+
const index = sortedIndex(prev, partialGuild.id)
203+
const next = [...prev]
204+
next.splice(index, 0, partialGuild.id)
205+
return next
206+
})
207+
}
208+
189209
patchGuild(partialGuild: PartialGuild) {
190210
const guild = this.guilds.get(partialGuild.id)
191211
if (!guild) return
@@ -364,7 +384,8 @@ export default class ApiCache {
364384
const roles = (member.roles ?? []).map(id => this.roles.get(BigInt(id))).filter(role => role != null) as Role[]
365385

366386
const defaultRoleId = snowflakes.withModelType(guildId, snowflakes.ModelType.Role)
367-
if (!roles.find(role => role.id === defaultRoleId)) roles.push(this.roles.get(defaultRoleId)!)
387+
const defaultRole = this.roles.get(defaultRoleId)
388+
if (defaultRole && !roles.find(role => role.id === defaultRoleId)) roles.push(defaultRole)
368389
return roles
369390
}
370391

@@ -375,7 +396,7 @@ export default class ApiCache {
375396
if (!member) return Permissions.empty()
376397

377398
const roles = this.getMemberRoles(guildId, userId)
378-
const overwrites = channelId && (this.channels.get(channelId) as GuildChannel).overwrites || undefined
399+
const overwrites = channelId && (this.channels.get(channelId) as GuildChannel)?.overwrites || undefined
379400
return calculatePermissions(userId, member.permissions, roles, overwrites)
380401
}
381402

@@ -388,14 +409,17 @@ export default class ApiCache {
388409
return this.roles.get(defaultRoleId)!
389410
}
390411

391-
getMemberTopRole(guildId: bigint, userId: bigint): Role {
412+
getMemberTopRole(guildId: bigint, userId: bigint): Role | undefined {
392413
const roles = this.getMemberRoles(guildId, userId)
393414
return maxIterator(roles, role => role.position) ?? this.getDefaultRole(guildId)
394415
}
395416

396417
clientCanManage(guildId: bigint, userId: bigint): boolean {
397-
return this.guilds.get(guildId)?.owner_id == this.clientId!
398-
|| this.getMemberTopRole(guildId, this.clientId!).position > this.getMemberTopRole(guildId, userId).position
418+
if (this.guilds.get(guildId)?.owner_id == this.clientId!) return true
419+
const clientTop = this.getMemberTopRole(guildId, this.clientId!)
420+
const targetTop = this.getMemberTopRole(guildId, userId)
421+
if (!clientTop || !targetTop) return false
422+
return clientTop.position > targetTop.position
399423
}
400424

401425
getMemberColor(guildId: bigint, memberId: bigint): ExtendedColor | undefined {
@@ -476,6 +500,16 @@ export default class ApiCache {
476500
}
477501
}
478502

503+
isGuildLoaded(guildId: bigint): boolean {
504+
const guild = this.guilds.get(guildId)
505+
if (!guild) return false
506+
return guild.channels != null && guild.roles != null
507+
}
508+
509+
isGuildLoading(guildId: bigint): boolean {
510+
return this.guildLoadingStates.get(guildId) ?? false
511+
}
512+
479513
countGuildMentionsIn(guildId: bigint, channelId: bigint): number | null {
480514
return this.guildMentions.get(guildId)?.get(channelId)?.length ?? null
481515
}

src/api/WsClient.tsx

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import {
66
ChannelCreateEvent, ChannelDeleteEvent, ChannelUpdateEvent,
77
GuildCreateEvent,
88
GuildRemoveEvent, GuildUpdateEvent,
9+
GuildsAvailableEvent,
910
MemberJoinEvent,
1011
MemberRemoveEvent, MemberUpdateEvent,
1112
MessageCreateEvent,
1213
MessageDeleteEvent, MessageUpdateEvent,
1314
PresenceUpdateEvent,
1415
ReadyEvent,
1516
RelationshipCreateEvent,
16-
RelationshipRemoveEvent, RoleCreateEvent, RoleDeleteEvent, RolePositionsUpdateEvent, RoleUpdateEvent,
17+
RelationshipRemoveEvent, RequestGuildsPayload, RoleCreateEvent, RoleDeleteEvent, RolePositionsUpdateEvent, RoleUpdateEvent,
1718
TypingStartEvent,
1819
TypingStopEvent,
1920
UpdatePresencePayload,
@@ -50,6 +51,10 @@ export const WsEventHandlers: Record<string, WsEventHandler> = {
5051

5152
ws.api.cache?.updateUser(data.after)
5253
},
54+
guilds_available(ws: WsClient, data: GuildsAvailableEvent) {
55+
for (const guild of data.guilds)
56+
ws.api.cache?.updateGuild(guild)
57+
},
5358
guild_create(ws: WsClient, data: GuildCreateEvent) {
5459
ws.api.cache?.updateGuild(data.guild)
5560
},
@@ -224,9 +229,7 @@ export default class WsClient {
224229

225230
async forceReady() {
226231
let [guilds, dmChannels, clientUser, relationships] = await Promise.all([
227-
this.api.request('GET', '/guilds', {
228-
params: { channels: true, members: true, roles: true }
229-
}),
232+
this.api.request('GET', '/guilds'),
230233
this.api.request('GET', '/users/me/channels'),
231234
this.api.request('GET', '/users/me'),
232235
this.api.request('GET', '/relationships')
@@ -328,6 +331,121 @@ export default class WsClient {
328331
localStorage.setItem('presence', JSON.stringify(presence))
329332
}
330333

334+
/**
335+
* Requests full guild data for up to 20 guild IDs. For each requested guild that the client is
336+
* a member of, harmony responds with a `guilds_available` event which is handled automatically
337+
* by the cache. Returns a promise that resolves once all responses have arrived.
338+
*/
339+
requestGuilds(guildIds: bigint[], nonce?: string): Promise<void> {
340+
const resolvedNonce = nonce ?? Math.random().toString(36).slice(2)
341+
const payload: RequestGuildsPayload = { guild_ids: guildIds, nonce: resolvedNonce }
342+
this.connection?.send(msgpack.encode({ op: 'request_guilds', ...payload }))
343+
344+
// Mark guilds as loading
345+
const cache = this.api.cache
346+
if (cache) {
347+
for (const id of guildIds) {
348+
if (!cache.isGuildLoaded(id))
349+
cache.guildLoadingStates.set(id, true)
350+
}
351+
}
352+
353+
return new Promise((resolve) => {
354+
const remove = this.on('guilds_available', (data: GuildsAvailableEvent, removeListener) => {
355+
if (data.nonce !== resolvedNonce) return
356+
removeListener()
357+
// Clear loading states for guilds that were returned
358+
if (cache) {
359+
for (const guild of data.guilds)
360+
cache.guildLoadingStates.delete(guild.id)
361+
// Also clear any that weren't returned (e.g. guild no longer exists)
362+
for (const id of guildIds)
363+
cache.guildLoadingStates.delete(id)
364+
}
365+
resolve()
366+
})
367+
setTimeout(() => {
368+
remove()
369+
if (cache) {
370+
for (const id of guildIds)
371+
cache.guildLoadingStates.delete(id)
372+
}
373+
resolve()
374+
}, 10_000)
375+
})
376+
}
377+
378+
/**
379+
* Ensures that a guild's full data (channels, roles, members, emojis) is loaded. If already
380+
* loaded, resolves immediately. If currently loading, waits for it to finish. Otherwise,
381+
* sends a request and waits for the response.
382+
*/
383+
ensureGuildLoaded(guildId: bigint): Promise<void> {
384+
const cache = this.api.cache
385+
if (!cache) return Promise.resolve()
386+
if (cache.isGuildLoaded(guildId)) return Promise.resolve()
387+
388+
if (cache.isGuildLoading(guildId)) {
389+
// Wait for the loading to complete by watching guilds_available
390+
return new Promise((resolve) => {
391+
const remove = this.on('guilds_available', (data: GuildsAvailableEvent) => {
392+
if (data.guilds.some(g => g.id === guildId) || !cache.isGuildLoading(guildId)) {
393+
remove()
394+
resolve()
395+
}
396+
})
397+
setTimeout(() => { remove(); resolve() }, 10_000)
398+
})
399+
}
400+
401+
return this.requestGuilds([guildId])
402+
}
403+
404+
/**
405+
* Loads all guilds in the background after the ready event, in priority order:
406+
* 1. The focused guild (from the current URL, if any)
407+
* 2. All remaining guilds sorted by member count (descending)
408+
*
409+
* Batches up to 20 guilds per request.
410+
*/
411+
async startBackgroundGuildLoading(focusedGuildId?: bigint) {
412+
const cache = this.api.cache
413+
if (!cache) return
414+
415+
const allGuildIds = cache.guildList
416+
417+
// Build priority-ordered list
418+
const ordered: bigint[] = []
419+
const seen = new Set<bigint>()
420+
421+
const add = (id: bigint) => {
422+
if (!seen.has(id) && !cache.isGuildLoaded(id)) {
423+
seen.add(id)
424+
ordered.push(id)
425+
}
426+
}
427+
428+
// Priority 1: focused guild
429+
if (focusedGuildId != null) add(focusedGuildId)
430+
431+
// Priority 2: remaining guilds sorted by member count desc
432+
const remaining = allGuildIds
433+
.filter(id => !seen.has(id))
434+
.sort((a, b) => {
435+
const aCount = cache.guilds.get(a)?.member_count?.total ?? 0
436+
const bCount = cache.guilds.get(b)?.member_count?.total ?? 0
437+
return bCount - aCount
438+
})
439+
for (const id of remaining) add(id)
440+
441+
// Load in batches of up to 20
442+
for (let i = 0; i < ordered.length; i += 20) {
443+
const batch = ordered.slice(i, i + 20)
444+
console.debug('[WS] Background loading guild batch:', batch.map(String))
445+
await this.requestGuilds(batch)
446+
}
447+
}
448+
331449
reconnectWithBackoff() {
332450
const delay = this.backoff.delay()
333451
console.debug(`[WS] Connection closed, attempting reconnect in ${delay / 1000} seconds`)

src/components/guilds/EditMemberRolesModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default function EditMemberRolesModal(props: { guildId: bigint, memberId:
2020
const [selected, setSelected] = createSignal<bigint[]>(currentRoles());
2121

2222
const toggle = (id: bigint) => setSelected(prev => prev.includes(id) ? prev.filter(r => r !== id) : [...prev, id]);
23-
const unaddable = (position: number) => clientTop().position <= position; // cannot manage equal-or-higher
23+
const unaddable = (position: number) => (clientTop()?.position ?? Infinity) <= position; // cannot manage equal-or-higher
2424

2525
const save = async () => {
2626
const resp = await api.request('PATCH', `/guilds/${props.guildId}/members/${props.memberId}`, { json: { roles: selected() } });

src/components/guilds/GuildIcon.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default function GuildIcon(props: GuildIconProps) {
5454
if (!id) return
5555

5656
for (const guildId of api!.cache!.guildList) {
57+
if (!api!.cache!.isGuildLoaded(guildId)) continue
5758
const member = api?.cache?.members.get(memberKey(guildId, id))
5859
if (!member) continue
5960

0 commit comments

Comments
 (0)