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 Compose from '$routes/Compose.svelte'
|
||||||
import NotFound from '$routes/NotFound.svelte'
|
import NotFound from '$routes/NotFound.svelte'
|
||||||
import type { TimelineKind } from '$lib/api/endpoints'
|
import type { TimelineKind } from '$lib/api/endpoints'
|
||||||
|
import { provideTimelineRefresh } from '$lib/timeline-refresh'
|
||||||
|
|
||||||
const { router, session } = useAppServices()
|
const { router, session } = useAppServices()
|
||||||
|
const timelineRefresh = provideTimelineRefresh()
|
||||||
|
|
||||||
let booted = $state(false)
|
let booted = $state(false)
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@
|
|||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<RefreshHotkey />
|
<RefreshHotkey onrefresh={() => timelineRefresh.refresh()} />
|
||||||
|
|
||||||
<div class="site">
|
<div class="site">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* navigating, and editable controls are ignored so composing text is safe.
|
* navigating, and editable controls are ignored so composing text is safe.
|
||||||
*/
|
*/
|
||||||
interface Props {
|
interface Props {
|
||||||
onrefresh?: () => void
|
/** Returning false means this page has no refreshable timeline. */
|
||||||
|
onrefresh?: () => void | boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onrefresh = () => window.location.reload() }: Props = $props()
|
let { onrefresh = () => window.location.reload() }: Props = $props()
|
||||||
@@ -35,8 +36,8 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault()
|
const handled = onrefresh()
|
||||||
onrefresh()
|
if (handled !== false) event.preventDefault()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -33,4 +33,18 @@ describe('RefreshHotkey', () => {
|
|||||||
|
|
||||||
expect(onrefresh).not.toHaveBeenCalled()
|
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 { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
|
||||||
import { toPlainText } from '$lib/util/html'
|
import { toPlainText } from '$lib/util/html'
|
||||||
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
|
import { relativeTime, shortDate, stampDate } from '$lib/util/time'
|
||||||
|
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Avatar from '$components/common/Avatar.svelte'
|
import Avatar from '$components/common/Avatar.svelte'
|
||||||
import RichText from '$components/common/RichText.svelte'
|
import RichText from '$components/common/RichText.svelte'
|
||||||
import Composer from '$components/blog/Composer.svelte'
|
import Composer from '$components/blog/Composer.svelte'
|
||||||
|
|
||||||
const { endpoints, session } = useAppServices()
|
const { endpoints, session } = useAppServices()
|
||||||
|
const timelineRefresh = useTimelineRefresh()
|
||||||
|
|
||||||
let friendStatus = $state<Status[]>([])
|
let friendStatus = $state<Status[]>([])
|
||||||
let bulletins = $state<Status[]>([])
|
let bulletins = $state<Status[]>([])
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
let friendStatusError = $state<string | null>(null)
|
let friendStatusError = $state<string | null>(null)
|
||||||
let bulletinError = $state<string | null>(null)
|
let bulletinError = $state<string | null>(null)
|
||||||
let loadGeneration = 0
|
let loadGeneration = 0
|
||||||
|
let primaryRefreshGeneration = 0
|
||||||
|
|
||||||
const me = $derived(session.me)
|
const me = $derived(session.me)
|
||||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
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> {
|
async function load(signedIn: boolean, host = session.host, generation = ++loadGeneration): Promise<void> {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
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 {
|
function moodFor(status: Status): string {
|
||||||
return fallbackMood(status.account.id)
|
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 { useAppServices } from '$lib/app-services'
|
||||||
import { Feed } from '$lib/stores/feed.svelte'
|
import { Feed } from '$lib/stores/feed.svelte'
|
||||||
import { profileCssFromFields } from '$lib/stores/theme.svelte'
|
import { profileCssFromFields } from '$lib/stores/theme.svelte'
|
||||||
|
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||||
import {
|
import {
|
||||||
buildProfileView,
|
buildProfileView,
|
||||||
displayNameOf,
|
displayNameOf,
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
let { acct, view = 'profile' }: Props = $props()
|
let { acct, view = 'profile' }: Props = $props()
|
||||||
|
|
||||||
const { endpoints, session, theme } = useAppServices()
|
const { endpoints, session, theme } = useAppServices()
|
||||||
|
const timelineRefresh = useTimelineRefresh()
|
||||||
let account = $state<Account | null>(null)
|
let account = $state<Account | null>(null)
|
||||||
let relationship = $state<Relationship | null>(null)
|
let relationship = $state<Relationship | null>(null)
|
||||||
let loading = $state(true)
|
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(
|
async function load(
|
||||||
handle: string,
|
handle: string,
|
||||||
currentView: Props['view'],
|
currentView: Props['view'],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { act, render, waitFor } from '@testing-library/svelte'
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { APP_SERVICES } from '$lib/app-services'
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
import type { Account } from '$lib/api/types'
|
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 { account, deferred, session, status, testServices, theme } from '$test/fixtures'
|
||||||
import Profile from './Profile.svelte'
|
import Profile from './Profile.svelte'
|
||||||
|
|
||||||
@@ -139,4 +140,31 @@ describe('Profile', () => {
|
|||||||
expect(view.queryByText('A movie')).not.toBeInTheDocument()
|
expect(view.queryByText('A movie')).not.toBeInTheDocument()
|
||||||
expect(view.queryByText('A reposted picture')).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 { displayNameOf, profilePath } from '$lib/util/profile'
|
||||||
import { stampDate, isoDate } from '$lib/util/time'
|
import { stampDate, isoDate } from '$lib/util/time'
|
||||||
import { toPlainText } from '$lib/util/html'
|
import { toPlainText } from '$lib/util/html'
|
||||||
|
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Avatar from '$components/common/Avatar.svelte'
|
import Avatar from '$components/common/Avatar.svelte'
|
||||||
import RichText from '$components/common/RichText.svelte'
|
import RichText from '$components/common/RichText.svelte'
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
let { id }: Props = $props()
|
let { id }: Props = $props()
|
||||||
|
|
||||||
const { endpoints, session } = useAppServices()
|
const { endpoints, session } = useAppServices()
|
||||||
|
const timelineRefresh = useTimelineRefresh()
|
||||||
let status = $state<Status | null>(null)
|
let status = $state<Status | null>(null)
|
||||||
let ancestors = $state<Status[]>([])
|
let ancestors = $state<Status[]>([])
|
||||||
let descendants = $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> {
|
async function load(currentId: string, host: string, generation: number): Promise<void> {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { useAppServices } from '$lib/app-services'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { Feed } from '$lib/stores/feed.svelte'
|
import { Feed } from '$lib/stores/feed.svelte'
|
||||||
import { instanceDomain } from '$lib/api/endpoints'
|
import { instanceDomain } from '$lib/api/endpoints'
|
||||||
|
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import TabBar from '$components/common/TabBar.svelte'
|
import TabBar from '$components/common/TabBar.svelte'
|
||||||
import BlogList from '$components/blog/BlogList.svelte'
|
import BlogList from '$components/blog/BlogList.svelte'
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
let { kind, tag }: Props = $props()
|
let { kind, tag }: Props = $props()
|
||||||
|
|
||||||
const { endpoints, session } = useAppServices()
|
const { endpoints, session } = useAppServices()
|
||||||
|
const timelineRefresh = useTimelineRefresh()
|
||||||
let feed = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
|
let feed = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
|
||||||
|
|
||||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
document.title = `${title} | plspace`
|
document.title = `${title} | plspace`
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => timelineRefresh?.register(() => void feed.reload()))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>
|
||||||
|
|||||||
Reference in New Issue
Block a user