meta-sovereign 0.49.9 → 0.49.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,7 @@
39
39
  * POST /api/email/pull -> local-server email API/protocol proxy
40
40
  * POST /api/email/send -> local-server email send proxy
41
41
  * GET /api/email/attachment -> a mail attachment from the media folder (routes-email.js)
42
+ * GET /api/media/<network>/<sha256>[.ext] -> imported archive media (routes-media.js, R-Z15)
42
43
  * GET /api/backups -> list archives (gated on archiveDir)
43
44
  * POST /api/backups -> { passphrase?, keep? } -> { file }
44
45
  * POST /api/backups/restore -> { file, passphrase? } -> { restored }
@@ -95,6 +96,7 @@ import { profileDirIn } from '../sources/whatsapp-web-signin.js';
95
96
  import { handleTelegramRoutes } from './routes-telegram.js';
96
97
  import { handleMessageRoutes } from './routes-messages.js';
97
98
  import { handleEmailGets } from './routes-email.js';
99
+ import { handleMediaGets } from './routes-media.js';
98
100
  import { createAttachmentStore } from './email-attachments.js';
99
101
  import { createEmailPoller } from './email-poller.js';
100
102
  import { setAttachmentSink } from '../sources/email.js';
@@ -586,6 +588,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
586
588
  () => handleTelegramRoutes(req, res, p, ctx),
587
589
  () => handleMessageRoutes(store, req, res, p, url, ctx),
588
590
  () => handleEmailGets(store, req, res, p, url, ctx),
591
+ () => handleMediaGets(req, res, p, ctx),
589
592
  () => handleDeletedRoutes(store, req, res, p, url, ctx),
590
593
  () => handleAudienceSyncRoute(store, req, res, p, ctx),
