diff --git a/config.json b/config.json index ef2a7a94..f98575d9 100644 --- a/config.json +++ b/config.json @@ -25,6 +25,9 @@ "dings": { "enabled": false }, + "fileZoneShare": { + "enabled": false + }, "registration": { "enabled": false, "url": "" diff --git a/docs/file-zone.md b/docs/file-zone.md index 01b5a4c5..7f380d69 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -71,21 +71,21 @@ Reuse the composer's pipeline, pointed at an album instead of the main timeline: - `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. - On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). -- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. +- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. - Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. ## Existing code to reuse (verified present in the repo) -| need | where | -|---|---| -| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | -| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | -| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | -| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | -| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | -| event content builders | `features/room/msgContent.ts` | -| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | -| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | +| need | where | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | +| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | +| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | +| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | +| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | +| event content builders | `features/room/msgContent.ts` | +| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | +| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). @@ -104,3 +104,19 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin - How the client identifies the zone room (config id recommended). - Whether exports are per listener (proposed) or a merged all-listeners file. - Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). + +## Share a set (built, behind `fileZoneShare.enabled`) + +The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events. + +Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else. + +Client, `src/app/features/albums`: + +- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`). +- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only). +- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`. + +Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts. + +Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files. diff --git a/src/app/features/albums/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts index c4f4fc79..d60dba43 100644 --- a/src/app/features/albums/AlbumView.css.ts +++ b/src/app/features/albums/AlbumView.css.ts @@ -49,3 +49,23 @@ export const FilesWide = style({ }); export const Dim = style({ opacity: config.opacity.P300 }); + +/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */ +export const SharePanel = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, +}); + +export const ShareTitle = style({ + all: 'unset', + boxSizing: 'border-box', + minHeight: 44, + width: '100%', + borderBottom: `1px solid ${color.Background.ContainerLine}`, + color: color.Background.OnContainer, + ':focus-visible': { borderBottomColor: color.Background.OnContainer }, +}); + +/** Picked names and send notes wrap, even a long unbroken name. */ +export const ShareNames = style({ overflowWrap: 'anywhere' }); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 1bf30579..b670ee2b 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -16,6 +16,7 @@ import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; import { FileReadBy } from './FileReadBy'; +import { ShareSet, useShareEnabled } from './ShareSet'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -93,6 +94,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const open: Album | undefined = albums.find((a) => a.id === openId); const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; const encrypted = room.hasEncryptionStateEvent(); + const canShare = useShareEnabled(room); let body: React.ReactNode; if (loading) body = Loading albums…; @@ -134,6 +136,12 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
{body} + {/* Stays mounted while an album is open, so a running send keeps its progress. */} + {canShare && ( + + )} diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx new file mode 100644 index 00000000..1d9c4302 --- /dev/null +++ b/src/app/features/albums/ShareSet.tsx @@ -0,0 +1,150 @@ +import React, { useRef, useState } from 'react'; +import { Text } from 'folds'; +import { useQueryClient } from '@tanstack/react-query'; +import { Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { uploadContent } from '../../utils/matrix'; +import { safeFile } from '../../utils/mimeTypes'; +import { getMsgContent } from '../room/msgContent'; +import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; +import { DEFAULT_TITLE, remainingAfter, resultNote, sendShareSet } from './shareSet'; +import * as css from './AlbumView.css'; + +/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ +export const useShareEnabled = (room: Room): boolean => + !!useClientConfig().fileZoneShare?.enabled && !room.hasEncryptionStateEvent(); + +/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */ +const sending = new Set(); + +type Phase = { kind: 'idle' } | { kind: 'sending'; done: number } | { kind: 'done'; text: string }; + +/** + * One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked + * names, Send. Sending makes one album of them (shareSet.ts). Nothing here is visible until used. + */ +export function ShareSet({ room }: { room: Room }) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const input = useRef(null); + const [files, setFiles] = useState([]); + const [title, setTitle] = useState(''); + const [phase, setPhase] = useState({ kind: 'idle' }); + + const reset = () => { + setFiles([]); + setTitle(''); + if (input.current) input.current.value = ''; + }; + + const send = async () => { + // One send at a time, and never into a room that has turned encrypted since the panel mounted. + if (sending.has(room.roomId)) return; + if (room.hasEncryptionStateEvent()) { + setPhase({ kind: 'done', text: 'Not shared: this room is encrypted.' }); + return; + } + sending.add(room.roomId); + const items = files.map((file) => ({ name: file.name, mimeType: file.type, file })); + setPhase({ kind: 'sending', done: 0 }); + let settled = 0; + try { + const res = await sendShareSet( + { + send: async (content) => (await mx.sendMessage(room.roomId, content as any)).event_id, + contentOf: async (f) => { + const file = safeFile(f.file); + const mxc = await new Promise((resolve, reject) => { + uploadContent(mx, file, { onSuccess: resolve, onError: reject }); + }); + const content = await getMsgContent( + mx, + { + file, + originalFile: f.file, + metadata: { markedAsSpoiler: false }, + encInfo: undefined, + }, + mxc + ); + const sha256 = await hashForSend(f.file); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; + return content; + }, + onFile: () => { + settled += 1; + setPhase({ kind: 'sending', done: settled }); + }, + }, + title, + items + ); + queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }); + const left = remainingAfter(items, res).map((i) => i.file); + setFiles(left); + if (left.length === 0) reset(); + setPhase({ kind: 'done', text: resultNote(res) }); + } catch (e: any) { + setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` }); + } finally { + sending.delete(room.roomId); + } + }; + + return ( +
+ { + setFiles(Array.from(e.target.files ?? [])); + setPhase({ kind: 'idle' }); + }} + /> + {files.length === 0 && phase.kind !== 'sending' && ( + + )} + {phase.kind === 'done' && ( + + {phase.text} + + )} + {files.length > 0 && ( + <> + setTitle(e.target.value)} + /> + + {files.map((f) => f.name).join(' · ')} + + {phase.kind === 'sending' ? ( + + Sending {phase.done} of {files.length}… + + ) : ( + <> + + + + )} + + )} +
+ ); +} diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts new file mode 100644 index 00000000..f43e5a35 --- /dev/null +++ b/src/app/features/albums/shareSet.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import config from '../../../../config.json'; +import { ZoneEvent, buildAlbums } from './buildAlbums'; +import { + DEFAULT_TITLE, + ShareFile, + SharePort, + remainingAfter, + resultNote, + sendShareSet, + threadRelation, +} from './shareSet'; + +const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file'); + +/** A fake room: records what is sent, as the events Synapse would return from /relations. */ +const room = (failOn: string[] = []) => { + const events: ZoneEvent[] = []; + const port: SharePort = { + send: async (content) => { + const id = `$e${events.length + 1}`; + events.push({ + event_id: id, + type: 'm.room.message', + sender: '@aqua:chat.gamecult.org', + origin_server_ts: 1000 + events.length, + content, + }); + return id; + }, + contentOf: async (f) => { + if (failOn.includes(f.name)) throw new Error('upload failed'); + return { + msgtype: kindOf(f), + body: f.name, + filename: f.name, + url: `mxc://x/${f.name}`, + info: { mimetype: f.mimeType }, + }; + }, + }; + const album = () => { + const [root, ...replies] = events; + return buildAlbums([{ root, replies }], 'chat.gamecult.org')[0]; + }; + return { events, port, album }; +}; + +const clips: ShareFile[] = [ + { name: 'a.ogg', mimeType: 'audio/ogg' }, + { name: 'b.ogg', mimeType: 'audio/ogg' }, + { name: 'c.txt', mimeType: 'text/plain' }, +]; + +describe('sendShareSet', () => { + it('reads back as one album of the files in pick order', async () => { + const r = room(); + const res = await sendShareSet(r.port, 'Take 4', clips); + expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] }); + const album = r.album(); + expect(album.title).toBe('Take 4'); + expect(album.files.map((f) => [f.name, f.kind])).toEqual([ + ['a.ogg', 'audio'], + ['b.ogg', 'audio'], + ['c.txt', 'file'], + ]); + }); + + it('threads every file under the root, each falling back to the previous event', async () => { + const r = room(); + await sendShareSet(r.port, 't', clips); + const rels = r.events.slice(1).map((e) => e.content?.['m.relates_to']); + expect(rels).toEqual([ + threadRelation('$e1', '$e1'), + threadRelation('$e1', '$e2'), + threadRelation('$e1', '$e3'), + ]); + expect(r.events[0].content?.['m.relates_to']).toBeUndefined(); + }); + + it('titles an untitled share', async () => { + const r = room(); + await sendShareSet(r.port, ' ', clips.slice(0, 1)); + expect(r.album().title).toBe(DEFAULT_TITLE); + }); + + it('names a failed file and still sends the rest', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet(r.port, 't', clips); + expect(res.failed).toEqual(['b.ogg']); + expect(res.sent).toBe(2); + expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg', 'c.txt']); + // the next file falls back to the last event that exists, not to the failed one + expect(r.events[2].content?.['m.relates_to']).toEqual(threadRelation('$e1', '$e2')); + }); + + it('throws when the root cannot be sent, uploading nothing', async () => { + let uploads = 0; + const port: SharePort = { + send: async () => { + throw new Error('M_FORBIDDEN'); + }, + contentOf: async () => { + uploads += 1; + return {}; + }, + }; + await expect(sendShareSet(port, 't', clips)).rejects.toThrow('M_FORBIDDEN'); + expect(uploads).toBe(0); + }); +}); + +describe('the share flag', () => { + it('ships off', () => { + expect(config.fileZoneShare).toEqual({ enabled: false }); + }); +}); + +describe('after a send', () => { + it('keeps the failed one of two same-named files, not both', async () => { + const twins: ShareFile[] = [ + { name: 'a.ogg', mimeType: 'audio/ogg' }, + { name: 'a.ogg', mimeType: 'audio/ogg' }, + ]; + let n = 0; + const r = room(); + const port: SharePort = { + ...r.port, + contentOf: async (f) => { + n += 1; + if (n === 2) throw new Error('second upload failed'); + return r.port.contentOf(f); + }, + }; + const res = await sendShareSet(port, 't', twins); + expect(res.failedAt).toEqual([1]); + expect(remainingAfter(twins, res)).toEqual([twins[1]]); + }); + + it('keeps only the failed files picked, so a retry is one tap', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg']); + expect(resultNote(res)).toBe('Shared 2. Not sent: b.ogg'); + }); + + it('keeps everything and says nothing was shared when every upload failed', async () => { + const r = room(clips.map((c) => c.name)); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual(clips); + expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg, b.ogg, c.txt'); + }); + + it('clears the pick on full success', async () => { + const r = room(); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual([]); + expect(resultNote(res)).toBe('Shared 3 files.'); + }); +}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts new file mode 100644 index 00000000..803fbb87 --- /dev/null +++ b/src/app/features/albums/shareSet.ts @@ -0,0 +1,83 @@ +// Share a set of files at once: one album root (a text message) and one thread reply per file. +// That is exactly the shape buildAlbums reads, so a set shared here and a set a Mind's mouth posts +// are the same thing to every viewer. Ports are injected so the order and the failure rules are +// testable without a homeserver. See docs/file-zone.md, "Share a set". + +export type ShareFile = { name: string; mimeType: string }; + +/** What sendShareSet needs from the outside world. */ +export type SharePort = { + /** Send an event and return its id. */ + send: (content: Record) => Promise; + /** Upload one file and build its message content (msgtype, url, info incl. sha256). */ + contentOf: (file: F) => Promise>; + /** Called after each file settles, for progress. */ + onFile?: (index: number, result: 'sent' | 'failed') => void; +}; + +export type ShareResult = { + /** The album root's event id; present as soon as the root is sent. */ + albumId: string; + sent: number; + failed: string[]; + /** Positions in the files array of those that failed; names can repeat, positions cannot. */ + failedAt: number[]; +}; + +/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */ +export const threadRelation = (rootId: string, lastId: string) => ({ + rel_type: 'm.thread', + event_id: rootId, + is_falling_back: true, + 'm.in_reply_to': { event_id: lastId }, +}); + +export const DEFAULT_TITLE = 'Shared files'; + +export const titleOrDefault = (title: string): string => title.trim() || DEFAULT_TITLE; + +/** + * Files go one at a time, in the order given, so thread order is pick order. A file that fails + * to upload is reported by name and the rest still go; the root is already out, so stopping would + * leave an empty album and the sender nothing to retry from. A root that fails to send throws: + * nothing exists yet. + */ +export const sendShareSet = async ( + port: SharePort, + title: string, + files: F[] +): Promise => { + const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) }); + const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] }; + let lastId = albumId; + for (let i = 0; i < files.length; i += 1) { + try { + // eslint-disable-next-line no-await-in-loop + const content = await port.contentOf(files[i]); + // eslint-disable-next-line no-await-in-loop + lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) }); + result.sent += 1; + port.onFile?.(i, 'sent'); + } catch { + result.failed.push(files[i].name); + result.failedAt.push(i); + port.onFile?.(i, 'failed'); + } + } + return result; +}; + +/** + * What stays picked after a send: only the files that did not go, so a retry is one tap. A retry + * makes a second album (adding to an existing one is not built), and a share where nothing went + * leaves its root as a lone message the album list does not show (it lists threads with replies). + */ +export const remainingAfter = (files: F[], result: ShareResult): F[] => + files.filter((_, i) => result.failedAt.includes(i)); + +export const resultNote = (result: ShareResult): string => { + if (result.failed.length === 0) + return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`; + if (result.sent === 0) return `Nothing was shared. Not sent: ${result.failed.join(', ')}`; + return `Shared ${result.sent}. Not sent: ${result.failed.join(', ')}`; +}; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 3f0a45d4..b4483d8b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -93,12 +93,7 @@ import { safeFile } from '../../utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; -import { - getAudioMsgContent, - getFileMsgContent, - getImageMsgContent, - getVideoMsgContent, -} from './msgContent'; +import { getMsgContent } from './msgContent'; import { FILE_HASH_KEY, hashForSend } from '../albums/fileReceipt'; import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; @@ -281,16 +276,7 @@ export const RoomInput = forwardRef( const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); - let content: IContent; - if (fileItem.file.type.startsWith('image')) { - content = await getImageMsgContent(mx, fileItem, upload.mxc); - } else if (fileItem.file.type.startsWith('video')) { - content = await getVideoMsgContent(mx, fileItem, upload.mxc); - } else if (fileItem.file.type.startsWith('audio')) { - content = getAudioMsgContent(fileItem, upload.mxc); - } else { - content = getFileMsgContent(fileItem, upload.mxc); - } + const content = await getMsgContent(mx, fileItem, upload.mxc); // A Mind verifies what it fetched against this. An encrypted file already carries the // ciphertext hash in file.hashes; a plaintext hash would leak the file's identity. if (!fileItem.encInfo) { diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index 5b7cd145..d6cd6b93 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -169,3 +169,16 @@ export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => { } return content; }; + +/** The message content for an uploaded file, by its type. */ +export const getMsgContent = async ( + mx: MatrixClient, + item: TUploadItem, + mxc: string +): Promise => { + const { type } = item.file; + if (type.startsWith('image')) return getImageMsgContent(mx, item, mxc); + if (type.startsWith('video')) return getVideoMsgContent(mx, item, mxc); + if (type.startsWith('audio')) return getAudioMsgContent(item, mxc); + return getFileMsgContent(item, mxc); +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7f0899be..2ba69290 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -34,6 +34,9 @@ export type ClientConfig = { // Rooms (ids or aliases) shown as the album view; a room whose create type is org.gamecult.file_zone needs no entry. fileZoneRooms?: string[]; + // Sharing a set of files into a file-zone room as one album. Off unless `enabled` is true. + fileZoneShare?: { enabled?: boolean }; + // The sign-up entry on the sign-in page. Off unless `enabled` is true; `url` is the account // service's own registration page (a value, never a route this client invents). registration?: RegistrationConfig;