Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0882c083 | ||
|
|
1e47b155f1 | ||
|
|
8ba931186f |
10 changed files with 469 additions and 27 deletions
|
|
@ -25,6 +25,9 @@
|
|||
"dings": {
|
||||
"enabled": false
|
||||
},
|
||||
"fileZoneShare": {
|
||||
"enabled": false
|
||||
},
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
150
src/app/features/albums/ShareSet.tsx
Normal file
150
src/app/features/albums/ShareSet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/app/features/albums/shareSet.test.ts
Normal file
160
src/app/features/albums/shareSet.test.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
83
src/app/features/albums/shareSet.ts
Normal file
83
src/app/features/albums/shareSet.ts
Normal 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(', ')}`;
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue