@stevecastaneda/paseo-progress 1.14.2 → 1.14.3

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.
Files changed (67) hide show
  1. package/README.md +24 -17
  2. package/client/attachments.tsx +122 -28
  3. package/client/blocks.tsx +33 -8
  4. package/client/chat-row-view.tsx +19 -6
  5. package/client/chat-row.ts +13 -2
  6. package/client/dashboard-query.ts +9 -4
  7. package/client/deliverables.tsx +82 -17
  8. package/client/dialog-stack.tsx +104 -42
  9. package/client/directory.ts +1 -3
  10. package/client/errors.ts +6 -1
  11. package/client/history.tsx +233 -79
  12. package/client/i18n.ts +10 -5
  13. package/client/launcher.tsx +57 -15
  14. package/client/locales/en.ts +28 -22
  15. package/client/locales/zh-CN.ts +18 -17
  16. package/client/messages.ts +1 -0
  17. package/client/motion.tsx +38 -14
  18. package/client/panel-opened.ts +3 -1
  19. package/client/panel.tsx +442 -160
  20. package/client/pdf.tsx +18 -4
  21. package/client/pieces.ts +6 -1
  22. package/client/pill-popover.tsx +108 -23
  23. package/client/pills.tsx +35 -12
  24. package/client/preview.tsx +265 -79
  25. package/client/questions.tsx +173 -52
  26. package/client/row.tsx +14 -3
  27. package/client/section-title.tsx +11 -2
  28. package/client/settings.tsx +76 -24
  29. package/client/show-more.tsx +23 -6
  30. package/client/spinner.tsx +11 -7
  31. package/client/status-badge.tsx +9 -7
  32. package/client/tabs.tsx +98 -20
  33. package/client/ticket-dialog.tsx +241 -73
  34. package/client/time-bar.tsx +5 -2
  35. package/client/video.tsx +15 -6
  36. package/client/whats-new.tsx +94 -36
  37. package/client/when.tsx +2 -1
  38. package/client/write-up.tsx +79 -12
  39. package/dist/server/cli.js +199 -101
  40. package/index.client.tsx +28 -13
  41. package/index.server.ts +19 -1
  42. package/package.json +15 -14
  43. package/server/cli.ts +72 -32
  44. package/server/dashboard.ts +9 -13
  45. package/server/launcher.ts +33 -17
  46. package/server/lock.ts +14 -7
  47. package/server/open.ts +32 -98
  48. package/server/progress-file.ts +6 -6
  49. package/server/question-mode.ts +12 -6
  50. package/server/skill.ts +22 -16
  51. package/server/store.ts +26 -41
  52. package/server/system.ts +113 -0
  53. package/server/tool.ts +89 -44
  54. package/server/whats-new.ts +25 -13
  55. package/shared/attachments.ts +40 -8
  56. package/shared/blocks.ts +9 -6
  57. package/shared/dashboard.ts +46 -29
  58. package/shared/events.ts +12 -10
  59. package/shared/markdown.ts +16 -8
  60. package/shared/question-modes.ts +8 -5
  61. package/shared/rpc.ts +9 -3
  62. package/shared/show.ts +20 -8
  63. package/shared/ticket-story.ts +1 -3
  64. package/skills/paseo-progress/SKILL.md +1 -0
  65. package/tsconfig.json +3 -10
  66. package/client/page-opener.ts +0 -9
  67. package/client/page-screen.tsx +0 -97
