feat(threads): preserve list state and scroll position across navigation

Sync thread list filters and search with URL query parameters
Save and restore list state and scroll position using session storage
This commit is contained in:
2026-05-07 13:39:15 +08:00
parent 64ca494d82
commit 520d988589

View File

@@ -42,6 +42,19 @@ type MessageGroup = {
messages: ThreadMessage[]; messages: ThreadMessage[];
}; };
type ThreadListState = {
selectedChannelId: number | null;
selectedTagId: number | null;
selectedLanguage: string;
sort: ThreadSort;
threadSearch: string;
threads: ThreadSummary[];
nextCursor: string | null;
hasMoreThreads: boolean;
scrollTop: number;
savedAt: number;
};
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -77,8 +90,11 @@ const messageActionBusyId = ref<number | null>(null);
const moderationBusyId = ref<number | null>(null); const moderationBusyId = ref<number | null>(null);
const socket = ref<WebSocket | null>(null); const socket = ref<WebSocket | null>(null);
const chatScroller = ref<HTMLElement | null>(null); const chatScroller = ref<HTMLElement | null>(null);
const threadListScroller = ref<HTMLElement | null>(null);
const showJump = ref(false); const showJump = ref(false);
const suppressThreadListWatch = ref(false);
const threadListStorageKey = 'pokopia_threads_list_state';
const reactionOptions: ThreadReactionType[] = ['👍', '❤️', '😂', '🔥', '👀']; const reactionOptions: ThreadReactionType[] = ['👍', '❤️', '😂', '🔥', '👀'];
const selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null); const selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null);
@@ -160,6 +176,119 @@ const messageGroups = computed<MessageGroup[]>(() => {
return groups; return groups;
}); });
function queryStringValue(value: unknown) {
const raw = Array.isArray(value) ? value[0] : value;
return typeof raw === 'string' ? raw : '';
}
function queryPositiveInteger(value: unknown) {
const raw = Number(queryStringValue(value));
return Number.isInteger(raw) && raw > 0 ? raw : null;
}
function validThreadSort(value: unknown): ThreadSort | null {
const raw = queryStringValue(value);
return raw === 'last-active' || raw === 'latest' || raw === 'most-discussed' ? raw : null;
}
function threadListQuery() {
const query: Record<string, string> = {};
query.channel = selectedChannelId.value === null ? 'all' : String(selectedChannelId.value);
if (selectedTagId.value !== null) {
query.tag = String(selectedTagId.value);
}
if (selectedLanguage.value !== 'all') {
query.language = selectedLanguage.value;
}
if (sort.value !== 'last-active') {
query.sort = sort.value;
}
const search = threadSearch.value.trim();
if (search) {
query.q = search;
}
return query;
}
function applyRouteListContext() {
const channel = queryStringValue(route.query.channel);
if (channel === 'all') {
selectedChannelId.value = null;
} else {
const channelId = queryPositiveInteger(route.query.channel);
if (channelId !== null) {
selectedChannelId.value = channelId;
}
}
selectedTagId.value = queryPositiveInteger(route.query.tag);
selectedLanguage.value = queryStringValue(route.query.language) || 'all';
sort.value = validThreadSort(route.query.sort) ?? 'last-active';
threadSearch.value = queryStringValue(route.query.q).trim();
}
function syncThreadListQuery() {
void router.replace({ path: route.path, query: threadListQuery() });
}
function threadListMatchesState(state: ThreadListState) {
return (
state.selectedChannelId === selectedChannelId.value &&
state.selectedTagId === selectedTagId.value &&
state.selectedLanguage === selectedLanguage.value &&
state.sort === sort.value &&
state.threadSearch === threadSearch.value.trim()
);
}
function saveThreadListState() {
if (typeof sessionStorage === 'undefined') return;
const state: ThreadListState = {
selectedChannelId: selectedChannelId.value,
selectedTagId: selectedTagId.value,
selectedLanguage: selectedLanguage.value,
sort: sort.value,
threadSearch: threadSearch.value.trim(),
threads: threads.value,
nextCursor: nextCursor.value,
hasMoreThreads: hasMoreThreads.value,
scrollTop: threadListScroller.value?.scrollTop ?? 0,
savedAt: Date.now()
};
sessionStorage.setItem(threadListStorageKey, JSON.stringify(state));
}
function restoreThreadListState() {
if (typeof sessionStorage === 'undefined') return null;
try {
const raw = sessionStorage.getItem(threadListStorageKey);
if (!raw) return null;
const state = JSON.parse(raw) as ThreadListState;
if (!state || Date.now() - state.savedAt > 30 * 60 * 1000 || !threadListMatchesState(state)) {
return null;
}
threads.value = Array.isArray(state.threads) ? state.threads : [];
nextCursor.value = state.nextCursor;
hasMoreThreads.value = Boolean(state.hasMoreThreads);
return state;
} catch {
return null;
}
}
async function restoreThreadListScroll(scrollTop: number) {
await nextTick();
if (threadListScroller.value) {
threadListScroller.value.scrollTop = scrollTop;
}
}
async function resetThreadListScroll() {
await restoreThreadListScroll(0);
}
function canUseThreads() { function canUseThreads() {
return currentUser.value?.emailVerified === true; return currentUser.value?.emailVerified === true;
} }
@@ -250,7 +379,12 @@ async function loadCurrentUser() {
async function loadChannels() { async function loadChannels() {
channels.value = await api.threadChannels(); channels.value = await api.threadChannels();
if (!selectedChannelId.value && channels.value[0]) { const channelQuery = queryStringValue(route.query.channel);
const selectedChannelExists =
selectedChannelId.value === null || channels.value.some((channel) => channel.id === selectedChannelId.value);
if (!selectedChannelExists) {
selectedChannelId.value = channels.value[0]?.id ?? null;
} else if (selectedChannelId.value === null && channelQuery !== 'all' && channels.value[0]) {
selectedChannelId.value = channels.value[0].id; selectedChannelId.value = channels.value[0].id;
} }
if (!threadForm.value.languageCode) { if (!threadForm.value.languageCode) {
@@ -272,6 +406,10 @@ async function loadThreads(reset = true) {
threads.value = reset ? page.items : [...threads.value, ...page.items]; threads.value = reset ? page.items : [...threads.value, ...page.items];
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMoreThreads.value = page.hasMore; hasMoreThreads.value = page.hasMore;
if (reset) {
await resetThreadListScroll();
}
saveThreadListState();
} finally { } finally {
loadingThreads.value = false; loadingThreads.value = false;
} }
@@ -321,10 +459,18 @@ async function loadMessages(reset = true) {
async function loadAll() { async function loadAll() {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
suppressThreadListWatch.value = true;
let restoredScrollTop: number | null = null;
try { try {
applyRouteListContext();
await loadCurrentUser(); await loadCurrentUser();
await loadChannels(); await loadChannels();
await loadThreads(true); const restoredList = restoreThreadListState();
if (restoredList) {
restoredScrollTop = restoredList.scrollTop;
} else {
await loadThreads(true);
}
if (activeThreadId.value) { if (activeThreadId.value) {
await loadActiveThread(); await loadActiveThread();
await loadMessages(true); await loadMessages(true);
@@ -333,22 +479,30 @@ async function loadAll() {
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed'); errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally { } finally {
suppressThreadListWatch.value = false;
loading.value = false; loading.value = false;
if (restoredScrollTop !== null) {
await restoreThreadListScroll(restoredScrollTop);
}
} }
} }
async function selectThread(thread: ThreadSummary) { async function selectThread(thread: ThreadSummary) {
await router.push(`/threads/${thread.id}`); saveThreadListState();
await router.push({ path: `/threads/${thread.id}`, query: threadListQuery() });
} }
function selectChannel(channelId: number | null) { function selectChannel(channelId: number | null) {
selectedChannelId.value = channelId; selectedChannelId.value = channelId;
selectedTagId.value = null; selectedTagId.value = null;
syncThreadListQuery();
void loadThreads(true); void loadThreads(true);
} }
function submitThreadSearch() { function submitThreadSearch() {
threadSearch.value = threadSearch.value.trim(); threadSearch.value = threadSearch.value.trim();
syncThreadListQuery();
saveThreadListState();
} }
function openCreateThread() { function openCreateThread() {
@@ -391,7 +545,8 @@ function cancelEditMessage() {
} }
async function closeThreadDetail() { async function closeThreadDetail() {
await router.push('/threads'); saveThreadListState();
await router.push({ path: '/threads', query: threadListQuery() });
} }
function toggleThreadTag(tag: ThreadChannelTag) { function toggleThreadTag(tag: ThreadChannelTag) {
@@ -429,7 +584,8 @@ async function submitThread() {
}); });
closeCreateThread(); closeCreateThread();
updateThreadInList(thread); updateThreadInList(thread);
await router.push(`/threads/${thread.id}`); saveThreadListState();
await router.push({ path: `/threads/${thread.id}`, query: threadListQuery() });
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.createFailed'); errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.createFailed');
} finally { } finally {
@@ -541,7 +697,8 @@ async function removeActiveThread() {
threads.value = threads.value.filter((item) => item.id !== thread.id); threads.value = threads.value.filter((item) => item.id !== thread.id);
activeThread.value = null; activeThread.value = null;
messages.value = []; messages.value = [];
await router.push('/threads'); saveThreadListState();
await router.push({ path: '/threads', query: threadListQuery() });
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed'); errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
} finally { } finally {
@@ -658,6 +815,8 @@ function onScroll() {
} }
watch([selectedLanguage, selectedTagId, sort], () => { watch([selectedLanguage, selectedTagId, sort], () => {
if (suppressThreadListWatch.value) return;
syncThreadListQuery();
void loadThreads(true); void loadThreads(true);
}); });
@@ -768,7 +927,7 @@ onBeforeUnmount(() => {
<Skeleton width="45%" /> <Skeleton width="45%" />
</article> </article>
</div> </div>
<div v-else-if="currentThreadList.length" class="thread-list"> <div v-else-if="currentThreadList.length" ref="threadListScroller" class="thread-list">
<button <button
v-for="thread in currentThreadList" v-for="thread in currentThreadList"
:key="thread.id" :key="thread.id"