mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
more targeted refresh
This commit is contained in:
+3
-1
@@ -19,8 +19,10 @@
|
||||
import Compose from '$routes/Compose.svelte'
|
||||
import NotFound from '$routes/NotFound.svelte'
|
||||
import type { TimelineKind } from '$lib/api/endpoints'
|
||||
import { provideTimelineRefresh } from '$lib/timeline-refresh'
|
||||
|
||||
const { router, session } = useAppServices()
|
||||
const timelineRefresh = provideTimelineRefresh()
|
||||
|
||||
let booted = $state(false)
|
||||
|
||||
@@ -57,7 +59,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<RefreshHotkey />
|
||||
<RefreshHotkey onrefresh={() => timelineRefresh.refresh()} />
|
||||
|
||||
<div class="site">
|
||||
<SiteHeader />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<TimelineRefreshController | undefined>(TIMELINE_REFRESH) ?? null
|
||||
}
|
||||
@@ -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<Status[]>([])
|
||||
let bulletins = $state<Status[]>([])
|
||||
@@ -38,6 +40,7 @@
|
||||
let friendStatusError = $state<string | null>(null)
|
||||
let bulletinError = $state<string | null>(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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<Account | null>(null)
|
||||
let relationship = $state<Relationship | null>(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'],
|
||||
|
||||
@@ -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: '<p>Timeline note</p>' })],
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Status | null>(null)
|
||||
let ancestors = $state<Status[]>([])
|
||||
let descendants = $state<Status[]>([])
|
||||
@@ -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<void> {
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
@@ -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<Feed<Status>>(new Feed<Status>(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()))
|
||||
</script>
|
||||
|
||||
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
||||
|
||||
Reference in New Issue
Block a user