591
594
  () => handleChatRoutes(req, res, p, url, ctx),
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Imported archive media (R-Z15):
3
+ * GET /api/media/<network>/<sha256>[.<ext>]
4
+ * serves a file that an archive import (R-Z10 Facebook/Messenger,
5
+ * Instagram, X, LinkedIn; R-Z15 WhatsApp) copied to
6
+ * `<store>/media/<network>/<sha256><ext>`. The path is built only from
7
+ * validated parts (a fixed network list, 64 hex digits, a fixed extension
8
+ * list), so nothing outside that folder can be named. Pictures, audio and
9
+ * video are shown inline; everything else, svg and html included, is
10
+ * offered as a download with a sandboxing CSP. Audio and video answer
11
+ * `Range`. The file is streamed, never read whole.
12
+ */
13
+
14
+ import { createReadStream, promises as fs } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fail } from './util.js';
17
+ import { fromApp } from './routes-telegram-files.js';
18
+
19
+ export const MEDIA_NETWORKS = Object.freeze([
20
+ 'facebook',
21
+ 'messenger',
22
+ 'instagram',
23
+ 'x',
24
+ 'linkedin',
25
+ 'whatsapp',
26
+ ]);
27
+
28
+ const INLINE = {
29
+ jpg: 'image/jpeg',
30
+ jpeg: 'image/jpeg',
31
+ png: 'image/png',
32
+ gif: 'image/gif',
33
+ webp: 'image/webp',
34
+ mp4: 'video/mp4',
35
+ m4v: 'video/mp4',
36
+ mov: 'video/quicktime',
37
+ webm: 'video/webm',
38
+ mp3: 'audio/mpeg',
39
+ m4a: 'audio/mp4',
40
+ aac: 'audio/aac',
41
+ ogg: 'audio/ogg',
42
+ opus: 'audio/ogg',
43
+ oga: 'audio/ogg',
44
+ wav: 'audio/wav',
45
+ };
46
+ // Downloaded, never shown: documents, archives, and the types a browser
47
+ // would run (svg, html), which are sent as opaque bytes.
48
+ const OPAQUE = 'application/octet-stream';
49
+ const DOWNLOAD = {
50
+ pdf: 'application/pdf',
51
+ txt: 'text/plain; charset=utf-8',
52
+ vcf: 'text/vcard; charset=utf-8',
53
+ csv: 'text/csv; charset=utf-8',
54
+ json: 'application/json',
55
+ zip: 'application/zip',
56
+ ...Object.fromEntries(
57
+ [
58
+ 'doc',
59
+ 'docx',
60
+ 'xls',
61
+ 'xlsx',
62
+ 'ppt',
63
+ 'pptx',
64
+ 'heic',
65
+ 'heif',
66
+ '3gp',
67
+ 'amr',
68
+ 'mkv',
69
+ 'svg',
70
+ 'html',
71
+ 'htm',
72
+ 'xml',
73
+ 'js',
74
+ ].map((ext) => [ext, OPAQUE])
75
+ ),
76
+ };
77
+
78
+ export const MEDIA_EXTENSIONS = Object.freeze([
79
+ ...Object.keys(INLINE),
80
+ ...Object.keys(DOWNLOAD),
81
+ ]);
82
+
83
+ const URL_SHAPE =
84
+ /^\/api\/media\/([a-z]{1,16})\/([0-9a-f]{64})(?:\.([a-z0-9]{1,8}))?$/;
85
+ const IN_MEDIA = /^\/api\/media(\/|$)/;
86
+
87
+ const refuse = (res, status, code, error) =>
88
+ fail(res, status, code, error) ?? true;
89
+ const notFound = (res) => refuse(res, 404, 'not-found', 'no such media file');
90
+
91
+ /** `{network, sha256, ext}` of a valid media path, else null. */
92
+ export const parseMediaPath = (p) => {
93
+ const m = URL_SHAPE.exec(p);
94
+ if (!m || !MEDIA_NETWORKS.includes(m[1])) {
95
+ return null;
96
+ }
97
+ const ext = m[3] ?? '';
98
+ return ext && !MEDIA_EXTENSIONS.includes(ext)
99
+ ? null
100
+ : { network: m[1], sha256: m[2], ext };
101
+ };
102
+
103
+ const rangeOf = (header, size) => {
104
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? ''));
105
+ if (!m || (m[1] === '' && m[2] === '')) {
106
+ return null;
107
+ }
108
+ const start = Math.max(0, m[1] === '' ? size - Number(m[2]) : Number(m[1]));
109
+ const end =
110
+ m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
111
+ return start <= end && start < size
112
+ ? { start, end }
113
+ : { unsatisfiable: true };
114
+ };
115
+
116
+ const send = ({ req, res, file, ext, size }) => {
117
+ const inline = ext in INLINE;
118
+ const headers = {
119
+ 'content-type': INLINE[ext] ?? DOWNLOAD[ext] ?? OPAQUE,
120
+ 'x-content-type-options': 'nosniff',
121
+ 'content-disposition': inline
122
+ ? 'inline'
123
+ : `attachment; filename="media${ext ? `.${ext}` : ''}"`,
124
+ 'cache-control': 'private, max-age=3600',
125
+ 'content-security-policy': "sandbox; default-src 'none'",
126
+ 'accept-ranges': inline ? 'bytes' : 'none',
127
+ };
128
+ const range = inline ? rangeOf(req.headers.range, size) : null;
129
+ if (range?.unsatisfiable) {
130
+ res.writeHead(416, { ...headers, 'content-range': `bytes */${size}` });
131
+ res.end();
132
+ return true;
133
+ }
134
+ const part = range ?? { start: 0, end: size - 1 };
135
+ res.writeHead(range ? 206 : 200, {
136
+ ...headers,
137
+ 'content-length': size === 0 ? 0 : part.end - part.start + 1,
138
+ ...(range
139
+ ? { 'content-range': `bytes ${part.start}-${part.end}/${size}` }
140
+ : {}),
141
+ });
142
+ if (req.method === 'HEAD' || size === 0) {
143
+ res.end();
144
+ return true;
145
+ }
146
+ const stream = createReadStream(file, part);
147
+ stream.on('error', () => res.destroy());
148
+ stream.pipe(res);
149
+ return true;
150
+ };
151
+
152
+ /** GET /api/media/... (see the header). */
153
+ export const handleMediaGets = async (req, res, p, ctx) => {
154
+ if (!IN_MEDIA.test(p) || (req.method !== 'GET' && req.method !== 'HEAD')) {
155
+ return false;
156
+ }
157
+ if (!fromApp(req, ctx?.allowOrigins)) {
158
+ return refuse(res, 403, 'forbidden', 'media loads only in the app');
159
+ }
160
+ const wanted = parseMediaPath(p);
161
+ if (!wanted || !ctx?.storeDir) {
162
+ return notFound(res);
163
+ }
164
+ const { network, sha256, ext } = wanted;
165
+ const file = path.join(
166
+ ctx.storeDir,
167
+ 'media',
168
+ network,
169
+ `${sha256}${ext ? `.${ext}` : ''}`
170
+ );
171
+ try {
172
+ const info = await fs.lstat(file);
173
+ return info.isFile()
174
+ ? send({ req, res, file, ext, size: info.size })
175
+ : notFound(res);
176
+ } catch {
177
+ return notFound(res);
178
+ }
179
+ };
@@ -6,16 +6,19 @@
6
6
  * POST /api/whatsapp/web/close -> closes it