@@ -3,7 +3,7 @@ import { Icon, Markdown, ScrollView } from "@getpaseo/plugin/client/ui";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import React, { useEffect, useMemo, useRef, useState } from "react";
5
5
  import { ActivityIndicator, Image, type LayoutChangeEvent, Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
6
- import { ATTACHMENT_ROW, type Attachment, AttachmentList, isPreviewable } from "./attachments";
6
+ import { ATTACHMENT_ROW, type Attachment, AttachmentList, isHostOpenable, isPreviewable } from "./attachments";
7
7
  import { errorText } from "./errors";
8
8
  import { folderAttachments, isFolder, isPieced, PDF_MIME_TYPE, previewKind } from "../shared/attachments";
9
9
  import { canOpenOnHost, previewDeliverable, readDeliverablePiece } from "../shared/rpc";
@@ -41,7 +41,22 @@ type PreviewProps = {
41
41
 
42
42
  // `onLong` says when the file is too tall for the dialog, so the host turns off
43
43
  // its own scrolling and the file scrolls with the actions pinned below.
44
- export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong, ownScroll, fullScreen, leftFullScreen, onFullScreen }: PreviewProps & {
44
+ export function PreviewContent({
45
+ colors,
46
+ attachment,
47
+ workspaceId,
48
+ workspaceDirectory,
49
+ onOpenOnHost,
50
+ onCopyPath,
51
+ onBack,
52
+ gallery,
53
+ onOpenEntry,
54
+ onLong,
55
+ ownScroll,
56
+ fullScreen,
57
+ leftFullScreen,
58
+ onFullScreen,
59
+ }: PreviewProps & {
45
60
  onLong(long: boolean): void;
46
61
  ownScroll: boolean;
47
62
  fullScreen: boolean;
@@ -53,11 +68,12 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
53
68
  const readPiece = useRpc(readDeliverablePiece);
54
69
  const checkHost = useRpc(canOpenOnHost);
55
70
  const queryClient = useQueryClient();
56
- const canOpen = useQuery({
57
- queryKey: ["progress", "can-open", workspaceId],
58
- queryFn: () => checkHost({ workspaceId, workspaceDirectory }),
59
- staleTime: Infinity,
60
- }).data?.canOpen === true;
71
+ const canOpen =
72
+ useQuery({
73
+ queryKey: ["progress", "can-open", workspaceId],
74
+ queryFn: () => checkHost({ workspaceId, workspaceDirectory }),
75
+ staleTime: Infinity,
76
+ }).data?.canOpen === true && isHostOpenable(attachment);
61
77
  const loadingKey = (target: Attachment) => ["progress", "preview-loading", workspaceId, target.ref, target.path];
62
78
  const previewQuery = (target: Attachment) => ({
63
79
  queryKey: ["progress", "preview", workspaceId, target.ref, target.path],
@@ -67,7 +83,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
67
83
  // and so on, so a tall image made the dialog jump between two sizes until closed.
68
84
  queryFn: async () => {
69
85
  const data = await fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref });
70
- if (data.kind === "image") return { ...data, ...await imageSize(data.dataUri) };
86
+ if (data.kind === "image") return { ...data, ...(await imageSize(data.dataUri)) };
71
87
  if (data.kind !== "video" && data.kind !== "pdf") return data;
72
88
  queryClient.setQueryData(loadingKey(target), 0);
73
89
  const blob = await loadPieces({
@@ -162,11 +178,15 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
162
178
  const long = data?.kind === "markdown" ? (stepping ? lastLong.current : certainlyLong || latched) : estimatedHeight(data, aspectRatio, maxMediaHeight) > room;
163
179
  lastLong.current = long;
164
180
  useEffect(() => onLong(long), [long]);
165
- const measure = !long && !stepping ? (event: LayoutChangeEvent) => {
166
- if (event.nativeEvent.layout.height > room) queryClient.setQueryData(longKey, true);
167
- } : undefined;
181
+ const measure =
182
+ !long && !stepping
183
+ ? (event: LayoutChangeEvent) => {
184
+ if (event.nativeEvent.layout.height > room) queryClient.setQueryData(longKey, true);
185
+ }
186
+ : undefined;
168
187
  const fileKind = previewKind(attachment.path ?? "");
169
- const piecesLoading = isPieced(fileKind) && preview.isFetching && loading !== undefined && loading < 1 ? m.loadingPieces(fileKind, Math.floor(loading * 100)) : null;
188
+ const piecesLoading =
189
+ isPieced(fileKind) && preview.isFetching && loading !== undefined && loading < 1 ? m.loadingPieces(fileKind, Math.floor(loading * 100)) : null;
170
190
  const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
171
191
  const loadingState = (
172
192
  <View style={{ alignItems: "center", gap: 8, padding: 24 }}>
@@ -174,7 +194,12 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
174
194
  {piecesLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{piecesLoading}</Text> : null}
175
195
  </View>
176
196
  );
177
- const truncatedNote = (paddingTop: number) => <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop }}>{m.truncatedText}{canOpen ? m.openRest : ""}</Text>;
197
+ const truncatedNote = (paddingTop: number) => (
198
+ <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop }}>
199
+ {m.truncatedText}
200
+ {canOpen ? m.openRest : ""}
201
+ </Text>
202
+ );
178
203
  const content = (
179
204
  <>
180
205
  {preview.isPending ? loadingState : null}
@@ -192,20 +217,35 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
192
217
  </View>
193
218
  ) : null}
194
219
  {/* Straight on the dialog: Paseo's code blocks and tables bring their own fills. */}
195
- {preview.data?.kind === "markdown" ? (certainlyLong && !ownScroll ? loadingState : (
196
- <View onLayout={measure} style={{ gap: 12, opacity: stepping ? 0.6 : 1 }}>
197
- <Markdown>{preview.data.markdown}</Markdown>
198
- {preview.data.truncated ? truncatedNote(0) : null}
199
- </View>
200
- )) : null}
220
+ {preview.data?.kind === "markdown" ? (
221
+ certainlyLong && !ownScroll ? (
222
+ loadingState
223
+ ) : (
224
+ <View onLayout={measure} style={{ gap: 12, opacity: stepping ? 0.6 : 1 }}>
225
+ <Markdown>{preview.data.markdown}</Markdown>
226
+ {preview.data.truncated ? truncatedNote(0) : null}
227
+ </View>
228
+ )
229
+ ) : null}
201
230
  {preview.data?.kind === "folder" ? (
202
- <FolderList colors={colors} folder={attachment} entries={folderAttachments(attachment, preview.data.entries)} truncated={preview.data.truncated}
203
- dimmed={stepping} onOpen={onOpenEntry} />
231
+ <FolderList
232
+ colors={colors}
233
+ folder={attachment}
234
+ entries={folderAttachments(attachment, preview.data.entries)}
235
+ truncated={preview.data.truncated}
236
+ dimmed={stepping}
237
+ root={workspaceDirectory}
238
+ onOpen={onOpenEntry}
239
+ />
204
240
  ) : null}
205
241
  {preview.data?.kind === "image" ? (
206
242
  // The Full screen button is the keyboard's way in, so the image isn't a second tab stop.
207
- <Pressable disabled={!canFullScreen} focusable={false} onPress={() => onFullScreen(true)}
208
- style={{ width: "100%", cursor: canFullScreen ? "zoom-in" as unknown as ViewStyle["cursor"] : undefined }}>
243
+ <Pressable
244
+ disabled={!canFullScreen}
245
+ focusable={false}
246
+ onPress={() => onFullScreen(true)}
247
+ style={{ width: "100%", cursor: canFullScreen ? ("zoom-in" as unknown as ViewStyle["cursor"]) : undefined }}
248
+ >
209
249
  <Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
210
250
  </Pressable>
211
251
  ) : null}
@@ -216,33 +256,48 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
216
256
  // The path as a caption under the file, where it has the full width, with Copy
217
257
  // beside it so it can go into a message without leaving the dialog. A long
218
258
  // file scrolls above it, so it stays in view with the actions.
219
- const path = !preview.isPending && piecesLoading ? (
220
- <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{piecesLoading}</Text>
221
- ) : (
222
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
223
- <Text selectable numberOfLines={1} ellipsizeMode="middle" style={[caption, { flex: 1, minWidth: 0 }]}>
224
- {attachment.path}
225
- </Text>
226
- {/* Optical: the 22px button centers on the 17px caption line without growing it. */}
227
- <PressScale accessibilityRole="button" accessibilityLabel={m.copyItemPath(attachment.title)} hitSlop={6} outerStyle={{ marginVertical: -3 }}
228
- onPress={onCopyPath}
229
- style={({ pressed, hovered }) => ({ padding: 4, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
230
- <Icon name="Copy" size={14} color={colors.foregroundMuted} />
231
- </PressScale>
232
- </View>
233
- );
259
+ const path =
260
+ !preview.isPending && piecesLoading ? (
261
+ <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{piecesLoading}</Text>
262
+ ) : (
263
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
264
+ <Text selectable numberOfLines={1} ellipsizeMode="middle" style={[caption, { flex: 1, minWidth: 0 }]}>
265
+ {attachment.path}
266
+ </Text>
267
+ {/* Optical: the 22px button centers on the 17px caption line without growing it. */}
268
+ <PressScale
269
+ accessibilityRole="button"
270
+ accessibilityLabel={m.copyItemPath(attachment.title)}
271
+ hitSlop={6}
272
+ outerStyle={{ marginVertical: -3 }}
273
+ onPress={onCopyPath}
274
+ style={({ pressed, hovered }) => ({
275
+ padding: 4,
276
+ borderRadius: 4,
277
+ backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent",
278
+ })}
279
+ >
280
+ <Icon name="Copy" size={14} color={colors.foregroundMuted} />
281
+ </PressScale>
282
+ </View>
283
+ );
234
284
  // Back on the left, Previous and Next in the middle, Open on the right.
235
285
  const actions = (
236
286
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
237
- <View style={{ flex: 1, alignItems: "flex-start" }}>
238
- {onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
239
- </View>
287
+ <View style={{ flex: 1, alignItems: "flex-start" }}>{onBack ? <BackButton colors={colors} onPress={onBack} /> : null}</View>
240
288
  {canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
241
289
  <View style={{ flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
242
290
  {canFullScreen ? (
243
291
  // An inline outline beats Paseo's :focus-visible ring.
244
- <DialogButton ref={fullScreenButton} colors={colors} icon="Maximize2" accessibilityLabel={m.fullScreen} onPress={() => onFullScreen(true)}
245
- outerStyle={quiet ? { outlineWidth: 0 } : undefined} onBlur={() => setQuiet(false)} />
292
+ <DialogButton
293
+ ref={fullScreenButton}
294
+ colors={colors}
295
+ icon="Maximize2"
296
+ accessibilityLabel={m.fullScreen}
297
+ onPress={() => onFullScreen(true)}
298
+ outerStyle={quiet ? { outlineWidth: 0 } : undefined}
299
+ onBlur={() => setQuiet(false)}
300
+ />
246
301
  ) : null}
247
302
  {canOpen ? <DialogButton colors={colors} icon="ExternalLink" label={m.open} accessibilityLabel={m.openDefault} onPress={onOpenOnHost} /> : null}
248
303
  </View>
@@ -251,10 +306,17 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
251
306
  const layerShown = fullScreen && showsLayer;
252
307
  // An SVG stays sharp at any size, so only a bitmap stops at its own pixels. An
253
308
  // icon's SVG can say it's 24px wide.
254
- const fullScreenLayer = layerShown && preview.data?.kind === "image" ? (
255
- <FullScreenImage title={attachment.title} uri={preview.data.dataUri} ratio={aspectRatio} dimmed={stepping} onExit={() => onFullScreen(false)}
256
- naturalWidth={preview.data.dataUri.startsWith("data:image/svg+xml") ? null : preview.data.width} />
257
- ) : null;
309
+ const fullScreenLayer =
310
+ layerShown && preview.data?.kind === "image" ? (
311
+ <FullScreenImage
312
+ title={attachment.title}
313
+ uri={preview.data.dataUri}
314
+ ratio={aspectRatio}
315
+ dimmed={stepping}
316
+ onExit={() => onFullScreen(false)}
317
+ naturalWidth={preview.data.dataUri.startsWith("data:image/svg+xml") ? null : preview.data.width}
318
+ />
319
+ ) : null;
258
320
  // Paseo's focus trap counts the controls under the layer, so Shift+Tab would
259
321
  // reach a hidden Open. Inert takes them out until full screen ends.
260
322
  const body = useRef<View>(null);
@@ -268,7 +330,9 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
268
330
  <>
269
331
  {long ? (
270
332
  <View ref={body} style={{ flex: 1 }}>
271
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
333
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>
334
+ {content}
335
+ </ScrollView>
272
336
  <View style={{ marginTop: 8 }}>{path}</View>
273
337
  {actions}
274
338
  </View>
@@ -289,9 +353,33 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
289
353
  const FULL_SCREEN_MARGIN = 24;
290
354
  const FULL_SCREEN_BAR = 52;
291
355
 
356
+ function barButton({ pressed, hovered }: { pressed: boolean; hovered: boolean }) {
357
+ return {
358
+ height: 36,
359
+ alignItems: "center",
360
+ justifyContent: "center",
361
+ borderRadius: 6,
362
+ backgroundColor: pressed ? "rgba(255, 255, 255, 0.16)" : hovered ? "rgba(255, 255, 255, 0.1)" : "transparent",
363
+ } as const;
364
+ }
365
+
292
366
  // The image over the whole window, never past its own pixels. It fits whole, or a
293
367
  // long one can fill the width and scroll.
294
- function FullScreenImage({ title, uri, naturalWidth, ratio, dimmed, onExit }: { title: string; uri: string; naturalWidth: number | null; ratio: number; dimmed: boolean; onExit(): void }) {
368
+ function FullScreenImage({
369
+ title,
370
+ uri,
371
+ naturalWidth,
372
+ ratio,
373
+ dimmed,
374
+ onExit,
375
+ }: {
376
+ title: string;
377
+ uri: string;
378
+ naturalWidth: number | null;
379
+ ratio: number;
380
+ dimmed: boolean;
381
+ onExit(): void;
382
+ }) {
295
383
  const { messages: m } = useProgressLanguage();
296
384
  const { width: windowWidth, height: windowHeight } = useWindowDimensions();
297
385
  const closeButton = useRef<View>(null);
@@ -317,36 +405,62 @@ function FullScreenImage({ title, uri, naturalWidth, ratio, dimmed, onExit }: {
317
405
  scrolled.current = scrolls;
318
406
  }, [scrolls, uri]);
319
407
  const light = "rgba(255, 255, 255, 0.85)";
320
- const barButton = ({ pressed, hovered }: { pressed: boolean; hovered: boolean }) => ({
321
- height: 36, alignItems: "center", justifyContent: "center", borderRadius: 6,
322
- backgroundColor: pressed ? "rgba(255, 255, 255, 0.16)" : hovered ? "rgba(255, 255, 255, 0.1)" : "transparent",
323
- }) as const;
324
408
  // A press on the image stops there, so only the black around it exits.
325
409
  // The fit button is the keyboard's way to the toggle, so the image isn't a tab stop.
326
410
  const area = (
327
- <Pressable accessible={false} focusable={false} onPress={onExit}
328
- style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", padding: FULL_SCREEN_MARGIN }}>
329
- <Pressable accessible={false} focusable={false} onPress={long ? toggle : () => {}}
330
- style={{ cursor: (long ? (scrolls ? "zoom-out" : "zoom-in") : "default") as unknown as ViewStyle["cursor"] }}>
331
- <Image accessibilityLabel={title} source={{ uri }} resizeMode="contain"
332
- style={{ width: scrolls ? widthFit : screenFit, aspectRatio: ratio, borderRadius: 6, borderWidth: 1, borderColor: "rgba(255, 255, 255, 0.1)", opacity: dimmed ? 0.6 : 1 }} />
411
+ <Pressable
412
+ accessible={false}
413
+ focusable={false}
414
+ onPress={onExit}
415
+ style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", padding: FULL_SCREEN_MARGIN }}
416
+ >
417
+ <Pressable
418
+ accessible={false}
419
+ focusable={false}
420
+ onPress={long ? toggle : () => {}}
421
+ style={{ cursor: (long ? (scrolls ? "zoom-out" : "zoom-in") : "default") as unknown as ViewStyle["cursor"] }}
422
+ >
423
+ <Image
424
+ accessibilityLabel={title}
425
+ source={{ uri }}
426
+ resizeMode="contain"
427
+ style={{
428
+ width: scrolls ? widthFit : screenFit,
429
+ aspectRatio: ratio,
430
+ borderRadius: 6,
431
+ borderWidth: 1,
432
+ borderColor: "rgba(255, 255, 255, 0.1)",
433
+ opacity: dimmed ? 0.6 : 1,
434
+ }}
435
+ />
333
436
  </Pressable>
334
437
  </Pressable>
335
438
  );
336
439
  return (
337
440
  <View style={{ position: "fixed" as unknown as ViewStyle["position"], top: 0, right: 0, bottom: 0, left: 0, zIndex: 1000, backgroundColor: "black" }}>
338
441
  <View style={{ height: FULL_SCREEN_BAR, flexDirection: "row", alignItems: "center", gap: 12, paddingStart: FULL_SCREEN_MARGIN, paddingEnd: 12 }}>
339
- <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: light, fontSize: 13, lineHeight: 18 }}>{title}</Text>
442
+ <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: light, fontSize: 13, lineHeight: 18 }}>
443
+ {title}
444
+ </Text>
340
445
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
341
446
  {long ? (
342
- <PressScale ref={fitButton} accessibilityRole="button" onPress={toggle}
343
- style={(state) => ({ ...barButton(state), flexDirection: "row", gap: 6, paddingHorizontal: 10 })}>
447
+ <PressScale
448
+ ref={fitButton}
449
+ accessibilityRole="button"
450
+ onPress={toggle}
451
+ style={(state) => ({ ...barButton(state), flexDirection: "row", gap: 6, paddingHorizontal: 10 })}
452
+ >
344
453
  <Icon name={scrolls ? "Shrink" : "MoveHorizontal"} size={16} color={light} />
345
454
  <Text style={{ color: light, fontSize: 13, lineHeight: 18 }}>{scrolls ? m.fitToScreen : m.fitToWidth}</Text>
346
455
  </PressScale>
347
456
  ) : null}
348
- <PressScale ref={closeButton} accessibilityRole="button" accessibilityLabel={m.exitFullScreen} onPress={onExit}
349
- style={(state) => ({ ...barButton(state), width: 36 })}>
457
+ <PressScale
458
+ ref={closeButton}
459
+ accessibilityRole="button"
460
+ accessibilityLabel={m.exitFullScreen}
461
+ onPress={onExit}
462
+ style={(state) => ({ ...barButton(state), width: 36 })}
463
+ >
350
464
  <Icon name="X" size={18} color={light} />
351
465
  </PressScale>
352
466
  </View>
@@ -354,8 +468,12 @@ function FullScreenImage({ title, uri, naturalWidth, ratio, dimmed, onExit }: {
354
468
  {/* Keyed so the next image starts at its top. The fit button stays the visible
355
469
  keyboard control, so the scroll area draws no focus ring. */}
356
470
  {scrolls ? (
357
- <ScrollView key={uri} ref={scrollArea} focusable style={{ flex: 1, outlineWidth: 0 }} contentContainerStyle={{ flexGrow: 1 }}>{area}</ScrollView>
358
- ) : area}
471
+ <ScrollView key={uri} ref={scrollArea} focusable style={{ flex: 1, outlineWidth: 0 }} contentContainerStyle={{ flexGrow: 1 }}>
472
+ {area}
473
+ </ScrollView>
474
+ ) : (
475
+ area
476
+ )}
359
477
  </View>
360
478
  );
361
479
  }
@@ -378,7 +496,11 @@ const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
378
496
  const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
379
497
 
380
498
  // Roughly how tall the preview draws, including wrapped lines.
381
- function estimatedHeight(data: { kind: "image" | "video" | "pdf" | "html" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
499
+ function estimatedHeight(
500
+ data: { kind: "image" | "video" | "pdf" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined,
501
+ aspectRatio: number,
502
+ maxMediaHeight: number,
503
+ ): number {
382
504
  if (!data) return 0;
383
505
  if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
384
506
  if (data.kind === "pdf") return maxMediaHeight;
@@ -398,13 +520,29 @@ export function markdownFloor(markdown: string): number {
398
520
  return lines * MIN_MARKDOWN_LINE;
399
521
  }
400
522
 
401
- function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { colors: Colors; folder: Attachment; entries: Attachment[]; truncated: boolean; dimmed: boolean; onOpen(attachment: Attachment, group: Attachment[]): void }) {
523
+ function FolderList({
524
+ colors,
525
+ folder,
526
+ entries,
527
+ truncated,
528
+ dimmed,
529
+ root,
530
+ onOpen,
531
+ }: {
532
+ colors: Colors;
533
+ folder: Attachment;
534
+ entries: Attachment[];
535
+ truncated: boolean;
536
+ dimmed: boolean;
537
+ root: string;
538
+ onOpen(attachment: Attachment, group: Attachment[]): void;
539
+ }) {
402
540
  const { messages: m } = useProgressLanguage();
403
541
  const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
404
542
  if (!entries.length) return <Text style={caption}>{m.emptyFolder(folder.title)}</Text>;
405
543
  return (
406
544
  <View style={{ gap: 8, opacity: dimmed ? 0.6 : 1 }}>
407
- <AttachmentList colors={colors} attachments={entries} onOpen={onOpen} />
545
+ <AttachmentList colors={colors} attachments={entries} root={root} onOpen={onOpen} />
408
546
  {truncated ? <Text style={caption}>{m.firstEntries(entries.length)}</Text> : null}
409
547
  </View>
410
548
  );
@@ -414,8 +552,11 @@ function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { co
414
552
  // Read from the data because onLoad doesn't report a size for data URIs on every platform.
415
553
  function imageSize(uri: string): Promise<{ width: number | null; ratio: number | null }> {
416
554
  return new Promise((resolve) => {
417
- Image.getSize(uri, (width, height) => resolve(width && height ? { width, ratio: width / height } : { width: null, ratio: null }),
418
- () => resolve({ width: null, ratio: null }));
555
+ Image.getSize(
556
+ uri,
557
+ (width, height) => resolve(width && height ? { width, ratio: width / height } : { width: null, ratio: null }),
558
+ () => resolve({ width: null, ratio: null }),
559
+ );
419
560
  });
420
561
  }
421
562
 
@@ -427,14 +568,45 @@ export function roomForContent(windowHeight: number): number {
427
568
  }
428
569
 
429
570
  // A dialog footer button: 13px label and 14px icon, 30px tall. Icon-only is a 30px square.
430
- export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, onBlur, outerStyle, ref }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; onBlur?(): void; outerStyle?: ViewStyle; ref?: React.Ref<View> }) {
571
+ export function DialogButton({
572
+ colors,
573
+ icon,
574
+ label,
575
+ accessibilityLabel,
576
+ onPress,
577
+ onBlur,
578
+ outerStyle,
579
+ ref,
580
+ }: {
581
+ colors: Colors;
582
+ icon: string;
583
+ label?: string;
584
+ accessibilityLabel?: string;
585
+ onPress(): void;
586
+ onBlur?(): void;
587
+ outerStyle?: ViewStyle;
588
+ ref?: React.Ref<View>;
589
+ }) {
431
590
  return (
432
- <PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} onBlur={onBlur} outerStyle={outerStyle}
591
+ <PressScale
592
+ ref={ref}
593
+ accessibilityRole="button"
594
+ accessibilityLabel={accessibilityLabel ?? label}
595
+ onPress={onPress}
596
+ onBlur={onBlur}
597
+ outerStyle={outerStyle}
433
598
  style={({ pressed }) => ({
434
- flexDirection: "row", alignItems: "center", gap: 6,
435
- paddingVertical: label ? 6 : 8, paddingLeft: label ? 8 : 8, paddingRight: label ? 10 : 8,
436
- borderRadius: 6, ...raised(colors), backgroundColor: pressed ? colors.surface1 : colors.surface2,
437
- })}>
599
+ flexDirection: "row",
600
+ alignItems: "center",
601
+ gap: 6,
602
+ paddingVertical: label ? 6 : 8,
603
+ paddingLeft: label ? 8 : 8,
604
+ paddingRight: label ? 10 : 8,
605
+ borderRadius: 6,
606
+ ...raised(colors),
607
+ backgroundColor: pressed ? colors.surface1 : colors.surface2,
608
+ })}
609
+ >
438
610
  <Icon name={icon} size={14} color={colors.foreground} />
439
611
  {label ? <Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 18 }}>{label}</Text> : null}
440
612
  </PressScale>
@@ -448,12 +620,26 @@ export function BackButton({ colors, onPress }: { colors: Colors; onPress(): voi
448
620
  }
449
621
 
450
622
  // Previous and next through a gallery, with "2 of 3" between.
451
- function Stepper({ colors, index, count, onStep, nextRef }: { colors: Colors; index: number; count: number; onStep(offset: number): void; nextRef: React.Ref<View> }) {
623
+ function Stepper({
624
+ colors,
625
+ index,
626
+ count,
627
+ onStep,
628
+ nextRef,
629
+ }: {
630
+ colors: Colors;
631
+ index: number;
632
+ count: number;
633
+ onStep(offset: number): void;
634
+ nextRef: React.Ref<View>;
635
+ }) {
452
636
  const { messages: m } = useProgressLanguage();
453
637
  return (
454
638
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
455
639
  <DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel={m.previousFile} onPress={() => onStep(-1)} />
456
- <Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{m.galleryPosition(index + 1, count)}</Text>
640
+ <Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>
641
+ {m.galleryPosition(index + 1, count)}
642
+ </Text>
457
643
  <DialogButton ref={nextRef} colors={colors} icon="ChevronRight" accessibilityLabel={m.nextFile} onPress={() => onStep(1)} />
458
644
  </View>
459
645
  );