improved component composition and added tests.

This commit is contained in:
Moon.eth
2026-07-29 10:30:28 +09:00
parent 95f9d73681
commit 264ae17a8d
39 changed files with 1811 additions and 147 deletions
+5 -5
View File
@@ -11,8 +11,7 @@
* a federated round trip is slow enough that waiting feels broken.
*/
import type { Status } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { favouriteStatus, reblogStatus, deleteStatus } from '$lib/api/endpoints'
import { useAppServices } from '$lib/app-services'
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
import { renderDisplayName } from '$lib/util/html'
import { isoDate, longDate, stampDate } from '$lib/util/time'
@@ -36,6 +35,7 @@
let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props()
const { endpoints, session } = useAppServices()
/** The status actually being displayed; a boost renders its target. */
const entry = $derived(status.reblog ?? status)
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) }))
try {
const updated = await favouriteStatus(session.api, entry.id, next)
const updated = await endpoints.favouriteStatus(session.api, entry.id, next)
onupdate?.(rewrap(status, updated))
} catch (cause) {
onupdate?.(status)
@@ -92,7 +92,7 @@
onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) }))
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.
onupdate?.(rewrap(status, updated.reblog ?? updated))
} catch (cause) {
@@ -108,7 +108,7 @@
if (!confirm('Delete this entry? This cannot be undone.')) return
busy = true
try {
await deleteStatus(session.api, entry.id)
await endpoints.deleteStatus(session.api, entry.id)
ondelete?.(status.id)
} catch (cause) {
actionError = cause instanceof Error ? cause.message : 'Could not delete that.'
+9 -11
View File
@@ -8,14 +8,14 @@
*/
import { untrack } from 'svelte'
import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types'
import { session } from '$lib/stores/session.svelte'
import { postStatus, uploadMedia } from '$lib/api/endpoints'
import { useAppServices } from '$lib/app-services'
interface Props {
/** Set to reply to an existing entry. */
inReplyTo?: Status | null
/** Prefilled body, e.g. the mentions of the entry being replied to. */
initialText?: string
initialVisibility?: StatusVisibility
placeholder?: string
submitLabel?: string
onposted?: (status: Status) => void
@@ -24,16 +24,20 @@
let {
inReplyTo = null,
initialText = '',
initialVisibility = 'public',
placeholder = 'What are you up to?',
submitLabel = 'Post Entry',
onposted,
}: Props = $props()
const { endpoints, session } = useAppServices()
// Seeded once from the prop; afterwards the textarea owns the value.
let text = $state(untrack(() => initialText))
let warning = $state('')
let showWarning = $state(false)
let visibility = $state<StatusVisibility>('public')
let visibility = $state<StatusVisibility>(
untrack(() => inReplyTo?.visibility ?? initialVisibility),
)
let attachments = $state<MediaAttachment[]>([])
let busy = $state(false)
let uploading = $state(false)
@@ -46,12 +50,6 @@
!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> {
const input = event.currentTarget as HTMLInputElement
const files = Array.from(input.files ?? [])
@@ -61,7 +59,7 @@
error = null
try {
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]
}
} catch (cause) {
@@ -83,7 +81,7 @@
busy = true
error = null
try {
const created = await postStatus(session.api, {
const created = await endpoints.postStatus(session.api, {
status: text,
in_reply_to_id: inReplyTo?.id ?? null,
visibility,
+27
View File
@@ -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',
})
})
})