7
7
  * POST /api/whatsapp/archive {name, data(base64)}
8
8
  * -> imports an "Export chat" .txt or .zip
9
+ * (a zip's photos/videos/files are kept in media/whatsapp, R-Z15)
9
10
  *
10
11
  * The sign-in window is for the person to scan the QR code themselves
11
12
  * (see ../sources/whatsapp-web-signin.js); these routes never touch it.
12
13
  */
13
14
 
15
+ import path from 'node:path';
14
16
  import { json, readBody } from './util.js';
15
17
  import { isTrustedMutation } from './routes-auto-import.js';
16
18
  import { safeMessage } from './redact.js';
17
19
  import { createWhatsAppWebSignIn } from '../sources/whatsapp-web-signin.js';
18
20
  import { importWhatsAppFile } from '../sources/whatsapp-file.js';
21
+ import { isFatalWrite } from '../sources/archive/context.js';
19
22
 
20
23
  const PREFIX = '/api/whatsapp/';
21
24
 
@@ -43,7 +46,7 @@ const failure = (res, error) =>
43
46
  code: error.code ?? 'internal',
44
47
  });
45
48
 
46
- const archiveRoute = async (store, req, res) => {
49
+ const archiveRoute = async (store, req, res, ctx) => {
47
50
  const body = await readBody(req, { limit: 64 * 1024 * 1024 });
48
51
  const bytes = Buffer.from(String(body.data ?? ''), 'base64');
49
52
  if (bytes.length === 0 || typeof body.name !== 'string') {
@@ -53,9 +56,21 @@ const archiveRoute = async (store, req, res) => {
53
56
  });
54
57
  }
55
58
  try {
56
- return reply(res, 200, await importWhatsAppFile(store, body.name, bytes));
59
+ // Media files of a zip are kept next to the store (R-Z15).
60
+ const mediaDir = ctx?.storeDir ? path.join(ctx.storeDir, 'media') : null;
61
+ return reply(
62
+ res,
63
+ 200,
64
+ await importWhatsAppFile(store, body.name, bytes, { mediaDir })
65
+ );
57
66
  } catch (error) {
58
- return reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
67
+ // A full or unwritable disk is not a bad file (R-Z14).
68
+ return isFatalWrite(error)
69
+ ? reply(res, 507, {
70
+ error: safeMessage(error),
71
+ code: 'store-write-failed',
72
+ })
73
+ : reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
59
74
  }
60
75
  };
61
76
 
@@ -94,7 +109,7 @@ export const handleWhatsAppRoutes = async (store, req, res, p, ctx) => {
94
109
  });
95
110
  }
96
111
  if (isArchive) {
97
- return req.method === 'POST' ? archiveRoute(store, req, res) : false;
112
+ return req.method === 'POST' ? archiveRoute(store, req, res, ctx) : false;
98
113
  }
99
114
  const expected = route === 'web' ? 'GET' : 'POST';
100
115
  return req.method === expected
