egregoros compat

This commit is contained in:
Moon.eth
2026-07-29 20:59:52 +09:00
parent 081460c7f6
commit 5b7b4360d2
16 changed files with 713 additions and 137 deletions
+136 -114
View File
@@ -5,6 +5,7 @@
*/
import { untrack } from 'svelte'
import type { CredentialAccount } from '$lib/api/types'
import { isEgregoros, publicProfileCapabilities } from '$lib/api/capabilities'
import { useAppServices } from '$lib/app-services'
import { profileFieldLimits } from '$lib/stores/theme.svelte'
import { toPlainText } from '$lib/util/html'
@@ -65,16 +66,21 @@
let error = $state<string | null>(null)
const isPleroma = $derived(Boolean(session.instance?.pleroma))
const isEgregorosServer = $derived(isEgregoros(session.instance))
const capabilities = $derived(publicProfileCapabilities(session.instance))
const limits = $derived(profileFieldLimits(session.instance))
const reservedFields = $derived(internalFields(session.me).length)
const availablePublicFields = $derived(Math.max(0, limits.maxFields - reservedFields))
const availablePublicFields = $derived(
capabilities.fields ? Math.max(0, limits.maxFields - reservedFields) : 0,
)
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,
),
!capabilities.fields ||
(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)
@@ -140,23 +146,25 @@
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 hidden = capabilities.fields ? internalFields(session.me) : []
const visible = capabilities.fields
? 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),
fields: capabilities.fields ? [...visible, ...hidden] : undefined,
avatar: capabilities.avatar ? imageValue(avatarMode, avatarFile) : undefined,
header: capabilities.header ? imageValue(headerMode, headerFile) : undefined,
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,
bot: capabilities.identity && !isPleroma ? actorType === 'Service' : undefined,
actorType: capabilities.identity && isPleroma ? actorType : undefined,
birthday: capabilities.identity && isPleroma ? birthday : undefined,
showBirthday: capabilities.identity && isPleroma ? showBirthday : undefined,
})
session.me = updated
resetFrom(updated)
@@ -176,6 +184,12 @@
<p class="notice">
Everything in this form is public-facing and may be federated to other servers.
</p>
{#if isEgregorosServer}
<p class="notice">
Egregoros currently exposes display name, bio, and profile photo through its Mastodon API.
plspace hides the other profile controls because that server would silently ignore them.
</p>
{/if}
{#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}
@@ -210,41 +224,45 @@
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 capabilities.removeAvatar}
<button
type="button"
class="button button--small"
onclick={() => {
avatarMode = 'remove'
avatarFile = null
}}
>Remove photo</button>
{/if}
{#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}
{#if capabilities.header}
<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>
</div>
{/if}
{#if isPleroma}
<div class="profile-editor-image profile-editor-image--wide">
@@ -287,78 +305,82 @@
{/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>
{#if capabilities.fields}
<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>
{/if}
<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} />
{#if capabilities.identity}
<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" bind:checked={showBirthday} />
Show my birthday publicly
<input
type="checkbox"
checked={actorType === 'Service'}
onchange={(event) => (actorType = event.currentTarget.checked ? 'Service' : 'Person')}
/>
This is a bot account
</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>
{/if}
</fieldset>
{/if}
<div class="field-row">
<button class="button button--primary" type="submit" disabled={!canSave}>
@@ -135,4 +135,53 @@ describe('PublicProfileEditor', () => {
})
expect(await view.findByText('Your public profile was updated.')).toBeInTheDocument()
})
it('shows and submits only the profile fields Egregoros exposes through its API', async () => {
const me = credential()
const updatePublicProfile = vi.fn().mockResolvedValue(me)
const services = testServices({
session: session({
token: 'token',
me,
signedIn: true,
instance: {
title: 'Egregoros',
version: 'egregoros/0.1.0',
},
}),
endpoints: { updatePublicProfile },
})
const view = render(PublicProfileEditor, {
context: new Map([[APP_SERVICES, services]]),
})
expect(view.getByText(/currently exposes display name, bio, and profile photo/i)).toBeInTheDocument()
expect(view.queryByText('Profile banner')).not.toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Remove photo' })).not.toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Add profile detail' })).not.toBeInTheDocument()
expect(view.queryByRole('checkbox', { name: 'This is a bot account' })).not.toBeInTheDocument()
await fireEvent.input(view.getByLabelText('Display name'), {
target: { value: 'Alice on Egregoros' },
})
await fireEvent.input(view.getByLabelText('About me / bio'), {
target: { value: 'An Egregoros bio' },
})
const avatar = new File(['avatar'], '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 on Egregoros',
note: 'An Egregoros bio',
avatar,
})
expect(sent.fields).toBeUndefined()
expect(sent.header).toBeUndefined()
expect(sent.bot).toBeUndefined()
})
})
@@ -6,6 +6,7 @@
* server mutation and changes what every plspace visitor sees on this profile.
*/
import { useAppServices } from '$lib/app-services'
import { publicProfileCapabilities } from '$lib/api/capabilities'
import {
profileFieldLimits,
publishedCssFieldValues,
@@ -23,6 +24,7 @@
let locallyPublishedCss = $state<string | null>(null)
const limits = $derived(profileFieldLimits(session.instance))
const canPublish = $derived(publicProfileCapabilities(session.instance).fields)
const currentCss = $derived(
locallyPublishedCss ?? publishedCssFromFields(session.me?.fields),
)
@@ -89,7 +91,13 @@
browser. Other clients may display the CSS as ordinary profile fields.
</p>
{#if editing}
{#if !canPublish}
<p class="notice">
This server does not expose profile fields through its Mastodon API, so plspace cannot
publish profile CSS here. Your private browser-only CSS above still works.
</p>
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
{:else if editing}
<label class="field-label" for="published-css">CSS shown on your profile</label>
<textarea
id="published-css"
@@ -99,4 +99,27 @@ describe('PublishedCssEditor', () => {
expect(sent.slice(1).map((field) => field.name)).toEqual(['plspace:css1'])
expect(publishedCssFromFields(sent.slice(1))).toBe('.new { color: pink; }')
})
it('does not offer publishing when the server has no profile-fields API', () => {
const updateProfileFields = vi.fn()
const services = testServices({
session: session({
token: 'token',
me: credential([]),
signedIn: true,
instance: {
title: 'Egregoros',
version: 'egregoros/0.1.0',
},
}),
endpoints: { updateProfileFields },
})
const view = render(PublishedCssEditor, {
context: new Map([[APP_SERVICES, services]]),
})
expect(view.getByText(/does not expose profile fields through its Mastodon API/i)).toBeInTheDocument()
expect(view.queryByRole('button', { name: 'Add CSS to my profile' })).not.toBeInTheDocument()
expect(updateProfileFields).not.toHaveBeenCalled()
})
})