@robono/messaging-react-native 0.1.0 → 0.3.0
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.
- package/CHANGELOG.md +65 -0
- package/README.md +160 -8
- package/SBOM.spdx.json +1985 -209
- package/dist/account.d.ts +15 -0
- package/dist/account.js +27 -0
- package/dist/call-message.js +7 -1
- package/dist/calling.js +5 -9
- package/dist/conversation-details.d.ts +24 -0
- package/dist/conversation-details.js +83 -0
- package/dist/emoji-text.d.ts +2 -0
- package/dist/emoji-text.js +8 -0
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-history.d.ts +3 -0
- package/dist/expo-history.js +59 -0
- package/dist/expo-media.d.ts +1 -0
- package/dist/expo-media.js +69 -43
- package/dist/expo-messenger.d.ts +35 -0
- package/dist/expo-messenger.js +39 -0
- package/dist/expo-notifications.d.ts +31 -0
- package/dist/expo-notifications.js +92 -0
- package/dist/expo-outbox.d.ts +13 -0
- package/dist/expo-outbox.js +82 -0
- package/dist/expo-privacy.d.ts +4 -0
- package/dist/expo-privacy.js +7 -0
- package/dist/expo-saved.d.ts +24 -0
- package/dist/expo-saved.js +76 -0
- package/dist/expo-screen.d.ts +42 -0
- package/dist/expo-screen.js +557 -0
- package/dist/expo-social.d.ts +3 -0
- package/dist/expo-social.js +21 -0
- package/dist/expo-thumbnails.d.ts +9 -0
- package/dist/expo-thumbnails.js +68 -0
- package/dist/expo.d.ts +2 -36
- package/dist/expo.js +2 -429
- package/dist/extras-lifecycle.d.ts +2 -0
- package/dist/extras-lifecycle.js +27 -0
- package/dist/extras.d.ts +49 -0
- package/dist/extras.js +49 -0
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +62 -0
- package/dist/group-editor.d.ts +18 -0
- package/dist/group-editor.js +98 -0
- package/dist/header-calls.d.ts +18 -0
- package/dist/header-calls.js +45 -0
- package/dist/icons.d.ts +1 -1
- package/dist/inbox-search.d.ts +11 -0
- package/dist/inbox-search.js +42 -0
- package/dist/inbox.d.ts +19 -0
- package/dist/inbox.js +52 -0
- package/dist/index.d.ts +76 -3
- package/dist/index.js +648 -167
- package/dist/media-controller.d.ts +21 -0
- package/dist/media-controller.js +157 -3
- package/dist/media-readiness.d.ts +1 -1
- package/dist/media-ui.d.ts +27 -5
- package/dist/media-ui.js +95 -29
- package/dist/message-links.d.ts +5 -0
- package/dist/message-links.js +30 -0
- package/dist/message-menu.d.ts +28 -0
- package/dist/message-menu.js +24 -0
- package/dist/message-order.d.ts +7 -0
- package/dist/message-order.js +27 -0
- package/dist/messenger-video-view.d.ts +8 -0
- package/dist/messenger-video-view.js +46 -0
- package/dist/native-outbox.d.ts +55 -0
- package/dist/native-outbox.js +53 -0
- package/dist/photo-gesture.d.ts +20 -0
- package/dist/photo-gesture.js +52 -0
- package/dist/pollVoteQueue.d.ts +3 -0
- package/dist/pollVoteQueue.js +29 -0
- package/dist/polls.d.ts +7 -2
- package/dist/polls.js +89 -20
- package/dist/popup.d.ts +51 -0
- package/dist/popup.js +73 -0
- package/dist/quick-send.d.ts +15 -0
- package/dist/quick-send.js +26 -0
- package/dist/recent-media.js +1 -0
- package/dist/safety.d.ts +10 -1
- package/dist/safety.js +29 -8
- package/dist/saved.d.ts +10 -0
- package/dist/saved.js +51 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.js +59 -0
- package/dist/social.d.ts +25 -0
- package/dist/social.js +31 -0
- package/dist/translation-settings.d.ts +12 -0
- package/dist/translation-settings.js +16 -0
- package/dist/use-quote-gesture.d.ts +5 -1
- package/dist/use-quote-gesture.js +35 -17
- package/dist/visual/RobonoIcon.js +2 -1
- package/dist/visual/WeatherArtwork.d.ts +9 -0
- package/dist/visual/WeatherArtwork.js +43 -0
- package/dist/visual/weather-artwork.d.ts +17 -0
- package/dist/visual/weather-artwork.js +86 -0
- package/dist/visual/weather-conditions.d.ts +5 -0
- package/dist/visual/weather-conditions.js +2 -0
- package/dist/voice-composer.js +2 -2
- package/dist/voice-waveform.d.ts +2 -0
- package/dist/voice-waveform.js +21 -0
- package/package.json +41 -5
|
@@ -20,6 +20,7 @@ export interface MessagingMediaStorage {
|
|
|
20
20
|
conversationId: string;
|
|
21
21
|
kind: MediaKind;
|
|
22
22
|
}): Promise<MessageMedia>;
|
|
23
|
+
thumbnail?(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia | null>;
|
|
23
24
|
download(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia>;
|
|
24
25
|
/** Optional native streaming for voice/video. Return null to use a verified download. */
|
|
25
26
|
stream?(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia | null>;
|
|
@@ -28,6 +29,8 @@ export interface MessagingMediaStorage {
|
|
|
28
29
|
}
|
|
29
30
|
export interface MessagingMediaDevice {
|
|
30
31
|
pick(kind: Exclude<MediaKind, 'voice'>, settings: MessagingSettings, signal: AbortSignal, source?: "camera" | "call-video"): Promise<LocalMessagingMedia | null>;
|
|
32
|
+
/** Ordered local selections. No upload or send occurs in the picker. */
|
|
33
|
+
pickMany?(kind: Exclude<MediaKind, 'voice'>, settings: MessagingSettings, signal: AbortSignal, maximum: number): Promise<LocalMessagingMedia[]>;
|
|
31
34
|
startRecording(maxDurationMs: number, signal: AbortSignal): Promise<void>;
|
|
32
35
|
recordingStatus(): {
|
|
33
36
|
durationMs: number;
|
|
@@ -69,24 +72,42 @@ export declare class MessagingMediaController {
|
|
|
69
72
|
private stageRecording;
|
|
70
73
|
private openedMessage;
|
|
71
74
|
private prepared;
|
|
75
|
+
private preparedUploads;
|
|
76
|
+
private preparedAborts;
|
|
72
77
|
getPrepared: (assetId: string) => LocalMessagingMedia | undefined;
|
|
78
|
+
restorePrepared(assetId: string, file: LocalMessagingMedia, kind: MediaKind): void;
|
|
73
79
|
releasePrepared(assetId: string): Promise<void>;
|
|
80
|
+
/** Browser drafts stay local until Send; the original ID survives upload retries. */
|
|
81
|
+
uploadPrepared(assetId: string, kind: MediaKind): Promise<MessageMedia>;
|
|
82
|
+
private forwardUploads;
|
|
83
|
+
/** Download through source authorization and upload a new destination-owned asset. */
|
|
84
|
+
prepareForward(message: MessagingMessage, target: {
|
|
85
|
+
conversationId: string;
|
|
86
|
+
settings: MessagingSettings;
|
|
87
|
+
uploadId: string;
|
|
88
|
+
}): Promise<MessageMedia>;
|
|
74
89
|
constructor(options: {
|
|
75
90
|
device: MessagingMediaDevice;
|
|
76
91
|
storage: MessagingMediaStorage;
|
|
77
92
|
createId: () => string;
|
|
78
93
|
getSettings: () => MessagingSettings;
|
|
79
94
|
getConversationId: () => string;
|
|
95
|
+
stageLocal?: boolean;
|
|
80
96
|
});
|
|
81
97
|
getSnapshot: () => MessagingMediaState;
|
|
82
98
|
subscribe: (f: () => void) => () => void;
|
|
83
99
|
private update;
|
|
84
100
|
private isCurrent;
|
|
85
101
|
pick(kind: MediaKind, source?: "camera" | "call-video", selectionIntent?: "preview" | "send" | "attach"): Promise<MessageMedia | null>;
|
|
102
|
+
pickMany(kind: Exclude<MediaKind, 'voice'>, maximum: number): Promise<MessageMedia[]>;
|
|
86
103
|
private check;
|
|
87
104
|
finishRecording(interrupted?: boolean): Promise<void>;
|
|
88
105
|
send(): Promise<void>;
|
|
106
|
+
/** View an owned cache copy of a draft. Closing never removes the original draft. */
|
|
107
|
+
previewLocal(file: LocalMessagingMedia, kind: MediaKind): Promise<void>;
|
|
89
108
|
open(message: MessagingMessage): Promise<void>;
|
|
109
|
+
/** Save/share a local copy of a playing stream without replacing its playback source. */
|
|
110
|
+
exportFile<T>(save: (file: LocalMessagingMedia) => Promise<T>): Promise<T>;
|
|
90
111
|
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
91
112
|
fallbackStream(uri: string, positionMs: number, autoPlay: boolean): Promise<void>;
|
|
92
113
|
private openMedia;
|
package/dist/media-controller.js
CHANGED
|
@@ -18,9 +18,70 @@ export class MessagingMediaController {
|
|
|
18
18
|
stageRecording = false;
|
|
19
19
|
openedMessage = null;
|
|
20
20
|
prepared = new Map();
|
|
21
|
+
preparedUploads = new Map();
|
|
22
|
+
preparedAborts = new Map();
|
|
21
23
|
getPrepared = (assetId) => this.prepared.get(assetId);
|
|
22
|
-
|
|
24
|
+
restorePrepared(assetId, file, kind) {
|
|
25
|
+
if (this.disposed)
|
|
26
|
+
throw Error('Messaging media is closed.');
|
|
27
|
+
validateMessageMedia(kind, { ...file, assetId }, this.options.getSettings());
|
|
28
|
+
this.prepared.set(assetId, file);
|
|
29
|
+
}
|
|
30
|
+
async releasePrepared(assetId) { const file = this.prepared.get(assetId); this.prepared.delete(assetId); this.preparedUploads.delete(assetId); this.preparedAborts.get(assetId)?.abort(); this.preparedAborts.delete(assetId); if (file)
|
|
23
31
|
await this.options.device.remove(file).catch(() => { }); }
|
|
32
|
+
/** Browser drafts stay local until Send; the original ID survives upload retries. */
|
|
33
|
+
async uploadPrepared(assetId, kind) {
|
|
34
|
+
const saved = this.preparedUploads.get(assetId);
|
|
35
|
+
if (saved)
|
|
36
|
+
return saved;
|
|
37
|
+
const file = this.prepared.get(assetId);
|
|
38
|
+
if (!file || this.disposed)
|
|
39
|
+
throw Error('Choose the attachment again.');
|
|
40
|
+
const conversationId = this.options.getConversationId(), abort = new AbortController();
|
|
41
|
+
this.preparedAborts.set(assetId, abort);
|
|
42
|
+
try {
|
|
43
|
+
validateMessageMedia(kind, { ...file, assetId }, this.options.getSettings());
|
|
44
|
+
const media = await this.options.storage.upload(file, { uploadId: assetId, conversationId, kind, signal: abort.signal, onProgress: () => { } });
|
|
45
|
+
if (abort.signal.aborted || this.disposed || conversationId !== this.options.getConversationId())
|
|
46
|
+
throw Error('The conversation changed.');
|
|
47
|
+
validateMessageMedia(kind, media, this.options.getSettings());
|
|
48
|
+
this.preparedUploads.set(assetId, media);
|
|
49
|
+
return media;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (this.preparedAborts.get(assetId) === abort)
|
|
53
|
+
this.preparedAborts.delete(assetId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
forwardUploads = new Map();
|
|
57
|
+
/** Download through source authorization and upload a new destination-owned asset. */
|
|
58
|
+
async prepareForward(message, target) {
|
|
59
|
+
if (!message.media || message.kind === 'text' || message.kind === 'poll' || this.disposed)
|
|
60
|
+
throw Error('This attachment cannot be forwarded.');
|
|
61
|
+
validateMessageMedia(message.kind, message.media, target.settings);
|
|
62
|
+
const key = target.conversationId + ':' + target.uploadId, saved = this.forwardUploads.get(key);
|
|
63
|
+
if (saved)
|
|
64
|
+
return saved;
|
|
65
|
+
const source = this.options.getConversationId(), abort = new AbortController();
|
|
66
|
+
this.preparedAborts.set(key, abort);
|
|
67
|
+
let file;
|
|
68
|
+
try {
|
|
69
|
+
file = await this.options.storage.download(message, { signal: abort.signal, onProgress: () => { } });
|
|
70
|
+
if (abort.signal.aborted || this.disposed || source !== this.options.getConversationId())
|
|
71
|
+
throw Error('The conversation changed.');
|
|
72
|
+
const media = await this.options.storage.upload(file, { ...target, kind: message.kind, signal: abort.signal, onProgress: () => { } });
|
|
73
|
+
validateMessageMedia(message.kind, media, target.settings);
|
|
74
|
+
this.forwardUploads.set(key, media);
|
|
75
|
+
if (this.forwardUploads.size > 100)
|
|
76
|
+
this.forwardUploads.delete(this.forwardUploads.keys().next().value);
|
|
77
|
+
return media;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
this.preparedAborts.delete(key);
|
|
81
|
+
if (file)
|
|
82
|
+
await this.options.device.remove(file).catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
24
85
|
constructor(options) {
|
|
25
86
|
this.options = options;
|
|
26
87
|
}
|
|
@@ -86,7 +147,17 @@ export class MessagingMediaController {
|
|
|
86
147
|
this.update({ file, ...(source === 'camera' ? { kind: file.mimeType.startsWith('video/') ? 'video' : 'image' } : {}) });
|
|
87
148
|
this.check(file);
|
|
88
149
|
this.update({ phase: 'review' });
|
|
89
|
-
if (selectionIntent === '
|
|
150
|
+
if (this.options.stageLocal && selectionIntent === 'attach') {
|
|
151
|
+
const media = { assetId: this.uploadId, mimeType: file.mimeType, fileName: file.fileName, byteSize: file.byteSize, ...(file.durationMs ? { durationMs: file.durationMs } : {}) };
|
|
152
|
+
this.prepared.set(media.assetId, file);
|
|
153
|
+
const resolve = this.resolve;
|
|
154
|
+
this.resolve = null;
|
|
155
|
+
this.uploadId = '';
|
|
156
|
+
this.abort = null;
|
|
157
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
158
|
+
resolve?.(media);
|
|
159
|
+
}
|
|
160
|
+
else if (selectionIntent === 'send' && !source)
|
|
90
161
|
await this.send();
|
|
91
162
|
}
|
|
92
163
|
}
|
|
@@ -97,6 +168,45 @@ export class MessagingMediaController {
|
|
|
97
168
|
})();
|
|
98
169
|
return result;
|
|
99
170
|
}
|
|
171
|
+
async pickMany(kind, maximum) {
|
|
172
|
+
if (!this.options.stageLocal || !this.options.device.pickMany) {
|
|
173
|
+
const one = await this.pick(kind, undefined, 'attach');
|
|
174
|
+
return one ? [one] : [];
|
|
175
|
+
}
|
|
176
|
+
const limit = Math.max(1, Math.min(10, Math.floor(maximum) || 1));
|
|
177
|
+
const intent = ++this.intent;
|
|
178
|
+
await this.closeCurrent();
|
|
179
|
+
if (intent !== this.intent)
|
|
180
|
+
return [];
|
|
181
|
+
if (this.disposed)
|
|
182
|
+
throw Error('Messaging media is closed.');
|
|
183
|
+
const settings = this.options.getSettings(), conversationId = this.options.getConversationId();
|
|
184
|
+
if (!settings.enabled || !settings.features[kind])
|
|
185
|
+
throw Error('This attachment type is unavailable.');
|
|
186
|
+
const g = ++this.generation, abort = this.abort = new AbortController();
|
|
187
|
+
let files = [];
|
|
188
|
+
this.update({ phase: 'choosing', kind, file: null, error: null, progress: 0, retryUpload: false });
|
|
189
|
+
try {
|
|
190
|
+
files = await this.options.device.pickMany(kind, settings, abort.signal, limit);
|
|
191
|
+
if (!this.isCurrent(g) || conversationId !== this.options.getConversationId())
|
|
192
|
+
return [];
|
|
193
|
+
if (files.length > limit)
|
|
194
|
+
throw Error(`Choose up to ${limit} attachments.`);
|
|
195
|
+
const media = files.map(file => ({ assetId: this.options.createId(), mimeType: file.mimeType, fileName: file.fileName, byteSize: file.byteSize, ...(file.durationMs === undefined ? {} : { durationMs: file.durationMs }) }));
|
|
196
|
+
// Validate the whole selection before accepting any part of it.
|
|
197
|
+
media.forEach(item => validateMessageMedia(kind, item, this.options.getSettings()));
|
|
198
|
+
media.forEach((item, index) => this.prepared.set(item.assetId, files[index]));
|
|
199
|
+
files = [];
|
|
200
|
+
return media;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await Promise.all(files.map(file => this.options.device.remove(file).catch(() => { })));
|
|
204
|
+
if (this.generation === g) {
|
|
205
|
+
this.abort = null;
|
|
206
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
100
210
|
check(file) {
|
|
101
211
|
validateMessageMedia(this.state.kind, { ...file, assetId: 'pending-upload' }, this.options.getSettings());
|
|
102
212
|
if (this.options.getConversationId() !== this.conversationId)
|
|
@@ -148,6 +258,17 @@ export class MessagingMediaController {
|
|
|
148
258
|
let retryUpload = false;
|
|
149
259
|
try {
|
|
150
260
|
this.check(file);
|
|
261
|
+
if (this.stageRecording && this.options.stageLocal) {
|
|
262
|
+
const media = { assetId: uploadId, mimeType: file.mimeType, fileName: file.fileName, byteSize: file.byteSize, durationMs: file.durationMs, waveform: [...(this.state.waveform ?? [])] };
|
|
263
|
+
this.prepared.set(uploadId, file);
|
|
264
|
+
const resolve = this.resolve;
|
|
265
|
+
this.resolve = null;
|
|
266
|
+
this.uploadId = '';
|
|
267
|
+
this.abort = null;
|
|
268
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
269
|
+
resolve?.(media);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
151
272
|
this.update({ phase: 'uploading', progress: 0, error: null, retryUpload: false });
|
|
152
273
|
retryUpload = true;
|
|
153
274
|
const media = await this.options.storage.upload(file, { uploadId, conversationId: this.conversationId, kind: this.state.kind, signal: abort.signal, onProgress: f => { if (this.isCurrent(g))
|
|
@@ -176,7 +297,39 @@ export class MessagingMediaController {
|
|
|
176
297
|
this.update({ phase: 'failed', error: this.error(e), retryUpload });
|
|
177
298
|
}
|
|
178
299
|
}
|
|
300
|
+
/** View an owned cache copy of a draft. Closing never removes the original draft. */
|
|
301
|
+
async previewLocal(file, kind) {
|
|
302
|
+
const intent = ++this.intent;
|
|
303
|
+
await this.closeCurrent();
|
|
304
|
+
if (intent !== this.intent || this.disposed) {
|
|
305
|
+
await this.options.device.remove(file);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.conversationId = this.options.getConversationId();
|
|
309
|
+
this.update({ phase: 'viewing', kind, file, error: null, progress: 0 });
|
|
310
|
+
}
|
|
179
311
|
open(message) { return this.openMedia(message); }
|
|
312
|
+
/** Save/share a local copy of a playing stream without replacing its playback source. */
|
|
313
|
+
async exportFile(save) {
|
|
314
|
+
const file = this.state.file, generation = this.generation, message = this.openedMessage;
|
|
315
|
+
if (this.disposed || !file || this.state.phase !== 'viewing')
|
|
316
|
+
throw Error('The media viewer is closed.');
|
|
317
|
+
if (!file.stream)
|
|
318
|
+
return save(file);
|
|
319
|
+
if (!message || !this.abort)
|
|
320
|
+
throw Error('The attachment is unavailable.');
|
|
321
|
+
const copy = await this.options.storage.download(message, { signal: this.abort.signal, onProgress: () => { } });
|
|
322
|
+
try {
|
|
323
|
+
if (!this.isCurrent(generation) || this.options.getConversationId() !== message.conversationId)
|
|
324
|
+
throw Error('The media viewer is closed.');
|
|
325
|
+
if (copy.stream)
|
|
326
|
+
throw Error('A local file is required to save this attachment.');
|
|
327
|
+
return await save(copy);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
await this.options.device.remove(copy).catch(() => { });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
180
333
|
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
181
334
|
fallbackStream(uri, positionMs, autoPlay) {
|
|
182
335
|
if (!this.state.file?.stream || this.state.file.uri !== uri || !this.openedMessage)
|
|
@@ -252,6 +405,7 @@ export class MessagingMediaController {
|
|
|
252
405
|
await this.finishRecording(true);
|
|
253
406
|
else if (this.state.phase === 'preparing')
|
|
254
407
|
await this.cancel(); }
|
|
255
|
-
async dispose() { this.disposed = true;
|
|
408
|
+
async dispose() { this.disposed = true; for (const abort of this.preparedAborts.values())
|
|
409
|
+
abort.abort(); this.forwardUploads.clear(); await this.cancel(); await Promise.all([...this.prepared.keys()].map(id => this.releasePrepared(id))); this.listeners.clear(); }
|
|
256
410
|
error(e) { return e instanceof Error ? e.message : 'The attachment could not be opened. Try again.'; }
|
|
257
411
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type MediaReadyStage = 'image-loaded' | 'video-frame' | 'audio-playing';
|
|
1
|
+
export type MediaReadyStage = 'image-loaded' | 'video-frame' | 'audio-playing' | 'video-playing' | 'file-opened';
|
|
2
2
|
export declare const MediaReadiness: import("react").Context<(stage: MediaReadyStage, uri: string) => void>;
|
|
3
3
|
/** Internal recovery channel; URLs are matched in memory, never logged. */
|
|
4
4
|
export declare const StreamRecovery: import("react").Context<(uri: string, positionMs: number, autoPlay: boolean) => void>;
|
package/dist/media-ui.d.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import React, { type ReactNode } from 'react';
|
|
2
2
|
import type { MessagingMessage } from '@robono/messaging';
|
|
3
3
|
export interface VoiceBubbleProps {
|
|
4
|
+
mine?: boolean;
|
|
5
|
+
heard?: boolean;
|
|
6
|
+
onPlayed?: () => void;
|
|
7
|
+
background?: string;
|
|
4
8
|
message: MessagingMessage;
|
|
5
9
|
ink: string;
|
|
6
10
|
accent: string;
|
|
7
11
|
waveform?: number[];
|
|
12
|
+
showWaveform?: boolean;
|
|
8
13
|
footer?: ReactNode;
|
|
9
14
|
onOpen: () => void;
|
|
10
15
|
onLongPress?: () => void;
|
|
@@ -21,20 +26,37 @@ export interface PlaybackProps {
|
|
|
21
26
|
onRate?: () => void;
|
|
22
27
|
}
|
|
23
28
|
/** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
|
|
24
|
-
export declare function VoicePlayback({ ink, accent, waveform, footer, onLongPress, playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps & {
|
|
29
|
+
export declare function VoicePlayback({ ink, accent, waveform, showWaveform, footer, onLongPress, mine, heard, onPlayed, background, playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps & {
|
|
25
30
|
ink: string;
|
|
26
31
|
accent: string;
|
|
27
32
|
waveform?: number[];
|
|
33
|
+
showWaveform?: boolean;
|
|
28
34
|
footer?: ReactNode;
|
|
29
35
|
onLongPress?: () => void;
|
|
36
|
+
mine?: boolean;
|
|
37
|
+
heard?: boolean;
|
|
38
|
+
onPlayed?: () => void;
|
|
39
|
+
background?: string;
|
|
30
40
|
}): React.JSX.Element;
|
|
31
|
-
export declare function MediaPlaybackControls({ playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
export declare function MediaPlaybackControls({ playing, loading, positionMs, durationMs, error, onToggle, onSeek, onFullscreen, fullscreen }: PlaybackProps & {
|
|
42
|
+
onFullscreen?: () => void;
|
|
43
|
+
fullscreen?: boolean;
|
|
44
|
+
}): React.JSX.Element;
|
|
45
|
+
/** A full-size photo surface. The same tap-to-hide-controls, pinch and pan handling runs in the preview and native app. */
|
|
46
|
+
export declare function ZoomablePhoto({ uri, label, onTap, expanded }: {
|
|
34
47
|
uri: string;
|
|
35
48
|
label?: string;
|
|
49
|
+
onTap?: () => void;
|
|
50
|
+
expanded?: boolean;
|
|
36
51
|
}): React.JSX.Element;
|
|
37
|
-
export declare function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview }: {
|
|
52
|
+
export declare function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading, expanded, onExpandedChange }: {
|
|
53
|
+
expanded?: boolean;
|
|
54
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
55
|
+
onForward?: () => void;
|
|
56
|
+
onDelete?: () => void;
|
|
57
|
+
downloading?: boolean;
|
|
58
|
+
onDownload?: () => void;
|
|
59
|
+
onShare?: () => void;
|
|
38
60
|
title: string;
|
|
39
61
|
subtitle?: string;
|
|
40
62
|
kind: 'image' | 'video' | 'document' | 'voice';
|
package/dist/media-ui.js
CHANGED
|
@@ -1,42 +1,108 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { messengerVoiceWaveform } from './voice-waveform.js';
|
|
3
|
+
import { createPhotoGesture } from './photo-gesture.js';
|
|
4
|
+
import { useRef, useContext, useState, useEffect } from 'react';
|
|
3
5
|
import { ActivityIndicator, Image, PanResponder, Platform, Pressable, Text, View } from 'react-native';
|
|
4
6
|
import { MediaReadiness } from './media-readiness.js';
|
|
5
7
|
import { MessageIcon, duration, fileSize } from './icons.js';
|
|
6
8
|
/** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
|
|
7
|
-
export function VoicePlayback({ ink, accent, waveform, footer, onLongPress, playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
8
|
-
const width = useRef(1), progress = durationMs ? Math.max(0, Math.min(1, positionMs / durationMs)) : 0;
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export function VoicePlayback({ ink, accent, waveform, showWaveform = true, footer, onLongPress, mine = false, heard = false, onPlayed, background, playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
10
|
+
const width = useRef(1), held = useRef(false), [played, setPlayed] = useState(false), progress = durationMs ? Math.max(0, Math.min(1, positionMs / durationMs)) : 0;
|
|
11
|
+
useEffect(() => { if (playing && positionMs > 0) {
|
|
12
|
+
setPlayed(true);
|
|
13
|
+
onPlayed?.();
|
|
14
|
+
} }, [playing, positionMs, onPlayed]);
|
|
15
|
+
const bars = showWaveform ? messengerVoiceWaveform(waveform, durationMs) : null, fill = mine ? ink : accent;
|
|
16
|
+
const hold = () => { held.current = true; onLongPress?.(); };
|
|
17
|
+
return _jsxs(View, { testID: "voice-message-player", style: { width: 220, maxWidth: '100%', minHeight: 44, borderRadius: 24, borderWidth: 1, borderColor: mine ? '#ffffff24' : '#2A2A2A', backgroundColor: background ?? (mine ? '#1F5F7A' : '#1D1D1D'), paddingLeft: 5, paddingRight: 8, paddingVertical: 3 }, children: [_jsxs(View, { style: { flexDirection: 'row', direction: 'ltr', alignItems: 'center', gap: 8 }, children: [_jsx(Pressable, { testID: "voice-play-control", accessibilityRole: "button", accessibilityLabel: playing ? 'Pause voice message' : 'Play voice message', accessibilityState: { busy: loading }, disabled: loading, hitSlop: 6, delayLongPress: 360, onPressIn: () => { held.current = false; }, onPress: () => { if (!held.current)
|
|
18
|
+
onToggle(); }, onLongPress: hold, style: { width: 36, height: 36, borderRadius: 18, paddingLeft: playing ? 0 : 2, backgroundColor: mine ? '#ffffff29' : heard || played ? accent : '#2e9d63', alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { size: "small", color: ink }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: ink, size: 20 }) }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 7 }, children: [_jsx(Pressable, { onLongPress: hold, accessibilityRole: "adjustable", accessibilityLabel: showWaveform ? 'Voice waveform' : 'Voice position', accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment', label: 'Forward five seconds' }, { name: 'decrement', label: 'Back five seconds' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPressIn: () => { held.current = false; }, onPress: e => { if (held.current)
|
|
19
|
+
return; const bounds = Platform.OS === 'web' ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX; const total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
|
|
20
|
+
onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { height: bars ? 24 : 9, transform: [{ translateY: 4 }], flexDirection: 'row', alignItems: 'center', gap: 1 }, children: bars ? bars.map((sample, i) => _jsx(View, { pointerEvents: "none", style: { flex: 1, flexBasis: 0, borderRadius: 2, overflow: 'hidden', backgroundColor: ink + (mine ? '33' : '38'), height: 2 + sample * 18 }, children: _jsx(View, { style: { height: '100%', backgroundColor: fill, width: `${Math.max(0, Math.min(1, progress * bars.length - i)) * 100}%` } }) }, i)) : _jsx(View, { pointerEvents: "none", style: { flex: 1, height: 9, borderRadius: 5, overflow: 'hidden', backgroundColor: ink + '33' }, children: _jsx(View, { style: { height: 9, borderRadius: 5, backgroundColor: fill, width: `${progress * 100}%` } }) }) }), _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 4, paddingRight: footer ? 6 : 0 }, children: [_jsx(Text, { testID: "voice-duration", style: { color: mine ? ink : '#A3A3A3', fontSize: 11, fontWeight: '800', fontVariant: ['tabular-nums'] }, children: duration(playing || positionMs > 0 ? Math.max(0, durationMs - positionMs) : durationMs) }), footer && _jsx(View, { testID: "voice-message-actions", style: { flexShrink: 1, maxWidth: 112, alignItems: 'flex-end' }, children: footer })] })] })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 12, marginTop: 6 }, children: error })] });
|
|
11
21
|
}
|
|
12
|
-
export function MediaPlaybackControls({ playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
22
|
+
export function MediaPlaybackControls({ playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek, onFullscreen, fullscreen = false }) {
|
|
13
23
|
const width = useRef(1);
|
|
14
|
-
return _jsxs(View, { style: { paddingHorizontal: 18, paddingVertical: 10, gap: 6, backgroundColor: '#111316' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: playing ? 'Pause video' : 'Play video', disabled: loading, onPress: onToggle, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: "#fff" }) }), _jsx(Text, { style: { color: '#ddd', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(positionMs) }), _jsx(Pressable, { accessibilityRole: "adjustable", accessibilityLabel: "Video position", accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment' }, { name: 'decrement' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPress: e =>
|
|
24
|
+
return _jsxs(View, { style: { paddingHorizontal: 18, paddingVertical: 10, gap: 6, backgroundColor: '#111316' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: playing ? 'Pause video' : 'Play video', disabled: loading, onPress: onToggle, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: "#fff" }) }), _jsx(Text, { style: { color: '#ddd', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(positionMs) }), _jsx(Pressable, { accessibilityRole: "adjustable", accessibilityLabel: "Video position", accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment' }, { name: 'decrement' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPress: e => { const bounds = Platform.OS === "web" ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX, total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
|
|
25
|
+
onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { flex: 1, height: 44, justifyContent: 'center' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#ffffff44' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#fff', width: `${durationMs ? Math.min(100, positionMs / durationMs * 100) : 0}%` } }) }) }), _jsx(Text, { style: { color: '#bbb', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(durationMs) }), onFullscreen && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: fullscreen ? "Exit full screen" : "Full screen video", disabled: loading, onPress: onFullscreen, style: { width: 44, height: 44, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: fullscreen ? "close" : "open-in-new", color: "#fff", size: 22 }) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', fontSize: 13 }, children: error })] });
|
|
15
26
|
}
|
|
16
|
-
/** A full-size photo surface. The same tap-to-
|
|
17
|
-
export function ZoomablePhoto({ uri, label = 'Photo' }) {
|
|
27
|
+
/** A full-size photo surface. The same tap-to-hide-controls, pinch and pan handling runs in the preview and native app. */
|
|
28
|
+
export function ZoomablePhoto({ uri, label = 'Photo', onTap, expanded = false }) {
|
|
18
29
|
const mediaReady = useContext(MediaReadiness);
|
|
19
|
-
const [
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
30
|
+
const [transform, setTransform] = useState({ zoom: 1, x: 0, y: 0 }), [error, setError] = useState(false);
|
|
31
|
+
const [viewport, setViewport] = useState({ width: 1, height: 1 }), [imageSize, setImageSize] = useState({ width: 1, height: 1 });
|
|
32
|
+
const rotated = expanded && imageSize.width > imageSize.height && viewport.height > viewport.width;
|
|
33
|
+
const rotation = useRef(rotated);
|
|
34
|
+
rotation.current = rotated;
|
|
35
|
+
const photoPoints = (points) => rotation.current ? points.map(p => ({ x: p.y, y: -p.x })) : points;
|
|
36
|
+
const stageWidth = rotated ? viewport.height : viewport.width, stageHeight = rotated ? viewport.width : viewport.height;
|
|
37
|
+
const gesture = useRef(null);
|
|
38
|
+
if (!gesture.current)
|
|
39
|
+
gesture.current = createPhotoGesture(setTransform);
|
|
40
|
+
const controller = gesture.current, tap = useRef(onTap);
|
|
41
|
+
tap.current = onTap;
|
|
42
|
+
const points = (e) => photoPoints(Array.from(e.nativeEvent.touches ?? [], (p) => ({ x: p.pageX, y: p.pageY })));
|
|
23
43
|
const pan = useRef(PanResponder.create({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
onPanResponderRelease:
|
|
35
|
-
|
|
44
|
+
// Own the photo surface from the first finger. A Pressable must not steal the pinch.
|
|
45
|
+
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true,
|
|
46
|
+
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true,
|
|
47
|
+
onPanResponderTerminationRequest: () => false, onShouldBlockNativeResponder: () => true,
|
|
48
|
+
onPanResponderGrant: e => controller.begin(points(e)),
|
|
49
|
+
onPanResponderStart: e => { const current = points(e); if (current.length > 1)
|
|
50
|
+
controller.begin(current); },
|
|
51
|
+
onPanResponderMove: e => controller.move(points(e)),
|
|
52
|
+
onPanResponderEnd: e => { const remaining = points(e); if (remaining.length)
|
|
53
|
+
controller.end(remaining); },
|
|
54
|
+
onPanResponderRelease: e => { if (controller.end(points(e)))
|
|
55
|
+
tap.current?.(); },
|
|
56
|
+
onPanResponderTerminate: () => { controller.end([], true); },
|
|
36
57
|
})).current;
|
|
37
|
-
const
|
|
38
|
-
|
|
58
|
+
const webRef = useRef(null);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const element = webRef.current;
|
|
61
|
+
if (Platform.OS !== 'web' || !element)
|
|
62
|
+
return;
|
|
63
|
+
const active = new Map();
|
|
64
|
+
const down = (e) => { if (e.button !== 0)
|
|
65
|
+
return; e.preventDefault(); active.set(e.pointerId, { x: e.clientX, y: e.clientY }); element.setPointerCapture(e.pointerId); controller.begin(photoPoints([...active.values()])); };
|
|
66
|
+
const move = (e) => { if (!active.has(e.pointerId))
|
|
67
|
+
return; e.preventDefault(); active.set(e.pointerId, { x: e.clientX, y: e.clientY }); controller.move(photoPoints([...active.values()])); };
|
|
68
|
+
const end = (e) => { if (!active.delete(e.pointerId))
|
|
69
|
+
return; e.preventDefault(); if (controller.end(photoPoints([...active.values()]), e.type !== 'pointerup'))
|
|
70
|
+
tap.current?.(); if (element.hasPointerCapture(e.pointerId))
|
|
71
|
+
element.releasePointerCapture(e.pointerId); };
|
|
72
|
+
const wheel = (e) => { if (!e.ctrlKey)
|
|
73
|
+
return; e.preventDefault(); controller.zoom(controller.value.zoom * Math.exp(-e.deltaY * .01)); };
|
|
74
|
+
const key = (e) => { if (e.key === 'Enter' || e.key === ' ') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
tap.current?.();
|
|
77
|
+
}
|
|
78
|
+
else if (e.key === '+' || e.key === '=' || e.key === '-') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
controller.zoom(controller.value.zoom * (e.key === '-' ? .8 : 1.25));
|
|
81
|
+
} };
|
|
82
|
+
const prevent = (e) => e.preventDefault();
|
|
83
|
+
element.addEventListener('pointerdown', down);
|
|
84
|
+
element.addEventListener('pointermove', move);
|
|
85
|
+
element.addEventListener('pointerup', end);
|
|
86
|
+
element.addEventListener('pointercancel', end);
|
|
87
|
+
element.addEventListener('lostpointercapture', end);
|
|
88
|
+
element.addEventListener('wheel', wheel, { passive: false });
|
|
89
|
+
element.addEventListener('keydown', key);
|
|
90
|
+
element.addEventListener('dragstart', prevent);
|
|
91
|
+
return () => { active.clear(); controller.end([], true); element.removeEventListener('pointerdown', down); element.removeEventListener('pointermove', move); element.removeEventListener('pointerup', end); element.removeEventListener('pointercancel', end); element.removeEventListener('lostpointercapture', end); element.removeEventListener('wheel', wheel); element.removeEventListener('keydown', key); element.removeEventListener('dragstart', prevent); };
|
|
92
|
+
}, [controller, uri]);
|
|
93
|
+
useEffect(() => { let active = true; controller.reset(); setError(false); Image.getSize(uri, (width, height) => { if (active) {
|
|
94
|
+
controller.image(width, height);
|
|
95
|
+
setImageSize({ width, height });
|
|
96
|
+
} }, () => { }); return () => { active = false; }; }, [uri, controller]);
|
|
97
|
+
useEffect(() => { controller.reset(); }, [expanded, controller]);
|
|
98
|
+
useEffect(() => { controller.layout(stageWidth, stageHeight); }, [controller, stageWidth, stageHeight]);
|
|
99
|
+
return _jsxs(View, { style: { flex: 1, minHeight: 0, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }, onLayout: e => setViewport(e.nativeEvent.layout), children: [_jsx(View, { testID: "photo-image-stage", style: { width: stageWidth, height: stageHeight, transform: rotated ? [{ rotate: '90deg' }] : [] }, children: _jsx(View, { ref: webRef, ...(Platform.OS === 'web' ? {} : pan.panHandlers), testID: "photo-gesture-surface", accessible: true, accessibilityRole: "button", accessibilityLabel: "Toggle photo controls", onAccessibilityTap: () => tap.current?.(), ...(Platform.OS === 'web' ? { tabIndex: 0 } : {}), style: { flex: 1, overflow: 'hidden', ...(Platform.OS === 'web' ? { touchAction: 'none', userSelect: 'none' } : {}) }, children: _jsx(Image, { testID: "photo-zoom-image", accessibilityLabel: label, source: { uri }, resizeMode: "contain", onLoad: () => mediaReady('image-loaded', uri), onError: () => setError(true), style: { width: '100%', height: '100%', transform: [{ translateX: transform.x }, { translateY: transform.y }, { scale: transform.zoom }] } }) }) }), error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', top: '50%', alignSelf: 'center', color: '#fff' }, children: "This photo could not be opened." })] });
|
|
39
100
|
}
|
|
40
|
-
export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview }) {
|
|
41
|
-
|
|
101
|
+
export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading = false, expanded, onExpandedChange }) {
|
|
102
|
+
const [localExpanded, setLocalExpanded] = useState(false);
|
|
103
|
+
const photoExpanded = kind === 'image' && (expanded ?? localExpanded), controls = !photoExpanded;
|
|
104
|
+
const setExpanded = (value) => { setLocalExpanded(value); onExpandedChange?.(value); };
|
|
105
|
+
useEffect(() => { setLocalExpanded(false); }, [uri]);
|
|
106
|
+
const visual = kind === 'image' || kind === 'video';
|
|
107
|
+
return _jsxs(View, { testID: "messenger-media-viewer", accessibilityViewIsModal: true, style: { flex: 1, minHeight: 0, backgroundColor: controls ? 'rgba(0,0,0,0.94)' : '#000000' }, children: [_jsxs(View, { style: { flex: 1, minHeight: 0, justifyContent: 'center', paddingHorizontal: visual && controls ? 12 : 0 }, children: [controls && _jsxs(View, { testID: "media-action-toolbar", style: { position: 'absolute', top: 12, left: 12, right: 12, zIndex: 3, minHeight: 52, borderRadius: 26, borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', backgroundColor: 'rgba(18,18,18,0.92)', paddingLeft: 8, paddingRight: 4, flexDirection: 'row', alignItems: 'center', gap: 2 }, children: [[[onDownload, 'Download', 'download-outline', downloading, false], [onDelete, 'Delete', 'trash-outline', false, true], [onForward, 'Forward', 'arrow-redo-outline', false, false], [onShare, 'Share', 'share-social-outline', false, false]].filter(([action, label]) => !!action || (visual && !reviewAction && label !== 'Download')).map(([action, label, icon, busy, destructive]) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${label} media`, accessibilityState: { busy }, disabled: !action || busy || downloading, onPress: action, style: { opacity: !action ? .5 : 1, flex: 1, minWidth: 44, minHeight: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', gap: 1, paddingHorizontal: 2 }, children: [busy ? _jsx(ActivityIndicator, { size: "small", color: "#e8843a" }) : _jsx(MessageIcon, { name: icon, size: 20, color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7' }), _jsx(Text, { numberOfLines: 1, style: { color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7', fontSize: 10, fontWeight: '800' }, children: label })] }, label)), _jsx(View, { style: { flex: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare ? 1 : 0, minWidth: 0 }, children: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare && _jsx(Text, { numberOfLines: 1, style: { color: '#fff', fontSize: 14, paddingLeft: 8 }, children: title }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close media", onPress: onClose, style: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.08)', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 24, color: "#fff" }) })] }), loading ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }, children: [_jsx(ActivityIndicator, { color: "#fff" }), _jsx(Text, { style: { color: '#c1c7ce' }, children: "Opening\u2026" })] }) : kind === 'image' && uri ? _jsx(View, { style: { height: controls ? '82%' : '100%', width: '100%' }, children: _jsx(ZoomablePhoto, { uri: uri, label: fileName, expanded: photoExpanded, onTap: () => setExpanded(!photoExpanded) }) }) : kind === 'document' ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 20 }, children: [_jsx(View, { style: { width: 88, height: 106, borderRadius: 16, backgroundColor: '#ffffff12', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "document", color: "#fff", size: 40 }) }), _jsx(Text, { style: { color: '#fff', fontSize: 19, fontWeight: '600', textAlign: 'center' }, children: fileName }), _jsxs(Text, { style: { color: '#9ba3ad', fontSize: 13 }, children: [fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(byteSize)] }), onFileAction && _jsx(Pressable, { accessibilityRole: "button", onPress: onFileAction, style: { borderRadius: 24, paddingHorizontal: 28, paddingVertical: 14, backgroundColor: '#fff' }, children: _jsx(Text, { style: { color: '#15191e', fontWeight: '600' }, children: "Open file" }) })] }) : kind === 'video' ? _jsx(View, { testID: "media-video-stage", style: { height: '72%', width: '100%' }, children: children }) : children] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', padding: 16 }, children: error }), controls && reviewAction && onReview && _jsx(View, { style: { padding: 16, borderTopWidth: 1, borderTopColor: '#ffffff15' }, children: _jsx(Pressable, { accessibilityRole: "button", onPress: onReview, style: { minHeight: 48, borderRadius: 24, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontWeight: '600', color: '#121619' }, children: reviewAction }) }) })] });
|
|
42
108
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Only explicit web links become actions. Never interpret arbitrary URI schemes. */
|
|
2
|
+
export function messageLinks(text) {
|
|
3
|
+
const parts = [];
|
|
4
|
+
let offset = 0;
|
|
5
|
+
for (const match of text.matchAll(/(?:https?:\/\/|www\.)[^\s<>]+/gi)) {
|
|
6
|
+
const start = match.index, raw = match[0];
|
|
7
|
+
let label = raw.replace(/[.,!?;:]+$/, '');
|
|
8
|
+
while (/[)\]}]$/.test(label)) {
|
|
9
|
+
const close = label.at(-1), open = { ')': '(', ']': '[', '}': '{' }[close];
|
|
10
|
+
if (label.split(close).length <= label.split(open).length)
|
|
11
|
+
break;
|
|
12
|
+
label = label.slice(0, -1);
|
|
13
|
+
}
|
|
14
|
+
if (!label)
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(/^www\./i.test(label) ? 'https://' + label : label);
|
|
18
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
|
|
19
|
+
continue;
|
|
20
|
+
if (start > offset)
|
|
21
|
+
parts.push({ text: text.slice(offset, start) });
|
|
22
|
+
parts.push({ text: label, url: url.href });
|
|
23
|
+
offset = start + label.length;
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
}
|
|
27
|
+
if (offset < text.length)
|
|
28
|
+
parts.push({ text: text.slice(offset) });
|
|
29
|
+
return parts.length ? parts : [{ text }];
|
|
30
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { type ReactNode } from 'react';
|
|
2
|
+
import { MessageIcon } from './icons.js';
|
|
3
|
+
export type MessageMenuPosition = {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
above: boolean;
|
|
7
|
+
available: number;
|
|
8
|
+
};
|
|
9
|
+
/** Anchor outside the selected bubble, never at its text's center. */
|
|
10
|
+
export declare function messageMenuPosition(x: number, y: number, width: number, height: number, screenWidth: number, screenHeight: number, mine: boolean): MessageMenuPosition;
|
|
11
|
+
export declare function MessageMenuRow({ icon, label, onPress, disabled, danger, divider }: {
|
|
12
|
+
icon: React.ComponentProps<typeof MessageIcon>['name'];
|
|
13
|
+
label: string;
|
|
14
|
+
onPress: () => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
danger?: boolean;
|
|
17
|
+
divider?: boolean;
|
|
18
|
+
}): React.JSX.Element;
|
|
19
|
+
export declare function MessageMenu({ visible, position, screenHeight, mine, reactions, children, onClose, onDismiss }: {
|
|
20
|
+
visible: boolean;
|
|
21
|
+
position: MessageMenuPosition;
|
|
22
|
+
screenHeight: number;
|
|
23
|
+
mine: boolean;
|
|
24
|
+
reactions: ReactNode;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
onDismiss: () => void;
|
|
28
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { messagingPopupBackdrop } from './popup.js';
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { Modal, Platform, Pressable, ScrollView, Text, View } from 'react-native';
|
|
5
|
+
import { MessageIcon } from './icons.js';
|
|
6
|
+
/** Anchor outside the selected bubble, never at its text's center. */
|
|
7
|
+
export function messageMenuPosition(x, y, width, height, screenWidth, screenHeight, mine) {
|
|
8
|
+
const top = Math.max(12, Math.min(y, screenHeight - 12)), bottom = Math.max(12, Math.min(y + height, screenHeight - 12));
|
|
9
|
+
const above = top - 24 > screenHeight - bottom - 24;
|
|
10
|
+
const menuWidth = Math.min(300, screenWidth - 24);
|
|
11
|
+
return { x: Math.max(12, Math.min(mine ? x + width - menuWidth : x, screenWidth - menuWidth - 12)), y: above ? top : bottom, above, available: Math.max(44, (above ? top : screenHeight - bottom) - 24) };
|
|
12
|
+
}
|
|
13
|
+
export function MessageMenuRow({ icon, label, onPress, disabled, danger, divider }) {
|
|
14
|
+
return _jsxs(_Fragment, { children: [divider && _jsx(View, { style: { height: 1, marginHorizontal: 12, marginVertical: 4, backgroundColor: '#ffffff22' } }), _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, disabled: disabled, onPress: onPress, style: ({ pressed }) => ({ minHeight: 43, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 8, opacity: disabled ? .5 : 1, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: icon, color: danger ? '#F87171' : '#f5f5f5', size: 22 }), _jsx(Text, { style: { flex: 1, color: danger ? '#F87171' : '#f5f5f5', fontSize: 15, fontWeight: '700' }, children: label })] })] });
|
|
15
|
+
}
|
|
16
|
+
export function MessageMenu({ visible, position, screenHeight, mine, reactions, children, onClose, onDismiss }) {
|
|
17
|
+
useEffect(() => { if (!visible || Platform.OS !== 'web')
|
|
18
|
+
return; const key = (e) => { if (e.key === 'Escape') {
|
|
19
|
+
e.stopPropagation();
|
|
20
|
+
onClose();
|
|
21
|
+
} }; document.addEventListener('keydown', key, true); return () => document.removeEventListener('keydown', key, true); }, [visible, onClose]);
|
|
22
|
+
const card = _jsx(View, { testID: "message-action-card", style: { width: 218, maxWidth: '100%', maxHeight: Math.min(310, Math.max(43, position.available - (reactions ? 58 : 0))), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006', alignSelf: mine ? 'flex-end' : 'flex-start' }, children: _jsx(ScrollView, { showsVerticalScrollIndicator: false, bounces: false, keyboardShouldPersistTaps: "handled", children: children }) });
|
|
23
|
+
return _jsx(Modal, { visible: visible, transparent: true, animationType: "none", onRequestClose: onClose, onDismiss: onDismiss, children: _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close message options", onPress: onClose, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { testID: "message-context-menu", style: { position: 'absolute', left: position.x, ...(position.above ? { bottom: screenHeight - position.y + 12 } : { top: position.y + 12 }), width: 300, maxWidth: '94%', gap: 8 }, children: [position.above ? card : reactions, position.above ? reactions : card] })] }) });
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MessagingMessage } from '@robono/messaging';
|
|
2
|
+
/** Only adjacent parts of the same send share a rail. Never join across a
|
|
3
|
+
* missing part, another sender/conversation, or a date separator. */
|
|
4
|
+
export declare function continuesAttachmentSend(before: MessagingMessage | undefined, after: MessagingMessage | undefined): boolean;
|
|
5
|
+
/** Upload completion order must not reorder the attachments selected together.
|
|
6
|
+
* Transport state remains in server sequence order for reconnect cursors. */
|
|
7
|
+
export declare function orderChatMessages<T extends MessagingMessage>(messages: readonly T[]): T[];
|