@@ -154,6 +154,11 @@ const KINDS = [
154
154
  const kindOf = (name) =>
155
155
  KINDS.find(([, re]) => re.test(name))?.[0] ?? 'document';
156
156
 
157
+ // The page's network-neutral attachment kinds (R-Z15); `type` stays as
158
+ // stored by R-Z9 so earlier imports and later ones read alike.
159
+ const PAGE_KIND = { image: 'photo', document: 'file', contact: 'file' };
160
+ const pageKind = (type) => PAGE_KIND[type] ?? type;
161
+
157
162
  const lookupFile = (files, name) => {
158
163
  const base = (f) => String(f).split(/[\\/]/).pop();
159
164
  return (
@@ -169,7 +174,13 @@ const mediaOf = (body, files) => {
169
174
  const line = first.trim();
170
175
  if (OMITTED.test(line)) {
171
176
  return {
172
- attachment: { type: 'media', name: null, file: null, omitted: true },
177
+ attachment: {
178
+ type: 'media',
179
+ kind: 'file',
180
+ name: null,
181
+ file: null,
182
+ omitted: true,
183
+ },
173
184
  caption: more.join('\n').trim(),
174
185
  };
175
186
  }
@@ -179,8 +190,9 @@ const mediaOf = (body, files) => {
179
190
  return null;
180
191
  }
181
192
  const file = lookupFile(files, name);
193
+ const type = kindOf(name);
182
194
  return {
183
- attachment: { type: kindOf(name), name, file, omitted: false },
195
+ attachment: { type, kind: pageKind(type), name, file, omitted: false },
184
196
  caption: more.join('\n').trim(),
185
197
  };
186
198
  };
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Import one WhatsApp "Export chat" file that arrived as bytes (R-Z9):
3
- * the `.txt`, or the `.zip` of an export with media. The text is parsed
4
- * by ./whatsapp-archive.js; the zip's other entries are only used to map
5
- * `<attached: ...>` names to files inside the zip (the media bytes are
6
- * not copied into the store).
2
+ * Import one WhatsApp "Export chat" file that arrived as bytes (R-Z9,
3
+ * R-Z15): the `.txt`, or the `.zip` of an export with media. The text is
4
+ * parsed by ./whatsapp-archive.js. A zip is written to a private temp file
5
+ * and read with the streaming reader (./archive/zip-file.js); each file
6
+ * the chat names (`<attached: f>`, `f (file attached)`) is copied into
7
+ * `<store>/media/whatsapp/<sha256>.<ext>` (0600) and the attachment gets
8
+ * `path`, `sha256` and `size`, so the page can show it. Without a
9
+ * `mediaDir` the names are mapped but no bytes are kept.
7
10
  */
8
11
 
9
- import { readZipEntries } from './auto-import/archive.js';
12
+ import { promises as fs } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { createArchiveContext } from './archive/context.js';
16
+ import { mediaSaver } from './archive/media-store.js';
17
+ import { openZipFile } from './archive/zip-file.js';
10
18
  import { getSource, stampSourceLink } from './index.js';
11
19
 
12
20
  const ZIP_LOCAL_HEADER = 0x04034b50;
@@ -19,23 +27,88 @@ const textEntry = (entries) =>
19
27
  entries.find((e) => /(^|\/)_chat\.txt$/i.test(e.name)) ??
20
28
  entries.find((e) => /\.txt$/i.test(e.name));
21
29
 
22
- export const importWhatsAppFile = async (store, name, bytes) => {
23
- let archive = { text: bytes.toString('utf8'), fileName: name, files: [] };
24
- if (isZip(name, bytes)) {
25
- const entries = readZipEntries(bytes);
26
- const chat = textEntry(entries);
27
- if (!chat) {
28
- throw new Error('no chat .txt found in this zip');
30
+ // Copy each attachment's file into media/ and say where it went.
31
+ const withMedia = async (links, ctx, counts) => {
32
+ const out = [];
33
+ for (const link of links) {
34
+ const attachments = [];
35
+ for (const attachment of link.attachments ?? []) {
36
+ const copy = attachment.file ? await ctx.media(attachment.file) : null;
37
+ counts[copy ? 'saved' : 'missing'] += attachment.omitted ? 0 : 1;
38
+ attachments.push(
39
+ copy
40
+ ? {
41
+ ...attachment,
42
+ path: copy.path,
43
+ sha256: copy.sha256,
44
+ size: copy.size,
45
+ }
46
+ : attachment
47
+ );
29
48
  }
30
- archive = {
31
- text: chat.read().toString('utf8'),
32
- fileName: /whatsapp/i.test(chat.name) ? chat.name : name,
33
- files: entries.map((e) => e.name),
34
- };
49
+ out.push(attachments.length ? { ...link, attachments } : link);
35
50
  }
36
- const links = await getSource('whatsapp').parseArchive(archive);
51
+ return out;
52
+ };
53
+
54
+ const readZip = async (name, bytes, mediaDir) => {
55
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-whatsapp-'));
56
+ const file = path.join(dir, 'export.zip');
57
+ try {
58
+ await fs.writeFile(file, bytes, { mode: 0o600 });
59
+ const zip = await openZipFile(file);
60
+ try {
61
+ const chat = textEntry(zip.entries);
62
+ if (!chat) {
63
+ throw new Error('no chat .txt found in this zip');
64
+ }
65
+ const archive = {
66
+ text: (await chat.read()).toString('utf8'),
67
+ fileName: /whatsapp/i.test(chat.name) ? chat.name : name,
68
+ files: zip.entries.map((e) => e.name),
69
+ };
70
+ const ctx = createArchiveContext({
71
+ network: 'whatsapp',
72
+ entries: zip.entries,
73
+ save: mediaDir ? mediaSaver(mediaDir) : null,
74
+ });
75
+ const counts = { saved: 0, missing: 0 };
76
+ const links = await withMedia(
77
+ await getSource('whatsapp').parseArchive(archive),
78
+ ctx,
79
+ counts
80
+ );
81
+ return { links, media: { ...counts, warnings: ctx.warnings } };
82
+ } finally {
83
+ await zip.close();
84
+ }
85
+ } finally {
86
+ await fs.rm(dir, { recursive: true, force: true });
87
+ }
88
+ };
89
+
90
+ export const importWhatsAppFile = async (
91
+ store,
92
+ name,
93
+ bytes,
94
+ { mediaDir = null } = {}
95
+ ) => {
96
+ const { links, media } = isZip(name, bytes)
97
+ ? await readZip(name, bytes, mediaDir)
98
+ : {
99
+ links: await getSource('whatsapp').parseArchive({
100
+ text: bytes.toString('utf8'),
101
+ fileName: name,
102
+ files: [],
103
+ }),
104
+ media: null,
105
+ };
37
106
  for (const link of links) {
38
107
  await store.put(stampSourceLink(link, 'whatsapp'));
39
108
  }
40
- return { imported: links.length, chat: links[0]?.chat ?? null };
109
+ return {
110
+ imported: links.length,
111
+ chat: links[0]?.chat ?? null,
112
+ ...(media ? { media } : {}),
113
+ };
41
114
  };
@@ -12556,3 +12556,14 @@ a.remote-chip:hover {
12556
12556
  main[data-view='chat'] > .api-error-toast {
12557
12557
  margin: 0;
12558
12558
  }
12559
+
12560
+ /* R-Z15: attachments of imported archive messages (pictures, players,
12561
+ file chips, "media not imported"). */
12562
+ .archive-media audio,
12563
+ .archive-media video {
12564
+ max-width: 100%;
12565
+ }
12566
+ .remote-chip.is-missing {
12567
+ color: var(--text-muted);
12568
+ border-style: dashed;
12569
+ }
@@ -0,0 +1,28 @@
1
+ // What the page needs to know about an imported archive attachment
2
+ // (R-Z15); pure, so it is tested without a browser.
3
+
4
+ // `media/<network>/<64 hex>[.ext]`, the only paths the importers write.
5
+ const STORED = /^media\/([a-z]{1,16})\/([0-9a-f]{64}(?:\.[a-z0-9]{1,8})?)$/;
6
+
7
+ // What older WhatsApp imports stored: `type`, no `kind`.
8
+ const LEGACY_KIND = { image: 'photo', document: 'file', contact: 'file' };
9
+
10
+ /** The server path (`/api/media/...`) of a stored attachment, or null. */
11
+ export const mediaPathOf = (attachment) => {
12
+ const m = STORED.exec(String(attachment?.path ?? ''));
13
+ return m ? `/api/media/${m[1]}/${m[2]}` : null;
14
+ };
15
+
16
+ /** One attachment as the page draws it. */
17
+ export const archiveItem = (attachment) => {
18
+ const a = attachment ?? {};
19
+ return {
20
+ kind: a.kind ?? LEGACY_KIND[a.type] ?? a.type ?? 'file',
21
+ name: a.name ?? null,
22
+ size: Number(a.size) || 0,
23
+ path: mediaPathOf(a),
24
+ url: /^https?:\/\//.test(a.url ?? '') ? a.url : null,
25
+ omitted: Boolean(a.omitted),
26
+ caption: a.caption ?? null,
27
+ };
28
+ };
@@ -0,0 +1,143 @@
1
+ // Attachments of imported archive messages (R-Z15): Facebook/Messenger,
2
+ // Instagram, X, LinkedIn and WhatsApp exports. The importers copy each
3
+ // file to `media/<network>/<sha256>.<ext>` and store `{kind, name, path,
4
+ // sha256, size}` on the message; the server shows it at
5
+ // /api/media/<network>/<file> (../server/routes-media.js). Pictures are
6
+ // drawn inline, audio and video get a player, other files are chips with
7
+ // name and size; a file that was not imported (the archive did not hold
8
+ // it, or WhatsApp omitted it) is a "media not imported" chip, never a
9
+ // broken picture.
10
+
11
+ import React, { useEffect, useState } from 'react';
12
+ import { useT } from './i18n.js';
13
+ import { mediaSrc } from './media-src.js';
14
+ import { formatSize } from './download/model.js';
15
+
16
+ const el = React.createElement;
17
+
18
+ // The address the page loads the file from (the app's server).
19
+ const useMediaUrl = (api, path) => {
20
+ const [url, setUrl] = useState(null);
21
+ useEffect(() => {
22
+ let live = true;
23
+ if (!path) {
24
+ return undefined;
25
+ }
26
+ const wanted =
27
+ typeof api?.archiveMediaUrl === 'function'
28
+ ? api.archiveMediaUrl(path)
29
+ : Promise.resolve(path);
30
+ wanted.then((got) => live && setUrl(got ?? null)).catch(() => {});
31
+ return () => {
32
+ live = false;
33
+ };
34
+ }, [api, path]);
35
+ return url;
36
+ };
37
+
38
+ const Missing = ({ item }) => {
39
+ const t = useT();
40
+ return el(
41
+ 'span',
42
+ {
43
+ className: 'remote-chip is-missing',
44
+ 'data-archive-missing': item.kind,
45
+ title: t('chat.archive.notImportedShort'),
46
+ },
47
+ t('chat.archive.notImported', { name: item.name ?? t('chat.remote.file') })
48
+ );
49
+ };
50
+
51
+ const FileChip = ({ item, url }) => {
52
+ const t = useT();
53
+ const label = [item.name ?? t('chat.remote.file'), formatSize(item.size)]
54
+ .filter(Boolean)
55
+ .join(' · ');
56
+ return el(
57
+ 'a',
58
+ {
59
+ className: 'remote-chip',
60
+ 'data-archive-file': item.kind,
61
+ href: url,
62
+ download: item.name ?? '',
63
+ title: t('chat.archive.save', { name: item.name ?? '' }),
64
+ },
65
+ `📎 ${label}`
66
+ );
67
+ };
68
+
69
+ const PICTURES = new Set(['photo', 'gif', 'sticker']);
70
+
71
+ const Stored = ({ item, url }) => {
72
+ const [broken, setBroken] = useState(false);
73
+ if (!url) {
74
+ return null;
75
+ }
76
+ const label = item.name ?? item.kind;
77
+ if (PICTURES.has(item.kind) && !broken) {
78
+ return el(
79
+ 'a',
80
+ { href: url, target: '_blank', rel: 'noopener noreferrer', title: label },
81
+ el('img', {
82
+ ...mediaSrc(url),
83
+ className: `remote-img remote-${item.kind}`,
84
+ alt: label,
85
+ loading: 'lazy',
86
+ decoding: 'async',
87
+ 'data-archive-image': '',
88
+ onError: () => setBroken(true),
89
+ })
90
+ );
91
+ }
92
+ if (item.kind === 'video' && !broken) {
93
+ return el('video', {
94
+ ...mediaSrc(url),
95
+ className: 'remote-img',
96
+ controls: true,
97
+ preload: 'metadata',
98
+ 'data-archive-video': '',
99
+ onError: () => setBroken(true),
100
+ });
101
+ }
102
+ if ((item.kind === 'audio' || item.kind === 'voice') && !broken) {
103
+ return el('audio', {
104
+ ...mediaSrc(url),
105
+ controls: true,
106
+ preload: 'none',
107
+ 'data-archive-audio': '',
108
+ onError: () => setBroken(true),
109
+ });
110
+ }
111
+ return el(FileChip, { item, url });
112
+ };
113
+
114
+ const Item = ({ item, api }) => {
115
+ const url = useMediaUrl(api, item.path);
116
+ if (item.kind === 'link' && item.url) {
117
+ return el(
118
+ 'a',
119
+ {
120
+ className: 'remote-chip',
121
+ href: item.url,
122
+ target: '_blank',
123
+ rel: 'noopener noreferrer',
124
+ },
125
+ item.caption || item.url
126
+ );
127
+ }
128
+ if (item.omitted || !item.path) {
129
+ return el(Missing, { item });
130
+ }
131
+ return el(Stored, { item, url });
132
+ };
133
+
134
+ /** All the attachments of one imported message, in order. */
135
+ export const ArchiveAttachments = ({ media, api }) =>
136
+ el(
137
+ 'div',
138
+ {
139
+ className: 'remote-media archive-media',
140
+ 'data-archive-media': String(media.items.length),
141
+ },
142
+ media.items.map((item, index) => el(Item, { key: index, item, api }))
143
+ );
@@ -25,6 +25,7 @@ import { useNowSeconds } from './live-clock.js';
25
25
  import { useAutoLoad } from './auto-download.js';
26
26
  import { LottieSticker } from './lottie-sticker.js';
27
27
  import { RemoteAttachments } from './chat-remote-media.js';
28
+ import { ArchiveAttachments } from './chat-archive-media.js';
28
29
  import {
29
30
  Transcribable,
30
31
  TranscribeButton,
@@ -913,6 +914,8 @@ const LiveStreamCard = ({ media }) => {
913
914
  const CARDS = {
914
915
  // All the attachments of a message at another network (R-Z8).
915
916
  remote: RemoteAttachments,
917
+ // Files kept from an imported archive (R-Z15).
918
+ archive: ArchiveAttachments,
916
919
  'live-stream': LiveStreamCard,
917
920
  place: PlaceCard,
918
921
  contact: ContactCard,
@@ -292,7 +292,11 @@ const Reactions = ({ reactions, paid, api, chat, msgId }) => {
292
292
  ...(reactions ?? []).map((r) =>
293
293
  el(
294
294
  'span',
295
- { key: r.emoticon, className: 'bubble-reaction' },
295
+ {
296
+ key: r.emoticon,
297
+ className: 'bubble-reaction',
298
+ title: r.by?.length ? r.by.join(', ') : undefined,
299
+ },
296
300
  `${r.emoticon} ${r.count}`
297
301
  )
298
302
  ),
package/js/src/web/dom.js CHANGED
@@ -1334,6 +1334,12 @@ const rawApi = {
1334
1334
  return scopedUrl(origin ? new URL(path, origin).toString() : path);
1335
1335
  },
1336
1336
 
1337
+ // An imported archive file's address (R-Z15): `/api/media/<network>/<file>`.
1338
+ archiveMediaUrl: async (path) => {
1339
+ const { origin } = await ensure();
1340
+ return scopedUrl(origin ? new URL(path, origin).toString() : path);
1341
+ },
1342
+
1337
1343
  // Fetch new mail from the saved connection (R-Z5): { ok, imported, … }
1338
1344
  // or { ok: false, error, code }.
1339
1345
  emailPull: async (body = {}) =>
@@ -446,4 +446,8 @@ export const enChatAccount = {
446
446
  '{members} members read from {networks} networks.',
447
447
  'automations.audiences.syncFailed':
448
448
  'The members could not be read. Try again.',
449
+ // Imported archive media (R-Z15).
450
+ 'chat.archive.notImported': 'Media not imported: {name}',
451
+ 'chat.archive.notImportedShort': 'Media not imported',
452
+ 'chat.archive.save': 'Save {name}',
449
453
  };
@@ -450,4 +450,8 @@ export const hiChatAccount = {
450
450
  'automations.audiences.synced':
451
451
  '{networks} नेटवर्क से {members} सदस्य पढ़े गए।',
452
452
  'automations.audiences.syncFailed': 'सदस्य नहीं पढ़े जा सके। फिर कोशिश करें।',
453
+ // Imported archive media (R-Z15).
454
+ 'chat.archive.notImported': 'मीडिया आयात नहीं हुआ: {name}',
455
+ 'chat.archive.notImportedShort': 'मीडिया आयात नहीं हुआ',
456
+ 'chat.archive.save': '{name} सहेजें',
453
457
  };
@@ -471,4 +471,8 @@ export const ruChatAccount = {
471
471
  'Прочитано участников: {members}, сетей: {networks}.',
472
472
  'automations.audiences.syncFailed':
473
473
  'Не удалось прочитать участников. Попробуйте ещё раз.',
474
+ // Imported archive media (R-Z15).
475
+ 'chat.archive.notImported': 'Медиа не импортировано: {name}',
476
+ 'chat.archive.notImportedShort': 'Медиа не импортировано',
477
+ 'chat.archive.save': 'Сохранить {name}',
474
478
  };
@@ -410,4 +410,8 @@ export const zhChatAccount = {
410
410
  'automations.audiences.synced':
411
411
  '已从 {networks} 个网络读取 {members} 名成员。',
412
412
  'automations.audiences.syncFailed': '无法读取成员,请重试。',
413
+ // Imported archive media (R-Z15).
414
+ 'chat.archive.notImported': '媒体未导入:{name}',
415
+ 'chat.archive.notImportedShort': '媒体未导入',
416
+ 'chat.archive.save': '保存 {name}',
413
417
  };
@@ -8,6 +8,7 @@
8
8
  // code copied — the algorithm is Telegram's own spec).
9
9
 
10
10
  import { parsePoll, parseTodo } from './poll-parts.js';
11
+ import { archiveItem } from './archive-media-model.js';
11
12
  import { quoteRichText, richOf } from './rich-message-model.js';
12
13
  import { richServiceWords } from './rich-service-model.js';
13
14
  import { inTopicOnly } from './forum-topics.js';
@@ -683,7 +684,12 @@ const parseReactions = (raw, link) =>
683
684
  .map((r) => ({ emoticon: r.reaction.emoticon, count: r.count ?? 1 }))
684
685
  : (Array.isArray(link?.reactions) ? link.reactions : [])
685
686
  .filter((r) => r?.emoji)
686
- .map((r) => ({ emoticon: r.emoji, count: r.count ?? 1 }));
687
+ .map((r) => ({
688
+ emoticon: r.emoji,
689
+ count: r.count ?? 1,
690
+ // Who reacted, when the import kept it (R-Z15).
691
+ by: Array.isArray(r.by) ? r.by.map(String) : [],
692
+ }));
687
693
 
688
694
  // A label for an attachment that is not a file (a place, a contact…).
689
695
  const ATTACHMENT_LABELS = {
@@ -706,6 +712,22 @@ const remoteMedia = (list) => ({
706
712
  })),
707
713
  });
708
714
 
715
+ // Imported archives keep their files in media/ (R-Z15): all attachments
716
+ // of such a message are drawn together (./chat-archive-media.js).
717
+ const ARCHIVE_SOURCES = new Set([
718
+ 'facebook',
719
+ 'instagram',
720
+ 'x',
721
+ 'linkedin',
722
+ 'whatsapp',
723
+ ]);
724
+
725
+ const archiveMedia = (list) => ({
726
+ kind: 'archive',
727
+ type: list[0].kind ?? 'file',
728
+ items: list.map(archiveItem),
729
+ });
730
+
709
731
  // One attachment as a link or a file.
710
732
  const singleAttachmentMedia = (a) => {
711
733
  if (a.kind === 'link' && a.url) {
@@ -734,13 +756,17 @@ const singleAttachmentMedia = (a) => {
734
756
  * attachments together as `remote` (all of them), else the first one as a
735
757
  * link or a file.
736
758
  */
737
- export const attachmentMedia = (attachments) => {
759
+ export const attachmentMedia = (attachments, source = null) => {
760
+ const archive = ARCHIVE_SOURCES.has(source);
738
761
  const list = (Array.isArray(attachments) ? attachments : []).filter(
739
- (a) => a?.kind
762
+ (a) => a?.kind || (archive && a?.type)
740
763
  );
741
764
  if (list.length === 0) {
742
765
  return null;
743
766
  }
767
+ if (archive) {
768
+ return archiveMedia(list);
769
+ }
744
770
  return list.length > 1 || REMOTE_KINDS.has(list[0].kind)
745
771
  ? remoteMedia(list)
746
772
  : singleAttachmentMedia(list[0]);
@@ -751,6 +777,11 @@ export const attachmentMedia = (attachments) => {
751
777
  const bodyOf = (link, rich) =>
752
778
  typeof link?.body === 'string' && !rich && !link.bodyTree ? link.body : '';
753
779
 
780
+ const linkMedia = (link) =>
781
+ link?.source === 'email'
782
+ ? null
783
+ : attachmentMedia(link?.attachments, link?.source);
784
+
754
785
  /**
755
786
  * Parse a message link into all display parts.
756
787
  *
@@ -761,9 +792,7 @@ const bodyOf = (link, rich) =>
761
792
  export const parseMessageParts = (link, msgById = new Map()) => {
762
793
  const raw = link?.raw ?? {};
763
794
  // A mail lists its attachments itself (chat-email-body.js).
764
- const media =
765
- parseMedia(raw) ??
766
- (link?.source === 'email' ? null : attachmentMedia(link?.attachments));
795
+ const media = parseMedia(raw) ?? linkMedia(link);
767
796
  const rich = richOf(raw);
768
797
  const body = bodyOf(link, rich);
769
798
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meta-sovereign",
3
- "version": "0.49.9",
3
+ "version": "0.49.10",
4
4
  "description": "Personal meta profile sovereign system - fully local, privacy-respecting personal CRM and unified messenger across services",
5
5
  "type": "module",
6
6
  "main": "js/src/index.js",
@@ -43,6 +43,8 @@
43
43
  "test:e2e:browser:webkit": "E2E_BROWSER=webkit node js/tests/e2e-browser-spa.mjs",
44
44
  "test:e2e:pages": "node js/tests/e2e-pages.mjs",
45
45
  "test:pack": "node js/tests/pack-smoke.mjs",
46
+ "test:e2e:archive-media": "node js/tests/e2e-archive-media-browser.mjs",
47
+ "test:e2e:archive-media:webkit": "E2E_BROWSER=webkit node js/tests/e2e-archive-media-browser.mjs",
46
48
  "test:e2e:email": "node js/tests/e2e-email-browser.mjs",
47
49
  "test:e2e:email:webkit": "E2E_BROWSER=webkit node js/tests/e2e-email-browser.mjs",
48
50
  "test:e2e:cv": "node js/tests/e2e-cv-browser.mjs",