303 lines · 10.8 KB · TypeScript
RawDownload
import { SymbolView } from "../components/AppSymbol";import { videoMimeType } from "@t3tools/shared/video";import { useEffect, useMemo, useState } from "react";import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText";import { PierreEntryIcon } from "./PierreEntryIcon";import {  isFileBackedComposerAttachment,  type DraftComposerAttachment,  type DraftComposerFileAttachment,  type DraftComposerImageAttachment,} from "../lib/composerImages";import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";import { VideoAttachmentTile } from "./VideoAttachmentTile";import type { MediaActionsSource } from "../lib/mediaActions";import { PresentationSource } from "./NativePresentation";import type { FilePreviewSource } from "./FilePreviewModal";import { isPdfFile } from "../lib/filePreview";import type { EnvironmentId } from "@t3tools/contracts";import {  retryComposerAttachmentUpload,  useComposerAttachmentUploadState,} from "../state/composer-attachment-uploads"; export interface ComposerAttachmentStripProps {  readonly environmentId?: EnvironmentId;  /** Attachments to display. */  readonly attachments: ReadonlyArray<DraftComposerAttachment>;  /** Called when the user removes an attachment. */  readonly onRemove: (imageId: string) => void;  /** Called when the user taps an image or PDF to preview it. */  readonly onPressPreview?: (source: FilePreviewSource) => void;  readonly onPressVideo?: (    attachment: DraftComposerFileAttachment,    sourceIdentifier: string,  ) => void;  /** Image thumbnail size in points.  Defaults to 72. */  readonly imageSize?: number;  /** Border radius of each image thumbnail.  Defaults to 16. */  readonly imageBorderRadius?: number;  /** Whether the remove button should sit in its own gutter instead of overlapping the image. */  readonly removeButtonPlacement?: "overlay" | "gutter";} type ComposerAttachmentThumbnailProps = {  readonly environmentId?: EnvironmentId;  readonly attachment: DraftComposerAttachment;  readonly size: number;  readonly borderRadius: number;  readonly compact?: boolean;  readonly onPressPreview?: (source: FilePreviewSource) => void;  readonly onPressVideo?: (    attachment: DraftComposerFileAttachment,    sourceIdentifier: string,  ) => void;}; export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) {  const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id);  return (    <View style={{ width: props.size, height: props.size }}>      <ComposerAttachmentContent {...props} />      {upload && upload.status !== "ready" ? (        <Pressable          accessibilityRole={upload.status === "failed" ? "button" : "text"}          accessibilityLabel={            upload.status === "failed"              ? `Retry uploading ${props.attachment.name}`              : `Uploading ${props.attachment.name}, ${Math.floor(upload.progress * 100)}%`          }          accessibilityHint={upload.status === "failed" ? upload.reason : undefined}          disabled={upload.status !== "failed"}          onPress={() =>            props.environmentId &&            retryComposerAttachmentUpload(props.environmentId, props.attachment.id)          }          className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5"        >          <SymbolView            name={upload.status === "failed" ? "arrow.clockwise" : "arrow.up"}            size={props.compact ? 8 : 10}            tintColor="#ffffff"            type="monochrome"          />          {!props.compact ? (            <Text className="text-2xs text-white">              {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`}            </Text>          ) : null}        </Pressable>      ) : null}    </View>  );} /** * Thumbnail URI for a draft image. File-backed previews rebase into the * current iOS data container (its UUID changes across installs); the raw * persisted URI renders meanwhile, which is correct everywhere but after a * container move. */function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string {  const { fileUri, previewUri } = attachment;  const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null);  useEffect(() => {    if (fileUri === undefined) return;    let cancelled = false;    void (async () => {      const { Paths } = await import("expo-file-system");      const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);      // Re-render only when the container actually moved.      if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned });    })();    return () => {      cancelled = true;    };  }, [fileUri, previewUri]);  return fileUri !== undefined && rebased?.fileUri === fileUri ? rebased.uri : previewUri;} function ComposerImageAttachment(  props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerImageAttachment },) {  const { attachment } = props;  const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };  const previewUri = useComposerImagePreviewUri(attachment);  const sourceIdentifier = `draft-image:${attachment.id}`;  return (    <PresentationSource identifier={sourceIdentifier}>      <Pressable        accessibilityRole="imagebutton"        accessibilityLabel={`Open ${attachment.name}`}        disabled={!props.onPressPreview}        onPress={() =>          props.onPressPreview?.(            // File-backed images open through the retain-lease + container            // rebase path; legacy drafts still carry their inline bytes.            isFileBackedComposerAttachment(attachment)              ? { kind: "image", attachment, name: attachment.name, sourceIdentifier }              : {                  kind: "image",                  uri: attachment.dataUrl ?? attachment.previewUri,                  name: attachment.name,                  sourceIdentifier,                },          )        }      >        <Image          source={{ uri: previewUri }}          style={style}          className="bg-subtle"          resizeMode="cover"        />      </Pressable>    </PresentationSource>  );} function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {  const { attachment } = props;  const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };  if (attachment.type === "image") {    return <ComposerImageAttachment {...props} attachment={attachment} />;  }  const onPressVideo = props.onPressVideo;  if (onPressVideo && videoMimeType(attachment) !== null) {    return (      <ComposerVideoAttachment {...props} attachment={attachment} onPressVideo={onPressVideo} />    );  }  const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined;  const sourceIdentifier = `draft-file:${attachment.id}`;  return (    <PresentationSource identifier={sourceIdentifier}>      <Pressable        accessibilityRole="button"        accessibilityLabel={`Open ${attachment.name}`}        disabled={!canPreview}        onPress={() =>          props.onPressPreview?.({            kind: "pdf",            name: attachment.name,            attachment,            sourceIdentifier,          })        }        className={          props.compact            ? "items-center justify-center bg-subtle"            : "items-center justify-center gap-1 bg-subtle px-2"        }        style={style}      >        <PierreEntryIcon path={attachment.name} kind="file" size={props.compact ? 15 : 22} />        {!props.compact ? (          <Text className="w-full text-center text-2xs text-foreground" numberOfLines={1}>            {attachment.name}          </Text>        ) : null}      </Pressable>    </PresentationSource>  );} function ComposerVideoAttachment(props: {  readonly attachment: DraftComposerFileAttachment;  readonly size: number;  readonly borderRadius: number;  readonly compact?: boolean;  readonly onPressVideo: (    attachment: DraftComposerFileAttachment,    sourceIdentifier: string,  ) => void;}) {  const { attachment } = props;  const sourceIdentifier = `draft:${attachment.id}`;  const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };  const actionsSource = useMemo<MediaActionsSource>(    () => ({      name: attachment.name,      mimeType: videoMimeType(attachment) ?? attachment.mimeType,      sourceIdentifier,      attachment,    }),    [attachment, sourceIdentifier],  );   return (    <VideoAttachmentTile      name={attachment.name}      sourceIdentifier={sourceIdentifier}      thumbnailSource={attachment}      compact={props.compact}      onPress={() => props.onPressVideo(attachment, sourceIdentifier)}      actionsSource={actionsSource}      style={style}    />  );} /** * Attachment thumbnails used by the thread composer and the new-task draft screen. */export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {  const size = props.imageSize ?? 72;  const radius = props.imageBorderRadius ?? 16;  const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";  const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0;   if (props.attachments.length === 0) {    return null;  }   return (    <ScrollView      horizontal      showsHorizontalScrollIndicator={false}      keyboardShouldPersistTaps="always"      className="grow-0"    >      <View className="flex-row gap-2.5">        {props.attachments.map((attachment) => (          <View            key={attachment.id}            className="relative"            style={{              paddingTop: removeButtonGutter,              paddingRight: removeButtonGutter,            }}          >            <ComposerAttachmentThumbnail              environmentId={props.environmentId}              attachment={attachment}              size={size}              borderRadius={radius}              onPressPreview={props.onPressPreview}              onPressVideo={props.onPressVideo}            />            <Pressable              className="absolute h-[22px] w-[22px] items-center justify-center rounded-[11px] bg-black/55"              style={{                top: removeButtonPlacement === "gutter" ? 0 : 4,                right: removeButtonPlacement === "gutter" ? 0 : 4,              }}              hitSlop={6}              onPress={() => props.onRemove(attachment.id)}            >              <SymbolView                name="xmark"                size={9}                tintColor="#ffffff"                type="monochrome"                weight="bold"              />            </Pressable>          </View>        ))}      </View>    </ScrollView>  );}