mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
public profile editing
This commit is contained in:
@@ -188,10 +188,23 @@
|
|||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function composerShortcuts(form: HTMLFormElement): { destroy(): void } {
|
||||||
|
function keydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (canPost) form.requestSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('keydown', keydown)
|
||||||
|
return {
|
||||||
|
destroy: () => form.removeEventListener('keydown', keydown),
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.signedIn}
|
{#if session.signedIn}
|
||||||
<form class="composer" onsubmit={submit}>
|
<form class="composer" onsubmit={submit} use:composerShortcuts>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error-note" role="alert">{error}</p>
|
<p class="error-note" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -329,7 +342,12 @@
|
|||||||
|
|
||||||
<span class="composer-counter" data-over={remaining < 0 ? 'true' : 'false'}>{remaining}</span>
|
<span class="composer-counter" data-over={remaining < 0 ? 'true' : 'false'}>{remaining}</span>
|
||||||
|
|
||||||
<button class="button button--primary" type="submit" disabled={!canPost}>
|
<button
|
||||||
|
class="button button--primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!canPost}
|
||||||
|
title="Submit (Command+Enter or Ctrl+Enter)"
|
||||||
|
>
|
||||||
{busy ? 'Posting…' : submitLabel}
|
{busy ? 'Posting…' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,4 +79,27 @@ describe('Composer', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Command+Enter', { metaKey: true }],
|
||||||
|
['Ctrl+Enter', { ctrlKey: true }],
|
||||||
|
])('submits with %s from the focused compose field', async (_label, modifier) => {
|
||||||
|
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: 'Keyboard-posted entry' },
|
||||||
|
context: new Map([[APP_SERVICES, services]]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await fireEvent.keyDown(view.getByRole('textbox', { name: 'Entry text' }), {
|
||||||
|
key: 'Enter',
|
||||||
|
...modifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => expect(postStatus).toHaveBeenCalledOnce())
|
||||||
|
expect(postStatus.mock.calls[0][1].status).toBe('Keyboard-posted entry')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiClient } from './client'
|
import { ApiClient } from './client'
|
||||||
import { postStatus, updateProfileFields, votePoll } from './endpoints'
|
import { postStatus, updateProfileFields, updatePublicProfile, votePoll } from './endpoints'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
@@ -39,6 +39,56 @@ describe('updateProfileFields', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('updatePublicProfile', () => {
|
||||||
|
it('sends public text, images, fields and Pleroma identity data as multipart form data', async () => {
|
||||||
|
const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' })
|
||||||
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
const form = init?.body as FormData
|
||||||
|
expect(init?.method).toBe('PATCH')
|
||||||
|
expect(form.get('display_name')).toBe('Alice Example')
|
||||||
|
expect(form.get('note')).toBe('My public bio')
|
||||||
|
expect(form.get('avatar')).toBe(avatar)
|
||||||
|
expect(form.get('header')).toBe('')
|
||||||
|
expect(form.get('actor_type')).toBe('Group')
|
||||||
|
expect(form.get('birthday')).toBe('2000-01-02')
|
||||||
|
expect(form.get('show_birthday')).toBe('true')
|
||||||
|
expect(form.get('fields_attributes[0][name]')).toBe('Website')
|
||||||
|
expect(form.get('fields_attributes[0][value]')).toBe('https://example.test')
|
||||||
|
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await updatePublicProfile(new ApiClient('example.test', 'token'), {
|
||||||
|
displayName: 'Alice Example',
|
||||||
|
note: 'My public bio',
|
||||||
|
avatar,
|
||||||
|
header: '',
|
||||||
|
actorType: 'Group',
|
||||||
|
birthday: '2000-01-02',
|
||||||
|
showBirthday: true,
|
||||||
|
fields: [{ name: 'Website', value: 'https://example.test' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends an empty sentinel row when all public fields are cleared', async () => {
|
||||||
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
const form = init?.body as FormData
|
||||||
|
expect(form.get('fields_attributes[0][name]')).toBe('')
|
||||||
|
expect(form.get('fields_attributes[0][value]')).toBe('')
|
||||||
|
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
await updatePublicProfile(new ApiClient('example.test', 'token'), { fields: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('poll endpoints', () => {
|
describe('poll endpoints', () => {
|
||||||
it('submits selected poll option indexes as JSON', async () => {
|
it('submits selected poll option indexes as JSON', async () => {
|
||||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
|||||||
+58
-10
@@ -86,6 +86,63 @@ export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
|
|||||||
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
|
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicProfileUpdate {
|
||||||
|
displayName?: string
|
||||||
|
note?: string
|
||||||
|
fields?: Array<{ name: string; value: string }>
|
||||||
|
avatar?: File | '' | undefined
|
||||||
|
header?: File | '' | undefined
|
||||||
|
background?: File | '' | undefined
|
||||||
|
avatarDescription?: string
|
||||||
|
headerDescription?: string
|
||||||
|
bot?: boolean
|
||||||
|
actorType?: 'Person' | 'Service' | 'Group'
|
||||||
|
birthday?: string
|
||||||
|
showBirthday?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update only public-facing account profile data through the shared API. */
|
||||||
|
export async function updatePublicProfile(
|
||||||
|
api: ApiClient,
|
||||||
|
update: PublicProfileUpdate,
|
||||||
|
): Promise<CredentialAccount> {
|
||||||
|
const form = new FormData()
|
||||||
|
if (update.displayName !== undefined) form.set('display_name', update.displayName)
|
||||||
|
if (update.note !== undefined) form.set('note', update.note)
|
||||||
|
if (update.avatar !== undefined) form.set('avatar', update.avatar)
|
||||||
|
if (update.header !== undefined) form.set('header', update.header)
|
||||||
|
if (update.background !== undefined) form.set('pleroma_background_image', update.background)
|
||||||
|
if (update.avatarDescription !== undefined) {
|
||||||
|
form.set('avatar_description', update.avatarDescription)
|
||||||
|
}
|
||||||
|
if (update.headerDescription !== undefined) {
|
||||||
|
form.set('header_description', update.headerDescription)
|
||||||
|
}
|
||||||
|
if (update.bot !== undefined) form.set('bot', String(update.bot))
|
||||||
|
if (update.actorType !== undefined) form.set('actor_type', update.actorType)
|
||||||
|
if (update.birthday !== undefined) form.set('birthday', update.birthday)
|
||||||
|
if (update.showBirthday !== undefined) form.set('show_birthday', String(update.showBirthday))
|
||||||
|
// An omitted collection means "leave fields unchanged". To explicitly clear
|
||||||
|
// every field, submit one empty row; Pleroma and Mastodon both discard it
|
||||||
|
// while still recognizing that fields_attributes was present.
|
||||||
|
const submittedFields =
|
||||||
|
update.fields === undefined
|
||||||
|
? undefined
|
||||||
|
: update.fields.length > 0
|
||||||
|
? update.fields
|
||||||
|
: [{ name: '', value: '' }]
|
||||||
|
submittedFields?.forEach((field, index) => {
|
||||||
|
form.append(`fields_attributes[${index}][name]`, field.name)
|
||||||
|
form.append(`fields_attributes[${index}][value]`, field.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data } = await api.raw<CredentialAccount>('/api/v1/accounts/update_credentials', {
|
||||||
|
method: 'PATCH',
|
||||||
|
form,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the signed-in account's profile fields while leaving every other
|
* Replace the signed-in account's profile fields while leaving every other
|
||||||
* credential untouched. Mastodon and Pleroma both accept this indexed
|
* credential untouched. Mastodon and Pleroma both accept this indexed
|
||||||
@@ -95,16 +152,7 @@ export async function updateProfileFields(
|
|||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
fields: Array<{ name: string; value: string }>,
|
fields: Array<{ name: string; value: string }>,
|
||||||
): Promise<CredentialAccount> {
|
): Promise<CredentialAccount> {
|
||||||
const form = new FormData()
|
return updatePublicProfile(api, { fields })
|
||||||
fields.forEach((field, index) => {
|
|
||||||
form.append(`fields_attributes[${index}][name]`, field.name)
|
|
||||||
form.append(`fields_attributes[${index}][value]`, field.value)
|
|
||||||
})
|
|
||||||
const { data } = await api.raw<CredentialAccount>('/api/v1/accounts/update_credentials', {
|
|
||||||
method: 'PATCH',
|
|
||||||
form,
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
|
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ export interface Account {
|
|||||||
uri?: string
|
uri?: string
|
||||||
avatar: string
|
avatar: string
|
||||||
avatar_static: string
|
avatar_static: string
|
||||||
|
avatar_description?: string | null
|
||||||
header: string
|
header: string
|
||||||
header_static: string
|
header_static: string
|
||||||
|
header_description?: string | null
|
||||||
locked: boolean
|
locked: boolean
|
||||||
bot?: boolean
|
bot?: boolean
|
||||||
group?: boolean
|
group?: boolean
|
||||||
@@ -84,6 +86,9 @@ export interface Account {
|
|||||||
relationship?: Relationship
|
relationship?: Relationship
|
||||||
/** Some deployments expose the user's own profile CSS here. */
|
/** Some deployments expose the user's own profile CSS here. */
|
||||||
background_color?: string | null
|
background_color?: string | null
|
||||||
|
birthday?: string | null
|
||||||
|
avatar_description?: string | null
|
||||||
|
header_description?: string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +100,11 @@ export interface CredentialAccount extends Account {
|
|||||||
sensitive: boolean
|
sensitive: boolean
|
||||||
language: string | null
|
language: string | null
|
||||||
follow_requests_count?: number
|
follow_requests_count?: number
|
||||||
|
pleroma?: {
|
||||||
|
actor_type?: 'Person' | 'Service' | 'Group'
|
||||||
|
show_birthday?: boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
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'
|
||||||
|
import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte'
|
||||||
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
|
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
|
||||||
|
|
||||||
const { session, theme } = useAppServices()
|
const { session, theme } = useAppServices()
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
|
|
||||||
<div class="page settings-page">
|
<div class="page settings-page">
|
||||||
<h1 class="page-title">Settings</h1>
|
<h1 class="page-title">Settings</h1>
|
||||||
<p class="page-subtitle">Everything here is stored in this browser only.</p>
|
<p class="page-subtitle">Edit your public profile and customise how plspace looks.</p>
|
||||||
|
|
||||||
<div class="layout--single">
|
<div class="layout--single">
|
||||||
<Module title="Your account">
|
<Module title="Your account">
|
||||||
@@ -118,6 +119,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Module>
|
</Module>
|
||||||
|
|
||||||
|
<Module title="Your public profile" variant="band">
|
||||||
|
<PublicProfileEditor />
|
||||||
|
</Module>
|
||||||
|
|
||||||
<Module title="Pick a layout" variant="band">
|
<Module title="Pick a layout" variant="band">
|
||||||
<ul class="preset-list">
|
<ul class="preset-list">
|
||||||
{#each PRESETS as preset (preset.id)}
|
{#each PRESETS as preset (preset.id)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ input[type='search'],
|
|||||||
input[type='url'],
|
input[type='url'],
|
||||||
input[type='email'],
|
input[type='email'],
|
||||||
input[type='password'],
|
input[type='password'],
|
||||||
|
input[type='date'],
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
@@ -206,6 +207,124 @@ textarea {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- profile editor */
|
||||||
|
|
||||||
|
.profile-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid var(--ms-module-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor legend {
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--ms-heading-fg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-bio {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-images {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image > img,
|
||||||
|
.profile-editor-image-placeholder {
|
||||||
|
display: block;
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
border: 1px solid var(--ms-avatar-border);
|
||||||
|
background: var(--ms-table-stripe-bg);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image--wide > img,
|
||||||
|
.profile-editor-image--wide > .profile-editor-image-placeholder {
|
||||||
|
height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--ms-muted-fg);
|
||||||
|
font-size: var(--ms-font-size-small);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image > div > strong {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-description {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 1fr) minmax(160px, 2fr) auto;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-field input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-identity .field {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.profile-editor-image {
|
||||||
|
grid-template-columns: 70px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image > img,
|
||||||
|
.profile-editor-image-placeholder {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-image--wide > img,
|
||||||
|
.profile-editor-image--wide > .profile-editor-image-placeholder {
|
||||||
|
height: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-field {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor-field label:first-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* The CSS editor in Settings wants to be a code surface, not prose. */
|
/* The CSS editor in Settings wants to be a code surface, not prose. */
|
||||||
.css-editor {
|
.css-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user