diff --git a/src/components/blog/Composer.svelte b/src/components/blog/Composer.svelte index bebae44..5f26a23 100644 --- a/src/components/blog/Composer.svelte +++ b/src/components/blog/Composer.svelte @@ -188,10 +188,23 @@ 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), + } + } {#if session.signedIn} -
+ {#if error} {/if} @@ -329,7 +342,12 @@ {remaining} - diff --git a/src/components/blog/Composer.test.ts b/src/components/blog/Composer.test.ts index 8ad6ea3..7d22ae8 100644 --- a/src/components/blog/Composer.test.ts +++ b/src/components/blog/Composer.test.ts @@ -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') + }) }) diff --git a/src/components/profile/PublicProfileEditor.svelte b/src/components/profile/PublicProfileEditor.svelte new file mode 100644 index 0000000..c3663e5 --- /dev/null +++ b/src/components/profile/PublicProfileEditor.svelte @@ -0,0 +1,370 @@ + + +{#if !session.signedIn || !session.me} +

Sign in to edit your public profile.

+{:else} + +

+ Everything in this form is public-facing and may be federated to other servers. +

+ + {#if error}{/if} + {#if saved}

Your public profile was updated.

{/if} + +
+ + +
+ +
+ + +

Your server may support plain text, Markdown or other formatting here.

+
+ +
+ Profile images + +
+ Current avatar +
+ Profile photo + chooseImage('avatar', event)} + /> + + {#if avatarMode === 'replace' && avatarFile}{avatarFile.name}{/if} + {#if avatarMode === 'remove'}Will be removed when saved.{/if} +
+
+ +
+ Current banner +
+ Profile banner + chooseImage('header', event)} + /> + + {#if headerMode === 'replace' && headerFile}{headerFile.name}{/if} + {#if headerMode === 'remove'}Will be removed when saved.{/if} +
+
+ + {#if isPleroma} +
+ {#if session.me.pleroma?.background_image} + Current profile background + {:else} + No background + {/if} +
+ Profile background + chooseImage('background', event)} + /> + + {#if backgroundMode === 'replace' && backgroundFile}{backgroundFile.name}{/if} + {#if backgroundMode === 'remove'}Will be removed when saved.{/if} +
+
+ +
+ + +
+ {/if} +
+ +
+ Profile details +

+ These become the Interests and Details rows on your profile. Hidden + plspace: storage fields are preserved automatically. +

+ {#each fields as field (field.id)} +
+ + + +
+ {/each} + + {fields.length} of {availablePublicFields} public fields used. +
+ +
+ Public identity + {#if isPleroma} + +
+ + +
+ {:else} + + {/if} +
+ +
+ + View my profile +
+
+{/if} diff --git a/src/components/profile/PublicProfileEditor.test.ts b/src/components/profile/PublicProfileEditor.test.ts new file mode 100644 index 0000000..9b0af1b --- /dev/null +++ b/src/components/profile/PublicProfileEditor.test.ts @@ -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: '

Old bio

', + 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 => ({ + ...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() + }) +}) diff --git a/src/lib/api/endpoints.test.ts b/src/lib/api/endpoints.test.ts index 9be3665..92e80b0 100644 --- a/src/lib/api/endpoints.test.ts +++ b/src/lib/api/endpoints.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiClient } from './client' -import { postStatus, updateProfileFields, votePoll } from './endpoints' +import { postStatus, updateProfileFields, updatePublicProfile, votePoll } from './endpoints' afterEach(() => { 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', () => { it('submits selected poll option indexes as JSON', async () => { const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index d63bc2e..f0c2c9a 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -86,6 +86,63 @@ export function verifyCredentials(api: ApiClient): Promise { return api.get('/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 { + 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('/api/v1/accounts/update_credentials', { + method: 'PATCH', + form, + }) + return data +} + /** * Replace the signed-in account's profile fields while leaving every other * credential untouched. Mastodon and Pleroma both accept this indexed @@ -95,16 +152,7 @@ export async function updateProfileFields( api: ApiClient, fields: Array<{ name: string; value: string }>, ): Promise { - const form = new FormData() - 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('/api/v1/accounts/update_credentials', { - method: 'PATCH', - form, - }) - return data + return updatePublicProfile(api, { fields }) } export function fetchAccount(api: ApiClient, id: string): Promise { diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 64add8f..e0d7966 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -47,8 +47,10 @@ export interface Account { uri?: string avatar: string avatar_static: string + avatar_description?: string | null header: string header_static: string + header_description?: string | null locked: boolean bot?: boolean group?: boolean @@ -84,6 +86,9 @@ export interface Account { relationship?: Relationship /** Some deployments expose the user's own profile CSS here. */ 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 language: string | null follow_requests_count?: number + pleroma?: { + actor_type?: 'Person' | 'Service' | 'Group' + show_birthday?: boolean + [key: string]: unknown + } } } diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte index 385dd1a..234e303 100644 --- a/src/routes/Settings.svelte +++ b/src/routes/Settings.svelte @@ -13,6 +13,7 @@ import { instanceDomain } from '$lib/api/endpoints' import { displayNameOf, profilePath } from '$lib/util/profile' import Module from '$components/common/Module.svelte' + import PublicProfileEditor from '$components/profile/PublicProfileEditor.svelte' import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte' const { session, theme } = useAppServices() @@ -89,7 +90,7 @@

Settings

-

Everything here is stored in this browser only.

+

Edit your public profile and customise how plspace looks.

@@ -118,6 +119,10 @@ {/if} + + + +
    {#each PRESETS as preset (preset.id)} diff --git a/src/styles/forms.css b/src/styles/forms.css index 3636c74..b4d110d 100644 --- a/src/styles/forms.css +++ b/src/styles/forms.css @@ -20,6 +20,7 @@ input[type='search'], input[type='url'], input[type='email'], input[type='password'], +input[type='date'], select, textarea { padding: 2px 4px; @@ -206,6 +207,124 @@ textarea { 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. */ .css-editor { width: 100%;