public profile editing

This commit is contained in:
Moon.eth
2026-07-29 19:51:59 +09:00
parent 8f0d47b850
commit 17f385c1c3
9 changed files with 795 additions and 14 deletions
@@ -0,0 +1,370 @@
<script lang="ts">
/**
* Public-facing profile data. Account behavior and private preferences belong
* in a separate settings editor.
*/
import { untrack } from 'svelte'
import type { CredentialAccount } from '$lib/api/types'
import { useAppServices } from '$lib/app-services'
import { profileFieldLimits } from '$lib/stores/theme.svelte'
import { toPlainText } from '$lib/util/html'
interface EditableField {
id: number
name: string
value: string
}
type ImageMode = 'keep' | 'replace' | 'remove'
const { endpoints, session } = useAppServices()
let fieldSerial = 0
function rawFields(account: CredentialAccount | null): Array<{ name: string; value: string }> {
if (!account) return []
if (account.source?.fields) {
return account.source.fields.map(({ name, value }) => ({ name, value }))
}
return account.fields.map(({ name, value }) => ({ name, value: toPlainText(value) }))
}
function internalFields(account: CredentialAccount | null): Array<{ name: string; value: string }> {
return rawFields(account).filter((field) => field.name.trim().toLowerCase().startsWith('plspace:'))
}
function publicFields(account: CredentialAccount | null): EditableField[] {
return rawFields(account)
.filter((field) => !field.name.trim().toLowerCase().startsWith('plspace:'))
.map((field) => ({ id: ++fieldSerial, ...field }))
}
const initial = untrack(() => session.me)
let displayName = $state(initial?.display_name ?? '')
let note = $state(initial?.source?.note ?? toPlainText(initial?.note ?? ''))
let fields = $state<EditableField[]>(publicFields(initial))
let actorType = $state<'Person' | 'Service' | 'Group'>(
initial?.source?.pleroma?.actor_type ?? (initial?.bot ? 'Service' : 'Person'),
)
let birthday = $state(initial?.pleroma?.birthday ?? '')
let showBirthday = $state(Boolean(initial?.source?.pleroma?.show_birthday))
let avatarDescription = $state(
initial?.avatar_description ?? initial?.pleroma?.avatar_description ?? '',
)
let headerDescription = $state(
initial?.header_description ?? initial?.pleroma?.header_description ?? '',
)
let avatarMode = $state<ImageMode>('keep')
let headerMode = $state<ImageMode>('keep')
let backgroundMode = $state<ImageMode>('keep')
let avatarFile = $state<File | null>(null)
let headerFile = $state<File | null>(null)
let backgroundFile = $state<File | null>(null)
let busy = $state(false)
let saved = $state(false)
let error = $state<string | null>(null)
const isPleroma = $derived(Boolean(session.instance?.pleroma))
const limits = $derived(profileFieldLimits(session.instance))
const reservedFields = $derived(internalFields(session.me).length)
const availablePublicFields = $derived(Math.max(0, limits.maxFields - reservedFields))
const canAddField = $derived(fields.length < availablePublicFields)
const fieldsValid = $derived(
fields.filter((field) => field.name.trim()).length <= availablePublicFields &&
fields.every(
(field) =>
field.name.length <= limits.nameLength && field.value.length <= limits.valueLength,
),
)
const canSave = $derived(Boolean(displayName.trim()) && fieldsValid && !busy)
function addField(): void {
if (!canAddField) return
fields = [...fields, { id: ++fieldSerial, name: '', value: '' }]
}
function removeField(id: number): void {
fields = fields.filter((field) => field.id !== id)
}
function chooseImage(
kind: 'avatar' | 'header' | 'background',
event: Event,
): void {
const file = (event.currentTarget as HTMLInputElement).files?.[0] ?? null
if (!file) return
if (kind === 'avatar') {
avatarFile = file
avatarMode = 'replace'
} else if (kind === 'header') {
headerFile = file
headerMode = 'replace'
} else {
backgroundFile = file
backgroundMode = 'replace'
}
}
function imageValue(mode: ImageMode, file: File | null): File | '' | undefined {
if (mode === 'remove') return ''
if (mode === 'replace' && file) return file
return undefined
}
function resetFrom(account: CredentialAccount): void {
displayName = account.display_name
note = account.source?.note ?? toPlainText(account.note)
fields = publicFields(account)
actorType = account.source?.pleroma?.actor_type ?? (account.bot ? 'Service' : 'Person')
birthday = account.pleroma?.birthday ?? ''
showBirthday = Boolean(account.source?.pleroma?.show_birthday)
avatarDescription =
account.avatar_description ?? account.pleroma?.avatar_description ?? ''
headerDescription =
account.header_description ?? account.pleroma?.header_description ?? ''
avatarMode = 'keep'
headerMode = 'keep'
backgroundMode = 'keep'
avatarFile = null
headerFile = null
backgroundFile = null
}
async function save(event: SubmitEvent): Promise<void> {
event.preventDefault()
if (!session.me || !canSave) return
busy = true
saved = false
error = null
try {
// Read these at save time so a CSS edit made while this form is open is
// never overwritten by an older copy of the hidden storage fields.
const hidden = internalFields(session.me)
const visible = fields
.filter((field) => field.name.trim())
.map(({ name, value }) => ({ name: name.trim(), value }))
const updated = await endpoints.updatePublicProfile(session.api, {
displayName: displayName.trim(),
note,
fields: [...visible, ...hidden],
avatar: imageValue(avatarMode, avatarFile),
header: imageValue(headerMode, headerFile),
background: isPleroma ? imageValue(backgroundMode, backgroundFile) : undefined,
avatarDescription: isPleroma ? avatarDescription : undefined,
headerDescription: isPleroma ? headerDescription : undefined,
bot: isPleroma ? undefined : actorType === 'Service',
actorType: isPleroma ? actorType : undefined,
birthday: isPleroma ? birthday : undefined,
showBirthday: isPleroma ? showBirthday : undefined,
})
session.me = updated
resetFrom(updated)
saved = true
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Could not update your profile.'
} finally {
busy = false
}
}
</script>
{#if !session.signedIn || !session.me}
<p class="empty-note"><a href="#/login">Sign in</a> to edit your public profile.</p>
{:else}
<form class="profile-editor" onsubmit={save}>
<p class="notice">
Everything in this form is public-facing and may be federated to other servers.
</p>
{#if error}<p class="error-note" role="alert">{error}</p>{/if}
{#if saved}<p class="notice" role="status">Your public profile was updated.</p>{/if}
<div class="field">
<label class="field-label" for="profile-display-name">Display name</label>
<input
id="profile-display-name"
class="field-input"
type="text"
bind:value={displayName}
required
/>
</div>
<div class="field">
<label class="field-label" for="profile-bio">About me / bio</label>
<textarea id="profile-bio" class="field-input profile-editor-bio" bind:value={note} rows="7"></textarea>
<p class="field-hint">Your server may support plain text, Markdown or other formatting here.</p>
</div>
<fieldset class="profile-editor-images">
<legend>Profile images</legend>
<div class="profile-editor-image">
<img src={session.me.avatar} alt="Current avatar" />
<div>
<strong>Profile photo</strong>
<input
type="file"
accept="image/*"
aria-label="Choose a new profile photo"
onchange={(event) => chooseImage('avatar', event)}
/>
<button
type="button"
class="button button--small"
onclick={() => {
avatarMode = 'remove'
avatarFile = null
}}
>Remove photo</button>
{#if avatarMode === 'replace' && avatarFile}<span class="field-hint">{avatarFile.name}</span>{/if}
{#if avatarMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
</div>
</div>
<div class="profile-editor-image profile-editor-image--wide">
<img src={session.me.header} alt="Current banner" />
<div>
<strong>Profile banner</strong>
<input
type="file"
accept="image/*"
aria-label="Choose a new profile banner"
onchange={(event) => chooseImage('header', event)}
/>
<button
type="button"
class="button button--small"
onclick={() => {
headerMode = 'remove'
headerFile = null
}}
>Remove banner</button>
{#if headerMode === 'replace' && headerFile}<span class="field-hint">{headerFile.name}</span>{/if}
{#if headerMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
</div>
</div>
{#if isPleroma}
<div class="profile-editor-image profile-editor-image--wide">
{#if session.me.pleroma?.background_image}
<img src={session.me.pleroma.background_image} alt="Current profile background" />
{:else}
<span class="profile-editor-image-placeholder">No background</span>
{/if}
<div>
<strong>Profile background</strong>
<input
type="file"
accept="image/*"
aria-label="Choose a new profile background"
onchange={(event) => chooseImage('background', event)}
/>
<button
type="button"
class="button button--small"
onclick={() => {
backgroundMode = 'remove'
backgroundFile = null
}}
>Remove background</button>
{#if backgroundMode === 'replace' && backgroundFile}<span class="field-hint">{backgroundFile.name}</span>{/if}
{#if backgroundMode === 'remove'}<span class="field-hint">Will be removed when saved.</span>{/if}
</div>
</div>
<div class="field-row">
<label class="profile-editor-description">
<span class="field-label">Profile photo description</span>
<input class="field-input" type="text" bind:value={avatarDescription} />
</label>
<label class="profile-editor-description">
<span class="field-label">Banner description</span>
<input class="field-input" type="text" bind:value={headerDescription} />
</label>
</div>
{/if}
</fieldset>
<fieldset class="profile-editor-fields">
<legend>Profile details</legend>
<p class="field-hint">
These become the Interests and Details rows on your profile. Hidden
<code>plspace:</code> storage fields are preserved automatically.
</p>
{#each fields as field (field.id)}
<div class="profile-editor-field">
<label>
<span class="visually-hidden">Field name</span>
<input
type="text"
bind:value={field.name}
maxlength={limits.nameLength}
placeholder="Label, e.g. Music"
/>
</label>
<label>
<span class="visually-hidden">Field value</span>
<input
type="text"
bind:value={field.value}
maxlength={limits.valueLength}
placeholder="Value"
/>
</label>
<button
type="button"
class="button button--small"
aria-label={`Remove ${field.name || 'empty'} profile field`}
onclick={() => removeField(field.id)}
>Remove</button>
</div>
{/each}
<button type="button" class="button button--small" disabled={!canAddField} onclick={addField}>
Add profile detail
</button>
<span class="field-hint">{fields.length} of {availablePublicFields} public fields used.</span>
</fieldset>
<fieldset class="profile-editor-identity">
<legend>Public identity</legend>
{#if isPleroma}
<label class="field">
<span class="field-label">Account type</span>
<select bind:value={actorType}>
<option value="Person">Person</option>
<option value="Service">Bot</option>
<option value="Group">Group</option>
</select>
</label>
<div class="field-row">
<label>
<span class="field-label">Birthday</span>
<input type="date" bind:value={birthday} />
</label>
<label class="checkbox-field">
<input type="checkbox" bind:checked={showBirthday} />
Show my birthday publicly
</label>
</div>
{:else}
<label class="checkbox-field">
<input
type="checkbox"
checked={actorType === 'Service'}
onchange={(event) => (actorType = event.currentTarget.checked ? 'Service' : 'Person')}
/>
This is a bot account
</label>
{/if}
</fieldset>
<div class="field-row">
<button class="button button--primary" type="submit" disabled={!canSave}>
{busy ? 'Saving…' : 'Save public profile'}
</button>
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
</div>
</form>
{/if}
@@ -0,0 +1,138 @@
import { fireEvent, render, waitFor } from '@testing-library/svelte'
import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services'
import type { PublicProfileUpdate } from '$lib/api/endpoints'
import type { CredentialAccount } from '$lib/api/types'
import { account, session, testServices } from '$test/fixtures'
import PublicProfileEditor from './PublicProfileEditor.svelte'
function credential(): CredentialAccount {
const fields = [
{ name: 'Website', value: 'https://old.example' },
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
]
return {
...account({
display_name: 'Alice',
note: '<p>Old bio</p>',
avatar: 'https://media.example/avatar.png',
header: 'https://media.example/header.png',
fields,
pleroma: {
background_image: 'https://media.example/background.png',
birthday: '1999-01-01',
},
}),
source: {
note: 'Old bio',
fields,
privacy: 'public',
sensitive: false,
language: null,
pleroma: {
actor_type: 'Person',
show_birthday: false,
},
},
}
}
describe('PublicProfileEditor', () => {
it('edits public data and preserves hidden plspace fields with no backend', async () => {
const me = credential()
const updatePublicProfile = vi.fn(
async (_api, update: PublicProfileUpdate): Promise<CredentialAccount> => ({
...me,
display_name: update.displayName ?? me.display_name,
fields: update.fields ?? me.fields,
source: {
...me.source!,
note: update.note ?? me.source!.note,
fields: update.fields ?? me.source!.fields,
pleroma: {
...me.source!.pleroma,
actor_type: update.actorType,
show_birthday: update.showBirthday,
},
},
pleroma: {
...me.pleroma,
birthday: update.birthday,
},
}),
)
const services = testServices({
session: session({
token: 'token',
me,
signedIn: true,
instance: {
title: 'Pleroma',
version: '2.9.0',
configuration: {
accounts: {
max_profile_fields: 10,
profile_field_name_limit: 512,
profile_field_value_limit: 2048,
},
},
pleroma: {
metadata: {
fields_limits: { max_fields: 10, name_length: 512, value_length: 2048 },
},
},
},
}),
endpoints: { updatePublicProfile },
})
const view = render(PublicProfileEditor, {
context: new Map([[APP_SERVICES, services]]),
})
expect(view.queryByDisplayValue('plspace:css1')).not.toBeInTheDocument()
await fireEvent.input(view.getByLabelText('Display name'), {
target: { value: 'Alice Updated' },
})
await fireEvent.input(view.getByLabelText('About me / bio'), {
target: { value: 'A new public bio' },
})
await fireEvent.input(view.getByPlaceholderText('Value'), {
target: { value: 'https://new.example' },
})
await fireEvent.click(view.getByRole('button', { name: 'Add profile detail' }))
const labels = view.getAllByPlaceholderText('Label, e.g. Music')
const values = view.getAllByPlaceholderText('Value')
await fireEvent.input(labels[labels.length - 1], { target: { value: 'Music' } })
await fireEvent.input(values[values.length - 1], { target: { value: 'Synthpop' } })
await fireEvent.change(view.getByLabelText('Account type'), {
target: { value: 'Group' },
})
await fireEvent.input(view.getByLabelText('Birthday'), {
target: { value: '2000-02-03' },
})
await fireEvent.click(view.getByRole('checkbox', { name: 'Show my birthday publicly' }))
const avatar = new File(['new avatar'], 'new-avatar.png', { type: 'image/png' })
await fireEvent.change(view.getByLabelText('Choose a new profile photo'), {
target: { files: [avatar] },
})
await fireEvent.click(view.getByRole('button', { name: 'Save public profile' }))
await waitFor(() => expect(updatePublicProfile).toHaveBeenCalledOnce())
const sent = updatePublicProfile.mock.calls[0][1]
expect(sent).toMatchObject({
displayName: 'Alice Updated',
note: 'A new public bio',
avatar,
actorType: 'Group',
birthday: '2000-02-03',
showBirthday: true,
fields: [
{ name: 'Website', value: 'https://new.example' },
{ name: 'Music', value: 'Synthpop' },
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
],
})
expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument()
})
})