Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/composables/useThreadQueries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useAccountStore } from '@/stores/account'
import { api } from '@/lib/api'
Expand All @@ -15,19 +15,35 @@ export function useThreadListQuery(status: () => ThreadStatus | undefined) {
const accountStore = useAccountStore()
const accountId = computed(() => accountStore.accountId)

// Sent as `refresh` on the query's very first fetch (covers a page load/reload) and again
// whenever requestRefresh() runs (the inbox's Refresh button) — the backend treats a present
// `refresh` value as a signal to wake the IMAP/JMAP IDLE listener for the account, on top of
// whatever TanStack Query itself decides to (re)fetch. Consumed after one fetch so routine
// refetches (pagination, window focus, mutation-triggered invalidation) don't keep re-sending it.
const pendingRefresh = ref<string | undefined>(new Date().toISOString())

const query = useInfiniteQuery({
queryKey: computed(() => queryKeys.threads.list(accountId.value!, status())),
queryFn: async ({ pageParam }) =>
unwrap(await api.listThreads(accountId.value!, {
queryFn: async ({ pageParam }) => {
const refresh = pendingRefresh.value
pendingRefresh.value = undefined
return unwrap(await api.listThreads(accountId.value!, {
status: status(),
cursor: pageParam,
limit: 50,
})),
...(refresh ? { refresh } : {}),
}))
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.pagination.cursor ?? undefined,
enabled: computed(() => !!accountId.value),
})

function requestRefresh() {
pendingRefresh.value = new Date().toISOString()
return query.refetch()
}

const threads = computed<Thread[]>(() =>
query.data.value?.pages.flatMap(p => p.threads).filter(t => {
// Filter out sentinel/ghost threads: null lastSignalAt means signals were
Expand All @@ -40,7 +56,7 @@ export function useThreadListQuery(status: () => ThreadStatus | undefined) {
const activeCount = computed(() => threads.value.length)
const hasMore = computed(() => query.hasNextPage?.value ?? false)

return { query, threads, activeCount, hasMore }
return { query, threads, activeCount, hasMore, requestRefresh }
}

export function useThreadDetailQuery(threadId: () => string | undefined) {
Expand Down
4 changes: 2 additions & 2 deletions src/views/InboxView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function statusFor(tab: TabKey): ThreadStatus | undefined {
}

// TanStack Query — thread list driven by current tab
const { query: threadListQuery, threads: allThreads, hasMore } = useThreadListQuery(
const { query: threadListQuery, threads: allThreads, hasMore, requestRefresh } = useThreadListQuery(
() => statusFor(activeTab.value),
)

Expand Down Expand Up @@ -79,7 +79,7 @@ async function fetchRecentSignals() {

async function handleRefresh() {
refreshing.value = true
await threadListQuery.refetch()
await requestRefresh()
await fetchRecentSignals()
lastRefreshedAt.value = new Date().toLocaleTimeString()
refreshing.value = false
Expand Down
4 changes: 3 additions & 1 deletion tests/component/InboxView.regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ describe('InboxView — regression gate', () => {
if (refreshBtn) {
await refreshBtn.trigger('click')
await flushPromises()
expect(api.listThreads).toHaveBeenCalled()
// The backend treats a present `refresh` value as a signal to wake the IMAP/JMAP IDLE
// listener for the account (see threadsApi.ts) — the button is a no-op without it.
expect(api.listThreads).toHaveBeenCalledWith('acc_1', expect.objectContaining({ refresh: expect.any(String) }))
}
})
})
Loading