Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Void
1a0882c083 File zone share: failed files by position, send guard outside the component, say when the room turned encrypted
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 13:01:35 +00:00
Void
1e47b155f1 File zone share: keep failed files picked, one send at a time, encrypted re-check, stay mounted while an album is open, wrap long names
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 12:59:25 +00:00
Void
8ba931186f File zone: share a set of files as one album, behind fileZoneShare.enabled (off)
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 12:51:40 +00:00
10 changed files with 469 additions and 27 deletions

View file

@ -25,6 +25,9 @@
"dings": {
"enabled": false
},
"fileZoneShare": {
"enabled": false
},
"registration": {
"enabled": false,
"url": ""

View file

@ -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.

View file

@ -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' });

View file

@ -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 = <Text>Loading albums…</Text>;
@ -134,6 +136,12 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
<Scroll hideTrack visibility="Hover">
<div className={css.Column}>
{body}
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
{canShare && (
<div hidden={!!open}>
<ShareSet room={room} />
</div>
)}
<button type="button" className={css.Row} onClick={onTimeline}>
<Text size="B400">Timeline ›</Text>
</button>

View file

@ -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<string>();
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<HTMLInputElement>(null);
const [files, setFiles] = useState<File[]>([]);
const [title, setTitle] = useState('');
const [phase, setPhase] = useState<Phase>({ 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<string>((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 (
<div className={css.SharePanel}>
<input
ref={input}
type="file"
multiple
hidden
onChange={(e) => {
setFiles(Array.from(e.target.files ?? []));
setPhase({ kind: 'idle' });
}}
/>
{files.length === 0 && phase.kind !== 'sending' && (
<button type="button" className={css.Row} onClick={() => input.current?.click()}>
<Text size="B400">Share files ›</Text>
</button>
)}
{phase.kind === 'done' && (
<Text size="T200" className={css.ShareNames}>
{phase.text}
</Text>
)}
{files.length > 0 && (
<>
<input
className={css.ShareTitle}
value={title}
placeholder={DEFAULT_TITLE}
aria-label="Album title"
disabled={phase.kind === 'sending'}
onChange={(e) => setTitle(e.target.value)}
/>
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
{files.map((f) => f.name).join(' · ')}
</Text>
{phase.kind === 'sending' ? (
<Text size="T300">
Sending {phase.done} of {files.length}…
</Text>
) : (
<>
<button type="button" className={css.Row} onClick={send}>
<Text size="B400">
Send {files.length} {files.length === 1 ? 'file' : 'files'}
</Text>
</button>
<button type="button" className={css.Row} onClick={reset}>
<Text size="B400">Cancel</Text>
</button>
</>
)}
</>
)}
</div>
);
}

View file

@ -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<ShareFile> = {
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<ShareFile> = {
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<ShareFile> = {
...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.');
});
});

View file

@ -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<F extends ShareFile> = {
/** Send an event and return its id. */
send: (content: Record<string, unknown>) => Promise<string>;
/** Upload one file and build its message content (msgtype, url, info incl. sha256). */
contentOf: (file: F) => Promise<Record<string, unknown>>;
/** 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 <F extends ShareFile>(
port: SharePort<F>,
title: string,
files: F[]
): Promise<ShareResult> => {
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 = <F extends ShareFile>(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(', ')}`;
};

View file

@ -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<HTMLDivElement, RoomInputProps>(
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) {

View file

@ -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<IContent> => {
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);
};

View file

@ -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;