mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
improved component composition and added tests.
This commit is contained in:
Generated
+1173
-1
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -9,16 +9,22 @@
|
|||||||
"build": "svelte-check --tsconfig ./tsconfig.json && vite build",
|
"build": "svelte-check --tsconfig ./tsconfig.json && vite build",
|
||||||
"build:only": "vite build",
|
"build:only": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||||
|
"@testing-library/jest-dom": "^7.0.0",
|
||||||
|
"@testing-library/svelte": "^5.4.2",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"svelte": "^5.56.8",
|
"svelte": "^5.56.8",
|
||||||
"svelte-check": "^4.3.3",
|
"svelte-check": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.1.5"
|
"vite": "^8.1.5",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.12"
|
"dompurify": "^3.4.12"
|
||||||
|
|||||||
+8
-2
@@ -2,8 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Shell: restore the session, then render chrome plus whichever route matched.
|
* Shell: restore the session, then render chrome plus whichever route matched.
|
||||||
*/
|
*/
|
||||||
import { router } from '$lib/router.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
|
||||||
import SiteHeader from '$components/chrome/SiteHeader.svelte'
|
import SiteHeader from '$components/chrome/SiteHeader.svelte'
|
||||||
import SiteNav from '$components/chrome/SiteNav.svelte'
|
import SiteNav from '$components/chrome/SiteNav.svelte'
|
||||||
import SiteFooter from '$components/chrome/SiteFooter.svelte'
|
import SiteFooter from '$components/chrome/SiteFooter.svelte'
|
||||||
@@ -20,11 +19,15 @@
|
|||||||
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'
|
||||||
|
|
||||||
|
const { router, session } = useAppServices()
|
||||||
|
|
||||||
let booted = $state(false)
|
let booted = $state(false)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
let active = true
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const landing = await session.restore()
|
const landing = await session.restore()
|
||||||
|
if (!active) return
|
||||||
booted = true
|
booted = true
|
||||||
|
|
||||||
if (landing) {
|
if (landing) {
|
||||||
@@ -34,6 +37,9 @@
|
|||||||
router.replace('#/login')
|
router.replace('#/login')
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const route = $derived(router.current)
|
const route = $derived(router.current)
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
* a federated round trip is slow enough that waiting feels broken.
|
* a federated round trip is slow enough that waiting feels broken.
|
||||||
*/
|
*/
|
||||||
import type { Status } from '$lib/api/types'
|
import type { Status } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { favouriteStatus, reblogStatus, deleteStatus } from '$lib/api/endpoints'
|
|
||||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||||
import { renderDisplayName } from '$lib/util/html'
|
import { renderDisplayName } from '$lib/util/html'
|
||||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||||
@@ -36,6 +35,7 @@
|
|||||||
|
|
||||||
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
|
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, session } = useAppServices()
|
||||||
/** The status actually being displayed; a boost renders its target. */
|
/** The status actually being displayed; a boost renders its target. */
|
||||||
const entry = $derived(status.reblog ?? status)
|
const entry = $derived(status.reblog ?? status)
|
||||||
const booster = $derived(status.reblog ? status.account : null)
|
const booster = $derived(status.reblog ? status.account : null)
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
onupdate?.(applyLocal(status, { favourited: next, favourites_count: entry.favourites_count + (next ? 1 : -1) }))
|
onupdate?.(applyLocal(status, { favourited: next, favourites_count: entry.favourites_count + (next ? 1 : -1) }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await favouriteStatus(session.api, entry.id, next)
|
const updated = await endpoints.favouriteStatus(session.api, entry.id, next)
|
||||||
onupdate?.(rewrap(status, updated))
|
onupdate?.(rewrap(status, updated))
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
onupdate?.(status)
|
onupdate?.(status)
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) }))
|
onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await reblogStatus(session.api, entry.id, next)
|
const updated = await endpoints.reblogStatus(session.api, entry.id, next)
|
||||||
// Reblogging returns the *wrapper* status; unwrap to the original.
|
// Reblogging returns the *wrapper* status; unwrap to the original.
|
||||||
onupdate?.(rewrap(status, updated.reblog ?? updated))
|
onupdate?.(rewrap(status, updated.reblog ?? updated))
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
if (!confirm('Delete this entry? This cannot be undone.')) return
|
if (!confirm('Delete this entry? This cannot be undone.')) return
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
await deleteStatus(session.api, entry.id)
|
await endpoints.deleteStatus(session.api, entry.id)
|
||||||
ondelete?.(status.id)
|
ondelete?.(status.id)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
actionError = cause instanceof Error ? cause.message : 'Could not delete that.'
|
actionError = cause instanceof Error ? cause.message : 'Could not delete that.'
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
*/
|
*/
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { postStatus, uploadMedia } from '$lib/api/endpoints'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Set to reply to an existing entry. */
|
/** Set to reply to an existing entry. */
|
||||||
inReplyTo?: Status | null
|
inReplyTo?: Status | null
|
||||||
/** Prefilled body, e.g. the mentions of the entry being replied to. */
|
/** Prefilled body, e.g. the mentions of the entry being replied to. */
|
||||||
initialText?: string
|
initialText?: string
|
||||||
|
initialVisibility?: StatusVisibility
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
submitLabel?: string
|
submitLabel?: string
|
||||||
onposted?: (status: Status) => void
|
onposted?: (status: Status) => void
|
||||||
@@ -24,16 +24,20 @@
|
|||||||
let {
|
let {
|
||||||
inReplyTo = null,
|
inReplyTo = null,
|
||||||
initialText = '',
|
initialText = '',
|
||||||
|
initialVisibility = 'public',
|
||||||
placeholder = 'What are you up to?',
|
placeholder = 'What are you up to?',
|
||||||
submitLabel = 'Post Entry',
|
submitLabel = 'Post Entry',
|
||||||
onposted,
|
onposted,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, session } = useAppServices()
|
||||||
// Seeded once from the prop; afterwards the textarea owns the value.
|
// Seeded once from the prop; afterwards the textarea owns the value.
|
||||||
let text = $state(untrack(() => initialText))
|
let text = $state(untrack(() => initialText))
|
||||||
let warning = $state('')
|
let warning = $state('')
|
||||||
let showWarning = $state(false)
|
let showWarning = $state(false)
|
||||||
let visibility = $state<StatusVisibility>('public')
|
let visibility = $state<StatusVisibility>(
|
||||||
|
untrack(() => inReplyTo?.visibility ?? initialVisibility),
|
||||||
|
)
|
||||||
let attachments = $state<MediaAttachment[]>([])
|
let attachments = $state<MediaAttachment[]>([])
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
let uploading = $state(false)
|
let uploading = $state(false)
|
||||||
@@ -46,12 +50,6 @@
|
|||||||
!busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0),
|
!busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default replies to the visibility of what they answer, so a private thread
|
|
||||||
// doesn't accidentally get a public reply.
|
|
||||||
$effect(() => {
|
|
||||||
if (inReplyTo) visibility = inReplyTo.visibility
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onFiles(event: Event): Promise<void> {
|
async function onFiles(event: Event): Promise<void> {
|
||||||
const input = event.currentTarget as HTMLInputElement
|
const input = event.currentTarget as HTMLInputElement
|
||||||
const files = Array.from(input.files ?? [])
|
const files = Array.from(input.files ?? [])
|
||||||
@@ -61,7 +59,7 @@
|
|||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
for (const file of files.slice(0, maxAttachments - attachments.length)) {
|
for (const file of files.slice(0, maxAttachments - attachments.length)) {
|
||||||
const media = await uploadMedia(session.api, file)
|
const media = await endpoints.uploadMedia(session.api, file)
|
||||||
attachments = [...attachments, media]
|
attachments = [...attachments, media]
|
||||||
}
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
@@ -83,7 +81,7 @@
|
|||||||
busy = true
|
busy = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
const created = await postStatus(session.api, {
|
const created = await endpoints.postStatus(session.api, {
|
||||||
status: text,
|
status: text,
|
||||||
in_reply_to_id: inReplyTo?.id ?? null,
|
in_reply_to_id: inReplyTo?.id ?? null,
|
||||||
visibility,
|
visibility,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
|
import { account, session, status, testServices } from '$test/fixtures'
|
||||||
|
import Composer from './Composer.svelte'
|
||||||
|
|
||||||
|
describe('Composer', () => {
|
||||||
|
it('posts through an in-memory endpoint double', async () => {
|
||||||
|
const postStatus = vi.fn().mockResolvedValue(status())
|
||||||
|
const services = testServices({
|
||||||
|
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||||
|
endpoints: { postStatus },
|
||||||
|
})
|
||||||
|
const view = render(Composer, {
|
||||||
|
props: { initialText: 'A backend-free component test' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.click(view.getByRole('button', { name: 'Post Entry' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(postStatus).toHaveBeenCalledOnce())
|
||||||
|
expect(postStatus.mock.calls[0][1]).toMatchObject({
|
||||||
|
status: 'A backend-free component test',
|
||||||
|
visibility: 'public',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { instanceDomain } from '$lib/api/endpoints'
|
import { instanceDomain } from '$lib/api/endpoints'
|
||||||
import { profilePath } from '$lib/util/profile'
|
import { profilePath } from '$lib/util/profile'
|
||||||
|
|
||||||
|
const { session } = useAppServices()
|
||||||
|
|
||||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : null)
|
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : null)
|
||||||
const version = $derived(session.instance?.version ?? null)
|
const version = $derived(session.instance?.version ?? null)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
/**
|
/**
|
||||||
* The navy utility bar and the boxed logo strip beneath it.
|
* The navy utility bar and the boxed logo strip beneath it.
|
||||||
*/
|
*/
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { routeTo } from '$lib/router.svelte'
|
||||||
import { router, routeTo } from '$lib/router.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { instanceDomain } from '$lib/api/endpoints'
|
import { instanceDomain } from '$lib/api/endpoints'
|
||||||
// Imported rather than referenced by path so Vite fingerprints it and the
|
// Imported rather than referenced by path so Vite fingerprints it and the
|
||||||
// relative `base` still resolves when hosted from a subdirectory.
|
// relative `base` still resolves when hosted from a subdirectory.
|
||||||
import logoUrl from '../../assets/plspace-logo.webp'
|
import logoUrl from '../../assets/plspace-logo.webp'
|
||||||
|
|
||||||
|
const { router, session } = useAppServices()
|
||||||
|
|
||||||
let query = $state('')
|
let query = $state('')
|
||||||
|
|
||||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
* Entries that need a token disappear when browsing logged out rather than
|
* Entries that need a token disappear when browsing logged out rather than
|
||||||
* erroring on click.
|
* erroring on click.
|
||||||
*/
|
*/
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { router } from '$lib/router.svelte'
|
|
||||||
import { profilePath } from '$lib/util/profile'
|
import { profilePath } from '$lib/util/profile'
|
||||||
|
|
||||||
|
const { router, session } = useAppServices()
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
|
|||||||
@@ -34,6 +34,14 @@
|
|||||||
for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
|
for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
|
||||||
return hash % 360
|
return hash % 360
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A keyed list usually preserves account identity, but standalone usages can
|
||||||
|
// receive a different account. A failure from the previous URL must not hide
|
||||||
|
// the next account's valid image.
|
||||||
|
$effect(() => {
|
||||||
|
src
|
||||||
|
failed = false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet image()}
|
{#snippet image()}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
<p class="error-note" role="alert">
|
<p class="error-note" role="alert">
|
||||||
<strong class="error-note-title">Couldn’t load this list.</strong>
|
<strong class="error-note-title">Couldn’t load this list.</strong>
|
||||||
{feed.error}
|
{feed.error}
|
||||||
|
<button type="button" class="button button--small" onclick={() => void feed.reload()}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||||
import { renderDisplayName } from '$lib/util/html'
|
import { renderDisplayName } from '$lib/util/html'
|
||||||
import { relativeTime } from '$lib/util/time'
|
import { relativeTime } from '$lib/util/time'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
|
import Avatar from '../common/Avatar.svelte'
|
||||||
import RichText from '../common/RichText.svelte'
|
import RichText from '../common/RichText.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,18 +16,13 @@
|
|||||||
|
|
||||||
let { account, actions }: Props = $props()
|
let { account, actions }: Props = $props()
|
||||||
|
|
||||||
|
const { session } = useAppServices()
|
||||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li class="person-row" data-account={account.acct} data-bot={account.bot ? 'true' : 'false'}>
|
<li class="person-row" data-account={account.acct} data-bot={account.bot ? 'true' : 'false'}>
|
||||||
<a href={profilePath(account)} class="person-row-photo-link">
|
<a href={profilePath(account)} class="person-row-photo-link">
|
||||||
<img
|
<Avatar {account} plain class="person-row-photo" />
|
||||||
class="person-row-photo"
|
|
||||||
src={account.avatar_static || account.avatar}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="person-row-body">
|
<div class="person-row-body">
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
* rather than disappearing, so the box keeps its shape.
|
* rather than disappearing, so the box keeps its shape.
|
||||||
*/
|
*/
|
||||||
import type { Account, Relationship } from '$lib/api/types'
|
import type { Account, Relationship } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { blockAccount, followAccount, unblockAccount, unfollowAccount } from '$lib/api/endpoints'
|
|
||||||
import { displayNameOf } from '$lib/util/profile'
|
import { displayNameOf } from '$lib/util/profile'
|
||||||
import Module from '../common/Module.svelte'
|
import Module from '../common/Module.svelte'
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@
|
|||||||
|
|
||||||
let { account, relationship, onrelationship }: Props = $props()
|
let { account, relationship, onrelationship }: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, session } = useAppServices()
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
|
|
||||||
@@ -56,17 +56,19 @@
|
|||||||
|
|
||||||
function toggleFollow(): void {
|
function toggleFollow(): void {
|
||||||
if (blocking) {
|
if (blocking) {
|
||||||
void run(() => unblockAccount(session.api, account.id))
|
void run(() => endpoints.unblockAccount(session.api, account.id))
|
||||||
} else if (following || requested) {
|
} else if (following || requested) {
|
||||||
void run(() => unfollowAccount(session.api, account.id))
|
void run(() => endpoints.unfollowAccount(session.api, account.id))
|
||||||
} else {
|
} else {
|
||||||
void run(() => followAccount(session.api, account.id))
|
void run(() => endpoints.followAccount(session.api, account.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleBlock(): void {
|
function toggleBlock(): void {
|
||||||
void run(() =>
|
void run(() =>
|
||||||
blocking ? unblockAccount(session.api, account.id) : blockAccount(session.api, account.id),
|
blocking
|
||||||
|
? endpoints.unblockAccount(session.api, account.id)
|
||||||
|
: endpoints.blockAccount(session.api, account.id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<Module {title} flush>
|
<Module {title} flush>
|
||||||
<table class="data-table details-table">
|
<table class="data-table details-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each fields as field (field.name)}
|
{#each fields as field, index (`${field.name}:${index}`)}
|
||||||
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
|
<tr class="details-row" data-verified={field.verified ? 'true' : 'false'}>
|
||||||
<th class="data-table-label details-label" scope="row">{field.name}</th>
|
<th class="data-table-label details-label" scope="row">{field.name}</th>
|
||||||
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
|
<td class="data-table-value details-value" data-verified={field.verified ? 'true' : 'false'}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import type { Account } from '$lib/api/types'
|
import type { Account } from '$lib/api/types'
|
||||||
import { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
|
import { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
|
||||||
import Module from '../common/Module.svelte'
|
import Module from '../common/Module.svelte'
|
||||||
|
import Avatar from '../common/Avatar.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
@@ -66,13 +67,7 @@
|
|||||||
<li class="friend-card" data-account={friend.acct}>
|
<li class="friend-card" data-account={friend.acct}>
|
||||||
<a class="friend-card-link" href={profilePath(friend)}>
|
<a class="friend-card-link" href={profilePath(friend)}>
|
||||||
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
||||||
<img
|
<Avatar account={friend} plain size="friend" class="friend-card-photo" />
|
||||||
class="friend-card-photo"
|
|
||||||
src={friend.avatar_static || friend.avatar}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<Module {title} flush>
|
<Module {title} flush>
|
||||||
<table class="data-table interests-table">
|
<table class="data-table interests-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each interests as entry (entry.row)}
|
{#each interests as entry, index (`${entry.row}:${index}`)}
|
||||||
<tr class="interests-row" data-row={entry.row.toLowerCase()}>
|
<tr class="interests-row" data-row={entry.row.toLowerCase()}>
|
||||||
<th class="data-table-label interests-label" scope="row">{entry.row}</th>
|
<th class="data-table-label interests-label" scope="row">{entry.row}</th>
|
||||||
<td class="data-table-value interests-value">
|
<td class="data-table-value interests-value">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { displayNameOf, fullHandle } from '$lib/util/profile'
|
import { displayNameOf, fullHandle } from '$lib/util/profile'
|
||||||
import { renderDisplayName } from '$lib/util/html'
|
import { renderDisplayName } from '$lib/util/html'
|
||||||
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
|
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
profile: ProfileView
|
profile: ProfileView
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
let { profile }: Props = $props()
|
let { profile }: Props = $props()
|
||||||
|
|
||||||
|
const { session } = useAppServices()
|
||||||
const account = $derived(profile.account)
|
const account = $derived(profile.account)
|
||||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||||
const handle = $derived(fullHandle(account, session.host))
|
const handle = $derived(fullHandle(account, session.host))
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { render } from '@testing-library/svelte'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import DetailsTable from './DetailsTable.svelte'
|
||||||
|
import InterestsTable from './InterestsTable.svelte'
|
||||||
|
|
||||||
|
describe('profile tables', () => {
|
||||||
|
it('renders duplicate interest aliases without duplicate keyed-each failures', () => {
|
||||||
|
const view = render(InterestsTable, {
|
||||||
|
title: 'Interests',
|
||||||
|
interests: [
|
||||||
|
{ row: 'Music', value: 'Synthpop' },
|
||||||
|
{ row: 'Music', value: 'Shoegaze' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.getByText('Synthpop')).toBeInTheDocument()
|
||||||
|
expect(view.getByText('Shoegaze')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders repeated free-form field names', () => {
|
||||||
|
const view = render(DetailsTable, {
|
||||||
|
title: 'Details',
|
||||||
|
fields: [
|
||||||
|
{ name: 'Website', value: 'One', verified: false },
|
||||||
|
{ name: 'Website', value: 'Two', verified: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.getByText('One')).toBeInTheDocument()
|
||||||
|
expect(view.getByText('Two')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Runtime dependencies used by Svelte components.
|
||||||
|
*
|
||||||
|
* Components read these through context instead of importing backend functions
|
||||||
|
* and global stores directly. Production gets the real implementations by
|
||||||
|
* default; tests can replace only the pieces they exercise.
|
||||||
|
*/
|
||||||
|
import { getContext } from 'svelte'
|
||||||
|
import type { ApiClient } from './api/client'
|
||||||
|
import type { CredentialAccount, InstanceInfo } from './api/types'
|
||||||
|
import * as endpointImplementations from './api/endpoints'
|
||||||
|
import { router as defaultRouter, type RouteMatch } from './router.svelte'
|
||||||
|
import { session as defaultSession } from './stores/session.svelte'
|
||||||
|
import { theme as defaultTheme } from './stores/theme.svelte'
|
||||||
|
|
||||||
|
export interface SessionService {
|
||||||
|
host: string
|
||||||
|
token: string | null
|
||||||
|
me: CredentialAccount | null
|
||||||
|
instance: InstanceInfo | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
readonly api: ApiClient
|
||||||
|
readonly signedIn: boolean
|
||||||
|
readonly connected: boolean
|
||||||
|
restore(): Promise<string | null>
|
||||||
|
connect(host: string): Promise<void>
|
||||||
|
login(host: string, returnTo?: string): Promise<void>
|
||||||
|
logout(): Promise<void>
|
||||||
|
disconnect(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouterService {
|
||||||
|
current: RouteMatch
|
||||||
|
go(to: string): void
|
||||||
|
replace(to: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeService {
|
||||||
|
viewerCss: string
|
||||||
|
allowProfileCss: boolean
|
||||||
|
setViewerCss(css: string): void
|
||||||
|
setAllowProfileCss(allow: boolean): void
|
||||||
|
applyProfileCss(css: string | null | undefined): void
|
||||||
|
clearProfileCss(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppServices {
|
||||||
|
session: SessionService
|
||||||
|
router: RouterService
|
||||||
|
theme: ThemeService
|
||||||
|
endpoints: typeof endpointImplementations
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_SERVICES = Symbol('plspace.app-services')
|
||||||
|
|
||||||
|
export const defaultAppServices: AppServices = {
|
||||||
|
session: defaultSession,
|
||||||
|
router: defaultRouter,
|
||||||
|
theme: defaultTheme,
|
||||||
|
endpoints: endpointImplementations,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read dependencies supplied by a parent/test, falling back to production. */
|
||||||
|
export function useAppServices(): AppServices {
|
||||||
|
return getContext<AppServices | undefined>(APP_SERVICES) ?? defaultAppServices
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppServiceOverrides {
|
||||||
|
session?: SessionService
|
||||||
|
router?: RouterService
|
||||||
|
theme?: ThemeService
|
||||||
|
endpoints?: Partial<typeof endpointImplementations>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a complete service object from small test doubles. */
|
||||||
|
export function createAppServices(overrides: AppServiceOverrides = {}): AppServices {
|
||||||
|
return {
|
||||||
|
session: overrides.session ?? defaultAppServices.session,
|
||||||
|
router: overrides.router ?? defaultAppServices.router,
|
||||||
|
theme: overrides.theme ?? defaultAppServices.theme,
|
||||||
|
endpoints: { ...defaultAppServices.endpoints, ...overrides.endpoints },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,12 @@ function matchPattern(pattern: string, path: string): Record<string, string> | n
|
|||||||
if (prefixed) {
|
if (prefixed) {
|
||||||
const [, prefix, name] = prefixed
|
const [, prefix, name] = prefixed
|
||||||
if (prefix && !actual.startsWith(prefix)) return null
|
if (prefix && !actual.startsWith(prefix)) return null
|
||||||
const value = decodeURIComponent(actual.slice(prefix.length))
|
let value: string
|
||||||
|
try {
|
||||||
|
value = decodeURIComponent(actual.slice(prefix.length))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
params[name.slice(1)] = value
|
params[name.slice(1)] = value
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseHash } from './router.svelte'
|
||||||
|
|
||||||
|
describe('parseHash', () => {
|
||||||
|
it('decodes valid route parameters', () => {
|
||||||
|
expect(parseHash('#/@alice%40remote.test').params.acct).toBe('alice@remote.test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats malformed percent escapes as not found', () => {
|
||||||
|
expect(() => parseHash('#/@broken%ZZ')).not.toThrow()
|
||||||
|
expect(parseHash('#/@broken%ZZ').name).toBe('notfound')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,13 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { Account } from '$lib/api/types'
|
import type { Account } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { Feed } from '$lib/stores/feed.svelte'
|
import { Feed } from '$lib/stores/feed.svelte'
|
||||||
import { fetchDirectory, instanceDomain } from '$lib/api/endpoints'
|
import { instanceDomain } from '$lib/api/endpoints'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import PersonList from '$components/people/PersonList.svelte'
|
import PersonList from '$components/people/PersonList.svelte'
|
||||||
import { router } from '$lib/router.svelte'
|
|
||||||
|
|
||||||
|
const { endpoints, router, session } = useAppServices()
|
||||||
let order = $state<'active' | 'new'>('active')
|
let order = $state<'active' | 'new'>('active')
|
||||||
let localOnly = $state(true)
|
let localOnly = $state(true)
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
feed = new Feed<Account>(async (cursor) => {
|
feed = new Feed<Account>(async (cursor) => {
|
||||||
if (!cursor.max_id) offset = 0
|
if (!cursor.max_id) offset = 0
|
||||||
const limit = cursor.limit ?? 20
|
const limit = cursor.limit ?? 20
|
||||||
const items = await fetchDirectory(session.api, {
|
const items = await endpoints.fetchDirectory(session.api, {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
order: currentOrder,
|
order: currentOrder,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/** A full-page composer, reachable from the nav and from "Send Message". */
|
/** A full-page composer, reachable from the nav and from "Send Message". */
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { router } from '$lib/router.svelte'
|
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Composer from '$components/blog/Composer.svelte'
|
import Composer from '$components/blog/Composer.svelte'
|
||||||
|
|
||||||
@@ -12,6 +11,7 @@
|
|||||||
|
|
||||||
let { to }: Props = $props()
|
let { to }: Props = $props()
|
||||||
|
|
||||||
|
const { router, session } = useAppServices()
|
||||||
const prefill = $derived(to ? `@${to.replace(/^@/, '')} ` : '')
|
const prefill = $derived(to ? `@${to.replace(/^@/, '')} ` : '')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -26,12 +26,15 @@
|
|||||||
|
|
||||||
<div class="layout--single">
|
<div class="layout--single">
|
||||||
<Module title={to ? 'New message' : 'New entry'}>
|
<Module title={to ? 'New message' : 'New entry'}>
|
||||||
|
{#key to}
|
||||||
<Composer
|
<Composer
|
||||||
initialText={prefill}
|
initialText={prefill}
|
||||||
|
initialVisibility={to ? 'direct' : 'public'}
|
||||||
placeholder={to ? 'Say something…' : 'What are you up to?'}
|
placeholder={to ? 'Say something…' : 'What are you up to?'}
|
||||||
submitLabel={to ? 'Send' : 'Post Entry'}
|
submitLabel={to ? 'Send' : 'Post Entry'}
|
||||||
onposted={(status) => router.go(`#/blog/${status.id}`)}
|
onposted={(status) => router.go(`#/blog/${status.id}`)}
|
||||||
/>
|
/>
|
||||||
|
{/key}
|
||||||
</Module>
|
</Module>
|
||||||
|
|
||||||
{#if !session.signedIn}
|
{#if !session.signedIn}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { render } from '@testing-library/svelte'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
|
import { account, session, testServices } from '$test/fixtures'
|
||||||
|
import Compose from './Compose.svelte'
|
||||||
|
|
||||||
|
describe('Compose route', () => {
|
||||||
|
it('resets addressing and defaults to direct when the recipient changes', async () => {
|
||||||
|
const services = testServices({
|
||||||
|
session: session({ token: 'token', me: account(), signedIn: true }),
|
||||||
|
})
|
||||||
|
const view = render(Compose, {
|
||||||
|
props: { to: 'alice@example.test' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@alice@example.test ')
|
||||||
|
expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct')
|
||||||
|
|
||||||
|
await view.rerender({ to: 'bob@example.test' })
|
||||||
|
|
||||||
|
expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ')
|
||||||
|
expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct')
|
||||||
|
})
|
||||||
|
})
|
||||||
+44
-18
@@ -8,11 +8,8 @@
|
|||||||
* what its local timeline looks like right now.
|
* what its local timeline looks like right now.
|
||||||
*/
|
*/
|
||||||
import type { Account, Notification, Status } from '$lib/api/types'
|
import type { Account, Notification, Status } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import {
|
import {
|
||||||
fetchFollowing,
|
|
||||||
fetchNotifications,
|
|
||||||
fetchTimeline,
|
|
||||||
instanceDomain,
|
instanceDomain,
|
||||||
instanceStats,
|
instanceStats,
|
||||||
instanceThumbnail,
|
instanceThumbnail,
|
||||||
@@ -25,6 +22,8 @@
|
|||||||
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()
|
||||||
|
|
||||||
let friendStatus = $state<Status[]>([])
|
let friendStatus = $state<Status[]>([])
|
||||||
let bulletins = $state<Status[]>([])
|
let bulletins = $state<Status[]>([])
|
||||||
let following = $state<Account[]>([])
|
let following = $state<Account[]>([])
|
||||||
@@ -38,6 +37,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
|
||||||
|
|
||||||
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) : '')
|
||||||
@@ -65,13 +65,18 @@
|
|||||||
const host = session.host
|
const host = session.host
|
||||||
const signedIn = session.signedIn
|
const signedIn = session.signedIn
|
||||||
if (!host) {
|
if (!host) {
|
||||||
|
loadGeneration += 1
|
||||||
loading = false
|
loading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void load(signedIn)
|
const generation = ++loadGeneration
|
||||||
|
void load(signedIn, host, generation)
|
||||||
|
return () => {
|
||||||
|
if (generation === loadGeneration) loadGeneration += 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function load(signedIn: boolean): Promise<void> {
|
async function load(signedIn: boolean, host = session.host, generation = ++loadGeneration): Promise<void> {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
friendStatusError = null
|
friendStatusError = null
|
||||||
@@ -81,32 +86,49 @@
|
|||||||
// Everything here is independent; a failure in one shouldn't blank the page.
|
// Everything here is independent; a failure in one shouldn't blank the page.
|
||||||
const [statusPage, bulletinPage] = await Promise.all([
|
const [statusPage, bulletinPage] = await Promise.all([
|
||||||
signedIn
|
signedIn
|
||||||
? fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => {
|
? endpoints.fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => {
|
||||||
friendStatusError = messageOf(cause)
|
if (generation === loadGeneration) friendStatusError = messageOf(cause)
|
||||||
return { items: [], links: {} }
|
return { items: [], links: {} }
|
||||||
})
|
})
|
||||||
: Promise.resolve({ items: [], links: {} }),
|
: Promise.resolve({ items: [], links: {} }),
|
||||||
fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => {
|
endpoints.fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => {
|
||||||
bulletinError = messageOf(cause)
|
if (generation === loadGeneration) bulletinError = messageOf(cause)
|
||||||
return { items: [], links: {} }
|
return { items: [], links: {} }
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
if (generation !== loadGeneration || session.host !== host) return
|
||||||
friendStatus = statusPage.items
|
friendStatus = statusPage.items
|
||||||
bulletins = bulletinPage.items
|
bulletins = bulletinPage.items
|
||||||
|
|
||||||
if (signedIn && session.me) {
|
if (signedIn && session.me) {
|
||||||
void fetchFollowing(session.api, session.me.id, { limit: 12 })
|
const accountId = session.me.id
|
||||||
.then((page) => (following = page.items))
|
void endpoints
|
||||||
.catch(() => (following = []))
|
.fetchFollowing(session.api, accountId, { limit: 12 })
|
||||||
void fetchNotifications(session.api, { limit: 40 })
|
.then((page) => {
|
||||||
.then((page) => (notifications = page.items))
|
if (generation === loadGeneration) following = page.items
|
||||||
.catch(() => (notifications = []))
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (generation === loadGeneration) following = []
|
||||||
|
})
|
||||||
|
void endpoints
|
||||||
|
.fetchNotifications(session.api, { limit: 40 })
|
||||||
|
.then((page) => {
|
||||||
|
if (generation === loadGeneration) notifications = page.items
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (generation === loadGeneration) notifications = []
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
following = []
|
||||||
|
notifications = []
|
||||||
}
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (generation === loadGeneration) {
|
||||||
error = cause instanceof Error ? cause.message : 'Could not load your home page.'
|
error = cause instanceof Error ? cause.message : 'Could not load your home page.'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
if (generation === loadGeneration) loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +236,11 @@
|
|||||||
<div class="layout-column layout-column--main">
|
<div class="layout-column layout-column--main">
|
||||||
{#if session.signedIn}
|
{#if session.signedIn}
|
||||||
<Module title="Post a bulletin">
|
<Module title="Post a bulletin">
|
||||||
<Composer placeholder="What are you up to?" submitLabel="Post" onposted={() => void load(true)} />
|
<Composer
|
||||||
|
placeholder="What are you up to?"
|
||||||
|
submitLabel="Post"
|
||||||
|
onposted={() => void load(true)}
|
||||||
|
/>
|
||||||
</Module>
|
</Module>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
* server and redirects to its consent screen, while "just look around" only
|
* server and redirects to its consent screen, while "just look around" only
|
||||||
* needs the host and works on any server that allows anonymous API reads.
|
* needs the host and works on any server that allows anonymous API reads.
|
||||||
*/
|
*/
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { normalizeHost } from '$lib/api/client'
|
import { normalizeHost } from '$lib/api/client'
|
||||||
import { router } from '$lib/router.svelte'
|
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
|
|
||||||
|
const { router, session } = useAppServices()
|
||||||
|
|
||||||
let host = $state(session.host)
|
let host = $state(session.host)
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
|
|||||||
+11
-12
@@ -9,19 +9,14 @@
|
|||||||
*/
|
*/
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { Account, Notification } from '$lib/api/types'
|
import type { Account, Notification } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { Feed } from '$lib/stores/feed.svelte'
|
import { Feed } from '$lib/stores/feed.svelte'
|
||||||
import {
|
|
||||||
authorizeFollowRequest,
|
|
||||||
fetchFollowRequests,
|
|
||||||
fetchNotifications,
|
|
||||||
rejectFollowRequest,
|
|
||||||
} from '$lib/api/endpoints'
|
|
||||||
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile'
|
||||||
import { toPlainText } from '$lib/util/html'
|
import { toPlainText } from '$lib/util/html'
|
||||||
import { stampDate } from '$lib/util/time'
|
import { stampDate } from '$lib/util/time'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Pager from '$components/common/Pager.svelte'
|
import Pager from '$components/common/Pager.svelte'
|
||||||
|
import Avatar from '$components/common/Avatar.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folder?: string
|
folder?: string
|
||||||
@@ -29,6 +24,7 @@
|
|||||||
|
|
||||||
let { folder = 'inbox' }: Props = $props()
|
let { folder = 'inbox' }: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, session } = useAppServices()
|
||||||
interface Folder {
|
interface Folder {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@@ -73,11 +69,11 @@
|
|||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (key === 'requests') {
|
if (key === 'requests') {
|
||||||
requests = new Feed<Account>((cursor) => fetchFollowRequests(session.api, cursor))
|
requests = new Feed<Account>((cursor) => endpoints.fetchFollowRequests(session.api, cursor))
|
||||||
void requests.reload()
|
void requests.reload()
|
||||||
} else {
|
} else {
|
||||||
notifications = new Feed<Notification>((cursor) =>
|
notifications = new Feed<Notification>((cursor) =>
|
||||||
fetchNotifications(session.api, cursor, types.length > 0 ? types : undefined),
|
endpoints.fetchNotifications(session.api, cursor, types.length > 0 ? types : undefined),
|
||||||
)
|
)
|
||||||
void notifications.reload()
|
void notifications.reload()
|
||||||
}
|
}
|
||||||
@@ -90,7 +86,10 @@
|
|||||||
busyIds = { ...busyIds, [account.id]: true }
|
busyIds = { ...busyIds, [account.id]: true }
|
||||||
actionError = null
|
actionError = null
|
||||||
try {
|
try {
|
||||||
await (approve ? authorizeFollowRequest : rejectFollowRequest)(session.api, account.id)
|
await (approve ? endpoints.authorizeFollowRequest : endpoints.rejectFollowRequest)(
|
||||||
|
session.api,
|
||||||
|
account.id,
|
||||||
|
)
|
||||||
requests.remove(account.id)
|
requests.remove(account.id)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
actionError = cause instanceof Error ? cause.message : 'That didn’t work.'
|
actionError = cause instanceof Error ? cause.message : 'That didn’t work.'
|
||||||
@@ -156,7 +155,7 @@
|
|||||||
<tr class="mail-row" data-kind="follow_request" data-account={account.acct}>
|
<tr class="mail-row" data-kind="follow_request" data-account={account.acct}>
|
||||||
<td class="mail-table-from">
|
<td class="mail-table-from">
|
||||||
<a href={profilePath(account)}>
|
<a href={profilePath(account)}>
|
||||||
<img src={account.avatar_static || account.avatar} alt="" loading="lazy" />
|
<Avatar {account} plain />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -210,7 +209,7 @@
|
|||||||
<td class="mail-table-date">{stampDate(item.created_at)}</td>
|
<td class="mail-table-date">{stampDate(item.created_at)}</td>
|
||||||
<td class="mail-table-from">
|
<td class="mail-table-from">
|
||||||
<a href={profilePath(item.account)}>
|
<a href={profilePath(item.account)}>
|
||||||
<img src={item.account.avatar_static || item.account.avatar} alt="" loading="lazy" />
|
<Avatar account={item.account} plain />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="mail-table-subject">
|
<td class="mail-table-subject">
|
||||||
|
|||||||
+37
-27
@@ -11,15 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { Account, Relationship, Status } from '$lib/api/types'
|
import type { Account, Relationship, Status } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { Feed } from '$lib/stores/feed.svelte'
|
import { Feed } from '$lib/stores/feed.svelte'
|
||||||
import { theme, profileCssFromFields } from '$lib/stores/theme.svelte'
|
import { profileCssFromFields } from '$lib/stores/theme.svelte'
|
||||||
import {
|
|
||||||
fetchAccountStatuses,
|
|
||||||
fetchFollowers,
|
|
||||||
fetchRelationship,
|
|
||||||
lookupAccount,
|
|
||||||
} from '$lib/api/endpoints'
|
|
||||||
import {
|
import {
|
||||||
buildProfileView,
|
buildProfileView,
|
||||||
displayNameOf,
|
displayNameOf,
|
||||||
@@ -46,6 +40,7 @@
|
|||||||
|
|
||||||
let { acct, view = 'profile' }: Props = $props()
|
let { acct, view = 'profile' }: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, session, theme } = useAppServices()
|
||||||
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)
|
||||||
@@ -53,6 +48,7 @@
|
|||||||
|
|
||||||
let friends = $state<Account[]>([])
|
let friends = $state<Account[]>([])
|
||||||
let friendsLoading = $state(false)
|
let friendsLoading = $state(false)
|
||||||
|
let loadGeneration = 0
|
||||||
|
|
||||||
// Recreated whenever the account changes, so the feed never shows one
|
// Recreated whenever the account changes, so the feed never shows one
|
||||||
// person's entries under another's name.
|
// person's entries under another's name.
|
||||||
@@ -77,20 +73,32 @@
|
|||||||
const host = session.host
|
const host = session.host
|
||||||
if (!host) return
|
if (!host) return
|
||||||
|
|
||||||
untrack(() => void load(handle, currentView))
|
const generation = ++loadGeneration
|
||||||
|
theme.clearProfileCss()
|
||||||
|
untrack(() => void load(handle, currentView, host, generation))
|
||||||
|
return () => {
|
||||||
|
if (generation === loadGeneration) loadGeneration += 1
|
||||||
|
theme.clearProfileCss()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function load(handle: string, currentView: Props['view']): Promise<void> {
|
async function load(
|
||||||
|
handle: string,
|
||||||
|
currentView: Props['view'],
|
||||||
|
host: string,
|
||||||
|
generation: number,
|
||||||
|
): Promise<void> {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
account = null
|
account = null
|
||||||
relationship = null
|
relationship = null
|
||||||
friends = []
|
friends = []
|
||||||
|
friendsLoading = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const found = await lookupAccount(session.api, handle)
|
const found = await endpoints.lookupAccount(session.api, handle)
|
||||||
// A newer navigation won the race.
|
// A newer navigation won the race.
|
||||||
if (acct !== handle) return
|
if (generation !== loadGeneration || acct !== handle || session.host !== host) return
|
||||||
|
|
||||||
account = found
|
account = found
|
||||||
document.title = `${displayNameOf(found)} | plspace`
|
document.title = `${displayNameOf(found)} | plspace`
|
||||||
@@ -99,7 +107,7 @@
|
|||||||
|
|
||||||
entries = new Feed<Status>(
|
entries = new Feed<Status>(
|
||||||
(cursor) =>
|
(cursor) =>
|
||||||
fetchAccountStatuses(session.api, found.id, cursor, {
|
endpoints.fetchAccountStatuses(session.api, found.id, cursor, {
|
||||||
// The profile page mirrors "Latest Blog Entries": top-level posts.
|
// The profile page mirrors "Latest Blog Entries": top-level posts.
|
||||||
exclude_replies: currentView !== 'blog',
|
exclude_replies: currentView !== 'blog',
|
||||||
}),
|
}),
|
||||||
@@ -107,43 +115,45 @@
|
|||||||
)
|
)
|
||||||
void entries.reload()
|
void entries.reload()
|
||||||
|
|
||||||
void loadFriends(found, currentView)
|
void loadFriends(found, currentView, generation)
|
||||||
void loadRelationship(found)
|
void loadRelationship(found, generation)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (acct !== handle) return
|
if (generation !== loadGeneration) return
|
||||||
error = cause instanceof Error ? cause.message : 'Could not load that profile.'
|
error = cause instanceof Error ? cause.message : 'Could not load that profile.'
|
||||||
} finally {
|
} finally {
|
||||||
if (acct === handle) loading = false
|
if (generation === loadGeneration) loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFriends(target: Account, currentView: Props['view']): Promise<void> {
|
async function loadFriends(
|
||||||
|
target: Account,
|
||||||
|
currentView: Props['view'],
|
||||||
|
generation: number,
|
||||||
|
): Promise<void> {
|
||||||
if (followersHidden(target)) return
|
if (followersHidden(target)) return
|
||||||
friendsLoading = true
|
friendsLoading = true
|
||||||
try {
|
try {
|
||||||
const page = await fetchFollowers(session.api, target.id, {
|
const page = await endpoints.fetchFollowers(session.api, target.id, {
|
||||||
limit: currentView === 'friends' ? 40 : FRIEND_PREVIEW,
|
limit: currentView === 'friends' ? 40 : FRIEND_PREVIEW,
|
||||||
})
|
})
|
||||||
if (account?.id === target.id) friends = page.items
|
if (generation === loadGeneration && account?.id === target.id) friends = page.items
|
||||||
} catch {
|
} catch {
|
||||||
// Hidden or unavailable follower lists are normal; the count still shows.
|
// Hidden or unavailable follower lists are normal; the count still shows.
|
||||||
if (account?.id === target.id) friends = []
|
if (generation === loadGeneration && account?.id === target.id) friends = []
|
||||||
} finally {
|
} finally {
|
||||||
friendsLoading = false
|
if (generation === loadGeneration) friendsLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRelationship(target: Account): Promise<void> {
|
async function loadRelationship(target: Account, generation: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const found = await fetchRelationship(session.api, target.id)
|
const found = await endpoints.fetchRelationship(session.api, target.id)
|
||||||
if (account?.id === target.id) relationship = found
|
if (generation === loadGeneration && account?.id === target.id) relationship = found
|
||||||
} catch {
|
} catch {
|
||||||
/* relationships need auth; absence is fine */
|
/* relationships need auth; absence is fine */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => () => theme.clearProfileCss())
|
|
||||||
|
|
||||||
/** One line of an entry, for the headline list on the profile page. */
|
/** One line of an entry, for the headline list on the profile page. */
|
||||||
function teaserFor(entry: Status): string {
|
function teaserFor(entry: Status): string {
|
||||||
const text = toPlainText(entry.spoiler_text || entry.content)
|
const text = toPlainText(entry.spoiler_text || entry.content)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { act, render } 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 { account, deferred, session, testServices, theme } from '$test/fixtures'
|
||||||
|
import Profile from './Profile.svelte'
|
||||||
|
|
||||||
|
describe('Profile', () => {
|
||||||
|
it('does not apply profile CSS after it has unmounted', async () => {
|
||||||
|
const lookup = deferred<Account>()
|
||||||
|
const applyProfileCss = vi.fn()
|
||||||
|
const services = testServices({
|
||||||
|
session: session(),
|
||||||
|
theme: theme({ applyProfileCss }),
|
||||||
|
endpoints: { lookupAccount: vi.fn(() => lookup.promise) },
|
||||||
|
})
|
||||||
|
const view = render(Profile, {
|
||||||
|
props: { acct: 'alice' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
await act(() => lookup.resolve(account({ fields: [{ name: 'css', value: '.x { color: red }' }] })))
|
||||||
|
|
||||||
|
expect(applyProfileCss).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,9 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { SearchResults } from '$lib/api/types'
|
import type { SearchResults } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { search } from '$lib/api/endpoints'
|
import { routeTo } from '$lib/router.svelte'
|
||||||
import { router, routeTo } from '$lib/router.svelte'
|
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import PersonRow from '$components/people/PersonRow.svelte'
|
import PersonRow from '$components/people/PersonRow.svelte'
|
||||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||||
@@ -21,32 +20,43 @@
|
|||||||
|
|
||||||
let { q = '' }: Props = $props()
|
let { q = '' }: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, router, session } = useAppServices()
|
||||||
// Seeded from the route, then kept in sync by the effect below.
|
// Seeded from the route, then kept in sync by the effect below.
|
||||||
let input = $state(untrack(() => q))
|
let input = $state(untrack(() => q))
|
||||||
let results = $state<SearchResults | null>(null)
|
let results = $state<SearchResults | null>(null)
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const query = q
|
const query = q
|
||||||
input = query
|
input = query
|
||||||
if (!query || !session.host) {
|
if (!query || !session.host) {
|
||||||
|
generation += 1
|
||||||
results = null
|
results = null
|
||||||
|
loading = false
|
||||||
|
error = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
untrack(() => void run(query))
|
untrack(() => void run(query))
|
||||||
|
return () => {
|
||||||
|
generation += 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function run(query: string): Promise<void> {
|
async function run(query: string): Promise<void> {
|
||||||
|
const currentGeneration = ++generation
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
const found = await search(session.api, query, { limit: 20 })
|
const found = await endpoints.search(session.api, query, { limit: 20 })
|
||||||
if (q === query) results = found
|
if (currentGeneration === generation) results = found
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (q === query) error = cause instanceof Error ? cause.message : 'Search failed.'
|
if (currentGeneration === generation) {
|
||||||
|
error = cause instanceof Error ? cause.message : 'Search failed.'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (q === query) loading = false
|
if (currentGeneration === generation) loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { render } from '@testing-library/svelte'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { APP_SERVICES } from '$lib/app-services'
|
||||||
|
import type { SearchResults } from '$lib/api/types'
|
||||||
|
import { deferred, session, testServices } from '$test/fixtures'
|
||||||
|
import Search from './Search.svelte'
|
||||||
|
|
||||||
|
describe('Search', () => {
|
||||||
|
it('returns to the empty state when a pending query is cleared', async () => {
|
||||||
|
const pending = deferred<SearchResults>()
|
||||||
|
const services = testServices({
|
||||||
|
session: session(),
|
||||||
|
endpoints: { search: vi.fn(() => pending.promise) },
|
||||||
|
})
|
||||||
|
const view = render(Search, {
|
||||||
|
props: { q: 'alice' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await view.findByText('Searching…')).toBeInTheDocument()
|
||||||
|
await view.rerender({ q: '' })
|
||||||
|
|
||||||
|
expect(view.getByText('Enter something to search for.')).toBeInTheDocument()
|
||||||
|
expect(view.queryByText('Searching…')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,13 +7,15 @@
|
|||||||
* locally), and whether the CSS other people publish on their profiles is
|
* locally), and whether the CSS other people publish on their profiles is
|
||||||
* honoured when you visit them.
|
* honoured when you visit them.
|
||||||
*/
|
*/
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { theme, CSS_FIELD_NAMES, PROFILE_SCOPE } from '$lib/stores/theme.svelte'
|
import { CSS_FIELD_NAMES, PROFILE_SCOPE } from '$lib/stores/theme.svelte'
|
||||||
import { PRESETS, EXAMPLE_CSS } from '$lib/themes'
|
import { PRESETS, EXAMPLE_CSS } from '$lib/themes'
|
||||||
import { instanceDomain } from '$lib/api/endpoints'
|
import { instanceDomain } from '$lib/api/endpoints'
|
||||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
|
|
||||||
|
const { session, theme } = useAppServices()
|
||||||
|
|
||||||
let draft = $state(theme.viewerCss)
|
let draft = $state(theme.viewerCss)
|
||||||
let saved = $state(false)
|
let saved = $state(false)
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { Status } from '$lib/api/types'
|
import type { Status } from '$lib/api/types'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { fetchContext, fetchStatus } from '$lib/api/endpoints'
|
|
||||||
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'
|
||||||
@@ -25,11 +24,13 @@
|
|||||||
|
|
||||||
let { id }: Props = $props()
|
let { id }: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, session } = useAppServices()
|
||||||
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[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
|
let loadGeneration = 0
|
||||||
|
|
||||||
interface ThreadedReply {
|
interface ThreadedReply {
|
||||||
status: Status
|
status: Status
|
||||||
@@ -80,28 +81,32 @@
|
|||||||
const currentId = id
|
const currentId = id
|
||||||
const host = session.host
|
const host = session.host
|
||||||
if (!host) return
|
if (!host) return
|
||||||
untrack(() => void load(currentId))
|
const generation = ++loadGeneration
|
||||||
|
untrack(() => void load(currentId, host, generation))
|
||||||
|
return () => {
|
||||||
|
if (generation === loadGeneration) loadGeneration += 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function load(currentId: string): Promise<void> {
|
async function load(currentId: string, host: string, generation: number): Promise<void> {
|
||||||
loading = true
|
loading = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
const [entry, context] = await Promise.all([
|
const [entry, context] = await Promise.all([
|
||||||
fetchStatus(session.api, currentId),
|
endpoints.fetchStatus(session.api, currentId),
|
||||||
fetchContext(session.api, currentId).catch(() => ({ ancestors: [], descendants: [] })),
|
endpoints.fetchContext(session.api, currentId).catch(() => ({ ancestors: [], descendants: [] })),
|
||||||
])
|
])
|
||||||
if (id !== currentId) return
|
if (generation !== loadGeneration || id !== currentId || session.host !== host) return
|
||||||
|
|
||||||
status = entry
|
status = entry
|
||||||
ancestors = context.ancestors
|
ancestors = context.ancestors
|
||||||
descendants = context.descendants
|
descendants = context.descendants
|
||||||
document.title = `${toPlainText(entry.content).slice(0, 60)} | plspace`
|
document.title = `${toPlainText(entry.content).slice(0, 60)} | plspace`
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (id !== currentId) return
|
if (generation !== loadGeneration) return
|
||||||
error = cause instanceof Error ? cause.message : 'Could not load that entry.'
|
error = cause instanceof Error ? cause.message : 'Could not load that entry.'
|
||||||
} finally {
|
} finally {
|
||||||
if (id === currentId) loading = false
|
if (generation === loadGeneration) loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
import type { Status } from '$lib/api/types'
|
import type { Status } from '$lib/api/types'
|
||||||
import type { TimelineKind } from '$lib/api/endpoints'
|
import type { TimelineKind } from '$lib/api/endpoints'
|
||||||
import { session } from '$lib/stores/session.svelte'
|
import { useAppServices } from '$lib/app-services'
|
||||||
import { Feed } from '$lib/stores/feed.svelte'
|
import { Feed } from '$lib/stores/feed.svelte'
|
||||||
import { fetchTimeline, instanceDomain } from '$lib/api/endpoints'
|
import { instanceDomain } from '$lib/api/endpoints'
|
||||||
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'
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
let { kind, tag }: Props = $props()
|
let { kind, tag }: Props = $props()
|
||||||
|
|
||||||
|
const { endpoints, session } = useAppServices()
|
||||||
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) : '')
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
// Home needs a token; fall back rather than showing a 401.
|
// Home needs a token; fall back rather than showing a 401.
|
||||||
const resolved: TimelineKind = currentKind === 'home' && !authed ? 'local' : currentKind
|
const resolved: TimelineKind = currentKind === 'home' && !authed ? 'local' : currentKind
|
||||||
feed = new Feed<Status>((cursor) =>
|
feed = new Feed<Status>((cursor) =>
|
||||||
fetchTimeline(session.api, resolved, cursor, { tag: currentTag }),
|
endpoints.fetchTimeline(session.api, resolved, cursor, { tag: currentTag }),
|
||||||
)
|
)
|
||||||
void feed.reload()
|
void feed.reload()
|
||||||
document.title = `${title} | plspace`
|
document.title = `${title} | plspace`
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Component tests
|
||||||
|
|
||||||
|
Components obtain session, router, theme, and endpoint dependencies from
|
||||||
|
`$lib/app-services`. Production falls back to the real services. Tests render a
|
||||||
|
component with a context map and replace only the dependencies involved:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const services = testServices({
|
||||||
|
session: session({ signedIn: true }),
|
||||||
|
endpoints: { search: vi.fn().mockResolvedValue(results) },
|
||||||
|
})
|
||||||
|
|
||||||
|
render(Search, {
|
||||||
|
props: { q: 'alice' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps component tests deterministic and prevents accidental network
|
||||||
|
requests without requiring module-level mocks. Any endpoint that was not
|
||||||
|
explicitly supplied throws immediately with its name.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { ApiClient } from '$lib/api/client'
|
||||||
|
import type { Account, Status } from '$lib/api/types'
|
||||||
|
import {
|
||||||
|
createAppServices,
|
||||||
|
type AppServices,
|
||||||
|
type AppServiceOverrides,
|
||||||
|
type SessionService,
|
||||||
|
type ThemeService,
|
||||||
|
} from '$lib/app-services'
|
||||||
|
|
||||||
|
export function account(overrides: Partial<Account> = {}): Account {
|
||||||
|
return {
|
||||||
|
id: 'account-1',
|
||||||
|
username: 'alice',
|
||||||
|
acct: 'alice',
|
||||||
|
display_name: 'Alice',
|
||||||
|
note: '',
|
||||||
|
url: 'https://example.test/@alice',
|
||||||
|
avatar: '',
|
||||||
|
avatar_static: '',
|
||||||
|
header: '',
|
||||||
|
header_static: '',
|
||||||
|
locked: false,
|
||||||
|
created_at: '2020-01-01T00:00:00.000Z',
|
||||||
|
statuses_count: 0,
|
||||||
|
followers_count: 0,
|
||||||
|
following_count: 0,
|
||||||
|
fields: [],
|
||||||
|
emojis: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function status(overrides: Partial<Status> = {}): Status {
|
||||||
|
return {
|
||||||
|
id: 'status-1',
|
||||||
|
uri: 'https://example.test/statuses/1',
|
||||||
|
created_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
account: account(),
|
||||||
|
content: '<p>Hello</p>',
|
||||||
|
visibility: 'public',
|
||||||
|
sensitive: false,
|
||||||
|
spoiler_text: '',
|
||||||
|
in_reply_to_id: null,
|
||||||
|
in_reply_to_account_id: null,
|
||||||
|
replies_count: 0,
|
||||||
|
reblogs_count: 0,
|
||||||
|
favourites_count: 0,
|
||||||
|
media_attachments: [],
|
||||||
|
mentions: [],
|
||||||
|
tags: [],
|
||||||
|
emojis: [],
|
||||||
|
reblog: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function session(overrides: Partial<SessionService> = {}): SessionService {
|
||||||
|
const host = overrides.host ?? 'example.test'
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
token: null,
|
||||||
|
me: null,
|
||||||
|
instance: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
api: new ApiClient(host),
|
||||||
|
signedIn: false,
|
||||||
|
connected: Boolean(host),
|
||||||
|
restore: async () => null,
|
||||||
|
connect: async () => {},
|
||||||
|
login: async () => {},
|
||||||
|
logout: async () => {},
|
||||||
|
disconnect: () => {},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function theme(overrides: Partial<ThemeService> = {}): ThemeService {
|
||||||
|
return {
|
||||||
|
viewerCss: '',
|
||||||
|
allowProfileCss: true,
|
||||||
|
setViewerCss: () => {},
|
||||||
|
setAllowProfileCss: () => {},
|
||||||
|
applyProfileCss: () => {},
|
||||||
|
clearProfileCss: () => {},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test services fail fast on any endpoint that was not explicitly faked. This
|
||||||
|
* turns an accidental network request into a local, descriptive test failure.
|
||||||
|
*/
|
||||||
|
export function testServices(overrides: AppServiceOverrides = {}): AppServices {
|
||||||
|
const services = createAppServices({
|
||||||
|
...overrides,
|
||||||
|
session: overrides.session ?? session(),
|
||||||
|
})
|
||||||
|
const configured = overrides.endpoints ?? {}
|
||||||
|
services.endpoints = new Proxy(services.endpoints, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (property in configured) return Reflect.get(target, property, receiver)
|
||||||
|
const value = Reflect.get(target, property, receiver)
|
||||||
|
if (typeof value !== 'function') return value
|
||||||
|
return () => {
|
||||||
|
throw new Error(`Test attempted an unfaked endpoint: ${String(property)}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return services
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deferred<T>(): {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve(value: T): void
|
||||||
|
reject(cause: unknown): void
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (cause: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
const memoryStorage: Storage = {
|
||||||
|
get length() {
|
||||||
|
return values.size
|
||||||
|
},
|
||||||
|
clear: () => values.clear(),
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
key: (index) => [...values.keys()][index] ?? null,
|
||||||
|
removeItem: (key) => {
|
||||||
|
values.delete(key)
|
||||||
|
},
|
||||||
|
setItem: (key, value) => {
|
||||||
|
values.set(key, String(value))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: memoryStorage,
|
||||||
|
})
|
||||||
+2
-1
@@ -20,7 +20,8 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"$lib/*": ["src/lib/*"],
|
"$lib/*": ["src/lib/*"],
|
||||||
"$components/*": ["src/components/*"],
|
"$components/*": ["src/components/*"],
|
||||||
"$routes/*": ["src/routes/*"]
|
"$routes/*": ["src/routes/*"],
|
||||||
|
"$test/*": ["src/test/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"],
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"],
|
||||||
|
|||||||
+8
-2
@@ -1,21 +1,27 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vitest/config'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import { svelteTesting } from '@testing-library/svelte/vite'
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
// Fully static output: the app talks to a Mastodon/Pleroma server directly from
|
// Fully static output: the app talks to a Mastodon/Pleroma server directly from
|
||||||
// the browser, so `dist/` can be dropped on any static host (or file://-ish CDN).
|
// the browser, so `dist/` can be dropped on any static host (or file://-ish CDN).
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [svelte()],
|
plugins: [svelte(), svelteTesting()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
$lib: fileURLToPath(new URL('./src/lib', import.meta.url)),
|
$lib: fileURLToPath(new URL('./src/lib', import.meta.url)),
|
||||||
$components: fileURLToPath(new URL('./src/components', import.meta.url)),
|
$components: fileURLToPath(new URL('./src/components', import.meta.url)),
|
||||||
$routes: fileURLToPath(new URL('./src/routes', import.meta.url)),
|
$routes: fileURLToPath(new URL('./src/routes', import.meta.url)),
|
||||||
|
$test: fileURLToPath(new URL('./src/test', import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
cssCodeSplit: false,
|
cssCodeSplit: false,
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user