From 5f831ededa7160b289108e8fe70c481410253d15 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 18:43:51 -0800 Subject: [PATCH 1/3] fix(mobile): full-screen Mermaid diagrams open in the native image viewer Tapping a diagram used the React Native image viewer on iOS, so it could not be swiped down to dismiss and its header ran under the iPhone Duo's controls. It now opens through FilePreviewModal like other images: the native viewer on iOS, the same viewer as before on Android. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/native/MermaidCodeBlock.tsx | 29 +++++++++------------ 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/native/MermaidCodeBlock.tsx b/apps/mobile/src/native/MermaidCodeBlock.tsx index beea0ee18b6d5..d185d3e9a6cfa 100644 --- a/apps/mobile/src/native/MermaidCodeBlock.tsx +++ b/apps/mobile/src/native/MermaidCodeBlock.tsx @@ -6,7 +6,7 @@ import { Suspense, use, useRef, useState } from "react"; import { PixelRatio, Platform, Pressable, StyleSheet, Text, View } from "react-native"; import { parse, Svg, SvgXml } from "react-native-svg"; -import { MediaImagePreview } from "../components/MediaImagePreview"; +import { FilePreviewModal } from "../components/FilePreviewModal"; import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { mermaidSvgForNative, withDiagramBackground } from "../lib/mermaidSvg"; @@ -278,23 +278,20 @@ function MermaidBlock(props: MermaidCodeBlockProps & { readonly diagram: Diagram ) : null} {expansion?.status === "open" ? ( - setExpansion(null)} /> From afb97c7ec3f8595b2eaf9d5647480ee858701178 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 19:09:20 -0800 Subject: [PATCH 2/3] refactor(mobile): each media viewer is one component in components/ui FilePreviewModal and VideoPreviewModal now live in components/ui like the other platform primitives: the file is the iOS viewer and .android.tsx overrides it. Their platform pieces are no longer exported, so nothing can pick the Android image viewer on iOS, as MermaidCodeBlock did. FilePreviewSession holds what both platforms share, and its test covers both gallery behaviours without the separate Android test run. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 2 +- .../components/ComposerAttachmentStrip.tsx | 4 +- .../components/ComposerContextAttachment.tsx | 2 +- .../src/components/ComposerContextSheet.tsx | 4 +- .../src/components/FilePreviewModal.test.tsx | 140 ----------------- .../src/components/MediaImagePreview.tsx | 58 ------- .../{ => ui}/FilePreviewGallery.tsx | 2 +- .../FilePreviewModal.android.tsx} | 88 +++++++++-- .../FilePreviewModal.tsx} | 30 ++-- .../{ => ui}/FilePreviewModal.types.ts | 17 +- .../components/ui/FilePreviewSession.test.tsx | 146 ++++++++++++++++++ .../FilePreviewSession.tsx} | 64 ++++---- .../VideoPreviewModal.android.tsx} | 24 +-- .../VideoPreviewModal.tsx} | 18 +-- .../features/files/AttachmentFileScreen.tsx | 2 +- .../features/files/FileMarkdownPreview.tsx | 4 +- .../features/files/ThreadFilesRouteScreen.tsx | 2 +- .../files/WorkspaceFileImagePreview.tsx | 2 +- .../features/files/ZipArchiveView.test.tsx | 2 +- .../src/features/files/ZipArchiveView.tsx | 2 +- .../pull-requests/PullRequestDescription.tsx | 2 +- .../pull-requests/PullRequestMarkdown.tsx | 4 +- .../pull-requests/PullRequestMedia.tsx | 6 +- .../pull-requests/pullRequestMediaSource.ts | 2 +- .../review/ReviewCommentComposerSheet.tsx | 2 +- .../features/threads/QuestionAttachments.tsx | 4 +- .../src/features/threads/ThreadComposer.tsx | 4 +- .../src/features/threads/ThreadFeed.tsx | 6 +- .../threads/ThreadInlineFilePreview.tsx | 2 +- .../threads/ThreadMarkdownImage.test.tsx | 2 +- .../features/threads/ThreadMarkdownImage.tsx | 4 +- .../features/threads/messageImageGallery.ts | 2 +- .../src/features/threads/thread-work-log.tsx | 4 +- apps/mobile/src/lib/composerImageGallery.ts | 2 +- apps/mobile/src/lib/contextImageGallery.ts | 2 +- apps/mobile/src/lib/filePreview.test.ts | 6 +- .../mobile/src/lib/filePreviewGallery.test.ts | 2 +- apps/mobile/src/lib/filePreviewGallery.ts | 2 +- apps/mobile/src/lib/markdownMedia.ts | 2 +- .../src/lib/useMediaVideoPlayback.test.tsx | 6 +- apps/mobile/src/native/MermaidCodeBlock.tsx | 2 +- apps/mobile/vite.config.ts | 2 +- 42 files changed, 357 insertions(+), 326 deletions(-) delete mode 100644 apps/mobile/src/components/FilePreviewModal.test.tsx delete mode 100644 apps/mobile/src/components/MediaImagePreview.tsx rename apps/mobile/src/components/{ => ui}/FilePreviewGallery.tsx (88%) rename apps/mobile/src/components/{FilePreview.tsx => ui/FilePreviewModal.android.tsx} (59%) rename apps/mobile/src/components/{FilePreview.ios.tsx => ui/FilePreviewModal.tsx} (82%) rename apps/mobile/src/components/{ => ui}/FilePreviewModal.types.ts (68%) create mode 100644 apps/mobile/src/components/ui/FilePreviewSession.test.tsx rename apps/mobile/src/components/{FilePreviewModal.tsx => ui/FilePreviewSession.tsx} (82%) rename apps/mobile/src/components/{VideoPreviewModal.tsx => ui/VideoPreviewModal.android.tsx} (87%) rename apps/mobile/src/components/{VideoPreviewModal.ios.tsx => ui/VideoPreviewModal.tsx} (92%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5ef7cb37f8ab6..894f535ebcd8a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -48,7 +48,7 @@ "generate:highlights": "node scripts/generate-highlights.mts", "generate:nitro": "cd modules/t3-highlights && nitrogen", "eas-build-on-success": "node scripts/upload-sourcemaps.mts build", - "test": "vp test run --project unit --project react && T3_MOBILE_TEST_PLATFORM=android vp test run --project react src/components/FilePreviewModal.test.tsx", + "test": "vp test run --project unit --project react", "typecheck": "effect-tsgo tsrs --noEmit" }, "dependencies": { diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 923afb87a3a1f..49d30e06e45f3 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,4 +1,4 @@ -import { FilePreviewGallery, useFilePreviewGallery } from "./FilePreviewGallery"; +import { FilePreviewGallery, useFilePreviewGallery } from "./ui/FilePreviewGallery"; import { composerImageGallery } from "../lib/composerImageGallery"; import { SymbolView } from "./ui/AppSymbol"; import { imageMimeType } from "@t3tools/shared/image"; @@ -21,7 +21,7 @@ import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachment import { VideoAttachmentTile } from "./VideoAttachmentTile"; import { type MediaActionsSource } from "../lib/mediaActionsSource"; import { PresentationSource } from "./ui/NativePresentation"; -import type { FilePreviewSource } from "./FilePreviewModal"; +import type { FilePreviewSource } from "./ui/FilePreviewModal"; import { isPdfFile } from "../lib/filePreview"; import type { EnvironmentId } from "@t3tools/contracts"; import { diff --git a/apps/mobile/src/components/ComposerContextAttachment.tsx b/apps/mobile/src/components/ComposerContextAttachment.tsx index edb5522f3d0f8..fcd0da2b9d789 100644 --- a/apps/mobile/src/components/ComposerContextAttachment.tsx +++ b/apps/mobile/src/components/ComposerContextAttachment.tsx @@ -16,7 +16,7 @@ import { downloadAndShareAttachment } from "../lib/attachmentDownload"; import { assetImageSource } from "../lib/assetImageSource"; import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; import { AppText as Text } from "./ui/AppText"; -import { FilePreviewModal } from "./FilePreviewModal"; +import { FilePreviewModal } from "./ui/FilePreviewModal"; import { PresentationSource } from "./ui/NativePresentation"; export function ComposerContextAttachment(props: { diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index d7fb3a3c4cd14..8836662df1ff0 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -23,8 +23,8 @@ import { type DraftComposerAttachment, } from "../lib/composerImages"; import { contextImageGallery } from "../lib/contextImageGallery"; -import { FilePreviewModal } from "./FilePreviewModal"; -import { VideoPreviewModal } from "./VideoPreviewModal"; +import { FilePreviewModal } from "./ui/FilePreviewModal"; +import { VideoPreviewModal } from "./ui/VideoPreviewModal"; import { ComposerContextAttachment } from "./ComposerContextAttachment"; import { AppText as Text } from "./ui/AppText"; import { Sheet } from "./ui/Sheet"; diff --git a/apps/mobile/src/components/FilePreviewModal.test.tsx b/apps/mobile/src/components/FilePreviewModal.test.tsx deleted file mode 100644 index 3989180f62824..0000000000000 --- a/apps/mobile/src/components/FilePreviewModal.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, vi } from "vite-plus/test"; -import { EnvironmentId, SOURCE_CONTROL_MEDIA_ASSET } from "@t3tools/contracts"; - -const harness = vi.hoisted(() => ({ - refreshAssetUrl: vi.fn<(environmentId: string, resource: { url: string }) => Promise>(), - alert: vi.fn(), - previews: [] as unknown[], - mounts: 0, - platform: { OS: process.env.T3_MOBILE_TEST_PLATFORM === "android" ? "android" : "ios" }, -})); - -vi.mock("react-native", () => ({ Platform: harness.platform })); -vi.mock("../state/assets", () => ({ refreshAssetUrl: harness.refreshAssetUrl })); -vi.mock("../lib/localAttachmentPreview", () => ({ loadLocalAttachmentPreview: vi.fn() })); -vi.mock("../lib/attachmentDownload", () => ({ - downloadAndShareAttachment: vi.fn(), - nativeViewerErrorMessage: () => "", - shareLocalAttachment: vi.fn(), -})); -vi.mock("../lib/previewPresentation", () => ({ usePreviewPresentation: () => {} })); -vi.mock("./ui/Dialog", () => ({ alert: harness.alert })); -vi.mock("./FilePreview", async () => { - const { useState } = await import("react"); - return { - FilePreview: (props: { source: unknown }) => { - useState(() => harness.mounts++); - harness.previews.push(props.source); - return null; - }, - }; -}); - -import type { FilePreviewItem } from "./FilePreviewModal.types"; -import { FilePreviewModal } from "./FilePreviewModal"; - -const environmentId = EnvironmentId.make("environment"); -const image = (url: string): FilePreviewItem => ({ - kind: "image", - environmentId, - resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: "/repo", url }, -}); - -async function openGallery( - selected: FilePreviewItem, - gallery: readonly FilePreviewItem[], - platform = harness.platform.OS, -) { - harness.platform.OS = platform; - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - harness.previews.length = 0; - harness.mounts = 0; - harness.alert.mockClear(); - const root = createRoot(document.createElement("div")); - const onRequestClose = vi.fn(); - await act(async () => - root.render( - , - ), - ); - return { onRequestClose, [Symbol.asyncDispose]: () => act(async () => root.unmount()) }; -} - -it("keeps the gallery stable while a sibling is still resolving", async () => { - const platform = harness.platform.OS; - const slow = Promise.withResolvers(); - harness.refreshAssetUrl.mockImplementation(async (_environmentId, resource) => - resource.url.endsWith("slow.png") ? slow.promise : "https://signed.test/a.png", - ); - const selected = image("https://github.com/a.png"); - await using _preview = await openGallery( - selected, - [image("https://github.com/slow.png"), selected], - platform, - ); - - if (platform === "ios") { - expect(harness.previews.at(-1)).toMatchObject({ - kind: "image", - selectedIndex: 1, - images: [ - expect.not.objectContaining({ uri: expect.anything() }), - { uri: "https://signed.test/a.png" }, - ], - }); - } else { - expect(harness.previews).toEqual([]); - } - await act(async () => slow.resolve("https://signed.test/slow.png")); - expect(harness.previews.at(-1)).toMatchObject({ - selectedIndex: 1, - images: [{ uri: "https://signed.test/slow.png" }, { uri: "https://signed.test/a.png" }], - }); - expect(harness.mounts).toBe(1); -}); - -it("handles a failed sibling without leaving a pending page", async () => { - const platform = harness.platform.OS; - harness.refreshAssetUrl.mockImplementation(async (_environmentId, resource) => { - if (resource.url.endsWith("missing.png")) throw new Error("not found"); - return `https://signed.test/${resource.url.split("/").pop()}`; - }); - const selected = image("https://github.com/a.png"); - await using preview = await openGallery( - selected, - [selected, image("https://github.com/missing.png"), image("https://github.com/c.png")], - platform, - ); - - if (platform === "android") { - expect(preview.onRequestClose).toHaveBeenCalledOnce(); - expect(harness.alert).toHaveBeenCalledOnce(); - expect(harness.previews).toEqual([]); - return; - } - - expect(preview.onRequestClose).not.toHaveBeenCalled(); - expect(harness.alert).not.toHaveBeenCalled(); - expect(harness.previews.at(-1)).toMatchObject({ - selectedIndex: 0, - images: [ - { uri: "https://signed.test/a.png" }, - { failed: true }, - { uri: "https://signed.test/c.png" }, - ], - }); -}); - -it("closes the preview when the selected image cannot be resolved", async () => { - harness.refreshAssetUrl.mockImplementation(async (_environmentId, resource) => { - if (resource.url.endsWith("missing.png")) throw new Error("not found"); - return `https://signed.test/${resource.url.split("/").pop()}`; - }); - const selected = image("https://github.com/missing.png"); - await using preview = await openGallery(selected, [image("https://github.com/a.png"), selected]); - - expect(preview.onRequestClose).toHaveBeenCalledOnce(); - expect(harness.alert).toHaveBeenCalledOnce(); -}); diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx deleted file mode 100644 index 2a7b226574e73..0000000000000 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { createContext, useContext } from "react"; -import { View } from "react-native"; -import ImageViewing from "react-native-image-viewing"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -import { useMediaActions } from "../state/mediaActions"; -import type { FilePreviewImages } from "./FilePreviewModal.types"; -import { MediaPreviewHeader } from "./MediaPreviewHeader"; - -type MediaImagePreviewProps = { - readonly source: FilePreviewImages; - readonly onRequestClose: () => void; -}; - -const ImagePreviewContext = createContext(null); - -function ImagePreviewHeader(header: { readonly imageIndex: number }) { - const props = useContext(ImagePreviewContext)!; - const source = props.source.images[header.imageIndex]; - const insets = useSafeAreaInsets(); - const mediaActions = useMediaActions(source?.actionsSource, props.onRequestClose); - return ( - - - - ); -} - -// Images still resolving show the viewer's loading page, and remount by key once their URL arrives. -export function imageViewingProps(source: FilePreviewImages) { - return { - images: source.images.map((image) => (image.uri ? { uri: image.uri } : {})), - keyExtractor: (_: unknown, index: number) => source.images[index]?.uri ?? `pending:${index}`, - // Stays fixed: react-native-image-viewing remounts the Modal and replays its fade when it changes. - imageIndex: source.selectedIndex, - }; -} - -export function MediaImagePreview(props: MediaImagePreviewProps) { - return ( - - - - ); -} diff --git a/apps/mobile/src/components/FilePreviewGallery.tsx b/apps/mobile/src/components/ui/FilePreviewGallery.tsx similarity index 88% rename from apps/mobile/src/components/FilePreviewGallery.tsx rename to apps/mobile/src/components/ui/FilePreviewGallery.tsx index cd42342c7c0c4..0617520a581b8 100644 --- a/apps/mobile/src/components/FilePreviewGallery.tsx +++ b/apps/mobile/src/components/ui/FilePreviewGallery.tsx @@ -1,6 +1,6 @@ import { createContext, useContext } from "react"; import type { FilePreviewItem, FilePreviewSource } from "./FilePreviewModal.types"; -import { filePreviewGallery } from "../lib/filePreviewGallery"; +import { filePreviewGallery } from "../../lib/filePreviewGallery"; export const FilePreviewGallery = createContext([]); diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/ui/FilePreviewModal.android.tsx similarity index 59% rename from apps/mobile/src/components/FilePreview.tsx rename to apps/mobile/src/components/ui/FilePreviewModal.android.tsx index 26d2aa50248ab..b8fbf00c7fe08 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/ui/FilePreviewModal.android.tsx @@ -1,20 +1,29 @@ -import { Button } from "./ui/Button"; -import * as Dialog from "./ui/Dialog"; -import { useEffect, useEffectEvent } from "react"; +import { Button } from "./Button"; +import * as Dialog from "./Dialog"; +import { createContext, useContext, useEffect, useEffectEvent } from "react"; import { Modal, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { openAttachmentInViewer } from "../lib/attachmentDownload"; -import { useMediaActions } from "../state/mediaActions"; +import { openAttachmentInViewer } from "../../lib/attachmentDownload"; +import { useMediaActions } from "../../state/mediaActions"; import type { + FilePreviewImages, + FilePreviewModalProps, + FilePreviewViewerProps, ResolvedFilePreviewDocument, +} from "./FilePreviewModal.types"; +import { FilePreviewSession } from "./FilePreviewSession"; +import { MediaPreviewHeader } from "../MediaPreviewHeader"; + +export type { + FilePreviewItem, + FilePreviewSource, ResolvedFilePreviewSource, } from "./FilePreviewModal.types"; -import { imageViewingProps, MediaImagePreview } from "./MediaImagePreview"; -import { PdfPreview } from "./PdfPreview"; -import { SheetHeader } from "./ui/SheetHeader"; -import { AppText as Text } from "./ui/AppText"; +import { PdfPreview } from "../PdfPreview"; +import { SheetHeader } from "./SheetHeader"; +import { AppText as Text } from "./AppText"; function PdfDocumentPreview(props: { readonly source: ResolvedFilePreviewDocument; @@ -113,16 +122,62 @@ function DocumentPreview(props: { ); } -export function FilePreview(props: { - readonly source: ResolvedFilePreviewSource; +type ImagePreviewProps = { + readonly source: FilePreviewImages; readonly onRequestClose: () => void; - readonly onOpenError?: (error: unknown) => void; -}) { +}; + +const ImagePreviewContext = createContext(null); + +function ImagePreviewHeader(header: { readonly imageIndex: number }) { + const props = useContext(ImagePreviewContext)!; + const source = props.source.images[header.imageIndex]; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(source?.actionsSource, props.onRequestClose); + return ( + + + + ); +} + +// Images still resolving show the viewer's loading page, and remount by key once their URL arrives. +function imageViewingProps(source: FilePreviewImages) { + return { + images: source.images.map((image) => (image.uri ? { uri: image.uri } : {})), + keyExtractor: (_: unknown, index: number) => source.images[index]?.uri ?? `pending:${index}`, + // Stays fixed: react-native-image-viewing remounts the Modal and replays its fade when it changes. + imageIndex: source.selectedIndex, + }; +} + +function ImagePreview(props: ImagePreviewProps) { + return ( + + + + ); +} + +function AndroidFilePreview(props: FilePreviewViewerProps) { const { source } = props; if (source.kind === "pdf") return ; if (source.kind !== "image") return ; if (source.images[source.selectedIndex]?.actionsSource) - return ; + return ; return ( ); } + +/** Shows images, PDFs and documents full screen. */ +export function FilePreviewModal(props: FilePreviewModalProps) { + return ; +} diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/ui/FilePreviewModal.tsx similarity index 82% rename from apps/mobile/src/components/FilePreview.ios.tsx rename to apps/mobile/src/components/ui/FilePreviewModal.tsx index db62d2928fd4c..aff82eae479cd 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/ui/FilePreviewModal.tsx @@ -1,8 +1,19 @@ -import * as Dialog from "./ui/Dialog"; +import * as Dialog from "./Dialog"; import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId, useRef, useState } from "react"; -import type { FilePreviewImage, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; +import type { + FilePreviewImage, + FilePreviewModalProps, + FilePreviewViewerProps, +} from "./FilePreviewModal.types"; +import { FilePreviewSession } from "./FilePreviewSession"; + +export type { + FilePreviewItem, + FilePreviewSource, + ResolvedFilePreviewSource, +} from "./FilePreviewModal.types"; const NativeControls = requireNativeModule<{ presentFile( @@ -25,11 +36,7 @@ const NativeControls = requireNativeModule<{ // Null tells the viewer an image failed; undefined means its URL has not arrived yet. const nativeUri = (image: FilePreviewImage) => (image.failed ? null : image.uri); -function NativeFilePreview(props: { - readonly source: ResolvedFilePreviewSource; - readonly onRequestClose: () => void; - readonly onOpenError?: (error: unknown) => void; -}) { +function NativeFilePreview(props: FilePreviewViewerProps) { const identifier = useId(); // Later URLs reach the open viewer through updateFile instead of presenting again. const [presented] = useState(props.source); @@ -91,10 +98,7 @@ function NativeFilePreview(props: { return null; } -export function FilePreview(props: { - readonly source: ResolvedFilePreviewSource; - readonly onRequestClose: () => void; - readonly onOpenError?: (error: unknown) => void; -}) { - return ; +/** Shows images, PDFs and documents full screen in the native viewer (Android: FilePreviewModal.android.tsx). */ +export function FilePreviewModal(props: FilePreviewModalProps) { + return ; } diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/ui/FilePreviewModal.types.ts similarity index 68% rename from apps/mobile/src/components/FilePreviewModal.types.ts rename to apps/mobile/src/components/ui/FilePreviewModal.types.ts index 83ee7207b8e47..37dd9ea9e16b0 100644 --- a/apps/mobile/src/components/FilePreviewModal.types.ts +++ b/apps/mobile/src/components/ui/FilePreviewModal.types.ts @@ -1,7 +1,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import type { FileBackedComposerAttachment } from "../lib/composerImages"; -import type { MediaActionsSource } from "../lib/mediaActionsSource"; +import type { FileBackedComposerAttachment } from "../../lib/composerImages"; +import type { MediaActionsSource } from "../../lib/mediaActionsSource"; interface FilePreviewMetadata { readonly kind: "image" | "pdf" | "document"; @@ -42,3 +42,16 @@ export interface FilePreviewImages { } export type ResolvedFilePreviewSource = ResolvedFilePreviewDocument | FilePreviewImages; + +export interface FilePreviewModalProps { + readonly source: FilePreviewSource | null; + readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; +} + +/** A platform's viewer for one presentation, once FilePreviewSession has resolved it. */ +export interface FilePreviewViewerProps { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; +} diff --git a/apps/mobile/src/components/ui/FilePreviewSession.test.tsx b/apps/mobile/src/components/ui/FilePreviewSession.test.tsx new file mode 100644 index 0000000000000..07b75a58c1f76 --- /dev/null +++ b/apps/mobile/src/components/ui/FilePreviewSession.test.tsx @@ -0,0 +1,146 @@ +import { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, SOURCE_CONTROL_MEDIA_ASSET } from "@t3tools/contracts"; + +const harness = vi.hoisted(() => ({ + refreshAssetUrl: vi.fn<(environmentId: string, resource: { url: string }) => Promise>(), + alert: vi.fn(), + previews: [] as unknown[], + mounts: 0, +})); + +vi.mock("../../state/assets", () => ({ refreshAssetUrl: harness.refreshAssetUrl })); +vi.mock("../../lib/localAttachmentPreview", () => ({ loadLocalAttachmentPreview: vi.fn() })); +vi.mock("../../lib/attachmentDownload", () => ({ + downloadAndShareAttachment: vi.fn(), + nativeViewerErrorMessage: () => "", + shareLocalAttachment: vi.fn(), +})); +vi.mock("../../lib/previewPresentation", () => ({ usePreviewPresentation: () => {} })); +vi.mock("./Dialog", () => ({ alert: harness.alert })); + +import type { FilePreviewItem, FilePreviewViewerProps } from "./FilePreviewModal.types"; +import { FilePreviewSession } from "./FilePreviewSession"; + +function Viewer(props: FilePreviewViewerProps) { + useState(() => harness.mounts++); + harness.previews.push(props.source); + return null; +} + +const environmentId = EnvironmentId.make("environment"); +const image = (url: string): FilePreviewItem => ({ + kind: "image", + environmentId, + resource: { _tag: SOURCE_CONTROL_MEDIA_ASSET, cwd: "/repo", url }, +}); + +async function openGallery( + selected: FilePreviewItem, + gallery: readonly FilePreviewItem[], + progressiveGallery: boolean, +) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + harness.previews.length = 0; + harness.mounts = 0; + harness.alert.mockClear(); + const root = createRoot(document.createElement("div")); + const onRequestClose = vi.fn(); + await act(async () => + root.render( + , + ), + ); + return { onRequestClose, [Symbol.asyncDispose]: () => act(async () => root.unmount()) }; +} + +it.each([true, false])( + "keeps the gallery stable while a sibling is still resolving (progressive: %s)", + async (progressiveGallery) => { + const slow = Promise.withResolvers(); + harness.refreshAssetUrl.mockImplementation(async (_environmentId, resource) => + resource.url.endsWith("slow.png") ? slow.promise : "https://signed.test/a.png", + ); + const selected = image("https://github.com/a.png"); + await using _preview = await openGallery( + selected, + [image("https://github.com/slow.png"), selected], + progressiveGallery, + ); + + if (progressiveGallery) { + expect(harness.previews.at(-1)).toMatchObject({ + kind: "image", + selectedIndex: 1, + images: [ + expect.not.objectContaining({ uri: expect.anything() }), + { uri: "https://signed.test/a.png" }, + ], + }); + } else { + expect(harness.previews).toEqual([]); + } + await act(async () => slow.resolve("https://signed.test/slow.png")); + expect(harness.previews.at(-1)).toMatchObject({ + selectedIndex: 1, + images: [{ uri: "https://signed.test/slow.png" }, { uri: "https://signed.test/a.png" }], + }); + expect(harness.mounts).toBe(1); + }, +); + +it.each([true, false])( + "handles a failed sibling without leaving a pending page (progressive: %s)", + async (progressiveGallery) => { + harness.refreshAssetUrl.mockImplementation(async (_environmentId, resource) => { + if (resource.url.endsWith("missing.png")) throw new Error("not found"); + return `https://signed.test/${resource.url.split("/").pop()}`; + }); + const selected = image("https://github.com/a.png"); + await using preview = await openGallery( + selected, + [selected, image("https://github.com/missing.png"), image("https://github.com/c.png")], + progressiveGallery, + ); + + if (!progressiveGallery) { + expect(preview.onRequestClose).toHaveBeenCalledOnce(); + expect(harness.alert).toHaveBeenCalledOnce(); + expect(harness.previews).toEqual([]); + return; + } + + expect(preview.onRequestClose).not.toHaveBeenCalled(); + expect(harness.alert).not.toHaveBeenCalled(); + expect(harness.previews.at(-1)).toMatchObject({ + selectedIndex: 0, + images: [ + { uri: "https://signed.test/a.png" }, + { failed: true }, + { uri: "https://signed.test/c.png" }, + ], + }); + }, +); + +it("closes the preview when the selected image cannot be resolved", async () => { + harness.refreshAssetUrl.mockImplementation(async (_environmentId, resource) => { + if (resource.url.endsWith("missing.png")) throw new Error("not found"); + return `https://signed.test/${resource.url.split("/").pop()}`; + }); + const selected = image("https://github.com/missing.png"); + await using preview = await openGallery( + selected, + [image("https://github.com/a.png"), selected], + true, + ); + + expect(preview.onRequestClose).toHaveBeenCalledOnce(); + expect(harness.alert).toHaveBeenCalledOnce(); +}); diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/ui/FilePreviewSession.tsx similarity index 82% rename from apps/mobile/src/components/FilePreviewModal.tsx rename to apps/mobile/src/components/ui/FilePreviewSession.tsx index 4d6c97464dd67..53c09b4d01392 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/ui/FilePreviewSession.tsx @@ -1,28 +1,31 @@ import * as Schema from "effect/Schema"; -import * as Dialog from "./ui/Dialog"; -import { useContext, useEffect, useEffectEvent, useState } from "react"; -import { Platform } from "react-native"; +import * as Dialog from "./Dialog"; +import { useContext, useEffect, useEffectEvent, useState, type ComponentType } from "react"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { loadLocalAttachmentPreview } from "../../lib/localAttachmentPreview"; import { downloadAndShareAttachment, nativeViewerErrorMessage, shareLocalAttachment, -} from "../lib/attachmentDownload"; -import { usePreviewPresentation } from "../lib/previewPresentation"; -import { refreshAssetUrl } from "../state/assets"; -import { filePreviewGallery, filePreviewKey, filePreviewName } from "../lib/filePreviewGallery"; +} from "../../lib/attachmentDownload"; +import { usePreviewPresentation } from "../../lib/previewPresentation"; +import { refreshAssetUrl } from "../../state/assets"; +import { filePreviewGallery, filePreviewKey, filePreviewName } from "../../lib/filePreviewGallery"; import { FilePreviewGallery } from "./FilePreviewGallery"; -import { FilePreview } from "./FilePreview"; -import type { MediaActionsSource } from "../lib/mediaActionsSource"; -import type { FilePreviewItem, FilePreviewSource } from "./FilePreviewModal.types"; - -export type { +import type { MediaActionsSource } from "../../lib/mediaActionsSource"; +import type { FilePreviewItem, + FilePreviewModalProps, FilePreviewSource, - ResolvedFilePreviewSource, + FilePreviewViewerProps, } from "./FilePreviewModal.types"; +interface FilePreviewSessionOptions { + /** Opens before every image resolves; the viewer takes later URLs as they arrive. */ + readonly progressiveGallery: boolean; + readonly Viewer: ComponentType; +} + class PreviewResolutionError extends Schema.TaggedError()( "PreviewResolutionError", { cause: Schema.Defect(), fallbackMessage: Schema.String }, @@ -80,16 +83,18 @@ function pdfActionsSource(item: FilePreviewItem): MediaActionsSource { return { ...metadata, environmentId: item.environmentId, resource: item.resource }; } -function ResolvedFilePreview(props: { - readonly source: FilePreviewSource; - readonly onRequestClose: () => void; - readonly onOpenError?: (error: unknown) => void; -}) { +function ResolvedFilePreview( + props: FilePreviewSessionOptions & { + readonly source: FilePreviewSource; + readonly onRequestClose: () => void; + readonly onOpenError?: (error: unknown) => void; + }, +) { + const { progressiveGallery, Viewer } = props; // Freeze the group at opening; streaming messages and URL refreshes must not reopen the viewer. const [items] = useState(() => filePreviewGallery(props.source, [])); const selectedKey = filePreviewKey(props.source); const selectedIndex = items.findIndex((item) => filePreviewKey(item) === selectedKey); - const progressiveGallery = Platform.OS === "ios"; // Undefined while resolving, null once it cannot be. const [uris, setUris] = useState(() => items.map((item) => ("uri" in item ? item.uri : undefined)), @@ -133,7 +138,7 @@ function ResolvedFilePreview(props: { }, [items, selectedIndex, progressiveGallery]); const selected = { ...items[selectedIndex]!, name: filePreviewName(items[selectedIndex]!) }; if (selected.kind === "image") { - // Android's viewer resets image gestures when its sources change during a presentation. + // A viewer that resets image gestures when its sources change waits for every image. if (!progressiveGallery && uris.some((uri) => uri === undefined)) return null; const images = items.map((item, index) => { const uri = uris[index]; @@ -144,7 +149,7 @@ function ResolvedFilePreview(props: { }; }); return ( - void; - - readonly onOpenError?: (error: unknown) => void; -}) { +/** + * The part of FilePreviewModal both platforms share: it resolves sources, freezes the gallery + * at opening and closes with its screen. Each platform's FilePreviewModal passes its viewer. + */ +export function FilePreviewSession(props: FilePreviewModalProps & FilePreviewSessionOptions) { const group = useContext(FilePreviewGallery); const source = props.source === null @@ -214,6 +218,8 @@ export function FilePreviewModal(props: { key={filePreviewKey(source)} source={source} onRequestClose={props.onRequestClose} + progressiveGallery={props.progressiveGallery} + Viewer={props.Viewer} {...(props.onOpenError ? { onOpenError: props.onOpenError } : {})} /> ); diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx similarity index 87% rename from apps/mobile/src/components/VideoPreviewModal.tsx rename to apps/mobile/src/components/ui/VideoPreviewModal.android.tsx index 2f9d0063ca9eb..862d17d60d067 100644 --- a/apps/mobile/src/components/VideoPreviewModal.tsx +++ b/apps/mobile/src/components/ui/VideoPreviewModal.android.tsx @@ -2,25 +2,25 @@ import { videoMimeType } from "@t3tools/shared/video"; import { Modal, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppActivityIndicator } from "./ui/AppActivityIndicator"; -import { useLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import { usePreviewPresentation, videoPreviewKey } from "../lib/previewPresentation"; -import { useMediaActions } from "../state/mediaActions"; -import { type MediaActionsSource } from "../lib/mediaActionsSource"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { useLocalAttachmentPreview } from "../../lib/localAttachmentPreview"; +import { usePreviewPresentation, videoPreviewKey } from "../../lib/previewPresentation"; +import { useMediaActions } from "../../state/mediaActions"; +import { type MediaActionsSource } from "../../lib/mediaActionsSource"; import { mediaVideoPreviewUri, mediaVideoThumbnailKey, type LocalVideoPreviewSource, type MediaVideoPreviewSource, type VideoPreviewSource, -} from "../lib/videoPreviewSource"; -import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; -import { usePreparedConnection } from "../state/session"; -import { AppText } from "./ui/AppText"; -import { MediaPreviewHeader } from "./MediaPreviewHeader"; -import { MediaVideoPlayer } from "./MediaVideoPlayer"; +} from "../../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; +import { AppText } from "./AppText"; +import { MediaPreviewHeader } from "../MediaPreviewHeader"; +import { MediaVideoPlayer } from "../MediaVideoPlayer"; -export type { VideoPreviewSource } from "../lib/videoPreviewSource"; +export type { VideoPreviewSource } from "../../lib/videoPreviewSource"; interface PlaybackState { readonly uri: string | null; diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/ui/VideoPreviewModal.tsx similarity index 92% rename from apps/mobile/src/components/VideoPreviewModal.ios.tsx rename to apps/mobile/src/components/ui/VideoPreviewModal.tsx index 233cb063c42ed..89158669bbeed 100644 --- a/apps/mobile/src/components/VideoPreviewModal.ios.tsx +++ b/apps/mobile/src/components/ui/VideoPreviewModal.tsx @@ -1,23 +1,23 @@ -import * as Dialog from "./ui/Dialog"; +import * as Dialog from "./Dialog"; import { requireNativeModule } from "expo"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { useEffect, useEffectEvent, useId } from "react"; -import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import { usePreviewPresentation, videoPreviewKey } from "../lib/previewPresentation"; -import { fullscreenVideoClosed, fullscreenVideoOpened } from "../lib/videoAudioSession"; -import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource"; -import { refreshAssetUrl } from "../state/assets"; -import { usePreparedConnection } from "../state/session"; +import { loadLocalAttachmentPreview } from "../../lib/localAttachmentPreview"; +import { usePreviewPresentation, videoPreviewKey } from "../../lib/previewPresentation"; +import { fullscreenVideoClosed, fullscreenVideoOpened } from "../../lib/videoAudioSession"; +import { mediaVideoPreviewUri, type VideoPreviewSource } from "../../lib/videoPreviewSource"; +import { refreshAssetUrl } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; import { acquireAudioSession, activateAudioSession, isAudioSessionOwner, releaseAudioSession, -} from "../lib/audioSession"; +} from "../../lib/audioSession"; -export type { VideoPreviewSource } from "../lib/videoPreviewSource"; +export type { VideoPreviewSource } from "../../lib/videoPreviewSource"; const NativeControls = requireNativeModule<{ presentVideo( diff --git a/apps/mobile/src/features/files/AttachmentFileScreen.tsx b/apps/mobile/src/features/files/AttachmentFileScreen.tsx index d5e560e3267a9..fdf30e6f33b31 100644 --- a/apps/mobile/src/features/files/AttachmentFileScreen.tsx +++ b/apps/mobile/src/features/files/AttachmentFileScreen.tsx @@ -9,7 +9,7 @@ import { useRef, useState } from "react"; import { View } from "react-native"; import { AppBar, type AppBarMenu } from "../../components/ui/AppBar"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { useAttachmentDocument } from "../../lib/attachmentDocument"; import { nativeViewerErrorMessage } from "../../lib/attachmentDownload"; import { isFileBackedComposerAttachment } from "../../lib/composerImages"; diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index f9f81459bbf08..73629f02fd253 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -23,8 +23,8 @@ import { resolveMarkdownLinkPresentation, } from "../../lib/markdownLinks"; import { AppText as Text } from "../../components/ui/AppText"; -import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index a5d3abe4c91d0..c51e05de37cd5 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -20,7 +20,7 @@ import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/del import { DelimitedFilePreview } from "./DelimitedFilePreview"; import { AudioFilePreview } from "./AudioFilePreview"; import { EmptyState } from "../../components/ui/EmptyState"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { isPdfFile } from "../../lib/filePreview"; diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 60b2c902a76e5..55caeb0ee2e64 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -3,7 +3,7 @@ import { Image, Pressable, View } from "react-native"; import { FilePreviewLoading } from "./FilePreviewFeedback"; import { EmptyState } from "../../components/ui/EmptyState"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { PresentationSource } from "../../components/ui/NativePresentation"; import { useMediaActions } from "../../state/mediaActions"; import { type MediaActionsSource } from "../../lib/mediaActionsSource"; diff --git a/apps/mobile/src/features/files/ZipArchiveView.test.tsx b/apps/mobile/src/features/files/ZipArchiveView.test.tsx index 5b9dc2406973f..fac2d28f4ae2c 100644 --- a/apps/mobile/src/features/files/ZipArchiveView.test.tsx +++ b/apps/mobile/src/features/files/ZipArchiveView.test.tsx @@ -131,7 +131,7 @@ vi.mock("../../components/ui/useBarInset", () => ({ useContentTopInset: () => 0 vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); vi.mock("../../components/ui/Progress", () => ({ Progress: () => null })); vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); -vi.mock("../../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../../components/ui/FilePreviewModal", () => ({ FilePreviewModal: () => null })); vi.mock("../../lib/copyTextWithHaptic", () => ({})); vi.mock("./SourceFileSurface", () => ({ SourceFileSurface: (props: { readonly contents: string }) =>
{props.contents}
, diff --git a/apps/mobile/src/features/files/ZipArchiveView.tsx b/apps/mobile/src/features/files/ZipArchiveView.tsx index fb79723d6e39d..1d21ecf75e55a 100644 --- a/apps/mobile/src/features/files/ZipArchiveView.tsx +++ b/apps/mobile/src/features/files/ZipArchiveView.tsx @@ -29,7 +29,7 @@ import { EmptyState } from "../../components/ui/EmptyState"; import { Progress } from "../../components/ui/Progress"; import { NativeScrollView } from "../../native/NativeScrollViewMarker"; import * as Dialog from "../../components/ui/Dialog"; -import { FilePreviewModal } from "../../components/FilePreviewModal"; +import { FilePreviewModal } from "../../components/ui/FilePreviewModal"; import { nativeViewerErrorMessage, shareGeneratedAttachment, diff --git a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx index 59d2709a4f94a..2bee9303c9bf8 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx @@ -13,7 +13,7 @@ import { parseMarkdownDocument } from "../../lib/markdownDocument"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import type { FilePreviewItem } from "../../components/FilePreviewModal"; +import type { FilePreviewItem } from "../../components/ui/FilePreviewModal"; import { isBotBadgeImage } from "./botBadgeImages"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx index 41b93f966666b..05fe4b444a6ef 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx @@ -28,13 +28,13 @@ import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; import { markdownImageSources } from "../../lib/markdownImageSources"; -import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery"; +import { FilePreviewGallery, useFilePreviewGallery } from "../../components/ui/FilePreviewGallery"; import { pullRequestImageSource } from "./pullRequestMediaSource"; import { FilePreviewModal, type FilePreviewSource, type FilePreviewItem, -} from "../../components/FilePreviewModal"; +} from "../../components/ui/FilePreviewModal"; import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; import type { SelectableMarkdownTextProps } from "../../native/SelectableMarkdownText.types"; diff --git a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx index 3fcfbceb842f4..fdd7b1abc4b67 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMedia.tsx @@ -4,7 +4,7 @@ import { pullRequestImageSource, type PullRequestMediaLocation, } from "./pullRequestMediaSource"; -import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; +import { useFilePreviewGallery } from "../../components/ui/FilePreviewGallery"; import { videoMimeType } from "@t3tools/shared/video"; import { Pressable, View } from "react-native"; import { Image } from "expo-image"; @@ -13,13 +13,13 @@ import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; import { assetImageCacheKey } from "../../lib/assetImageSource"; import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; -import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal"; import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; import { FilePreviewModal, type FilePreviewSource, type FilePreviewItem, -} from "../../components/FilePreviewModal"; +} from "../../components/ui/FilePreviewModal"; export const PullRequestMediaVisibleContext = createContext(true); diff --git a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts index d20bbeac6385c..dfcf8ad8a39a0 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts @@ -1,6 +1,6 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { SOURCE_CONTROL_MEDIA_ASSET, type EnvironmentId } from "@t3tools/contracts"; -import type { FilePreviewItem } from "../../components/FilePreviewModal.types"; +import type { FilePreviewItem } from "../../components/ui/FilePreviewModal.types"; export interface PullRequestMediaLocation { readonly environmentId: EnvironmentId; diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index e290f7ea498c5..d5fa085eb18fc 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from "react"; import { Platform, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText"; import { SheetHeader } from "../../components/ui/SheetHeader"; diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index d98488c755dda..2c369c1e5b7bc 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -14,8 +14,8 @@ import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStri import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useNavigation } from "@react-navigation/native"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; -import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal"; import { useServerConfigs } from "../../state/entities"; import { appAtomRegistry } from "../../state/atom-registry"; import { diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 8987d9fadc9f0..84fa6ae6d3185 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -35,7 +35,7 @@ import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "rea import { I18nManager, Keyboard, Platform, View, type ViewStyle } from "react-native"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, @@ -76,7 +76,7 @@ import { ComposerAttachmentStrip, ComposerAttachmentThumbnail, } from "../../components/ComposerAttachmentStrip"; -import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { fileRoutePathSegments } from "../files/filePath"; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d6fdc9e955cde..e4ae7f06d28d1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -133,14 +133,14 @@ import { import { Image as ExpoImage } from "expo-image"; import { useResolveClassNames } from "uniwind"; import { useReduceMotionEnabled } from "../../lib/appState"; -import { useFilePreviewGallery, FilePreviewGallery } from "../../components/FilePreviewGallery"; +import { useFilePreviewGallery, FilePreviewGallery } from "../../components/ui/FilePreviewGallery"; import { assetImageSource } from "../../lib/assetImageSource"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { isFileBackedComposerAttachment } from "../../lib/composerImages"; import { useLocalAttachmentPreview } from "../../lib/localAttachmentPreview"; import { messageImageGallery } from "./messageImageGallery"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; -import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { inlineFilePreviewKind, isPdfFile } from "../../lib/filePreview"; import { ThreadInlineFilePreview } from "./ThreadInlineFilePreview"; import { flattenThemeColor } from "../../lib/mobileTheme"; @@ -166,7 +166,7 @@ import { } from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/ui/AppText"; -import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal"; import { markdownFileEmbedLabel, resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; import { attachmentVideoPreviewSource } from "../../lib/videoPreviewSource"; import { CopyTextButton } from "./CopyTextButton"; diff --git a/apps/mobile/src/features/threads/ThreadInlineFilePreview.tsx b/apps/mobile/src/features/threads/ThreadInlineFilePreview.tsx index e32c19a12786a..e44a422212712 100644 --- a/apps/mobile/src/features/threads/ThreadInlineFilePreview.tsx +++ b/apps/mobile/src/features/threads/ThreadInlineFilePreview.tsx @@ -3,7 +3,7 @@ import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments" import { useAudioPlayerStatus } from "expo-audio"; import { useContext, useEffect, useRef, useState } from "react"; import { AppState, Pressable, View } from "react-native"; -import type { FilePreviewSource } from "../../components/FilePreviewModal"; +import type { FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { filePreviewKey } from "../../lib/filePreviewGallery"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { AppText as Text } from "../../components/ui/AppText"; diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx index 46da789a3fc2b..209cccf8d2ef0 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx @@ -30,7 +30,7 @@ vi.mock("expo/fetch", () => ({ fetch: vi.fn() })); vi.mock("react-native-svg", () => ({ parse: vi.fn(), SvgAst: () => null })); vi.mock("@t3tools/client-runtime/state/assets", () => ({ MISSING_FILE_ACCESS_MESSAGE: "" })); vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null })); -vi.mock("../../components/FilePreviewGallery", () => ({ +vi.mock("../../components/ui/FilePreviewGallery", () => ({ useFilePreviewGallery: () => (source: unknown) => source, })); vi.mock("../../components/ui/AppText", () => ({ diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index 81d569fafb466..da9ba935838c0 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -11,9 +11,9 @@ import { parse, SvgAst, type JsxAST } from "react-native-svg"; import { Pressable, StyleSheet, View, type ViewStyle } from "react-native"; import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; -import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; +import { useFilePreviewGallery } from "../../components/ui/FilePreviewGallery"; import { AppText as Text } from "../../components/ui/AppText"; -import type { FilePreviewSource } from "../../components/FilePreviewModal"; +import type { FilePreviewSource } from "../../components/ui/FilePreviewModal"; import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { PresentationSource } from "../../components/ui/NativePresentation"; import { useMediaActions } from "../../state/mediaActions"; diff --git a/apps/mobile/src/features/threads/messageImageGallery.ts b/apps/mobile/src/features/threads/messageImageGallery.ts index c02269976a272..fb8fc3de67b78 100644 --- a/apps/mobile/src/features/threads/messageImageGallery.ts +++ b/apps/mobile/src/features/threads/messageImageGallery.ts @@ -1,6 +1,6 @@ import type { ChatAttachment, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { imageMimeType } from "@t3tools/shared/image"; -import type { FilePreviewItem } from "../../components/FilePreviewModal.types"; +import type { FilePreviewItem } from "../../components/ui/FilePreviewModal.types"; import { markdownImageSources } from "../../lib/markdownImageSources"; import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 9851584d23b45..070ff4728e9aa 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -88,8 +88,8 @@ import { import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; -import type { FilePreviewSource } from "../../components/FilePreviewModal"; -import { FilePreviewGallery } from "../../components/FilePreviewGallery"; +import type { FilePreviewSource } from "../../components/ui/FilePreviewModal"; +import { FilePreviewGallery } from "../../components/ui/FilePreviewGallery"; import { ThreadMarkdownImage } from "./ThreadMarkdownImage"; import Animated, { FadeIn, diff --git a/apps/mobile/src/lib/composerImageGallery.ts b/apps/mobile/src/lib/composerImageGallery.ts index f308dac2ff19b..ffc6167e8b50d 100644 --- a/apps/mobile/src/lib/composerImageGallery.ts +++ b/apps/mobile/src/lib/composerImageGallery.ts @@ -1,6 +1,6 @@ import { imageMimeType } from "@t3tools/shared/image"; import type { EnvironmentId } from "@t3tools/contracts"; -import type { FilePreviewItem } from "../components/FilePreviewModal.types"; +import type { FilePreviewItem } from "../components/ui/FilePreviewModal.types"; import { isFileBackedComposerAttachment, type DraftComposerAttachment } from "./composerImages"; export function composerImageGallery( diff --git a/apps/mobile/src/lib/contextImageGallery.ts b/apps/mobile/src/lib/contextImageGallery.ts index 37803bd5923c6..335554b0565c5 100644 --- a/apps/mobile/src/lib/contextImageGallery.ts +++ b/apps/mobile/src/lib/contextImageGallery.ts @@ -1,6 +1,6 @@ import type { ComposerContextRecord, EnvironmentId } from "@t3tools/contracts"; import { imageMimeType } from "@t3tools/shared/image"; -import type { FilePreviewItem } from "../components/FilePreviewModal.types"; +import type { FilePreviewItem } from "../components/ui/FilePreviewModal.types"; import { composerImageGallery } from "./composerImageGallery"; import type { DraftComposerAttachment } from "./composerImages"; diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 5dee41b54c167..d9b65cfbfc523 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -235,14 +235,14 @@ vi.mock("../components/ui/NativePresentation", () => ({ vi.mock("../components/ui/Menu", () => ({ Menu: ({ children }: { children?: ReactNode }) => children, })); -vi.mock("../components/FilePreviewGallery", () => ({ +vi.mock("../components/ui/FilePreviewGallery", () => ({ FilePreviewGallery: ({ children }: { children?: ReactNode }) => children, })); -vi.mock("../components/FilePreviewModal", () => ({ +vi.mock("../components/ui/FilePreviewModal", () => ({ FilePreviewModal: (props: { source: { name: string } | null }) => props.source ? createElement("div", { "data-file-preview": props.source.name }) : null, })); -vi.mock("../components/VideoPreviewModal", () => ({ VideoPreviewModal: () => null })); +vi.mock("../components/ui/VideoPreviewModal", () => ({ VideoPreviewModal: () => null })); vi.mock("../components/ComposerContextSheet", () => ({})); vi.mock("../native/useOpenPullRequest", () => ({ useOpenPullRequest: () => vi.fn() })); vi.mock("../native/useMarkdownImageSource", () => ({ useMarkdownImageSource: () => vi.fn() })); diff --git a/apps/mobile/src/lib/filePreviewGallery.test.ts b/apps/mobile/src/lib/filePreviewGallery.test.ts index 60576317b95c9..f85e33c851f07 100644 --- a/apps/mobile/src/lib/filePreviewGallery.test.ts +++ b/apps/mobile/src/lib/filePreviewGallery.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import type { FilePreviewItem } from "../components/FilePreviewModal.types"; +import type { FilePreviewItem } from "../components/ui/FilePreviewModal.types"; import type { DraftComposerAttachment } from "./composerImages"; import { composerImageGallery } from "./composerImageGallery"; import { messageImageGallery } from "../features/threads/messageImageGallery"; diff --git a/apps/mobile/src/lib/filePreviewGallery.ts b/apps/mobile/src/lib/filePreviewGallery.ts index 4150054562442..295f46e861825 100644 --- a/apps/mobile/src/lib/filePreviewGallery.ts +++ b/apps/mobile/src/lib/filePreviewGallery.ts @@ -1,4 +1,4 @@ -import type { FilePreviewItem, FilePreviewSource } from "../components/FilePreviewModal.types"; +import type { FilePreviewItem, FilePreviewSource } from "../components/ui/FilePreviewModal.types"; export function filePreviewKey(source: FilePreviewItem): string { if ("resource" in source) diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts index 31dcefb29f127..e03f91822eb23 100644 --- a/apps/mobile/src/lib/markdownMedia.ts +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -17,7 +17,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl, resolveMarkdownLinkPresentation } from "./markdownLinks"; import { resolveMobileMarkdownMediaSource } from "./markdownMediaSource"; -import type { FilePreviewSource } from "../components/FilePreviewModal"; +import type { FilePreviewSource } from "../components/ui/FilePreviewModal"; import type { MediaVideoPreviewSource } from "./videoPreviewSource"; import type { MediaActionsSource } from "./mediaActionsSource"; diff --git a/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx index 261017c49b06e..d5cc47250cb83 100644 --- a/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx +++ b/apps/mobile/src/lib/useMediaVideoPlayback.test.tsx @@ -103,11 +103,11 @@ vi.mock("./videoThumbnails", () => ({ cachedVideoThumbnail: () => null, loadVideoThumbnail: mocks.thumbnail, })); -vi.mock("../components/FilePreviewGallery", () => ({ +vi.mock("../components/ui/FilePreviewGallery", () => ({ useFilePreviewGallery: () => (value: unknown) => value, })); -vi.mock("../components/FilePreviewModal", () => ({ FilePreviewModal: () => null })); -vi.mock("../components/VideoPreviewModal", () => ({ VideoPreviewModal: () => null })); +vi.mock("../components/ui/FilePreviewModal", () => ({ FilePreviewModal: () => null })); +vi.mock("../components/ui/VideoPreviewModal", () => ({ VideoPreviewModal: () => null })); vi.mock("../features/threads/ThreadMarkdownImage", () => ({ ThreadMarkdownImageView: () => null })); import { diff --git a/apps/mobile/src/native/MermaidCodeBlock.tsx b/apps/mobile/src/native/MermaidCodeBlock.tsx index d185d3e9a6cfa..345f99b1b6355 100644 --- a/apps/mobile/src/native/MermaidCodeBlock.tsx +++ b/apps/mobile/src/native/MermaidCodeBlock.tsx @@ -6,7 +6,7 @@ import { Suspense, use, useRef, useState } from "react"; import { PixelRatio, Platform, Pressable, StyleSheet, Text, View } from "react-native"; import { parse, Svg, SvgXml } from "react-native-svg"; -import { FilePreviewModal } from "../components/FilePreviewModal"; +import { FilePreviewModal } from "../components/ui/FilePreviewModal"; import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { mermaidSvgForNative, withDiagramBackground } from "../lib/mermaidSvg"; diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 5aab6e4a1cd3b..ef296d17cb572 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -27,7 +27,7 @@ const reactTests = [ "src/features/settings/components/BranchNamingSettings.test.tsx", "src/features/settings/components/SettingsDragHandle.test.tsx", "src/features/threads/ThreadMarkdownImage.test.tsx", - "src/components/FilePreviewModal.test.tsx", + "src/components/ui/FilePreviewSession.test.tsx", "src/features/threads/UsageLimitRecoveryCard.test.tsx", "src/features/threads/NewTaskDraftRouteScreen.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", From 98cc245cb76f4581d7c3412e707e2db511d3afb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Nov 2026 19:43:22 -0800 Subject: [PATCH 3/3] refactor(mobile): platform primitives in components/ui are iOS files with .android overrides AppSymbol, FrostedCutout, NativePresentation and SegmentedControl had their iOS code in .ios.tsx and Android's in the base file; they now follow the rest of components/ui. AndroidScreenHeader and AndroidAnchoredMenu, which only Android files use, become .android.tsx modules imported by that name, so shared code can't reach them and the lint rule that guarded the header from features goes away. Co-Authored-By: Claude Opus 5.5 --- ...nu.tsx => AndroidAnchoredMenu.android.tsx} | 0 ...er.tsx => AndroidScreenHeader.android.tsx} | 2 +- .../src/components/ui/AppBar.android.test.tsx | 2 +- .../src/components/ui/AppBar.android.tsx | 2 +- .../src/components/ui/AppSymbol.android.tsx | 370 +++++++++++++++++ .../src/components/ui/AppSymbol.ios.tsx | 46 --- apps/mobile/src/components/ui/AppSymbol.tsx | 386 ++---------------- .../components/ui/FrostedCutout.android.tsx | 38 ++ .../src/components/ui/FrostedCutout.ios.tsx | 13 - .../src/components/ui/FrostedCutout.tsx | 41 +- .../mobile/src/components/ui/Menu.android.tsx | 2 +- .../src/components/ui/MenuPopup.android.tsx | 2 +- .../ui/NativePresentation.android.tsx | 7 + .../components/ui/NativePresentation.ios.tsx | 12 - .../src/components/ui/NativePresentation.tsx | 14 +- .../ui/ScreenHeaderButton.android.tsx | 2 +- .../ui/SegmentedControl.android.tsx | 1 + .../components/ui/SegmentedControl.ios.tsx | 55 --- .../src/components/ui/SegmentedControl.tsx | 56 ++- .../components/ui/WorkspaceAppBar.android.tsx | 4 +- vite.config.ts | 23 +- 21 files changed, 529 insertions(+), 549 deletions(-) rename apps/mobile/src/components/ui/{AndroidAnchoredMenu.tsx => AndroidAnchoredMenu.android.tsx} (100%) rename apps/mobile/src/components/ui/{AndroidScreenHeader.tsx => AndroidScreenHeader.android.tsx} (99%) create mode 100644 apps/mobile/src/components/ui/AppSymbol.android.tsx delete mode 100644 apps/mobile/src/components/ui/AppSymbol.ios.tsx create mode 100644 apps/mobile/src/components/ui/FrostedCutout.android.tsx delete mode 100644 apps/mobile/src/components/ui/FrostedCutout.ios.tsx create mode 100644 apps/mobile/src/components/ui/NativePresentation.android.tsx delete mode 100644 apps/mobile/src/components/ui/NativePresentation.ios.tsx create mode 100644 apps/mobile/src/components/ui/SegmentedControl.android.tsx delete mode 100644 apps/mobile/src/components/ui/SegmentedControl.ios.tsx diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx similarity index 100% rename from apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx rename to apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.android.tsx similarity index 99% rename from apps/mobile/src/components/ui/AndroidScreenHeader.tsx rename to apps/mobile/src/components/ui/AndroidScreenHeader.android.tsx index b7ab699b11b57..cee8acef23bae 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.android.tsx @@ -7,7 +7,7 @@ import { View } from "react-native"; import type { AppSymbolName } from "./AppSymbol"; import { NativeAppBar } from "./NativeAppBar.android"; import { IconButton } from "./IconButton"; -import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu.android"; import { splitAndroidHeaderActions } from "./headerMenu.android"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useToolbarLayout } from "./useToolbarLayout"; diff --git a/apps/mobile/src/components/ui/AppBar.android.test.tsx b/apps/mobile/src/components/ui/AppBar.android.test.tsx index 12859f11dfe1a..4aa9c755450f5 100644 --- a/apps/mobile/src/components/ui/AppBar.android.test.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.test.tsx @@ -13,7 +13,7 @@ vi.mock("@react-navigation/native", () => ({ throw new Error("Couldn't find a navigation object."); }, })); -vi.mock("./AndroidScreenHeader", () => ({ +vi.mock("./AndroidScreenHeader.android", () => ({ AndroidScreenHeader: (props: { readonly title: string }) =>

{props.title}

, })); vi.mock("./WorkspaceAppBar.android", () => ({ diff --git a/apps/mobile/src/components/ui/AppBar.android.tsx b/apps/mobile/src/components/ui/AppBar.android.tsx index 9708048704235..6667f06fb08da 100644 --- a/apps/mobile/src/components/ui/AppBar.android.tsx +++ b/apps/mobile/src/components/ui/AppBar.android.tsx @@ -8,7 +8,7 @@ import { useSidebarToggle } from "../../features/layout/useSidebarToggle"; import { useAdaptiveWorkspaceLayout } from "../../features/layout/adaptive-workspace-context"; import { InspectorCloseContext } from "../../features/layout/inspector-bar-store"; import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; -import { AndroidHeaderIconButton, AndroidScreenHeader } from "./AndroidScreenHeader"; +import { AndroidHeaderIconButton, AndroidScreenHeader } from "./AndroidScreenHeader.android"; import { ScreenHeaderButton } from "./ScreenHeaderButton.android"; import { Menu } from "./Menu"; import { Button } from "./Button"; diff --git a/apps/mobile/src/components/ui/AppSymbol.android.tsx b/apps/mobile/src/components/ui/AppSymbol.android.tsx new file mode 100644 index 0000000000000..47d199110cf4c --- /dev/null +++ b/apps/mobile/src/components/ui/AppSymbol.android.tsx @@ -0,0 +1,370 @@ +import type { Icon } from "@tabler/icons-react-native/types"; +/* Package-root imports eagerly register the entire Tabler icon set in Metro. */ +import IconAdjustmentsHorizontal from "@tabler/icons-react-native/IconAdjustmentsHorizontal"; +import IconAlignLeft from "@tabler/icons-react-native/IconAlignLeft"; +import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; +import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; +import IconApps from "@tabler/icons-react-native/IconApps"; +import IconArchive from "@tabler/icons-react-native/IconArchive"; +import IconCalendarClock from "@tabler/icons-react-native/IconCalendarClock"; +import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; +import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; +import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp"; +import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft"; +import IconArrowRight from "@tabler/icons-react-native/IconArrowRight"; +import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; +import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; +import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; +import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle"; +import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight"; +import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle"; +import IconArrowsLeftRight from "@tabler/icons-react-native/IconArrowsLeftRight"; +import IconArrowsDiagonal2 from "@tabler/icons-react-native/IconArrowsDiagonal2"; +import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; +import IconBan from "@tabler/icons-react-native/IconBan"; +import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; +import IconBolt from "@tabler/icons-react-native/IconBolt"; +import IconBox from "@tabler/icons-react-native/IconBox"; +import IconBrain from "@tabler/icons-react-native/IconBrain"; +import IconCamera from "@tabler/icons-react-native/IconCamera"; +import IconCar from "@tabler/icons-react-native/IconCar"; +import IconChartBar from "@tabler/icons-react-native/IconChartBar"; +import IconCheck from "@tabler/icons-react-native/IconCheck"; +import IconCloud from "@tabler/icons-react-native/IconCloud"; +import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; +import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; +import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; +import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; +import IconCircle from "@tabler/icons-react-native/IconCircle"; +import IconCircleFilled from "@tabler/icons-react-native/IconCircleFilled"; +import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; +import IconCircleDashed from "@tabler/icons-react-native/IconCircleDashed"; +import IconCircleDot from "@tabler/icons-react-native/IconCircleDot"; +import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; +import IconMoodPlus from "@tabler/icons-react-native/IconMoodPlus"; +import IconTag from "@tabler/icons-react-native/IconTag"; +import IconTicket from "@tabler/icons-react-native/IconTicket"; +import IconClock from "@tabler/icons-react-native/IconClock"; +import IconCode from "@tabler/icons-react-native/IconCode"; +import IconCopy from "@tabler/icons-react-native/IconCopy"; +import IconDatabase from "@tabler/icons-react-native/IconDatabase"; +import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; +import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; +import IconDeviceMobile from "@tabler/icons-react-native/IconDeviceMobile"; +import IconDots from "@tabler/icons-react-native/IconDots"; +import IconPlusMinus from "@tabler/icons-react-native/IconPlusMinus"; +import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; +import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; +import IconEdit from "@tabler/icons-react-native/IconEdit"; +import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; +import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; +import IconEye from "@tabler/icons-react-native/IconEye"; +import IconFileText from "@tabler/icons-react-native/IconFileText"; +import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; +import IconFilter from "@tabler/icons-react-native/IconFilter"; +import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; +import IconFolder from "@tabler/icons-react-native/IconFolder"; +import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; +import IconHeadphones from "@tabler/icons-react-native/IconHeadphones"; +import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus"; +import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequestClosed"; +import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; +import IconGitCommit from "@tabler/icons-react-native/IconGitCommit"; +import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; +import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; +import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; +import IconHammer from "@tabler/icons-react-native/IconHammer"; +import IconHome from "@tabler/icons-react-native/IconHome"; +import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; +import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; +import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; +import IconLock from "@tabler/icons-react-native/IconLock"; +import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; +import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; +import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; +import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing"; +import IconMicrophone from "@tabler/icons-react-native/IconMicrophone"; +import IconLink from "@tabler/icons-react-native/IconLink"; +import IconListNumbers from "@tabler/icons-react-native/IconListNumbers"; +import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; +import IconMessageCircleQuestion from "@tabler/icons-react-native/IconMessageCircleQuestion"; +import IconMessage from "@tabler/icons-react-native/IconMessage"; +import IconMessagePlus from "@tabler/icons-react-native/IconMessagePlus"; +import IconMinus from "@tabler/icons-react-native/IconMinus"; +import IconMoon from "@tabler/icons-react-native/IconMoon"; +import IconNetwork from "@tabler/icons-react-native/IconNetwork"; +import IconPalette from "@tabler/icons-react-native/IconPalette"; +import IconPencil from "@tabler/icons-react-native/IconPencil"; +import IconPlug from "@tabler/icons-react-native/IconPlug"; +import IconPhoto from "@tabler/icons-react-native/IconPhoto"; +import IconPin from "@tabler/icons-react-native/IconPin"; +import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; +import IconPlayerPause from "@tabler/icons-react-native/IconPlayerPause"; +import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay"; +import IconPlayerStopFilled from "@tabler/icons-react-native/IconPlayerStopFilled"; +import IconPlus from "@tabler/icons-react-native/IconPlus"; +import IconQrcode from "@tabler/icons-react-native/IconQrcode"; +import IconRefresh from "@tabler/icons-react-native/IconRefresh"; +import IconSearch from "@tabler/icons-react-native/IconSearch"; +import IconShare from "@tabler/icons-react-native/IconShare"; +import IconServer from "@tabler/icons-react-native/IconServer"; +import IconSettings from "@tabler/icons-react-native/IconSettings"; +import IconSparkles from "@tabler/icons-react-native/IconSparkles"; +import IconStack2 from "@tabler/icons-react-native/IconStack2"; +import IconStar from "@tabler/icons-react-native/IconStar"; +import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; +import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; +import IconSun from "@tabler/icons-react-native/IconSun"; +import IconShieldQuestion from "@tabler/icons-react-native/IconShieldQuestion"; +import IconTarget from "@tabler/icons-react-native/IconTarget"; +import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; +import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; +import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease"; +import IconTool from "@tabler/icons-react-native/IconTool"; +import IconTrash from "@tabler/icons-react-native/IconTrash"; +import IconTypography from "@tabler/icons-react-native/IconTypography"; +import IconDownload from "@tabler/icons-react-native/IconDownload"; +import IconSitemap from "@tabler/icons-react-native/IconSitemap"; +import IconUpload from "@tabler/icons-react-native/IconUpload"; +import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; +import IconUserCheck from "@tabler/icons-react-native/IconUserCheck"; +import IconUserX from "@tabler/icons-react-native/IconUserX"; +import IconUserQuestion from "@tabler/icons-react-native/IconUserQuestion"; +import IconUserPlus from "@tabler/icons-react-native/IconUserPlus"; +import IconUsers from "@tabler/icons-react-native/IconUsers"; +import IconWifi from "@tabler/icons-react-native/IconWifi"; +import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; +import IconWorld from "@tabler/icons-react-native/IconWorld"; +import IconX from "@tabler/icons-react-native/IconX"; +import type { AndroidSymbol, SFSymbol, SymbolViewProps } from "expo-symbols"; +import { withUniwind } from "uniwind"; + +const ANDROID_ICON_BY_SF_SYMBOL = { + plusminus: IconPlusMinus, + "git.pull-request.closed": IconGitPullRequestClosed, + "git.pull-request.draft": IconGitPullRequestDraft, + "git.commit": IconGitCommit, + "arrow.branch": IconGitBranch, + "arrow.left": IconArrowLeft, + "arrow.right": IconArrowRight, + "arrow.left.arrow.right": IconArrowsLeftRight, + "arrow.clockwise": IconRefresh, + "arrow.down": IconArrowDown, + "arrow.down.circle": IconArrowDownCircle, + "arrow.right.circle": IconArrowRightCircle, + "arrow.triangle.branch": IconGitBranch, + "arrow.triangle.merge": IconGitMerge, + "arrow.triangle.pull": IconGitPullRequest, + "square.3.layers.3d": IconStack2, + "arrow.turn.left.up": IconArrowBackUp, + "arrow.up": IconArrowUp, + "arrow.up.circle": IconArrowUpCircle, + "arrow.up.left.and.arrow.down.right": IconArrowsDiagonal2, + "arrow.down.right.and.arrow.up.left": IconArrowsMinimize, + "arrow.up.right": IconArrowUpRight, + "arrow.up.right.circle": IconArrowUpRightCircle, + "arrow.uturn.backward": IconArrowBackUp, + "arrow.uturn.forward": IconArrowForwardUp, + archivebox: IconArchive, + "archivebox.fill": IconArchive, + "bell.badge": IconBellRinging, + "bolt.circle": IconBolt, + "bolt.horizontal.circle": IconBolt, + brain: IconBrain, + "cable.connector": IconPlug, + camera: IconCamera, + car: IconCar, + "chart.bar.xaxis": IconChartBar, + checkmark: IconCheck, + "checkmark.circle": IconCircleCheck, + "checkmark.circle.fill": IconCircleCheck, + circle: IconCircle, + "circle.fill": IconCircleFilled, + "circle.dashed": IconCircleDashed, + "circle.inset.filled": IconCircleDot, + "circle.dashed.inset.filled": IconCircleDot, + "calendar.badge.clock": IconCalendarClock, + clock: IconClock, + timer: IconClock, + "face.smiling": IconMoodPlus, + tag: IconTag, + ticket: IconTicket, + cloud: IconCloud, + cube: IconBox, + "chevron.down": IconChevronDown, + "chevron.left": IconChevronLeft, + "chevron.left.forwardslash.chevron.right": IconCode, + "chevron.right": IconChevronRight, + "chevron.up": IconChevronUp, + desktopcomputer: IconDeviceDesktop, + doc: IconFileText, + "doc.on.doc": IconCopy, + "doc.text": IconFileText, + "file.diff": IconFileDiff, + ellipsis: IconDots, + moon: IconMoon, + "ellipsis.circle": IconDotsCircleHorizontal, + "exclamationmark.triangle": IconAlertTriangle, + "exclamationmark.circle": IconAlertCircle, + eye: IconEye, + "eye.slash": IconEyeOff, + folder: IconFolder, + "folder.badge.plus": IconFolderPlus, + "folder.fill": IconFolder, + gearshape: IconSettings, + globe: IconWorld, + hammer: IconHammer, + headphones: IconHeadphones, + house: IconHome, + "info.circle": IconInfoCircle, + internaldrive: IconDatabase, + iphone: IconDeviceMobile, + keyboard: IconKeyboard, + laptopcomputer: IconDeviceLaptop, + link: IconLink, + "list.number": IconListNumbers, + "line.3.horizontal": IconMenu2, + "line.3.horizontal.decrease": IconFilter, + "line.3.horizontal.decrease.circle": IconFilter, + "line.3.horizontal.decrease.circle.fill": IconFilterFilled, + macmini: IconServer, + macstudio: IconDeviceDesktop, + magnifyingglass: IconSearch, + mic: IconMicrophone, + paintbrush: IconPalette, + pencil: IconPencil, + "person.crop.circle": IconUserCircle, + "person.2": IconUsers, + "person.fill.checkmark": IconUserCheck, + "person.fill.xmark": IconUserX, + "person.badge.clock": IconUserQuestion, + "person.badge.plus": IconUserPlus, + photo: IconPhoto, + pin: IconPin, + "pin.slash": IconPinnedOff, + play: IconPlayerPlay, + pause: IconPlayerPause, + plus: IconPlus, + minus: IconMinus, + "slash.circle": IconBan, + "qrcode.viewfinder": IconQrcode, + "point.3.connected.trianglepath.dotted": IconNetwork, + flowchart: IconSitemap, + "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, + safari: IconExternalLink, + "server.rack": IconServer, + stethoscope: IconStethoscope, + "sidebar.left": IconLayoutSidebar, + "sidebar.right": IconLayoutSidebarRight, + "slider.horizontal.3": IconAdjustmentsHorizontal, + "square.and.arrow.up": IconShare, + "square.and.pencil": IconEdit, + "square.on.square": IconCopy, + "square.grid.2x2": IconApps, + "square.split.2x1": IconLayoutColumns, + star: IconStar, + "star.fill": IconStarFilled, + "sun.max": IconSun, + "stop.fill": IconPlayerStopFilled, + target: IconTarget, + terminal: IconTerminal2, + "text.alignleft": IconAlignLeft, + "text.bubble": IconMessage, + "questionmark.bubble": IconMessageCircleQuestion, + "exclamationmark.shield": IconShieldQuestion, + "bubble.left.and.bubble.right": IconMessage, + "bubble.left": IconMessage, + sparkles: IconSparkles, + "plus.bubble": IconMessagePlus, + "text.word.spacing": IconLetterSpacing, + "textformat.size": IconTypography, + "textformat.size.larger": IconTextIncrease, + "textformat.size.smaller": IconTextDecrease, + "tray.and.arrow.down": IconDownload, + "tray.and.arrow.up": IconUpload, + trash: IconTrash, + wifi: IconWifi, + "wifi.slash": IconWifiOff, + xmark: IconX, + "xmark.circle.fill": IconCircleXFilled, +} satisfies Partial< + Record< + SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", + Icon + > +>; +const SF_ICON_LOOKUP: Partial< + Record< + SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", + Icon + > +> = ANDROID_ICON_BY_SF_SYMBOL; + +const ANDROID_ICON_BY_MATERIAL_NAME = { + auto_awesome: IconSparkles, + bolt: IconBolt, + build: IconTool, + chat: IconMessage, + chat_bubble: IconMessage, + check: IconCheck, + close: IconX, + construction: IconHammer, + content_copy: IconCopy, + desktop_windows: IconDeviceDesktop, + edit: IconEdit, + error: IconAlertCircle, + folder: IconFolder, + folder_open: IconFolderOpen, + keyboard: IconKeyboard, + keyboard_arrow_down: IconChevronDown, + keyboard_arrow_up: IconChevronUp, + keyboard_hide: IconKeyboardHide, + lock: IconLock, + more_vert: IconDotsVertical, + merge: IconGitMerge, + psychology: IconBrain, + public: IconWorld, + remove: IconMinus, + smartphone: IconDeviceMobile, + terminal: IconTerminal2, + visibility: IconEye, +} satisfies Partial>; + +export type { SFSymbol } from "expo-symbols"; +export type AppSymbolName = + | keyof typeof ANDROID_ICON_BY_SF_SYMBOL + | { + ios: SFSymbol; + android: keyof typeof ANDROID_ICON_BY_MATERIAL_NAME; + }; + +export type AppSymbolViewProps = Omit & { name: AppSymbolName }; + +export function isAppSymbolName(name: string): name is Extract { + return Object.prototype.hasOwnProperty.call(ANDROID_ICON_BY_SF_SYMBOL, name); +} + +function AppSymbolView(props: AppSymbolViewProps) { + const materialName = typeof props.name === "string" ? undefined : props.name.android; + const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios; + const AndroidIcon = + (materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ?? + (sfSymbol ? SF_ICON_LOOKUP[sfSymbol] : undefined); + + if (!AndroidIcon) { + return props.fallback ?? null; + } + + return ( + + ); +} + +export const SymbolView = withUniwind(AppSymbolView); diff --git a/apps/mobile/src/components/ui/AppSymbol.ios.tsx b/apps/mobile/src/components/ui/AppSymbol.ios.tsx deleted file mode 100644 index 47b7e5be75bc3..0000000000000 --- a/apps/mobile/src/components/ui/AppSymbol.ios.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; -import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; -import IconGitCommit from "@tabler/icons-react-native/IconGitCommit"; -import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequestClosed"; -import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; -import type { SFSymbol } from "expo-symbols"; -import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; -import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; -import { SymbolView as ExpoSymbolView } from "expo-symbols"; -import { withUniwind } from "uniwind"; -import type { AppSymbolViewProps } from "./AppSymbol"; - -export type { SFSymbol } from "expo-symbols"; -export type { AppSymbolName } from "./AppSymbol"; - -const CUSTOM_ICONS = { - "file.diff": IconFileDiff, - "arrow.triangle.pull": IconGitPullRequest, - "arrow.triangle.merge": IconGitMerge, - "arrow.triangle.branch": IconGitBranch, - "arrow.branch": IconGitBranch, - "git.pull-request.closed": IconGitPullRequestClosed, - "git.pull-request.draft": IconGitPullRequestDraft, - "git.commit": IconGitCommit, -}; - -function AppSymbolView(props: AppSymbolViewProps) { - const name = typeof props.name === "string" ? props.name : props.name.ios; - const CustomIcon = CUSTOM_ICONS[name as keyof typeof CUSTOM_ICONS]; - if (CustomIcon) { - return ( - - ); - } - - return ; -} - -export const SymbolView = withUniwind(AppSymbolView); diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 47d199110cf4c..9e4915890e75b 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -1,370 +1,46 @@ -import type { Icon } from "@tabler/icons-react-native/types"; -/* Package-root imports eagerly register the entire Tabler icon set in Metro. */ -import IconAdjustmentsHorizontal from "@tabler/icons-react-native/IconAdjustmentsHorizontal"; -import IconAlignLeft from "@tabler/icons-react-native/IconAlignLeft"; -import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; -import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; -import IconApps from "@tabler/icons-react-native/IconApps"; -import IconArchive from "@tabler/icons-react-native/IconArchive"; -import IconCalendarClock from "@tabler/icons-react-native/IconCalendarClock"; -import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; -import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; -import IconArrowForwardUp from "@tabler/icons-react-native/IconArrowForwardUp"; -import IconArrowLeft from "@tabler/icons-react-native/IconArrowLeft"; -import IconArrowRight from "@tabler/icons-react-native/IconArrowRight"; -import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; -import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; -import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; -import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle"; -import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight"; -import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle"; -import IconArrowsLeftRight from "@tabler/icons-react-native/IconArrowsLeftRight"; -import IconArrowsDiagonal2 from "@tabler/icons-react-native/IconArrowsDiagonal2"; -import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; -import IconBan from "@tabler/icons-react-native/IconBan"; -import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; -import IconBolt from "@tabler/icons-react-native/IconBolt"; -import IconBox from "@tabler/icons-react-native/IconBox"; -import IconBrain from "@tabler/icons-react-native/IconBrain"; -import IconCamera from "@tabler/icons-react-native/IconCamera"; -import IconCar from "@tabler/icons-react-native/IconCar"; -import IconChartBar from "@tabler/icons-react-native/IconChartBar"; -import IconCheck from "@tabler/icons-react-native/IconCheck"; -import IconCloud from "@tabler/icons-react-native/IconCloud"; -import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; -import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; -import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; -import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; -import IconCircle from "@tabler/icons-react-native/IconCircle"; -import IconCircleFilled from "@tabler/icons-react-native/IconCircleFilled"; -import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; -import IconCircleDashed from "@tabler/icons-react-native/IconCircleDashed"; -import IconCircleDot from "@tabler/icons-react-native/IconCircleDot"; -import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; -import IconMoodPlus from "@tabler/icons-react-native/IconMoodPlus"; -import IconTag from "@tabler/icons-react-native/IconTag"; -import IconTicket from "@tabler/icons-react-native/IconTicket"; -import IconClock from "@tabler/icons-react-native/IconClock"; -import IconCode from "@tabler/icons-react-native/IconCode"; -import IconCopy from "@tabler/icons-react-native/IconCopy"; -import IconDatabase from "@tabler/icons-react-native/IconDatabase"; -import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; -import IconDeviceLaptop from "@tabler/icons-react-native/IconDeviceLaptop"; -import IconDeviceMobile from "@tabler/icons-react-native/IconDeviceMobile"; -import IconDots from "@tabler/icons-react-native/IconDots"; -import IconPlusMinus from "@tabler/icons-react-native/IconPlusMinus"; -import IconDotsVertical from "@tabler/icons-react-native/IconDotsVertical"; -import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; -import IconEdit from "@tabler/icons-react-native/IconEdit"; -import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; -import IconEyeOff from "@tabler/icons-react-native/IconEyeOff"; -import IconEye from "@tabler/icons-react-native/IconEye"; -import IconFileText from "@tabler/icons-react-native/IconFileText"; -import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; -import IconFilter from "@tabler/icons-react-native/IconFilter"; -import IconFilterFilled from "@tabler/icons-react-native/IconFilterFilled"; -import IconFolder from "@tabler/icons-react-native/IconFolder"; -import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; -import IconHeadphones from "@tabler/icons-react-native/IconHeadphones"; -import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus"; -import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequestClosed"; -import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; -import IconGitCommit from "@tabler/icons-react-native/IconGitCommit"; import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; +import IconGitCommit from "@tabler/icons-react-native/IconGitCommit"; +import IconGitPullRequestClosed from "@tabler/icons-react-native/IconGitPullRequestClosed"; +import IconGitPullRequestDraft from "@tabler/icons-react-native/IconGitPullRequestDraft"; +import type { SFSymbol } from "expo-symbols"; import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; -import IconHammer from "@tabler/icons-react-native/IconHammer"; -import IconHome from "@tabler/icons-react-native/IconHome"; -import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; -import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; -import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; -import IconLock from "@tabler/icons-react-native/IconLock"; -import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; -import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; -import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; -import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing"; -import IconMicrophone from "@tabler/icons-react-native/IconMicrophone"; -import IconLink from "@tabler/icons-react-native/IconLink"; -import IconListNumbers from "@tabler/icons-react-native/IconListNumbers"; -import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; -import IconMessageCircleQuestion from "@tabler/icons-react-native/IconMessageCircleQuestion"; -import IconMessage from "@tabler/icons-react-native/IconMessage"; -import IconMessagePlus from "@tabler/icons-react-native/IconMessagePlus"; -import IconMinus from "@tabler/icons-react-native/IconMinus"; -import IconMoon from "@tabler/icons-react-native/IconMoon"; -import IconNetwork from "@tabler/icons-react-native/IconNetwork"; -import IconPalette from "@tabler/icons-react-native/IconPalette"; -import IconPencil from "@tabler/icons-react-native/IconPencil"; -import IconPlug from "@tabler/icons-react-native/IconPlug"; -import IconPhoto from "@tabler/icons-react-native/IconPhoto"; -import IconPin from "@tabler/icons-react-native/IconPin"; -import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; -import IconPlayerPause from "@tabler/icons-react-native/IconPlayerPause"; -import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay"; -import IconPlayerStopFilled from "@tabler/icons-react-native/IconPlayerStopFilled"; -import IconPlus from "@tabler/icons-react-native/IconPlus"; -import IconQrcode from "@tabler/icons-react-native/IconQrcode"; -import IconRefresh from "@tabler/icons-react-native/IconRefresh"; -import IconSearch from "@tabler/icons-react-native/IconSearch"; -import IconShare from "@tabler/icons-react-native/IconShare"; -import IconServer from "@tabler/icons-react-native/IconServer"; -import IconSettings from "@tabler/icons-react-native/IconSettings"; -import IconSparkles from "@tabler/icons-react-native/IconSparkles"; -import IconStack2 from "@tabler/icons-react-native/IconStack2"; -import IconStar from "@tabler/icons-react-native/IconStar"; -import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; -import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; -import IconSun from "@tabler/icons-react-native/IconSun"; -import IconShieldQuestion from "@tabler/icons-react-native/IconShieldQuestion"; -import IconTarget from "@tabler/icons-react-native/IconTarget"; -import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; -import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; -import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease"; -import IconTool from "@tabler/icons-react-native/IconTool"; -import IconTrash from "@tabler/icons-react-native/IconTrash"; -import IconTypography from "@tabler/icons-react-native/IconTypography"; -import IconDownload from "@tabler/icons-react-native/IconDownload"; -import IconSitemap from "@tabler/icons-react-native/IconSitemap"; -import IconUpload from "@tabler/icons-react-native/IconUpload"; -import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; -import IconUserCheck from "@tabler/icons-react-native/IconUserCheck"; -import IconUserX from "@tabler/icons-react-native/IconUserX"; -import IconUserQuestion from "@tabler/icons-react-native/IconUserQuestion"; -import IconUserPlus from "@tabler/icons-react-native/IconUserPlus"; -import IconUsers from "@tabler/icons-react-native/IconUsers"; -import IconWifi from "@tabler/icons-react-native/IconWifi"; -import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; -import IconWorld from "@tabler/icons-react-native/IconWorld"; -import IconX from "@tabler/icons-react-native/IconX"; -import type { AndroidSymbol, SFSymbol, SymbolViewProps } from "expo-symbols"; +import IconFileDiff from "@tabler/icons-react-native/IconFileDiff"; +import { SymbolView as ExpoSymbolView } from "expo-symbols"; import { withUniwind } from "uniwind"; +import type { AppSymbolViewProps } from "./AppSymbol.android"; + +export type { SFSymbol } from "expo-symbols"; +export type { AppSymbolName, AppSymbolViewProps } from "./AppSymbol.android"; -const ANDROID_ICON_BY_SF_SYMBOL = { - plusminus: IconPlusMinus, +const CUSTOM_ICONS = { + "file.diff": IconFileDiff, + "arrow.triangle.pull": IconGitPullRequest, + "arrow.triangle.merge": IconGitMerge, + "arrow.triangle.branch": IconGitBranch, + "arrow.branch": IconGitBranch, "git.pull-request.closed": IconGitPullRequestClosed, "git.pull-request.draft": IconGitPullRequestDraft, "git.commit": IconGitCommit, - "arrow.branch": IconGitBranch, - "arrow.left": IconArrowLeft, - "arrow.right": IconArrowRight, - "arrow.left.arrow.right": IconArrowsLeftRight, - "arrow.clockwise": IconRefresh, - "arrow.down": IconArrowDown, - "arrow.down.circle": IconArrowDownCircle, - "arrow.right.circle": IconArrowRightCircle, - "arrow.triangle.branch": IconGitBranch, - "arrow.triangle.merge": IconGitMerge, - "arrow.triangle.pull": IconGitPullRequest, - "square.3.layers.3d": IconStack2, - "arrow.turn.left.up": IconArrowBackUp, - "arrow.up": IconArrowUp, - "arrow.up.circle": IconArrowUpCircle, - "arrow.up.left.and.arrow.down.right": IconArrowsDiagonal2, - "arrow.down.right.and.arrow.up.left": IconArrowsMinimize, - "arrow.up.right": IconArrowUpRight, - "arrow.up.right.circle": IconArrowUpRightCircle, - "arrow.uturn.backward": IconArrowBackUp, - "arrow.uturn.forward": IconArrowForwardUp, - archivebox: IconArchive, - "archivebox.fill": IconArchive, - "bell.badge": IconBellRinging, - "bolt.circle": IconBolt, - "bolt.horizontal.circle": IconBolt, - brain: IconBrain, - "cable.connector": IconPlug, - camera: IconCamera, - car: IconCar, - "chart.bar.xaxis": IconChartBar, - checkmark: IconCheck, - "checkmark.circle": IconCircleCheck, - "checkmark.circle.fill": IconCircleCheck, - circle: IconCircle, - "circle.fill": IconCircleFilled, - "circle.dashed": IconCircleDashed, - "circle.inset.filled": IconCircleDot, - "circle.dashed.inset.filled": IconCircleDot, - "calendar.badge.clock": IconCalendarClock, - clock: IconClock, - timer: IconClock, - "face.smiling": IconMoodPlus, - tag: IconTag, - ticket: IconTicket, - cloud: IconCloud, - cube: IconBox, - "chevron.down": IconChevronDown, - "chevron.left": IconChevronLeft, - "chevron.left.forwardslash.chevron.right": IconCode, - "chevron.right": IconChevronRight, - "chevron.up": IconChevronUp, - desktopcomputer: IconDeviceDesktop, - doc: IconFileText, - "doc.on.doc": IconCopy, - "doc.text": IconFileText, - "file.diff": IconFileDiff, - ellipsis: IconDots, - moon: IconMoon, - "ellipsis.circle": IconDotsCircleHorizontal, - "exclamationmark.triangle": IconAlertTriangle, - "exclamationmark.circle": IconAlertCircle, - eye: IconEye, - "eye.slash": IconEyeOff, - folder: IconFolder, - "folder.badge.plus": IconFolderPlus, - "folder.fill": IconFolder, - gearshape: IconSettings, - globe: IconWorld, - hammer: IconHammer, - headphones: IconHeadphones, - house: IconHome, - "info.circle": IconInfoCircle, - internaldrive: IconDatabase, - iphone: IconDeviceMobile, - keyboard: IconKeyboard, - laptopcomputer: IconDeviceLaptop, - link: IconLink, - "list.number": IconListNumbers, - "line.3.horizontal": IconMenu2, - "line.3.horizontal.decrease": IconFilter, - "line.3.horizontal.decrease.circle": IconFilter, - "line.3.horizontal.decrease.circle.fill": IconFilterFilled, - macmini: IconServer, - macstudio: IconDeviceDesktop, - magnifyingglass: IconSearch, - mic: IconMicrophone, - paintbrush: IconPalette, - pencil: IconPencil, - "person.crop.circle": IconUserCircle, - "person.2": IconUsers, - "person.fill.checkmark": IconUserCheck, - "person.fill.xmark": IconUserX, - "person.badge.clock": IconUserQuestion, - "person.badge.plus": IconUserPlus, - photo: IconPhoto, - pin: IconPin, - "pin.slash": IconPinnedOff, - play: IconPlayerPlay, - pause: IconPlayerPause, - plus: IconPlus, - minus: IconMinus, - "slash.circle": IconBan, - "qrcode.viewfinder": IconQrcode, - "point.3.connected.trianglepath.dotted": IconNetwork, - flowchart: IconSitemap, - "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, - safari: IconExternalLink, - "server.rack": IconServer, - stethoscope: IconStethoscope, - "sidebar.left": IconLayoutSidebar, - "sidebar.right": IconLayoutSidebarRight, - "slider.horizontal.3": IconAdjustmentsHorizontal, - "square.and.arrow.up": IconShare, - "square.and.pencil": IconEdit, - "square.on.square": IconCopy, - "square.grid.2x2": IconApps, - "square.split.2x1": IconLayoutColumns, - star: IconStar, - "star.fill": IconStarFilled, - "sun.max": IconSun, - "stop.fill": IconPlayerStopFilled, - target: IconTarget, - terminal: IconTerminal2, - "text.alignleft": IconAlignLeft, - "text.bubble": IconMessage, - "questionmark.bubble": IconMessageCircleQuestion, - "exclamationmark.shield": IconShieldQuestion, - "bubble.left.and.bubble.right": IconMessage, - "bubble.left": IconMessage, - sparkles: IconSparkles, - "plus.bubble": IconMessagePlus, - "text.word.spacing": IconLetterSpacing, - "textformat.size": IconTypography, - "textformat.size.larger": IconTextIncrease, - "textformat.size.smaller": IconTextDecrease, - "tray.and.arrow.down": IconDownload, - "tray.and.arrow.up": IconUpload, - trash: IconTrash, - wifi: IconWifi, - "wifi.slash": IconWifiOff, - xmark: IconX, - "xmark.circle.fill": IconCircleXFilled, -} satisfies Partial< - Record< - SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", - Icon - > ->; -const SF_ICON_LOOKUP: Partial< - Record< - SFSymbol | "git.pull-request.closed" | "git.pull-request.draft" | "git.commit" | "file.diff", - Icon - > -> = ANDROID_ICON_BY_SF_SYMBOL; - -const ANDROID_ICON_BY_MATERIAL_NAME = { - auto_awesome: IconSparkles, - bolt: IconBolt, - build: IconTool, - chat: IconMessage, - chat_bubble: IconMessage, - check: IconCheck, - close: IconX, - construction: IconHammer, - content_copy: IconCopy, - desktop_windows: IconDeviceDesktop, - edit: IconEdit, - error: IconAlertCircle, - folder: IconFolder, - folder_open: IconFolderOpen, - keyboard: IconKeyboard, - keyboard_arrow_down: IconChevronDown, - keyboard_arrow_up: IconChevronUp, - keyboard_hide: IconKeyboardHide, - lock: IconLock, - more_vert: IconDotsVertical, - merge: IconGitMerge, - psychology: IconBrain, - public: IconWorld, - remove: IconMinus, - smartphone: IconDeviceMobile, - terminal: IconTerminal2, - visibility: IconEye, -} satisfies Partial>; - -export type { SFSymbol } from "expo-symbols"; -export type AppSymbolName = - | keyof typeof ANDROID_ICON_BY_SF_SYMBOL - | { - ios: SFSymbol; - android: keyof typeof ANDROID_ICON_BY_MATERIAL_NAME; - }; - -export type AppSymbolViewProps = Omit & { name: AppSymbolName }; - -export function isAppSymbolName(name: string): name is Extract { - return Object.prototype.hasOwnProperty.call(ANDROID_ICON_BY_SF_SYMBOL, name); -} +}; function AppSymbolView(props: AppSymbolViewProps) { - const materialName = typeof props.name === "string" ? undefined : props.name.android; - const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios; - const AndroidIcon = - (materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ?? - (sfSymbol ? SF_ICON_LOOKUP[sfSymbol] : undefined); - - if (!AndroidIcon) { - return props.fallback ?? null; + const name = typeof props.name === "string" ? props.name : props.name.ios; + const CustomIcon = CUSTOM_ICONS[name as keyof typeof CUSTOM_ICONS]; + if (CustomIcon) { + return ( + + ); } - return ( - - ); + return ; } export const SymbolView = withUniwind(AppSymbolView); diff --git a/apps/mobile/src/components/ui/FrostedCutout.android.tsx b/apps/mobile/src/components/ui/FrostedCutout.android.tsx new file mode 100644 index 0000000000000..39e4b6c3900ef --- /dev/null +++ b/apps/mobile/src/components/ui/FrostedCutout.android.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { StyleSheet, View } from "react-native"; +import Svg, { Path } from "react-native-svg"; + +import type { FrostedCutoutProps } from "./FrostedCutout.types"; + +export function FrostedCutout(props: FrostedCutoutProps) { + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const x = (width - props.cutoutWidth) / 2; + const y = props.cutoutTop; + const w = props.cutoutWidth; + const h = props.cutoutHeight; + const r = Math.min(props.cutoutRadius, w / 2, h / 2); + const hole = `M${x + r},${y} H${x + w - r} A${r},${r} 0 0 1 ${x + w},${y + r} V${y + h - r} A${r},${r} 0 0 1 ${x + w - r},${y + h} H${x + r} A${r},${r} 0 0 1 ${x},${y + h - r} V${y + r} A${r},${r} 0 0 1 ${x + r},${y} Z`; + + return ( + { + setWidth(event.nativeEvent.layout.width); + setHeight(event.nativeEvent.layout.height); + }} + > + {width > 0 ? ( + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/FrostedCutout.ios.tsx b/apps/mobile/src/components/ui/FrostedCutout.ios.tsx deleted file mode 100644 index 66f098c6e22e2..0000000000000 --- a/apps/mobile/src/components/ui/FrostedCutout.ios.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { requireNativeView } from "expo"; -import { StyleSheet, type ViewProps } from "react-native"; - -import type { FrostedCutoutProps } from "./FrostedCutout.types"; - -const NativeFrostedCutout = requireNativeView( - "T3NativeControls", - "FrostedCutout", -); - -export function FrostedCutout(props: FrostedCutoutProps) { - return ; -} diff --git a/apps/mobile/src/components/ui/FrostedCutout.tsx b/apps/mobile/src/components/ui/FrostedCutout.tsx index 39e4b6c3900ef..66f098c6e22e2 100644 --- a/apps/mobile/src/components/ui/FrostedCutout.tsx +++ b/apps/mobile/src/components/ui/FrostedCutout.tsx @@ -1,38 +1,13 @@ -import { useState } from "react"; -import { StyleSheet, View } from "react-native"; -import Svg, { Path } from "react-native-svg"; +import { requireNativeView } from "expo"; +import { StyleSheet, type ViewProps } from "react-native"; import type { FrostedCutoutProps } from "./FrostedCutout.types"; -export function FrostedCutout(props: FrostedCutoutProps) { - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); - const x = (width - props.cutoutWidth) / 2; - const y = props.cutoutTop; - const w = props.cutoutWidth; - const h = props.cutoutHeight; - const r = Math.min(props.cutoutRadius, w / 2, h / 2); - const hole = `M${x + r},${y} H${x + w - r} A${r},${r} 0 0 1 ${x + w},${y + r} V${y + h - r} A${r},${r} 0 0 1 ${x + w - r},${y + h} H${x + r} A${r},${r} 0 0 1 ${x},${y + h - r} V${y + r} A${r},${r} 0 0 1 ${x + r},${y} Z`; +const NativeFrostedCutout = requireNativeView( + "T3NativeControls", + "FrostedCutout", +); - return ( - { - setWidth(event.nativeEvent.layout.width); - setHeight(event.nativeEvent.layout.height); - }} - > - {width > 0 ? ( - - - - ) : null} - - ); +export function FrostedCutout(props: FrostedCutoutProps) { + return ; } diff --git a/apps/mobile/src/components/ui/Menu.android.tsx b/apps/mobile/src/components/ui/Menu.android.tsx index 025f9de93faab..f6153d9eccc91 100644 --- a/apps/mobile/src/components/ui/Menu.android.tsx +++ b/apps/mobile/src/components/ui/Menu.android.tsx @@ -1,6 +1,6 @@ import * as Haptics from "expo-haptics"; import { cloneElement, isValidElement, type ReactElement } from "react"; -import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu.android"; import type { MenuProps } from "./Menu.types"; export function Menu(props: MenuProps) { diff --git a/apps/mobile/src/components/ui/MenuPopup.android.tsx b/apps/mobile/src/components/ui/MenuPopup.android.tsx index dd6ef2b046566..06eeead1bd202 100644 --- a/apps/mobile/src/components/ui/MenuPopup.android.tsx +++ b/apps/mobile/src/components/ui/MenuPopup.android.tsx @@ -15,7 +15,7 @@ import { resolveScaledTextRole } from "../../lib/appearancePreferences"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; import type { MenuPopupProps } from "./MenuPopup"; -import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol"; +import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol.android"; // The Compose glyph host consumes touches; run the action here and keep accessibility on the row. function MenuIcon(props: { diff --git a/apps/mobile/src/components/ui/NativePresentation.android.tsx b/apps/mobile/src/components/ui/NativePresentation.android.tsx new file mode 100644 index 0000000000000..e342c6decc77a --- /dev/null +++ b/apps/mobile/src/components/ui/NativePresentation.android.tsx @@ -0,0 +1,7 @@ +import { View } from "react-native"; + +import type { PresentationSourceProps } from "./NativePresentation"; + +export function PresentationSource({ identifier: _identifier, ...props }: PresentationSourceProps) { + return ; +} diff --git a/apps/mobile/src/components/ui/NativePresentation.ios.tsx b/apps/mobile/src/components/ui/NativePresentation.ios.tsx deleted file mode 100644 index b93578dde602a..0000000000000 --- a/apps/mobile/src/components/ui/NativePresentation.ios.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { requireNativeView } from "expo"; -import type { ComponentType } from "react"; -import type { PresentationSourceProps } from "./NativePresentation"; - -const NativeSource: ComponentType = requireNativeView( - "T3NativeControls", - "PresentationSource", -); - -export function PresentationSource(props: PresentationSourceProps) { - return ; -} diff --git a/apps/mobile/src/components/ui/NativePresentation.tsx b/apps/mobile/src/components/ui/NativePresentation.tsx index 77510daf7ec28..61ff2b2b226af 100644 --- a/apps/mobile/src/components/ui/NativePresentation.tsx +++ b/apps/mobile/src/components/ui/NativePresentation.tsx @@ -1,11 +1,17 @@ -import type { ReactElement } from "react"; -import { View, type ViewProps } from "react-native"; +import { requireNativeView } from "expo"; +import type { ComponentType, ReactElement } from "react"; +import type { ViewProps } from "react-native"; export interface PresentationSourceProps extends ViewProps { readonly children: ReactElement; readonly identifier: string; } -export function PresentationSource({ identifier: _identifier, ...props }: PresentationSourceProps) { - return ; +const NativeSource: ComponentType = requireNativeView( + "T3NativeControls", + "PresentationSource", +); + +export function PresentationSource(props: PresentationSourceProps) { + return ; } diff --git a/apps/mobile/src/components/ui/ScreenHeaderButton.android.tsx b/apps/mobile/src/components/ui/ScreenHeaderButton.android.tsx index 905997a3da6e2..99bc9212d4071 100644 --- a/apps/mobile/src/components/ui/ScreenHeaderButton.android.tsx +++ b/apps/mobile/src/components/ui/ScreenHeaderButton.android.tsx @@ -1 +1 @@ -export { AndroidHeaderIconButton as ScreenHeaderButton } from "./AndroidScreenHeader"; +export { AndroidHeaderIconButton as ScreenHeaderButton } from "./AndroidScreenHeader.android"; diff --git a/apps/mobile/src/components/ui/SegmentedControl.android.tsx b/apps/mobile/src/components/ui/SegmentedControl.android.tsx new file mode 100644 index 0000000000000..bbcbc4a5f4593 --- /dev/null +++ b/apps/mobile/src/components/ui/SegmentedControl.android.tsx @@ -0,0 +1 @@ +export { SegmentedControl, type SegmentedControlProps } from "./SegmentedControl.shared"; diff --git a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx deleted file mode 100644 index 15883d300da41..0000000000000 --- a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { requireNativeView } from "expo"; -import { View, type NativeSyntheticEvent, type ViewProps } from "react-native"; - -import { SegmentedControl as FlatSegmentedControl } from "./SegmentedControl.shared"; -import { segmentedControlColors } from "./segmentedControlColors"; -import { useControlTheme } from "./useControlTheme"; -import type { SegmentedControlProps } from "./SegmentedControl.types"; - -export type { SegmentedControlProps } from "./SegmentedControl.types"; - -const NativeSegmentedControl = requireNativeView< - ViewProps & { - labels: string[]; - selectedIndex: number; - trackColor: string; - selectedColor: string; - textColor: string; - mutedColor: string; - appearance: string; - onSelectionChange: (event: NativeSyntheticEvent<{ index: number }>) => void; - } ->("T3NativeControls", "SegmentedControl"); - -export function SegmentedControl( - props: SegmentedControlProps, -) { - const { colors, colorScheme, themeId } = useControlTheme(); - // UIKit adds a gray fill over the sunken card track. - if (props.variant === "card") return ; - const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( - colors, - colorScheme, - props.variant, - "ios", - themeId, - ); - return ( - - option.label)} - selectedIndex={props.options.findIndex((option) => option.value === props.selected)} - trackColor={trackColor} - selectedColor={selectedColor} - textColor={selectedTextColor} - mutedColor={mutedColor} - appearance={colorScheme} - onSelectionChange={({ nativeEvent }) => { - const option = props.options[nativeEvent.index]; - if (option) props.onSelect(option.value); - }} - style={{ height: props.size === "compact" ? 32 : 36 }} - /> - - ); -} diff --git a/apps/mobile/src/components/ui/SegmentedControl.tsx b/apps/mobile/src/components/ui/SegmentedControl.tsx index bbcbc4a5f4593..15883d300da41 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.tsx @@ -1 +1,55 @@ -export { SegmentedControl, type SegmentedControlProps } from "./SegmentedControl.shared"; +import { requireNativeView } from "expo"; +import { View, type NativeSyntheticEvent, type ViewProps } from "react-native"; + +import { SegmentedControl as FlatSegmentedControl } from "./SegmentedControl.shared"; +import { segmentedControlColors } from "./segmentedControlColors"; +import { useControlTheme } from "./useControlTheme"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; + +export type { SegmentedControlProps } from "./SegmentedControl.types"; + +const NativeSegmentedControl = requireNativeView< + ViewProps & { + labels: string[]; + selectedIndex: number; + trackColor: string; + selectedColor: string; + textColor: string; + mutedColor: string; + appearance: string; + onSelectionChange: (event: NativeSyntheticEvent<{ index: number }>) => void; + } +>("T3NativeControls", "SegmentedControl"); + +export function SegmentedControl( + props: SegmentedControlProps, +) { + const { colors, colorScheme, themeId } = useControlTheme(); + // UIKit adds a gray fill over the sunken card track. + if (props.variant === "card") return ; + const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( + colors, + colorScheme, + props.variant, + "ios", + themeId, + ); + return ( + + option.label)} + selectedIndex={props.options.findIndex((option) => option.value === props.selected)} + trackColor={trackColor} + selectedColor={selectedColor} + textColor={selectedTextColor} + mutedColor={mutedColor} + appearance={colorScheme} + onSelectionChange={({ nativeEvent }) => { + const option = props.options[nativeEvent.index]; + if (option) props.onSelect(option.value); + }} + style={{ height: props.size === "compact" ? 32 : 36 }} + /> + + ); +} diff --git a/apps/mobile/src/components/ui/WorkspaceAppBar.android.tsx b/apps/mobile/src/components/ui/WorkspaceAppBar.android.tsx index 5a6fed51e5d98..0d98816ad2dfd 100644 --- a/apps/mobile/src/components/ui/WorkspaceAppBar.android.tsx +++ b/apps/mobile/src/components/ui/WorkspaceAppBar.android.tsx @@ -11,8 +11,8 @@ import { } from "react"; import { Pressable, View } from "react-native"; -import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; -import { AndroidHeaderIconButton } from "./AndroidScreenHeader"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu.android"; +import { AndroidHeaderIconButton } from "./AndroidScreenHeader.android"; import { AppText } from "./AppText"; import { NativeAppBar } from "./NativeAppBar.android"; import { useControlTheme } from "./useControlTheme"; diff --git a/vite.config.ts b/vite.config.ts index fb6255cf96363..d891ec91f0545 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -396,27 +396,6 @@ export default defineConfig({ ], }, }, - { - files: ["apps/mobile/src/features/**"], - rules: { - "eslint/no-restricted-imports": [ - "error", - { - paths: [...RESTRICTED_IMPORT_PATHS, RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS], - patterns: [ - { - group: [ - "**/components/ui/AndroidScreenHeader", - "**/components/ui/WorkspaceAppBar*", - ], - importNames: ["AndroidScreenHeader", "WorkspaceAppBar"], - message: "Use AppBar, so every screen gets the same header and button metrics.", - }, - ], - }, - ], - }, - }, { files: ["apps/web/src/components/pullRequest/pullRequestIcons.tsx"], rules: { "eslint/no-restricted-imports": ["error", { paths: RESTRICTED_IMPORT_PATHS }] }, @@ -492,7 +471,7 @@ export default defineConfig({ files: ["apps/mobile/src/**"], excludeFiles: [ "apps/mobile/src/components/ui/AppSymbol.tsx", - "apps/mobile/src/components/ui/AppSymbol.ios.tsx", + "apps/mobile/src/components/ui/AppSymbol.android.tsx", "apps/mobile/src/features/pull-requests/pullRequestGlyphs.ts", ], rules: { "t3code/no-mobile-pull-request-glyph-literals": "error" },