mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
egregoros compat
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user