Skip to content

Commit 64ef765

Browse files
committed
[experiment] implement jump URLs
1 parent 97b2238 commit 64ef765

File tree

9 files changed

+560
-70
lines changed

9 files changed

+560
-70
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@solid-primitives/media": "^2.1.1",
2929
"@solid-primitives/set": "^0.4.2",
3030
"@solidjs/router": "^0.13",
31+
"@tanstack/solid-virtual": "^3.13.5",
3132
"@thisbeyond/solid-dnd": "^0.7.5",
3233
"cssnano": "^5.1.14",
3334
"emoji-regex": "^10.3.0",

src/Entrypoint.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ const Entrypoint: Component = () => {
195195
<Route path="/loading" component={Loading} />
196196
<Route path="/friends/requests" component={FriendRequests} />
197197
<Route path="/friends/*" component={FriendsList} />
198-
<Route path="/dms/:channelId" component={DmChannel} />
199-
<Route path="/guilds/:guildId/:channelId" component={GuildChannel} />
198+
<Route path="/dms/:channelId/:messageId?" component={DmChannel} />
199+
<Route path="/guilds/:guildId/:channelId/:messageId?" component={GuildChannel} />
200200
<Route path="/guilds/:guildId" component={GuildHome} />
201201
<Route path="/invite/:code" component={Invite} />
202202
<Route path="/" component={Home} />

src/api/MessageGrouper.ts

Lines changed: 260 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,101 @@ export function authorDefault(): User {
4545
export default class MessageGrouper {
4646
private readonly groupsSignal: Signal<MessageGroup[]>
4747
private currentGroup?: Message[]
48-
4948
private fetchBefore?: bigint
5049
private fetchLock: boolean = false
50+
private loading: Accessor<boolean>
51+
private setLoading: Setter<boolean>
5152
nonced: Map<string, [number, number]>
5253
noMoreMessages: Accessor<boolean>
5354
private setNoMoreMessages: Setter<boolean>
55+
private oldestMessageId?: Accessor<bigint | undefined>
56+
private setOldestMessageId: Setter<bigint | undefined>
57+
private newestMessageId?: Accessor<bigint | undefined>
58+
private setNewestMessageId: Setter<bigint | undefined>
59+
private hasGap: Accessor<boolean>
60+
private setHasGap: Setter<boolean>
5461

5562
constructor(
5663
private readonly api: Api,
5764
private readonly channelId: bigint,
5865
) {
5966
this.groupsSignal = createSignal([] as MessageGroup[])
6067
this.nonced = new Map();
61-
[this.noMoreMessages, this.setNoMoreMessages] = createSignal(false)
68+
[this.noMoreMessages, this.setNoMoreMessages] = createSignal(false);
69+
[this.loading, this.setLoading] = createSignal(false);
70+
[this.oldestMessageId, this.setOldestMessageId] = createSignal<bigint | undefined>(undefined);
71+
[this.newestMessageId, this.setNewestMessageId] = createSignal<bigint | undefined>(undefined);
72+
[this.hasGap, this.setHasGap] = createSignal(false);
73+
}
74+
75+
get isLoading() {
76+
return this.loading()
77+
}
78+
79+
get hasMessageGap() {
80+
return this.hasGap()
81+
}
82+
83+
get oldestLoaded() {
84+
return this.oldestMessageId?.()
85+
}
86+
87+
get newestLoaded() {
88+
return this.newestMessageId?.()
89+
}
90+
91+
/**
92+
* Update the oldest and newest message IDs after message insertion
93+
*/
94+
private updateMessageBoundaries() {
95+
let oldestId: bigint | undefined;
96+
let newestId: bigint | undefined;
97+
98+
for (const group of this.groups) {
99+
if (group.isDivider) continue;
100+
101+
for (const message of group) {
102+
if (!oldestId || message.id < oldestId) {
103+
oldestId = message.id;
104+
}
105+
if (!newestId || message.id > newestId) {
106+
newestId = message.id;
107+
}
108+
}
109+
}
110+
111+
this.setOldestMessageId(oldestId);
112+
this.setNewestMessageId(newestId);
113+
}
114+
115+
/**
116+
* Determines if there is a gap between the current message context and the newest loaded messages
117+
*/
118+
checkForMessageGap() {
119+
if (!this.oldestLoaded || !this.newestLoaded) {
120+
this.setHasGap(false);
121+
return false;
122+
}
123+
124+
// If the difference between newest and oldest is significantly larger than the loaded message count would suggest,
125+
// then we likely have a gap
126+
const totalMessageCount = this.getTotalMessageCount();
127+
const expectedDiff = BigInt(totalMessageCount * 2); // rough estimate assuming uniform message ID distribution
128+
const actualDiff = this.newestLoaded - this.oldestLoaded;
129+
130+
const hasGap = actualDiff > expectedDiff && actualDiff > BigInt(MESSAGE_HISTORY_LIMIT * 3);
131+
this.setHasGap(hasGap);
132+
return hasGap;
133+
}
134+
135+
/**
136+
* Gets the total number of actual messages (excluding dividers)
137+
*/
138+
getTotalMessageCount(): number {
139+
return this.groups.reduce((count, group) => {
140+
if (group.isDivider) return count;
141+
return count + group.length;
142+
}, 0);
62143
}
63144

64145
/**
@@ -139,27 +220,57 @@ export default class MessageGrouper {
139220
insertMessages(messages: Message[]) {
140221
if (messages.length === 0) return
141222

223+
// Create a set of existing message IDs to avoid duplicates
224+
const existingMessageIds = new Set<string>();
225+
for (const group of this.groups) {
226+
if (group.isDivider) continue;
227+
228+
for (const message of group) {
229+
existingMessageIds.add(message.id.toString());
230+
}
231+
}
232+
233+
// Filter out duplicate messages
234+
const uniqueMessages = messages.filter(message => !existingMessageIds.has(message.id.toString()));
235+
236+
if (uniqueMessages.length === 0) return;
237+
142238
let groups = this.groups
143239
if (this.currentGroup == null) groups.push([])
144240

145-
let [groupIndex, messageIndex] = this.findCloseMessageIndex(messages[0].id)
146-
let lastMessage: Message
241+
let [groupIndex, messageIndex] = this.findCloseMessageIndex(uniqueMessages[0].id)
242+
let lastMessage: Message | undefined
147243

148244
if (groupIndex <= 0) {
149245
let firstMessageGroup = groups[0]
150-
if (firstMessageGroup.isDivider || groupIndex < 0)
246+
if (firstMessageGroup?.isDivider || groupIndex < 0)
151247
groups = [firstMessageGroup = [], ...groups]
152248

153-
lastMessage = (<Message[]> firstMessageGroup)[0]
249+
// Only set lastMessage if the group actually has messages
250+
if (firstMessageGroup.length > 0) {
251+
lastMessage = firstMessageGroup[0]
252+
}
154253
groupIndex = 0
155254
} else {
156255
lastMessage = (<Message[]> groups[groupIndex])[messageIndex]
157256
}
158257

159-
for (const message of messages) {
160-
const behavior = this.nextMessageBehavior({ lastMessage, message })
258+
for (const message of uniqueMessages) {
259+
// Skip divider logic for the first message if we don't have a previous message for reference
260+
const behavior = lastMessage
261+
? this.nextMessageBehavior({ lastMessage, message })
262+
: false
263+
161264
if (behavior) {
162-
if (behavior !== true) groups.splice(++groupIndex, 0, behavior)
265+
if (behavior !== true) {
266+
// Only add a divider if the dates are actually different
267+
const messageDate = new Date(snowflakes.timestamp(message.id)).toDateString()
268+
const lastMessageDate = new Date(snowflakes.timestamp(lastMessage!.id)).toDateString()
269+
270+
if (messageDate !== lastMessageDate) {
271+
groups.splice(++groupIndex, 0, behavior)
272+
}
273+
}
163274
groups.splice(++groupIndex, 0, [])
164275
messageIndex = -1
165276
}
@@ -171,28 +282,148 @@ export default class MessageGrouper {
171282

172283
this.setGroups([...groups])
173284
this.currentGroup = last(groups) as any
285+
286+
// Update oldest and newest message IDs after inserting messages
287+
this.updateMessageBoundaries();
288+
289+
// Check if we have a gap in message history
290+
this.checkForMessageGap();
291+
}
292+
293+
/**
294+
* Finds a message by ID and scrolls to it, loading surrounding messages if needed
295+
*/
296+
async findMessage(id: bigint): Promise<[number, number] | null> {
297+
// First check if we already have the message
298+
const [groupIndex, messageIndex] = this.findCloseMessageIndex(id)
299+
if (groupIndex >= 0) {
300+
const group = this.groups[groupIndex]
301+
if (!group.isDivider) {
302+
const message = group[messageIndex]
303+
if (message.id === id) {
304+
return [groupIndex, messageIndex]
305+
}
306+
}
307+
}
308+
309+
// If we don't have the message, load messages around it
310+
await this.fetchMessages(id)
311+
312+
// Check again after loading
313+
const [newGroupIndex, newMessageIndex] = this.findCloseMessageIndex(id)
314+
if (newGroupIndex >= 0) {
315+
const group = this.groups[newGroupIndex]
316+
if (!group.isDivider) {
317+
const message = group[newMessageIndex]
318+
if (message.id === id) {
319+
return [newGroupIndex, newMessageIndex]
320+
}
321+
}
322+
}
323+
324+
return null
174325
}
175326

176327
/**
177328
* Fetches messages from the API and inserts into the grouper.
329+
* @param targetId If provided, fetch messages around this ID
330+
* @param after If true, fetch messages after the provided targetId or last loaded message
331+
* @param before If true, fetch messages before the provided targetId
178332
*/
179-
async fetchMessages() {
180-
if (this.noMoreMessages() || this.fetchLock)
333+
async fetchMessages(targetId?: bigint, after?: boolean, before?: boolean) {
334+
if (this.fetchLock)
181335
return
182336

183337
this.fetchLock = true
338+
this.setLoading(true)
184339
const params: Record<string, any> = { limit: MESSAGE_HISTORY_LIMIT }
185-
if (this.fetchBefore != null)
340+
341+
if (targetId) {
342+
if (after) {
343+
// Fetch messages after the target ID
344+
params.after = targetId
345+
} else if (before) {
346+
// Fetch messages before the target ID
347+
params.before = targetId
348+
} else {
349+
// Fetch messages around the target ID
350+
params.around = targetId
351+
}
352+
} else if (after) {
353+
// Fetch messages after the oldest loaded message
354+
const lastGroup = this.groups[this.groups.length - 1]
355+
if (lastGroup && !lastGroup.isDivider && lastGroup.length > 0) {
356+
const lastMessage = lastGroup[lastGroup.length - 1]
357+
params.after = lastMessage.id
358+
} else {
359+
// If we have no messages, just return
360+
this.fetchLock = false
361+
this.setLoading(false)
362+
return
363+
}
364+
} else if (this.fetchBefore != null) {
365+
// Fetch messages before the oldest loaded message
186366
params.before = this.fetchBefore
367+
}
187368

188-
const response = await this.api.request<Message[]>('GET', `/channels/${this.channelId}/messages`, { params })
189-
const messages = response.ensureOk().jsonOrThrow()
369+
try {
370+
const response = await this.api.request<Message[]>('GET', `/channels/${this.channelId}/messages`, { params })
371+
const messages = response.ensureOk().jsonOrThrow()
190372

191-
this.fetchBefore = last(messages)?.id
192-
if (messages.length < MESSAGE_HISTORY_LIMIT)
193-
this.setNoMoreMessages(true)
194-
this.insertMessages(messages.reverse())
195-
this.fetchLock = false
373+
if (messages.length === 0) {
374+
this.fetchLock = false
375+
this.setLoading(false)
376+
return
377+
}
378+
379+
if (!targetId && !after && !before) {
380+
this.fetchBefore = last(messages)?.id
381+
if (messages.length < MESSAGE_HISTORY_LIMIT)
382+
this.setNoMoreMessages(true)
383+
}
384+
385+
this.insertMessages(messages.reverse())
386+
387+
// After inserting messages, check if we still have a gap
388+
this.checkForMessageGap();
389+
} finally {
390+
this.fetchLock = false
391+
this.setLoading(false)
392+
}
393+
}
394+
395+
/**
396+
* Fetches the next chunk of messages to fill the gap between contexts
397+
*/
398+
async fetchNextChunk() {
399+
if (!this.oldestLoaded || !this.newestLoaded) return;
400+
401+
// Determine whether we should fetch messages after the oldest or before the newest
402+
// If the gap is closer to the oldest loaded message, fetch after the oldest
403+
// If the gap is closer to the newest loaded message, fetch before the newest
404+
405+
const totalMessageCount = this.getTotalMessageCount();
406+
407+
// If we have fewer than 50 messages loaded, we need to fetch more messages
408+
if (totalMessageCount < 50) {
409+
// If we're closer to the oldest message context (likely jumped to an old message)
410+
// then fetch messages after the oldest message to move forward in time
411+
await this.fetchMessages(this.oldestLoaded, true);
412+
}
413+
// Otherwise, if we have a lot of old messages loaded (scrolled up a lot)
414+
// then fetch newer messages to move toward present time
415+
else {
416+
await this.fetchMessages(this.newestLoaded, false, false);
417+
}
418+
}
419+
420+
/**
421+
* Fetches the latest messages to jump to bottom
422+
*/
423+
async fetchLatestMessages() {
424+
this.setGroups([]);
425+
this.fetchBefore = undefined;
426+
await this.fetchMessages();
196427
}
197428

198429
private nextMessageBehavior(
@@ -204,10 +435,18 @@ export default class MessageGrouper {
204435
lastMessage ??= this.lastMessage
205436
if (!lastMessage) return false
206437

207-
let timestamp
208-
if (!isSameDay(timestamp = snowflakes.timestamp(message.id), snowflakes.timestamp(lastMessage.id)))
438+
let timestamp = snowflakes.timestamp(message.id)
439+
let lastTimestamp = snowflakes.timestamp(lastMessage.id)
440+
441+
const dateStr = new Date(timestamp).toDateString()
442+
const lastDateStr = new Date(lastTimestamp).toDateString()
443+
444+
// only add a day divider if the messages are from different days
445+
if (dateStr !== lastDateStr) {
209446
return { isDivider: true, content: humanizeDate(timestamp) }
447+
}
210448

449+
// group messages if they are from the same author and within 15 minutes
211450
return message.author_id !== lastMessage.author_id
212451
|| message.id - lastMessage.id > SNOWFLAKE_BOUNDARY
213452
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {JSX} from "solid-js";
2+
3+
export default function ArrowDown(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
4+
return (
5+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" {...props}>
6+
{/*Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.*/}
7+
<path d="M169.4 470.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 370.8 224 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 306.7L54.6 265.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z"/>
8+
</svg>
9+
)
10+
}

src/components/icons/svg/Bell.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {JSX} from "solid-js";
2+
3+
export default function Bell(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
4+
return (
5+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
6+
{/*Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.*/}
7+
<path d="M224 0c-17.7 0-32 14.3-32 32V51.2C119 66.4 64 130.6 64 208v18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416H416c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8V208c0-77.4-55-142.1-128-156.8V32c0-17.7-14.3-32-32-32zM160 448c0 17.7 14.3 32 32 32s32-14.3 32-32H160z"/>
8+
</svg>
9+
)
10+
}

0 commit comments

Comments
 (0)