@marktiderman/genesis-chat 0.1.0 → 0.2.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/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* The photo viewer: a message's photos full screen on black
|
|
4
|
+
* (NativeFullScreenModal), swiped left and right, pinched or double-tapped
|
|
5
|
+
* to zoom, swiped down (or Close) to leave. Share and Save show when the app
|
|
6
|
+
* passed expo-sharing or expo-media-library (with `downloadFile` for photos
|
|
7
|
+
* that are only a signed link).
|
|
8
|
+
*
|
|
9
|
+
* No gesture library: on iOS each photo is a zooming ScrollView (the
|
|
10
|
+
* system's own pinch, and a pull down past the edge closes); on Android a
|
|
11
|
+
* PanResponder pinches, pans and drags down. With VoiceOver or TalkBack the
|
|
12
|
+
* pager is adjustable (swipe up or down for the next or previous photo).
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import {
|
|
16
|
+
Animated,
|
|
17
|
+
FlatList,
|
|
18
|
+
PanResponder,
|
|
19
|
+
Platform,
|
|
20
|
+
Pressable,
|
|
21
|
+
ScrollView,
|
|
22
|
+
View,
|
|
23
|
+
useWindowDimensions,
|
|
24
|
+
type GestureResponderEvent,
|
|
25
|
+
type NativeScrollEvent,
|
|
26
|
+
type NativeSyntheticEvent,
|
|
27
|
+
} from "react-native";
|
|
28
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
29
|
+
import { FULL_SCREEN_DARK, NativeFullScreenModal, NativeIcon, NativeImage, NativeText, type NativeIconName } from "@marktiderman/genesis-ui-native/components";
|
|
30
|
+
import { useReducedMotion } from "@marktiderman/genesis-ui-native/hooks";
|
|
31
|
+
import type { ChatMessage } from "../core/types";
|
|
32
|
+
import { fill } from "./copy";
|
|
33
|
+
import { useChatNative } from "./context";
|
|
34
|
+
import { fromServer, type BubbleAttachment } from "./media";
|
|
35
|
+
import { haptic } from "./modules";
|
|
36
|
+
|
|
37
|
+
export interface ChatPhoto {
|
|
38
|
+
key: string;
|
|
39
|
+
/** A signed link or a file on the phone. */
|
|
40
|
+
uri: string;
|
|
41
|
+
/** What a screen reader says for it. */
|
|
42
|
+
alt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ChatPhotoViewerProps {
|
|
46
|
+
/** The photos to page through; null closes the viewer. */
|
|
47
|
+
photos: readonly ChatPhoto[] | null;
|
|
48
|
+
/** The one to open at. Default the first. */
|
|
49
|
+
initialIndex?: number;
|
|
50
|
+
onClose: () => void;
|
|
51
|
+
testID?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Points pulled down (or dragged, on Android) that close the viewer on release. */
|
|
55
|
+
export const DISMISS_DISTANCE = 110;
|
|
56
|
+
/** Or a quick flick down, in points per millisecond. */
|
|
57
|
+
export const DISMISS_VELOCITY = 1.1;
|
|
58
|
+
export const MAX_ZOOM = 4;
|
|
59
|
+
|
|
60
|
+
/** Whether a drag down that ended here closes the viewer. */
|
|
61
|
+
export function shouldDismiss(dy: number, vy: number): boolean {
|
|
62
|
+
return dy > DISMISS_DISTANCE || (dy > 24 && vy > DISMISS_VELOCITY);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The zoom a pinch has reached: from where it started, by how far the fingers spread. */
|
|
66
|
+
export function pinchScale(startScale: number, startDistance: number, distance: number): number {
|
|
67
|
+
if (startDistance <= 0) return startScale;
|
|
68
|
+
return Math.min(MAX_ZOOM, Math.max(0.8, startScale * (distance / startDistance)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Points between two touches. */
|
|
72
|
+
export function touchDistance(touches: readonly { pageX: number; pageY: number }[]): number {
|
|
73
|
+
const [a, b] = touches;
|
|
74
|
+
if (!a || !b) return 0;
|
|
75
|
+
return Math.hypot(a.pageX - b.pageX, a.pageY - b.pageY);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** How far a zoomed photo may pan before an edge shows, per axis. */
|
|
79
|
+
export function panLimit(scale: number, size: number): number {
|
|
80
|
+
return Math.max(0, ((scale - 1) * size) / 2);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A file already on the phone, which shares and saves without a download. */
|
|
84
|
+
export function isLocalUri(uri: string): boolean {
|
|
85
|
+
return /^(file|ph|content|assets-library):/.test(uri);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const clamp = (v: number, limit: number) => Math.max(-limit, Math.min(limit, v));
|
|
89
|
+
// Between two `onPress`es (each fires on release): a quick double tap measured 330ms apart on iOS.
|
|
90
|
+
export const DOUBLE_TAP_MS = 400;
|
|
91
|
+
|
|
92
|
+
export function ChatPhotoViewer({ photos, initialIndex = 0, onClose, testID = "chat-photo-viewer" }: ChatPhotoViewerProps) {
|
|
93
|
+
const open = photos !== null && photos.length > 0;
|
|
94
|
+
return (
|
|
95
|
+
<NativeFullScreenModal open={open} onOpenChange={(o) => (o ? undefined : onClose())} tone="none" testID={testID}>
|
|
96
|
+
{open ? <ViewerBody photos={photos} initialIndex={Math.min(Math.max(0, initialIndex), photos.length - 1)} onClose={onClose} testID={testID} /> : null}
|
|
97
|
+
</NativeFullScreenModal>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ViewerBody({ photos, initialIndex, onClose, testID }: { photos: readonly ChatPhoto[]; initialIndex: number; onClose: () => void; testID: string }) {
|
|
102
|
+
const { copy, modules } = useChatNative();
|
|
103
|
+
const { width, height } = useWindowDimensions();
|
|
104
|
+
const insets = useSafeAreaInsets();
|
|
105
|
+
const reducedMotion = useReducedMotion();
|
|
106
|
+
const [index, setIndex] = useState(initialIndex);
|
|
107
|
+
const [zoomed, setZoomed] = useState(false);
|
|
108
|
+
const [chrome, setChrome] = useState(true);
|
|
109
|
+
const [busy, setBusy] = useState(false);
|
|
110
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
111
|
+
const backdrop = useRef(new Animated.Value(1)).current;
|
|
112
|
+
const listRef = useRef<FlatList<ChatPhoto> | null>(null);
|
|
113
|
+
const photo = photos[index] ?? photos[0]!;
|
|
114
|
+
const many = photos.length > 1;
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!notice) return;
|
|
118
|
+
const t = setTimeout(() => setNotice(null), 2500);
|
|
119
|
+
return () => clearTimeout(t);
|
|
120
|
+
}, [notice]);
|
|
121
|
+
|
|
122
|
+
const goTo = useCallback(
|
|
123
|
+
(i: number) => {
|
|
124
|
+
if (i < 0 || i >= photos.length) return;
|
|
125
|
+
setIndex(i);
|
|
126
|
+
setZoomed(false);
|
|
127
|
+
listRef.current?.scrollToIndex?.({ index: i, animated: !reducedMotion });
|
|
128
|
+
},
|
|
129
|
+
[photos.length, reducedMotion],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const onMomentumEnd = useCallback(
|
|
133
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
134
|
+
const i = Math.round(e.nativeEvent.contentOffset.x / Math.max(1, width));
|
|
135
|
+
if (i !== index && i >= 0 && i < photos.length) {
|
|
136
|
+
setIndex(i);
|
|
137
|
+
setZoomed(false);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
[width, index, photos.length],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// A copy on the phone for sharing and saving: a local file as is, a signed link through downloadFile.
|
|
144
|
+
const local = async (uri: string) => (isLocalUri(uri) ? uri : modules.downloadFile ? modules.downloadFile(uri) : null);
|
|
145
|
+
const reachable = Boolean(modules.downloadFile) || photos.every((p) => isLocalUri(p.uri));
|
|
146
|
+
const canShare = Boolean(modules.sharing) && reachable;
|
|
147
|
+
const canSave = Boolean(modules.mediaLibrary) && reachable;
|
|
148
|
+
|
|
149
|
+
const run = async (what: "share" | "save") => {
|
|
150
|
+
if (busy) return;
|
|
151
|
+
setBusy(true);
|
|
152
|
+
try {
|
|
153
|
+
if (what === "save") {
|
|
154
|
+
const lib = modules.mediaLibrary!;
|
|
155
|
+
const permission = await lib.requestPermissionsAsync(true);
|
|
156
|
+
if (!permission.granted) {
|
|
157
|
+
setNotice(copy.photosDenied);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const file = await local(photo.uri);
|
|
161
|
+
if (!file) throw new Error("no copy on the phone");
|
|
162
|
+
await lib.saveToLibraryAsync(file);
|
|
163
|
+
haptic(modules.haptics, "send");
|
|
164
|
+
setNotice(copy.photoSaved);
|
|
165
|
+
} else {
|
|
166
|
+
const sharing = modules.sharing!;
|
|
167
|
+
if (!(await sharing.isAvailableAsync())) throw new Error("sharing unavailable");
|
|
168
|
+
const file = await local(photo.uri);
|
|
169
|
+
if (!file) throw new Error("no copy on the phone");
|
|
170
|
+
await sharing.shareAsync(file, { dialogTitle: copy.sharePhoto });
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
haptic(modules.haptics, "error");
|
|
174
|
+
setNotice(copy.actionFailed);
|
|
175
|
+
} finally {
|
|
176
|
+
setBusy(false);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const toggleChrome = useCallback(() => setChrome((c) => !c), []);
|
|
181
|
+
const onPull = useCallback((dy: number) => backdrop.setValue(Math.max(0.35, 1 - Math.max(0, dy) / 400)), [backdrop]);
|
|
182
|
+
const renderItem = useCallback(
|
|
183
|
+
({ item, index: i }: { item: ChatPhoto; index: number }) => (
|
|
184
|
+
<ZoomablePhoto
|
|
185
|
+
photo={item}
|
|
186
|
+
width={width}
|
|
187
|
+
height={height}
|
|
188
|
+
active={i === index}
|
|
189
|
+
onZoomChange={setZoomed}
|
|
190
|
+
onPull={onPull}
|
|
191
|
+
onDismiss={onClose}
|
|
192
|
+
onTap={toggleChrome}
|
|
193
|
+
testID={`${testID}-photo-${i}`}
|
|
194
|
+
/>
|
|
195
|
+
),
|
|
196
|
+
[width, height, index, onPull, onClose, toggleChrome, testID],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const counter = many ? fill(copy.photoOf, { n: index + 1, count: photos.length }) : null;
|
|
200
|
+
return (
|
|
201
|
+
<View style={{ flex: 1 }}>
|
|
202
|
+
<Animated.View pointerEvents="none" style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0, backgroundColor: FULL_SCREEN_DARK, opacity: backdrop }} />
|
|
203
|
+
<View
|
|
204
|
+
style={{ flex: 1 }}
|
|
205
|
+
accessible
|
|
206
|
+
accessibilityRole="adjustable"
|
|
207
|
+
accessibilityLabel={photo.alt}
|
|
208
|
+
accessibilityValue={counter ? { text: counter } : undefined}
|
|
209
|
+
accessibilityHint={many ? copy.viewerHint : undefined}
|
|
210
|
+
accessibilityActions={many ? [{ name: "increment" }, { name: "decrement" }] : []}
|
|
211
|
+
onAccessibilityAction={(e) => {
|
|
212
|
+
if (e.nativeEvent.actionName === "increment") goTo(index + 1);
|
|
213
|
+
if (e.nativeEvent.actionName === "decrement") goTo(index - 1);
|
|
214
|
+
}}
|
|
215
|
+
testID={`${testID}-pager`}
|
|
216
|
+
>
|
|
217
|
+
<FlatList
|
|
218
|
+
ref={listRef}
|
|
219
|
+
testID={`${testID}-list`}
|
|
220
|
+
data={photos as ChatPhoto[]}
|
|
221
|
+
horizontal
|
|
222
|
+
pagingEnabled
|
|
223
|
+
scrollEnabled={many && !zoomed}
|
|
224
|
+
showsHorizontalScrollIndicator={false}
|
|
225
|
+
initialScrollIndex={initialIndex}
|
|
226
|
+
getItemLayout={(_d, i) => ({ length: width, offset: width * i, index: i })}
|
|
227
|
+
keyExtractor={(p) => p.key}
|
|
228
|
+
renderItem={renderItem}
|
|
229
|
+
onMomentumScrollEnd={onMomentumEnd}
|
|
230
|
+
extraData={index}
|
|
231
|
+
windowSize={3}
|
|
232
|
+
initialNumToRender={1}
|
|
233
|
+
/>
|
|
234
|
+
</View>
|
|
235
|
+
{chrome ? (
|
|
236
|
+
<View pointerEvents="box-none" style={{ position: "absolute", top: insets.top, left: 0, right: 0, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, minHeight: 52 }}>
|
|
237
|
+
<ViewerButton icon="close" label={copy.closeViewer} onPress={onClose} testID={`${testID}-close`} />
|
|
238
|
+
<View style={{ flex: 1, alignItems: "center" }}>
|
|
239
|
+
{counter ? (
|
|
240
|
+
<NativeText testID={`${testID}-counter`} accessibilityLiveRegion="polite" style={{ color: "#FFFFFF", fontWeight: "600", fontVariant: ["tabular-nums"] }}>
|
|
241
|
+
{counter}
|
|
242
|
+
</NativeText>
|
|
243
|
+
) : null}
|
|
244
|
+
</View>
|
|
245
|
+
{canSave ? <ViewerButton icon="download" label={copy.savePhoto} onPress={() => void run("save")} disabled={busy} testID={`${testID}-save`} /> : null}
|
|
246
|
+
{canShare ? <ViewerButton icon="share" label={copy.sharePhoto} onPress={() => void run("share")} disabled={busy} testID={`${testID}-share`} /> : null}
|
|
247
|
+
{!canSave && !canShare ? <View style={{ width: 44 }} /> : null}
|
|
248
|
+
</View>
|
|
249
|
+
) : null}
|
|
250
|
+
{notice ? (
|
|
251
|
+
<View pointerEvents="none" style={{ position: "absolute", left: 24, right: 24, bottom: insets.bottom + 24, alignItems: "center" }}>
|
|
252
|
+
<View accessibilityLiveRegion="assertive" testID={`${testID}-notice`} style={{ backgroundColor: "rgba(40,40,40,0.92)", borderRadius: 14, paddingHorizontal: 16, paddingVertical: 10 }}>
|
|
253
|
+
<NativeText style={{ color: "#FFFFFF", textAlign: "center" }}>{notice}</NativeText>
|
|
254
|
+
</View>
|
|
255
|
+
</View>
|
|
256
|
+
) : null}
|
|
257
|
+
</View>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function ViewerButton({ icon, label, onPress, disabled = false, testID }: { icon: NativeIconName; label: string; onPress: () => void; disabled?: boolean; testID: string }) {
|
|
262
|
+
return (
|
|
263
|
+
<Pressable
|
|
264
|
+
testID={testID}
|
|
265
|
+
accessibilityRole="button"
|
|
266
|
+
accessibilityLabel={label}
|
|
267
|
+
accessibilityState={{ disabled }}
|
|
268
|
+
disabled={disabled}
|
|
269
|
+
onPress={onPress}
|
|
270
|
+
hitSlop={6}
|
|
271
|
+
style={{ width: 44, height: 44, borderRadius: 22, alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0,0,0,0.45)", opacity: disabled ? 0.5 : 1 }}
|
|
272
|
+
>
|
|
273
|
+
<NativeIcon name={icon} size={22} color="#FFFFFF" />
|
|
274
|
+
</Pressable>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface ZoomableProps {
|
|
279
|
+
photo: ChatPhoto;
|
|
280
|
+
width: number;
|
|
281
|
+
height: number;
|
|
282
|
+
/** The page on screen: another page resets its zoom. */
|
|
283
|
+
active: boolean;
|
|
284
|
+
onZoomChange: (zoomed: boolean) => void;
|
|
285
|
+
/** How far it's pulled down, for the backdrop's fade. */
|
|
286
|
+
onPull: (dy: number) => void;
|
|
287
|
+
onDismiss: () => void;
|
|
288
|
+
onTap: () => void;
|
|
289
|
+
testID: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ZoomablePhoto(props: ZoomableProps) {
|
|
293
|
+
return Platform.OS === "ios" ? <ZoomableIos {...props} /> : <ZoomablePan {...props} />;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The photo itself, contained, on a transparent background so the backdrop's fade shows. */
|
|
297
|
+
function Photo({ photo, width, height, testID }: { photo: ChatPhoto; width: number; height: number; testID: string }) {
|
|
298
|
+
return <NativeImage src={photo.uri} alt={photo.alt} width={width} height={height} contentFit="contain" className="bg-transparent" testID={testID} />;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A tap that waits to see whether it's the first half of a double tap. */
|
|
302
|
+
function useTaps(onSingle: () => void, onDouble: (e: { locationX: number; locationY: number }) => void) {
|
|
303
|
+
const last = useRef(0);
|
|
304
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
305
|
+
useEffect(
|
|
306
|
+
() => () => {
|
|
307
|
+
if (timer.current) clearTimeout(timer.current);
|
|
308
|
+
},
|
|
309
|
+
[],
|
|
310
|
+
);
|
|
311
|
+
return (e: GestureResponderEvent) => {
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
const at = { locationX: e.nativeEvent?.locationX ?? 0, locationY: e.nativeEvent?.locationY ?? 0 };
|
|
314
|
+
if (now - last.current < DOUBLE_TAP_MS) {
|
|
315
|
+
last.current = 0;
|
|
316
|
+
if (timer.current) clearTimeout(timer.current);
|
|
317
|
+
onDouble(at);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
last.current = now;
|
|
321
|
+
timer.current = setTimeout(onSingle, DOUBLE_TAP_MS);
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
type ZoomRect = { x: number; y: number; width: number; height: number };
|
|
326
|
+
type ZoomScrollView = ScrollView & { scrollResponderZoomTo?: (rect: ZoomRect & { animated?: boolean }, animated?: boolean) => void };
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Zooms to a rect. React Native 0.88 drops `rect.animated` and animates per the deprecated second
|
|
330
|
+
* argument (ScrollView.js `scrollResponderZoomTo`), so both are given: the rect's keeps it from
|
|
331
|
+
* warning, the second is the one it uses.
|
|
332
|
+
*/
|
|
333
|
+
function zoomTo(view: ZoomScrollView | null, rect: ZoomRect, animated: boolean) {
|
|
334
|
+
view?.scrollResponderZoomTo?.({ ...rect, animated }, animated);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** iOS: the system's zooming scroll view; at full size, a pull down past the top closes. */
|
|
338
|
+
function ZoomableIos({ photo, width, height, active, onZoomChange, onPull, onDismiss, onTap, testID }: ZoomableProps) {
|
|
339
|
+
const ref = useRef<ZoomScrollView | null>(null);
|
|
340
|
+
const scale = useRef(1);
|
|
341
|
+
const reducedMotion = useReducedMotion();
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (!active && scale.current > 1.01) {
|
|
344
|
+
zoomTo(ref.current, { x: 0, y: 0, width, height }, false);
|
|
345
|
+
scale.current = 1;
|
|
346
|
+
}
|
|
347
|
+
}, [active, width, height]);
|
|
348
|
+
const tap = useTaps(onTap, (at) => {
|
|
349
|
+
if (scale.current > 1.01) return zoomTo(ref.current, { x: 0, y: 0, width, height }, !reducedMotion);
|
|
350
|
+
const w = width / 2.5;
|
|
351
|
+
const h = height / 2.5;
|
|
352
|
+
zoomTo(ref.current, { x: at.locationX - w / 2, y: at.locationY - h / 2, width: w, height: h }, !reducedMotion);
|
|
353
|
+
});
|
|
354
|
+
return (
|
|
355
|
+
<ScrollView
|
|
356
|
+
ref={ref as never}
|
|
357
|
+
testID={testID}
|
|
358
|
+
style={{ width, height }}
|
|
359
|
+
contentContainerStyle={{ width, height }}
|
|
360
|
+
maximumZoomScale={MAX_ZOOM}
|
|
361
|
+
minimumZoomScale={1}
|
|
362
|
+
bouncesZoom
|
|
363
|
+
centerContent
|
|
364
|
+
alwaysBounceVertical
|
|
365
|
+
showsHorizontalScrollIndicator={false}
|
|
366
|
+
showsVerticalScrollIndicator={false}
|
|
367
|
+
scrollEventThrottle={16}
|
|
368
|
+
onScroll={(e) => {
|
|
369
|
+
const z = e.nativeEvent.zoomScale ?? 1;
|
|
370
|
+
if (z > 1.01 !== scale.current > 1.01) onZoomChange(z > 1.01);
|
|
371
|
+
scale.current = z;
|
|
372
|
+
if (z <= 1.01) onPull(-e.nativeEvent.contentOffset.y);
|
|
373
|
+
}}
|
|
374
|
+
onScrollEndDrag={(e) => {
|
|
375
|
+
if (scale.current > 1.01) return;
|
|
376
|
+
// iOS reports the release velocity in points per millisecond, negative while pulling down.
|
|
377
|
+
if (shouldDismiss(-e.nativeEvent.contentOffset.y, -(e.nativeEvent.velocity?.y ?? 0))) onDismiss();
|
|
378
|
+
else onPull(0);
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
<Pressable accessible={false} onPress={tap} style={{ width, height }}>
|
|
382
|
+
<Photo photo={photo} width={width} height={height} testID={`${testID}-image`} />
|
|
383
|
+
</Pressable>
|
|
384
|
+
</ScrollView>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Android (no zooming ScrollView): two fingers pinch, one pans a zoomed photo or drags it down to close. */
|
|
389
|
+
function ZoomablePan({ photo, width, height, active, onZoomChange, onPull, onDismiss, onTap, testID }: ZoomableProps) {
|
|
390
|
+
const reducedMotion = useReducedMotion();
|
|
391
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
392
|
+
const tx = useRef(new Animated.Value(0)).current;
|
|
393
|
+
const ty = useRef(new Animated.Value(0)).current;
|
|
394
|
+
const s = useRef({ scale: 1, x: 0, y: 0, startScale: 1, startDistance: 0, startX: 0, startY: 0, pinching: false, dragging: false });
|
|
395
|
+
|
|
396
|
+
const settle = useCallback(
|
|
397
|
+
(to: { scale: number; x: number; y: number }) => {
|
|
398
|
+
s.current.scale = to.scale;
|
|
399
|
+
s.current.x = to.x;
|
|
400
|
+
s.current.y = to.y;
|
|
401
|
+
onZoomChange(to.scale > 1.01);
|
|
402
|
+
if (reducedMotion) {
|
|
403
|
+
scale.setValue(to.scale);
|
|
404
|
+
tx.setValue(to.x);
|
|
405
|
+
ty.setValue(to.y);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
Animated.parallel([
|
|
409
|
+
Animated.spring(scale, { toValue: to.scale, useNativeDriver: true }),
|
|
410
|
+
Animated.spring(tx, { toValue: to.x, useNativeDriver: true }),
|
|
411
|
+
Animated.spring(ty, { toValue: to.y, useNativeDriver: true }),
|
|
412
|
+
]).start();
|
|
413
|
+
},
|
|
414
|
+
[reducedMotion, onZoomChange, scale, tx, ty],
|
|
415
|
+
);
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (!active && s.current.scale > 1.01) settle({ scale: 1, x: 0, y: 0 });
|
|
418
|
+
}, [active, settle]);
|
|
419
|
+
|
|
420
|
+
const tap = useTaps(onTap, (at) => {
|
|
421
|
+
if (s.current.scale > 1.01) return settle({ scale: 1, x: 0, y: 0 });
|
|
422
|
+
const z = 2.5;
|
|
423
|
+
settle({ scale: z, x: clamp((width / 2 - at.locationX) * (z - 1), panLimit(z, width)), y: clamp((height / 2 - at.locationY) * (z - 1), panLimit(z, height)) });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const responder = useMemo(
|
|
427
|
+
() =>
|
|
428
|
+
PanResponder.create({
|
|
429
|
+
onStartShouldSetPanResponder: () => false,
|
|
430
|
+
onMoveShouldSetPanResponder: (e, g) => e.nativeEvent.touches.length >= 2 || s.current.scale > 1.01 || (g.dy > 8 && Math.abs(g.dy) > Math.abs(g.dx) * 1.5),
|
|
431
|
+
onPanResponderTerminationRequest: () => false,
|
|
432
|
+
onPanResponderGrant: (e) => {
|
|
433
|
+
const st = s.current;
|
|
434
|
+
st.pinching = e.nativeEvent.touches.length >= 2;
|
|
435
|
+
st.dragging = !st.pinching && st.scale <= 1.01;
|
|
436
|
+
st.startScale = st.scale;
|
|
437
|
+
st.startDistance = touchDistance(e.nativeEvent.touches);
|
|
438
|
+
st.startX = st.x;
|
|
439
|
+
st.startY = st.y;
|
|
440
|
+
},
|
|
441
|
+
onPanResponderMove: (e, g) => {
|
|
442
|
+
const st = s.current;
|
|
443
|
+
const touches = e.nativeEvent.touches;
|
|
444
|
+
if (touches.length >= 2) {
|
|
445
|
+
if (!st.pinching) {
|
|
446
|
+
st.pinching = true;
|
|
447
|
+
st.dragging = false;
|
|
448
|
+
st.startScale = st.scale;
|
|
449
|
+
st.startDistance = touchDistance(touches);
|
|
450
|
+
}
|
|
451
|
+
st.scale = pinchScale(st.startScale, st.startDistance, touchDistance(touches));
|
|
452
|
+
scale.setValue(st.scale);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (st.dragging) {
|
|
456
|
+
st.y = Math.max(0, g.dy);
|
|
457
|
+
ty.setValue(st.y);
|
|
458
|
+
onPull(st.y);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (st.scale > 1.01) {
|
|
462
|
+
st.x = clamp(st.startX + g.dx, panLimit(st.scale, width));
|
|
463
|
+
st.y = clamp(st.startY + g.dy, panLimit(st.scale, height));
|
|
464
|
+
tx.setValue(st.x);
|
|
465
|
+
ty.setValue(st.y);
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
onPanResponderRelease: (_e, g) => {
|
|
469
|
+
const st = s.current;
|
|
470
|
+
if (st.dragging) {
|
|
471
|
+
st.dragging = false;
|
|
472
|
+
if (shouldDismiss(g.dy, g.vy)) return onDismiss();
|
|
473
|
+
onPull(0);
|
|
474
|
+
return settle({ scale: 1, x: 0, y: 0 });
|
|
475
|
+
}
|
|
476
|
+
st.pinching = false;
|
|
477
|
+
const z = Math.max(1, st.scale);
|
|
478
|
+
settle({ scale: z, x: clamp(st.x, panLimit(z, width)), y: clamp(st.y, panLimit(z, height)) });
|
|
479
|
+
},
|
|
480
|
+
onPanResponderTerminate: () => {
|
|
481
|
+
onPull(0);
|
|
482
|
+
settle({ scale: Math.max(1, s.current.scale), x: 0, y: 0 });
|
|
483
|
+
},
|
|
484
|
+
}),
|
|
485
|
+
[width, height, onPull, onDismiss, settle, scale, tx, ty],
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<View testID={testID} style={{ width, height, overflow: "hidden" }} {...responder.panHandlers}>
|
|
490
|
+
<Animated.View style={{ width, height, transform: [{ translateX: tx }, { translateY: ty }, { scale }] }}>
|
|
491
|
+
<Pressable accessible={false} onPress={tap} style={{ width, height }}>
|
|
492
|
+
<Photo photo={photo} width={width} height={height} testID={`${testID}-image`} />
|
|
493
|
+
</Pressable>
|
|
494
|
+
</Animated.View>
|
|
495
|
+
</View>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The photos of the message a tapped photo belongs to, and where it is among them, for
|
|
501
|
+
* `ChatPhotoViewer`: swiping goes through that message's photos. A photo in no message
|
|
502
|
+
* (one still sending, shown from the phone) opens alone.
|
|
503
|
+
*/
|
|
504
|
+
export function galleryFor(
|
|
505
|
+
messages: readonly ChatMessage[],
|
|
506
|
+
urls: Record<string, string>,
|
|
507
|
+
uri: string,
|
|
508
|
+
alt: string,
|
|
509
|
+
label: (a: BubbleAttachment) => string,
|
|
510
|
+
): { photos: ChatPhoto[]; index: number } {
|
|
511
|
+
for (const m of messages) {
|
|
512
|
+
const images = m.attachments.filter((a) => a.kind === "image").map((a) => fromServer(a, urls));
|
|
513
|
+
if (!images.some((a) => a.uri === uri)) continue;
|
|
514
|
+
const photos = images.filter((a): a is BubbleAttachment & { uri: string } => Boolean(a.uri)).map((a) => ({ key: a.key, uri: a.uri, alt: label(a) }));
|
|
515
|
+
return { photos, index: Math.max(0, photos.findIndex((p) => p.uri === uri)) };
|
|
516
|
+
}
|
|
517
|
+
return { photos: [{ key: uri, uri, alt }], index: 0 };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface ChatMediaViewerProps {
|
|
521
|
+
/** The picture to show; null closes the viewer. */
|
|
522
|
+
image: { uri: string; alt: string } | null;
|
|
523
|
+
onClose: () => void;
|
|
524
|
+
testID?: string;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** One picture in the full-screen viewer (`ChatPhotoViewer` pages through several). */
|
|
528
|
+
export function ChatMediaViewer({ image, onClose, testID = "chat-media-viewer" }: ChatMediaViewerProps) {
|
|
529
|
+
const photos = useMemo(() => (image ? [{ key: image.uri, uri: image.uri, alt: image.alt }] : null), [image]);
|
|
530
|
+
return <ChatPhotoViewer photos={photos} onClose={onClose} testID={testID} />;
|
|
531
|
+
}
|