Skip to content

Commit d70dfb9

Browse files
authored
feat: infinite scrolling list of conversations (#1564)
* wip * wip: infinite scrolling continues * fix: merge regression * fix: infinite scrolling * fix: unify use of base over APP_BASE * fix: show loader properly when invalidating after reaching end of page
1 parent 1630a9a commit d70dfb9

File tree

5 files changed

+109
-26
lines changed

5 files changed

+109
-26
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script lang="ts">
2+
import { onMount, createEventDispatcher } from "svelte";
3+
4+
const dispatch = createEventDispatcher();
5+
let loader: HTMLDivElement;
6+
let observer: IntersectionObserver;
7+
let intervalId: ReturnType<typeof setInterval> | undefined;
8+
9+
onMount(() => {
10+
observer = new IntersectionObserver((entries) => {
11+
entries.forEach((entry) => {
12+
if (entry.isIntersecting) {
13+
// Clear any existing interval
14+
if (intervalId) {
15+
clearInterval(intervalId);
16+
}
17+
// Start new interval that dispatches every 250ms
18+
intervalId = setInterval(() => {
19+
dispatch("visible");
20+
}, 250);
21+
} else {
22+
// Clear interval when not intersecting
23+
if (intervalId) {
24+
clearInterval(intervalId);
25+
intervalId = undefined;
26+
}
27+
}
28+
});
29+
});
30+
31+
observer.observe(loader);
32+
33+
return () => {
34+
observer.disconnect();
35+
if (intervalId) {
36+
clearInterval(intervalId);
37+
}
38+
};
39+
});
40+
</script>
41+
42+
<div bind:this={loader} class="flex animate-pulse flex-col gap-4">
43+
<div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
44+
<div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
45+
<div class="ml-2 h-5 w-4/5 gap-5 rounded bg-gray-200 dark:bg-gray-700" />
46+
</div>

src/lib/components/NavMenu.svelte

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@
1010
import type { ConvSidebar } from "$lib/types/ConvSidebar";
1111
import type { Model } from "$lib/types/Model";
1212
import { page } from "$app/stores";
13+
import InfiniteScroll from "./InfiniteScroll.svelte";
14+
import type { Conversation } from "$lib/types/Conversation";
15+
import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
1316
1417
export let conversations: ConvSidebar[];
1518
export let canLogin: boolean;
1619
export let user: LayoutData["user"];
1720
21+
export let p = 0;
22+
23+
let hasMore = true;
24+
1825
function handleNewChatClick() {
1926
isAborted.set(true);
2027
}
@@ -44,6 +51,34 @@
4451
} as const;
4552
4653
const nModels: number = $page.data.models.filter((el: Model) => !el.unlisted).length;
54+
55+
async function handleVisible() {
56+
p++;
57+
const newConvs = await fetch(`${base}/api/conversations?p=${p}`)
58+
.then((res) => res.json())
59+
.then((convs) =>
60+
convs.map(
61+
(conv: Pick<Conversation, "_id" | "title" | "updatedAt" | "model" | "assistantId">) => ({
62+
...conv,
63+
updatedAt: new Date(conv.updatedAt),
64+
})
65+
)
66+
)
67+
.catch(() => []);
68+
69+
if (newConvs.length === 0) {
70+
hasMore = false;
71+
}
72+
73+
conversations = [...conversations, ...newConvs];
74+
}
75+
76+
$: if (conversations.length <= CONV_NUM_PER_PAGE) {
77+
// reset p to 0 if there's only one page of content
78+
// that would be caused by a data loading invalidation
79+
p = 0;
80+
hasMore = true;
81+
}
4782
</script>
4883

4984
<div class="sticky top-0 flex flex-none items-center justify-between px-1.5 py-3.5 max-sm:pt-0">
@@ -89,6 +124,9 @@
89124
{/if}
90125
{/each}
91126
</div>
127+
{#if hasMore}
128+
<InfiniteScroll on:visible={handleVisible} />
129+
{/if}
92130
{/await}
93131
</div>
94132
<div

src/lib/constants/pagination.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const CONV_NUM_PER_PAGE = 30;

src/routes/+layout.server.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import { toolFromConfigs } from "$lib/server/tools";
1212
import { MetricsServer } from "$lib/server/metrics";
1313
import type { ToolFront, ToolInputFile } from "$lib/types/Tool";
1414
import { ReviewStatus } from "$lib/types/Review";
15+
import { base } from "$app/paths";
1516

16-
export const load: LayoutServerLoad = async ({ locals, depends }) => {
17+
export const load: LayoutServerLoad = async ({ locals, depends, fetch }) => {
1718
depends(UrlDependency.ConversationList);
1819

1920
const settings = await collections.settings.findOne(authCondition(locals));
@@ -56,24 +57,17 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => {
5657
const conversations =
5758
nConversations === 0
5859
? Promise.resolve([])
59-
: collections.conversations
60-
.find(authCondition(locals))
61-
.sort({ updatedAt: -1 })
62-
.project<
63-
Pick<
64-
Conversation,
65-
"title" | "model" | "_id" | "updatedAt" | "createdAt" | "assistantId"
66-
>
67-
>({
68-
title: 1,
69-
model: 1,
70-
_id: 1,
71-
updatedAt: 1,
72-
createdAt: 1,
73-
assistantId: 1,
74-
})
75-
.limit(300)
76-
.toArray();
60+
: fetch(`${base}/api/conversations`)
61+
.then((res) => res.json())
62+
.then(
63+
(
64+
convs: Pick<Conversation, "_id" | "title" | "updatedAt" | "model" | "assistantId">[]
65+
) =>
66+
convs.map((conv) => ({
67+
...conv,
68+
updatedAt: new Date(conv.updatedAt),
69+
}))
70+
);
7771

7872
const userAssistants = settings?.assistants?.map((assistantId) => assistantId.toString()) ?? [];
7973
const userAssistantsSet = new Set(userAssistants);

src/routes/api/conversations/+server.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { collections } from "$lib/server/database";
22
import { models } from "$lib/server/models";
33
import { authCondition } from "$lib/server/auth";
44
import type { Conversation } from "$lib/types/Conversation";
5-
6-
const NUM_PER_PAGE = 300;
5+
import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
76

87
export async function GET({ locals, url }) {
98
const p = parseInt(url.searchParams.get("p") ?? "0");
@@ -20,19 +19,24 @@ export async function GET({ locals, url }) {
2019
assistantId: 1,
2120
})
2221
.sort({ updatedAt: -1 })
23-
.skip(p * NUM_PER_PAGE)
24-
.limit(NUM_PER_PAGE)
22+
.skip(p * CONV_NUM_PER_PAGE)
23+
.limit(CONV_NUM_PER_PAGE)
2524
.toArray();
2625

26+
if (convs.length === 0) {
27+
return Response.json([]);
28+
}
29+
2730
const res = convs.map((conv) => ({
28-
id: conv._id,
31+
_id: conv._id,
32+
id: conv._id, // legacy param iOS
2933
title: conv.title,
3034
updatedAt: conv.updatedAt,
31-
modelId: conv.model,
35+
model: conv.model,
36+
modelId: conv.model, // legacy param iOS
3237
assistantId: conv.assistantId,
3338
modelTools: models.find((m) => m.id == conv.model)?.tools ?? false,
3439
}));
35-
3640
return Response.json(res);
3741
} else {
3842
return Response.json({ message: "Must have session cookie" }, { status: 401 });

0 commit comments

Comments
 (0)