diff --git a/src/components/common/RefreshHotkey.svelte b/src/components/common/RefreshHotkey.svelte
index e416436..a8e8254 100644
--- a/src/components/common/RefreshHotkey.svelte
+++ b/src/components/common/RefreshHotkey.svelte
@@ -6,7 +6,8 @@
* navigating, and editable controls are ignored so composing text is safe.
*/
interface Props {
- onrefresh?: () => void
+ /** Returning false means this page has no refreshable timeline. */
+ onrefresh?: () => void | boolean
}
let { onrefresh = () => window.location.reload() }: Props = $props()
@@ -35,8 +36,8 @@
return
}
- event.preventDefault()
- onrefresh()
+ const handled = onrefresh()
+ if (handled !== false) event.preventDefault()
}
diff --git a/src/components/common/RefreshHotkey.test.ts b/src/components/common/RefreshHotkey.test.ts
index 486fb18..4106dbf 100644
--- a/src/components/common/RefreshHotkey.test.ts
+++ b/src/components/common/RefreshHotkey.test.ts
@@ -33,4 +33,18 @@ describe('RefreshHotkey', () => {
expect(onrefresh).not.toHaveBeenCalled()
})
+
+ it('leaves the shortcut unclaimed when the page has no timeline', () => {
+ render(RefreshHotkey, { props: { onrefresh: () => false } })
+ const event = new KeyboardEvent('keydown', {
+ key: 'R',
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ })
+
+ window.dispatchEvent(event)
+
+ expect(event.defaultPrevented).toBe(false)
+ })
})
diff --git a/src/lib/timeline-refresh.test.ts b/src/lib/timeline-refresh.test.ts
new file mode 100644
index 0000000..abfd9d8
--- /dev/null
+++ b/src/lib/timeline-refresh.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it, vi } from 'vitest'
+import { TimelineRefreshController } from './timeline-refresh'
+
+describe('TimelineRefreshController', () => {
+ it('refreshes only the currently registered route target', () => {
+ const controller = new TimelineRefreshController()
+ const first = vi.fn()
+ const second = vi.fn()
+ const unregisterFirst = controller.register(first)
+ const unregisterSecond = controller.register(second)
+
+ expect(controller.refresh()).toBe(true)
+ expect(first).not.toHaveBeenCalled()
+ expect(second).toHaveBeenCalledOnce()
+
+ // A stale route cleanup cannot remove the current route's target.
+ unregisterFirst()
+ expect(controller.refresh()).toBe(true)
+ expect(second).toHaveBeenCalledTimes(2)
+
+ unregisterSecond()
+ expect(controller.refresh()).toBe(false)
+ })
+})
diff --git a/src/lib/timeline-refresh.ts b/src/lib/timeline-refresh.ts
new file mode 100644
index 0000000..9fc4fb6
--- /dev/null
+++ b/src/lib/timeline-refresh.ts
@@ -0,0 +1,40 @@
+/**
+ * The active route's primary timeline refresh.
+ *
+ * App owns one controller and routes register while mounted. The guarded
+ * cleanup matters during navigation: an old route must not clear a newer
+ * route's callback if Svelte mounts the replacement before destroying it.
+ */
+import { getContext, setContext } from 'svelte'
+
+export type TimelineRefresh = () => void
+
+export class TimelineRefreshController {
+ private current: TimelineRefresh | null = null
+
+ register(refresh: TimelineRefresh): () => void {
+ this.current = refresh
+ return () => {
+ if (this.current === refresh) this.current = null
+ }
+ }
+
+ refresh(): boolean {
+ if (!this.current) return false
+ this.current()
+ return true
+ }
+}
+
+export const TIMELINE_REFRESH = Symbol('plspace.timeline-refresh')
+
+export function provideTimelineRefresh(): TimelineRefreshController {
+ const controller = new TimelineRefreshController()
+ setContext(TIMELINE_REFRESH, controller)
+ return controller
+}
+
+/** Routes remain independently renderable in tests without this context. */
+export function useTimelineRefresh(): TimelineRefreshController | null {
+ return getContext
(TIMELINE_REFRESH) ?? null
+}
diff --git a/src/routes/Home.svelte b/src/routes/Home.svelte
index 78a5d1f..3b8ad26 100644
--- a/src/routes/Home.svelte
+++ b/src/routes/Home.svelte
@@ -17,12 +17,14 @@
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html'
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
+ import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte'
import Composer from '$components/blog/Composer.svelte'
const { endpoints, session } = useAppServices()
+ const timelineRefresh = useTimelineRefresh()
let friendStatus = $state([])
let bulletins = $state([])
@@ -38,6 +40,7 @@
let friendStatusError = $state(null)
let bulletinError = $state(null)
let loadGeneration = 0
+ let primaryRefreshGeneration = 0
const me = $derived(session.me)
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
@@ -76,6 +79,13 @@
}
})
+ $effect(() => {
+ const host = session.host
+ const signedIn = session.signedIn
+ if (!timelineRefresh || !host) return
+ return timelineRefresh.register(() => void refreshPrimaryTimeline(signedIn, host))
+ })
+
async function load(signedIn: boolean, host = session.host, generation = ++loadGeneration): Promise {
loading = true
error = null
@@ -132,6 +142,46 @@
}
}
+ /** Refresh only the dashboard's visible primary timeline, not its sidebars. */
+ async function refreshPrimaryTimeline(signedIn: boolean, host: string): Promise {
+ const generation = ++primaryRefreshGeneration
+
+ if (signedIn) {
+ friendStatusError = null
+ try {
+ const page = await endpoints.fetchTimeline(session.api, 'home', { limit: 10 })
+ if (
+ generation === primaryRefreshGeneration &&
+ session.host === host &&
+ session.signedIn === signedIn
+ ) {
+ friendStatus = page.items
+ }
+ } catch (cause) {
+ if (generation === primaryRefreshGeneration && session.host === host) {
+ friendStatusError = messageOf(cause)
+ }
+ }
+ return
+ }
+
+ bulletinError = null
+ try {
+ const page = await endpoints.fetchTimeline(session.api, 'local', { limit: 10 })
+ if (
+ generation === primaryRefreshGeneration &&
+ session.host === host &&
+ session.signedIn === signedIn
+ ) {
+ bulletins = page.items
+ }
+ } catch (cause) {
+ if (generation === primaryRefreshGeneration && session.host === host) {
+ bulletinError = messageOf(cause)
+ }
+ }
+ }
+
function moodFor(status: Status): string {
return fallbackMood(status.account.id)
}
diff --git a/src/routes/Home.test.ts b/src/routes/Home.test.ts
new file mode 100644
index 0000000..b947386
--- /dev/null
+++ b/src/routes/Home.test.ts
@@ -0,0 +1,40 @@
+import { render, waitFor } from '@testing-library/svelte'
+import { describe, expect, it, vi } from 'vitest'
+import { APP_SERVICES } from '$lib/app-services'
+import { TIMELINE_REFRESH, TimelineRefreshController } from '$lib/timeline-refresh'
+import { account, session, testServices } from '$test/fixtures'
+import Home from './Home.svelte'
+
+describe('Home timeline refresh', () => {
+ it('refreshes Friend Status without reloading bulletins or sidebar lists', async () => {
+ const fetchTimeline = vi.fn().mockResolvedValue({ items: [], links: {} })
+ const fetchFollowing = vi.fn().mockResolvedValue({ items: [], links: {} })
+ const fetchNotifications = vi.fn().mockResolvedValue({ items: [], links: {} })
+ const services = testServices({
+ session: session({
+ token: 'token',
+ me: account(),
+ signedIn: true,
+ }),
+ endpoints: { fetchTimeline, fetchFollowing, fetchNotifications },
+ })
+ const refresh = new TimelineRefreshController()
+ const context = new Map()
+ context.set(APP_SERVICES, services)
+ context.set(TIMELINE_REFRESH, refresh)
+
+ render(Home, { context })
+
+ await waitFor(() => expect(fetchTimeline).toHaveBeenCalledTimes(2))
+ await waitFor(() => expect(fetchFollowing).toHaveBeenCalledOnce())
+ await waitFor(() => expect(fetchNotifications).toHaveBeenCalledOnce())
+
+ expect(refresh.refresh()).toBe(true)
+ await waitFor(() => expect(fetchTimeline).toHaveBeenCalledTimes(3))
+
+ expect(fetchTimeline.mock.calls.filter((call) => call[1] === 'home')).toHaveLength(2)
+ expect(fetchTimeline.mock.calls.filter((call) => call[1] === 'local')).toHaveLength(1)
+ expect(fetchFollowing).toHaveBeenCalledOnce()
+ expect(fetchNotifications).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/routes/Profile.svelte b/src/routes/Profile.svelte
index 79ea3eb..1b13957 100644
--- a/src/routes/Profile.svelte
+++ b/src/routes/Profile.svelte
@@ -14,6 +14,7 @@
import { useAppServices } from '$lib/app-services'
import { Feed } from '$lib/stores/feed.svelte'
import { profileCssFromFields } from '$lib/stores/theme.svelte'
+ import { useTimelineRefresh } from '$lib/timeline-refresh'
import {
buildProfileView,
displayNameOf,
@@ -42,6 +43,7 @@
let { acct, view = 'profile' }: Props = $props()
const { endpoints, session, theme } = useAppServices()
+ const timelineRefresh = useTimelineRefresh()
let account = $state(null)
let relationship = $state(null)
let loading = $state(true)
@@ -83,6 +85,13 @@
}
})
+ $effect(() => {
+ const currentView = view
+ const loaded = account !== null
+ if (!timelineRefresh || !loaded || currentView === 'friends') return
+ return timelineRefresh.register(() => void entries.reload())
+ })
+
async function load(
handle: string,
currentView: Props['view'],
diff --git a/src/routes/Profile.test.ts b/src/routes/Profile.test.ts
index c05ddb0..7485e20 100644
--- a/src/routes/Profile.test.ts
+++ b/src/routes/Profile.test.ts
@@ -2,6 +2,7 @@ import { act, render, waitFor } from '@testing-library/svelte'
import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import type { Account } from '$lib/api/types'
+import { TIMELINE_REFRESH, TimelineRefreshController } from '$lib/timeline-refresh'
import { account, deferred, session, status, testServices, theme } from '$test/fixtures'
import Profile from './Profile.svelte'
@@ -139,4 +140,31 @@ describe('Profile', () => {
expect(view.queryByText('A movie')).not.toBeInTheDocument()
expect(view.queryByText('A reposted picture')).not.toBeInTheDocument()
})
+
+ it('refreshes only the entry feed without reloading the profile', async () => {
+ const lookupAccount = vi.fn().mockResolvedValue(account())
+ const fetchAccountStatuses = vi.fn().mockResolvedValue({
+ items: [status({ content: 'Timeline note
' })],
+ links: {},
+ })
+ const services = testServices({
+ session: session(),
+ theme: theme(),
+ endpoints: { lookupAccount, fetchAccountStatuses },
+ })
+ const refresh = new TimelineRefreshController()
+ const context = new Map()
+ context.set(APP_SERVICES, services)
+ context.set(TIMELINE_REFRESH, refresh)
+ const view = render(Profile, {
+ props: { acct: 'alice', view: 'blog' },
+ context,
+ })
+
+ expect(await view.findByText('Timeline note')).toBeInTheDocument()
+ expect(refresh.refresh()).toBe(true)
+
+ await waitFor(() => expect(fetchAccountStatuses).toHaveBeenCalledTimes(2))
+ expect(lookupAccount).toHaveBeenCalledOnce()
+ })
})
diff --git a/src/routes/StatusPage.svelte b/src/routes/StatusPage.svelte
index 4dfa805..0a574f8 100644
--- a/src/routes/StatusPage.svelte
+++ b/src/routes/StatusPage.svelte
@@ -12,6 +12,7 @@
import { displayNameOf, profilePath } from '$lib/util/profile'
import { stampDate, isoDate } from '$lib/util/time'
import { toPlainText } from '$lib/util/html'
+ import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte'
@@ -25,6 +26,7 @@
let { id }: Props = $props()
const { endpoints, session } = useAppServices()
+ const timelineRefresh = useTimelineRefresh()
let status = $state(null)
let ancestors = $state([])
let descendants = $state([])
@@ -88,6 +90,17 @@
}
})
+ $effect(() => {
+ const currentId = id
+ const host = session.host
+ const loaded = status !== null
+ if (!timelineRefresh || !host || !loaded) return
+ return timelineRefresh.register(() => {
+ const generation = ++loadGeneration
+ void load(currentId, host, generation)
+ })
+ })
+
async function load(currentId: string, host: string, generation: number): Promise {
loading = true
error = null
diff --git a/src/routes/Timeline.svelte b/src/routes/Timeline.svelte
index 41e95d8..95b3591 100644
--- a/src/routes/Timeline.svelte
+++ b/src/routes/Timeline.svelte
@@ -9,6 +9,7 @@
import { useAppServices } from '$lib/app-services'
import { Feed } from '$lib/stores/feed.svelte'
import { instanceDomain } from '$lib/api/endpoints'
+ import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte'
import TabBar from '$components/common/TabBar.svelte'
import BlogList from '$components/blog/BlogList.svelte'
@@ -22,6 +23,7 @@
let { kind, tag }: Props = $props()
const { endpoints, session } = useAppServices()
+ const timelineRefresh = useTimelineRefresh()
let feed = $state>(new Feed(async () => ({ items: [], links: {} })))
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
@@ -61,6 +63,8 @@
document.title = `${title} | plspace`
})
})
+
+ $effect(() => timelineRefresh?.register(() => void feed.reload()))