mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
improved component composition and added tests.
This commit is contained in:
@@ -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.'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { profilePath } from '$lib/util/profile'
|
||||
|
||||
const { session } = useAppServices()
|
||||
|
||||
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : null)
|
||||
const version = $derived(session.instance?.version ?? null)
|
||||
</script>
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
/**
|
||||
* The navy utility bar and the boxed logo strip beneath it.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router, routeTo } from '$lib/router.svelte'
|
||||
import { routeTo } from '$lib/router.svelte'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
// Imported rather than referenced by path so Vite fingerprints it and the
|
||||
// relative `base` still resolves when hosted from a subdirectory.
|
||||
import logoUrl from '../../assets/plspace-logo.webp'
|
||||
|
||||
const { router, session } = useAppServices()
|
||||
|
||||
let query = $state('')
|
||||
|
||||
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
|
||||
* erroring on click.
|
||||
*/
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { router } from '$lib/router.svelte'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { profilePath } from '$lib/util/profile'
|
||||
|
||||
const { router, session } = useAppServices()
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
|
||||
@@ -34,6 +34,14 @@
|
||||
for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0
|
||||
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>
|
||||
|
||||
{#snippet image()}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
<p class="error-note" role="alert">
|
||||
<strong class="error-note-title">Couldn’t load this list.</strong>
|
||||
{feed.error}
|
||||
<button type="button" class="button button--small" onclick={() => void feed.reload()}>
|
||||
Try again
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
@@ -15,18 +16,13 @@
|
||||
|
||||
let { account, actions }: Props = $props()
|
||||
|
||||
const { session } = useAppServices()
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
</script>
|
||||
|
||||
<li class="person-row" data-account={account.acct} data-bot={account.bot ? 'true' : 'false'}>
|
||||
<a href={profilePath(account)} class="person-row-photo-link">
|
||||
<img
|
||||
class="person-row-photo"
|
||||
src={account.avatar_static || account.avatar}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<Avatar {account} plain class="person-row-photo" />
|
||||
</a>
|
||||
|
||||
<div class="person-row-body">
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
* rather than disappearing, so the box keeps its shape.
|
||||
*/
|
||||
import type { Account, Relationship } from '$lib/api/types'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { blockAccount, followAccount, unblockAccount, unfollowAccount } from '$lib/api/endpoints'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import { displayNameOf } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
|
||||
@@ -20,6 +19,7 @@
|
||||
|
||||
let { account, relationship, onrelationship }: Props = $props()
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
@@ -56,17 +56,19 @@
|
||||
|
||||
function toggleFollow(): void {
|
||||
if (blocking) {
|
||||
void run(() => unblockAccount(session.api, account.id))
|
||||
void run(() => endpoints.unblockAccount(session.api, account.id))
|
||||
} else if (following || requested) {
|
||||
void run(() => unfollowAccount(session.api, account.id))
|
||||
void run(() => endpoints.unfollowAccount(session.api, account.id))
|
||||
} else {
|
||||
void run(() => followAccount(session.api, account.id))
|
||||
void run(() => endpoints.followAccount(session.api, account.id))
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBlock(): void {
|
||||
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>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<Module {title} flush>
|
||||
<table class="data-table details-table">
|
||||
<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'}>
|
||||
<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'}>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { Account } from '$lib/api/types'
|
||||
import { displayNameOf, formatCount, profilePath } from '$lib/util/profile'
|
||||
import Module from '../common/Module.svelte'
|
||||
import Avatar from '../common/Avatar.svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -66,13 +67,7 @@
|
||||
<li class="friend-card" data-account={friend.acct}>
|
||||
<a class="friend-card-link" href={profilePath(friend)}>
|
||||
<span class="friend-card-name">{displayNameOf(friend)}</span>
|
||||
<img
|
||||
class="friend-card-photo"
|
||||
src={friend.avatar_static || friend.avatar}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<Avatar account={friend} plain size="friend" class="friend-card-photo" />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<Module {title} flush>
|
||||
<table class="data-table interests-table">
|
||||
<tbody>
|
||||
{#each interests as entry (entry.row)}
|
||||
{#each interests as entry, index (`${entry.row}:${index}`)}
|
||||
<tr class="interests-row" data-row={entry.row.toLowerCase()}>
|
||||
<th class="data-table-label interests-label" scope="row">{entry.row}</th>
|
||||
<td class="data-table-value interests-value">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { displayNameOf, fullHandle } from '$lib/util/profile'
|
||||
import { renderDisplayName } from '$lib/util/html'
|
||||
import { relativeTime, shortDate, yearsSince } from '$lib/util/time'
|
||||
import { session } from '$lib/stores/session.svelte'
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
|
||||
interface Props {
|
||||
profile: ProfileView
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
let { profile }: Props = $props()
|
||||
|
||||
const { session } = useAppServices()
|
||||
const account = $derived(profile.account)
|
||||
const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user