Unify attachment display and enable file uploads on all platforms (#1501)
Attachment pills were drawn by four separate style sets, so files, images, GitHub items, and reviews rendered at different heights in the same tray. Move the frame, the two-line label, and the image thumbnail into shared components so every attachment lines up at one height, across both the composer and sent messages. Sent-message images now open the lightbox, and file uploads work on iOS and Android via expo-document-picker instead of being gated to desktop.
This commit is contained in:
Generated
+10
@@ -20410,6 +20410,15 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-document-picker": {
|
||||||
|
"version": "14.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||||
|
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-eas-client": {
|
"node_modules/expo-eas-client": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz",
|
||||||
@@ -37111,6 +37120,7 @@
|
|||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
"expo-crypto": "^15.0.8",
|
"expo-crypto": "^15.0.8",
|
||||||
"expo-dev-client": "^6.0.15",
|
"expo-dev-client": "^6.0.15",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.17",
|
"expo-file-system": "~19.0.17",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
"expo-crypto": "^15.0.8",
|
"expo-crypto": "^15.0.8",
|
||||||
"expo-dev-client": "^6.0.15",
|
"expo-dev-client": "^6.0.15",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.17",
|
"expo-file-system": "~19.0.17",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export function getFileExtension(path: string): string {
|
|||||||
return normalizedPath.slice(extensionIndex).toLowerCase();
|
return normalizedPath.slice(extensionIndex).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFileTypeLabel(path: string): string | null {
|
||||||
|
const extension = getFileExtension(path).slice(1);
|
||||||
|
return extension ? extension.toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMimeTypeFromPath(path: string): string {
|
export function getMimeTypeFromPath(path: string): string {
|
||||||
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
|
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
||||||
import { Pressable, View } from "react-native";
|
import { Image, Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
// Every attachment pill body — image thumbnail or labelled — renders at this
|
||||||
|
// height so mixed attachment trays line up.
|
||||||
|
const ATTACHMENT_CONTENT_HEIGHT = 48;
|
||||||
|
|
||||||
interface AttachmentPillProps {
|
interface AttachmentPillProps {
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
@@ -24,7 +31,6 @@ export function AttachmentPill({
|
|||||||
testID,
|
testID,
|
||||||
children,
|
children,
|
||||||
}: AttachmentPillProps) {
|
}: AttachmentPillProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [isBodyHovered, setIsBodyHovered] = useState(false);
|
const [isBodyHovered, setIsBodyHovered] = useState(false);
|
||||||
const [isCloseHovered, setIsCloseHovered] = useState(false);
|
const [isCloseHovered, setIsCloseHovered] = useState(false);
|
||||||
@@ -48,7 +54,7 @@ export function AttachmentPill({
|
|||||||
onHoverOut={handleBodyHoverOut}
|
onHoverOut={handleBodyHoverOut}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={openAccessibilityLabel}
|
accessibilityLabel={openAccessibilityLabel}
|
||||||
style={styles.body}
|
style={styles.frame}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -62,22 +68,127 @@ export function AttachmentPill({
|
|||||||
accessibilityLabel={removeAccessibilityLabel}
|
accessibilityLabel={removeAccessibilityLabel}
|
||||||
style={closeButtonStyle}
|
style={closeButtonStyle}
|
||||||
>
|
>
|
||||||
<X size={12} color={theme.colors.foregroundMuted} />
|
<ThemedX size={12} uniProps={iconForegroundMutedMapping} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AttachmentFrameProps {
|
||||||
|
onPress?: () => void;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
testID?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bare attachment frame for read-only surfaces (sent messages) — no remove button. */
|
||||||
|
export function AttachmentFrame({
|
||||||
|
onPress,
|
||||||
|
accessibilityLabel,
|
||||||
|
testID,
|
||||||
|
children,
|
||||||
|
}: AttachmentFrameProps) {
|
||||||
|
if (!onPress) {
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={styles.frame}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
testID={testID}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
style={styles.frame}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentLabelProps {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-line labelled pill body: attachment name over its type. */
|
||||||
|
export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.labelBody}>
|
||||||
|
{icon ? <View style={styles.labelIcon}>{icon}</View> : null}
|
||||||
|
<View style={styles.labelTextColumn}>
|
||||||
|
<Text style={styles.labelTitle} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.labelSubtitle} numberOfLines={1}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Square image preview pill body. */
|
||||||
|
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
|
||||||
|
const uri = useAttachmentPreviewUrl(metadata);
|
||||||
|
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
||||||
|
if (!uri) {
|
||||||
|
return <View style={styles.thumbnailPlaceholder} />;
|
||||||
|
}
|
||||||
|
return <Image source={source} style={styles.thumbnail} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedX = withUnistyles(X);
|
||||||
|
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
body: {
|
frame: {
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
|
labelBody: {
|
||||||
|
height: ATTACHMENT_CONTENT_HEIGHT,
|
||||||
|
maxWidth: 260,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
labelIcon: {
|
||||||
|
width: 18,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
labelTextColumn: {
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
labelTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
labelSubtitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
width: ATTACHMENT_CONTENT_HEIGHT,
|
||||||
|
height: ATTACHMENT_CONTENT_HEIGHT,
|
||||||
|
},
|
||||||
|
thumbnailPlaceholder: {
|
||||||
|
width: ATTACHMENT_CONTENT_HEIGHT,
|
||||||
|
height: ATTACHMENT_CONTENT_HEIGHT,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: -8,
|
top: -8,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import MaskedView from "@react-native-masked-view/masked-view";
|
import MaskedView from "@react-native-masked-view/masked-view";
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
|
CircleDot,
|
||||||
Info,
|
Info,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -41,13 +42,15 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Copy,
|
Copy,
|
||||||
|
GitPullRequest,
|
||||||
|
MessageSquareCode,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
Scissors,
|
Scissors,
|
||||||
MicVocal,
|
MicVocal,
|
||||||
FileSymlink,
|
FileSymlink,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -101,6 +104,13 @@ import {
|
|||||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
|
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||||
|
import {
|
||||||
|
AttachmentFrame,
|
||||||
|
AttachmentLabel,
|
||||||
|
AttachmentThumbnail,
|
||||||
|
} from "@/components/attachment-pill";
|
||||||
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||||
@@ -160,6 +170,10 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
|
|||||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||||||
|
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||||
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||||
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||||
|
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
@@ -363,34 +377,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
imagePreviewSpacing: {
|
imagePreviewSpacing: {
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
imagePill: {
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
imageThumbnail: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
},
|
|
||||||
imageThumbnailPlaceholder: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
structuredAttachmentPill: {
|
|
||||||
maxWidth: 220,
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
},
|
|
||||||
structuredAttachmentText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
copyButton: {
|
copyButton: {
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
padding: theme.spacing[1],
|
padding: theme.spacing[1],
|
||||||
@@ -417,36 +403,82 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
const attachmentReviewIcon = (
|
||||||
const uri = useAttachmentPreviewUrl(image);
|
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||||
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
);
|
||||||
if (!uri) {
|
const attachmentGithubPrIcon = (
|
||||||
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
|
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||||
}
|
);
|
||||||
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
|
const attachmentGithubIssueIcon = (
|
||||||
|
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
const attachmentFileIcon = (
|
||||||
|
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||||
|
);
|
||||||
|
|
||||||
|
interface UserMessageImagePillProps {
|
||||||
|
image: UserMessageImageAttachment;
|
||||||
|
onOpen: (image: UserMessageImageAttachment) => void;
|
||||||
|
accessibilityLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserMessageAttachmentLabel(
|
function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessageImagePillProps) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onOpen(image);
|
||||||
|
}, [onOpen, image]);
|
||||||
|
return (
|
||||||
|
<AttachmentFrame onPress={handlePress} accessibilityLabel={accessibilityLabel}>
|
||||||
|
<AttachmentThumbnail metadata={image} />
|
||||||
|
</AttachmentFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMessageAttachmentContent {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserMessageAttachmentContent(
|
||||||
attachment: AgentAttachment,
|
attachment: AgentAttachment,
|
||||||
t: ReturnType<typeof useTranslation>["t"],
|
t: ReturnType<typeof useTranslation>["t"],
|
||||||
): string {
|
): UserMessageAttachmentContent {
|
||||||
switch (attachment.type) {
|
switch (attachment.type) {
|
||||||
case "review": {
|
case "review": {
|
||||||
const count = attachment.comments.length;
|
const count = attachment.comments.length;
|
||||||
return count === 1
|
return {
|
||||||
? t("message.attachments.reviewOne")
|
icon: attachmentReviewIcon,
|
||||||
: t("message.attachments.reviewMany", { count });
|
title: t("message.attachments.review"),
|
||||||
|
subtitle:
|
||||||
|
count === 1
|
||||||
|
? t("message.attachments.commentsOne")
|
||||||
|
: t("message.attachments.commentsMany", { count }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case "github_pr":
|
case "github_pr":
|
||||||
return `PR #${attachment.number}`;
|
return {
|
||||||
|
icon: attachmentGithubPrIcon,
|
||||||
|
title: attachment.title,
|
||||||
|
subtitle: `PR #${attachment.number}`,
|
||||||
|
};
|
||||||
case "github_issue":
|
case "github_issue":
|
||||||
return `Issue #${attachment.number}`;
|
return {
|
||||||
|
icon: attachmentGithubIssueIcon,
|
||||||
|
title: attachment.title,
|
||||||
|
subtitle: `Issue #${attachment.number}`,
|
||||||
|
};
|
||||||
case "text":
|
case "text":
|
||||||
return attachment.title ?? t("message.attachments.textAttachment");
|
return {
|
||||||
|
icon: attachmentFileIcon,
|
||||||
|
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||||
|
subtitle: t("message.attachments.text"),
|
||||||
|
};
|
||||||
case "uploaded_file":
|
case "uploaded_file":
|
||||||
return attachment.fileName;
|
return {
|
||||||
default:
|
icon: attachmentFileIcon,
|
||||||
return "";
|
title: attachment.fileName,
|
||||||
|
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +499,8 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
|
||||||
|
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
|
||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const hasText = message.trim().length > 0;
|
const hasText = message.trim().length > 0;
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
@@ -534,24 +568,31 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
{hasImages ? (
|
{hasImages ? (
|
||||||
<View style={imagePreviewContainerStyle}>
|
<View style={imagePreviewContainerStyle}>
|
||||||
{images.map((image) => (
|
{images.map((image) => (
|
||||||
<View key={image.id} style={userMessageStylesheet.imagePill}>
|
<UserMessageImagePill
|
||||||
<UserMessageAttachmentThumbnail image={image} />
|
key={image.id}
|
||||||
</View>
|
image={image}
|
||||||
|
onOpen={setLightboxMetadata}
|
||||||
|
accessibilityLabel={t("composer.attachments.openImage")}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{hasAttachments ? (
|
{hasAttachments ? (
|
||||||
<View style={attachmentPreviewContainerStyle}>
|
<View style={attachmentPreviewContainerStyle}>
|
||||||
{attachments.map((attachment, index) => (
|
{attachments.map((attachment, index) => {
|
||||||
<View
|
const content = getUserMessageAttachmentContent(attachment, t);
|
||||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
return (
|
||||||
style={userMessageStylesheet.structuredAttachmentPill}
|
<AttachmentFrame
|
||||||
>
|
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||||
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
|
>
|
||||||
{getUserMessageAttachmentLabel(attachment, t)}
|
<AttachmentLabel
|
||||||
</Text>
|
icon={content.icon}
|
||||||
</View>
|
title={content.title}
|
||||||
))}
|
subtitle={content.subtitle}
|
||||||
|
/>
|
||||||
|
</AttachmentFrame>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{hasText ? (
|
{hasText ? (
|
||||||
@@ -579,6 +620,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||||
import type {
|
import type {
|
||||||
ComposerAttachment,
|
ComposerAttachment,
|
||||||
UserComposerAttachment,
|
UserComposerAttachment,
|
||||||
WorkspaceComposerAttachment,
|
WorkspaceComposerAttachment,
|
||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||||
import {
|
import {
|
||||||
isWorkspaceAttachment,
|
isWorkspaceAttachment,
|
||||||
@@ -112,16 +111,31 @@ function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string
|
|||||||
return "Review";
|
return "Review";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPillLabel(attachment: WorkspaceComposerAttachment, t: TranslationFn): string {
|
interface PillContent {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent {
|
||||||
if (attachment.kind === "browser_element") {
|
if (attachment.kind === "browser_element") {
|
||||||
return t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
|
return {
|
||||||
|
title: attachment.attachment.tag,
|
||||||
|
subtitle: t("composer.attachments.element"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
if (isPullRequestContextAttachment(attachment)) {
|
||||||
return `${getContextSourceLabel(attachment)} · ${attachment.title}`;
|
return {
|
||||||
|
title: attachment.title,
|
||||||
|
subtitle: getContextSourceLabel(attachment),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return attachment.commentCount === 1
|
return {
|
||||||
? t("message.attachments.reviewOne")
|
title: t("message.attachments.review"),
|
||||||
: t("message.attachments.reviewMany", { count: attachment.commentCount });
|
subtitle:
|
||||||
|
attachment.commentCount === 1
|
||||||
|
? t("message.attachments.commentsOne")
|
||||||
|
: t("message.attachments.commentsMany", { count: attachment.commentCount }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenAccessibilityLabel(
|
function getOpenAccessibilityLabel(
|
||||||
@@ -309,7 +323,7 @@ function WorkspaceAttachmentPill({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: WorkspaceAttachmentPillProps) {
|
}: WorkspaceAttachmentPillProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const label = getPillLabel(attachment, t);
|
const content = getPillContent(attachment, t);
|
||||||
const handleOpen = useCallback(() => {
|
const handleOpen = useCallback(() => {
|
||||||
onOpen(attachment);
|
onOpen(attachment);
|
||||||
}, [onOpen, attachment]);
|
}, [onOpen, attachment]);
|
||||||
@@ -325,12 +339,11 @@ function WorkspaceAttachmentPill({
|
|||||||
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<View style={styles.pillBody}>
|
<AttachmentLabel
|
||||||
<View style={styles.pillIcon}>{renderPillIcon(attachment)}</View>
|
icon={renderPillIcon(attachment)}
|
||||||
<Text style={styles.pillText} numberOfLines={1}>
|
title={content.title}
|
||||||
{label}
|
subtitle={content.subtitle}
|
||||||
</Text>
|
/>
|
||||||
</View>
|
|
||||||
</AttachmentPill>
|
</AttachmentPill>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -343,30 +356,6 @@ export const composerWorkspaceAttachment = {
|
|||||||
useBinding: useWorkspaceAttachmentBinding,
|
useBinding: useWorkspaceAttachmentBinding,
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme: Theme) => ({
|
|
||||||
pillBody: {
|
|
||||||
minHeight: 48,
|
|
||||||
maxWidth: 260,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
pillIcon: {
|
|
||||||
width: 18,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
pillText: {
|
|
||||||
minWidth: 0,
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
})) as unknown as Record<string, object>;
|
|
||||||
|
|
||||||
const ThemedMousePointer2 = withUnistyles(MousePointer2);
|
const ThemedMousePointer2 = withUnistyles(MousePointer2);
|
||||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||||
const ThemedFileText = withUnistyles(FileText);
|
const ThemedFileText = withUnistyles(FileText);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
@@ -100,9 +99,9 @@ import type {
|
|||||||
WorkspaceComposerAttachment,
|
WorkspaceComposerAttachment,
|
||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill";
|
||||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||||
@@ -560,15 +559,6 @@ function QueuedMessageRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
|
|
||||||
const uri = useAttachmentPreviewUrl(image);
|
|
||||||
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
|
||||||
if (!uri) {
|
|
||||||
return <View style={styles.imageThumbnailPlaceholder} />;
|
|
||||||
}
|
|
||||||
return <Image source={source} style={styles.imageThumbnail} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImageAttachmentPillProps {
|
interface ImageAttachmentPillProps {
|
||||||
attachment: Extract<ComposerAttachment, { kind: "image" }>;
|
attachment: Extract<ComposerAttachment, { kind: "image" }>;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -603,7 +593,7 @@ function ImageAttachmentPill({
|
|||||||
removeAccessibilityLabel={removeLabel}
|
removeAccessibilityLabel={removeLabel}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<ImageAttachmentThumbnail image={attachment.metadata} />
|
<AttachmentThumbnail metadata={attachment.metadata} />
|
||||||
</AttachmentPill>
|
</AttachmentPill>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -644,18 +634,11 @@ function GithubAttachmentPill({
|
|||||||
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
|
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<View style={styles.githubPillBody}>
|
<AttachmentLabel
|
||||||
<View style={styles.githubPillIcon}>
|
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
|
||||||
{item.kind === "pr" ? (
|
title={item.title}
|
||||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
|
||||||
) : (
|
/>
|
||||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.githubPillText} numberOfLines={1}>
|
|
||||||
#{item.number} {item.title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</AttachmentPill>
|
</AttachmentPill>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -675,26 +658,25 @@ function FileAttachmentPill({
|
|||||||
onRemove,
|
onRemove,
|
||||||
removeLabel,
|
removeLabel,
|
||||||
}: FileAttachmentPillProps) {
|
}: FileAttachmentPillProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
onRemove(index);
|
onRemove(index);
|
||||||
}, [onRemove, index]);
|
}, [onRemove, index]);
|
||||||
|
const fileName = attachment.attachment.fileName;
|
||||||
return (
|
return (
|
||||||
<AttachmentPill
|
<AttachmentPill
|
||||||
testID="composer-file-attachment-pill"
|
testID="composer-file-attachment-pill"
|
||||||
onOpen={noopCallback}
|
onOpen={noopCallback}
|
||||||
onRemove={handleRemove}
|
onRemove={handleRemove}
|
||||||
openAccessibilityLabel={attachment.attachment.fileName}
|
openAccessibilityLabel={fileName}
|
||||||
removeAccessibilityLabel={removeLabel}
|
removeAccessibilityLabel={removeLabel}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<View style={styles.filePillBody}>
|
<AttachmentLabel
|
||||||
<View style={styles.filePillIcon}>
|
icon={filePillIcon}
|
||||||
<ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
title={fileName}
|
||||||
</View>
|
subtitle={getFileTypeLabel(fileName) ?? t("message.attachments.file")}
|
||||||
<Text style={styles.filePillText} numberOfLines={1}>
|
/>
|
||||||
{attachment.attachment.fileName}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</AttachmentPill>
|
</AttachmentPill>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1705,18 +1687,14 @@ export function Composer({
|
|||||||
setIsGithubPickerOpen(true);
|
setIsGithubPickerOpen(true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(!isNative
|
{
|
||||||
? [
|
id: "file",
|
||||||
{
|
label: t("composer.attachments.addFile"),
|
||||||
id: "file",
|
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
||||||
label: t("composer.attachments.addFile"),
|
onSelect: () => {
|
||||||
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
|
void handlePickFile();
|
||||||
onSelect: () => {
|
},
|
||||||
void handlePickFile();
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
],
|
||||||
[handlePickImage, handlePickFile, t],
|
[handlePickImage, handlePickFile, t],
|
||||||
);
|
);
|
||||||
@@ -2059,50 +2037,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
},
|
},
|
||||||
imageThumbnail: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
},
|
|
||||||
imageThumbnailPlaceholder: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
githubPillBody: {
|
|
||||||
minHeight: 32,
|
|
||||||
maxWidth: 260,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
githubPillIcon: {
|
|
||||||
width: 18,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
filePillBody: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
filePillIcon: {
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
filePillText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
githubPillText: {
|
|
||||||
minWidth: 0,
|
|
||||||
flexShrink: 1,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -2173,3 +2107,11 @@ const ThemedGithub = withUnistyles(Github);
|
|||||||
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||||
|
|
||||||
|
const githubPrPillIcon = (
|
||||||
|
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||||
|
);
|
||||||
|
const githubIssuePillIcon = (
|
||||||
|
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||||
|
);
|
||||||
|
const filePillIcon = <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
|
import * as DocumentPicker from "expo-document-picker";
|
||||||
|
import { File } from "expo-file-system";
|
||||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||||
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
|
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
|
||||||
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
|
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
|
||||||
@@ -113,6 +115,25 @@ function pickFilesWithWebInput(): Promise<PickedFile[] | null> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pickFilesWithDocumentPicker(): Promise<PickedFile[] | null> {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
multiple: true,
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || result.assets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
result.assets.map(async (asset) => ({
|
||||||
|
fileName: asset.name,
|
||||||
|
mimeType: asset.mimeType ?? getMimeTypeFromPath(asset.name),
|
||||||
|
bytes: await new File(asset.uri).bytes(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useFilePicker() {
|
export function useFilePicker() {
|
||||||
const isPickingRef = useRef(false);
|
const isPickingRef = useRef(false);
|
||||||
|
|
||||||
@@ -131,8 +152,7 @@ export function useFilePicker() {
|
|||||||
return await pickFilesWithWebInput();
|
return await pickFilesWithWebInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native not supported yet
|
return await pickFilesWithDocumentPicker();
|
||||||
return null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[FilePicker] Failed to pick files:", error);
|
console.error("[FilePicker] Failed to pick files:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -575,10 +575,11 @@ describe("translation resources", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes remaining utility chrome keys for the Batch 4T migration", () => {
|
it("includes remaining utility chrome keys for the Batch 4T migration", () => {
|
||||||
expect(en.message.attachments.reviewOne).toBe("Review · 1 comment");
|
expect(en.message.attachments.review).toBe("Review");
|
||||||
expect(en.message.attachments.reviewMany).toBe("Review · {{count}} comments");
|
expect(en.message.attachments.commentsOne).toBe("1 comment");
|
||||||
|
expect(en.message.attachments.commentsMany).toBe("{{count}} comments");
|
||||||
expect(en.message.attachments.textAttachment).toBe("Text attachment");
|
expect(en.message.attachments.textAttachment).toBe("Text attachment");
|
||||||
expect(en.composer.attachments.browserElement).toBe("Element · {{tag}}");
|
expect(en.composer.attachments.element).toBe("Element");
|
||||||
expect(en.workspace.hoverCard.scriptsAccessibility).toBe("Workspace scripts");
|
expect(en.workspace.hoverCard.scriptsAccessibility).toBe("Workspace scripts");
|
||||||
expect(en.branchSwitcher.restoreStashTitle).toBe("Restore stashed changes?");
|
expect(en.branchSwitcher.restoreStashTitle).toBe("Restore stashed changes?");
|
||||||
expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored");
|
expect(en.branchSwitcher.stashRestored).toBe("Stashed changes restored");
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const ar: TranslationResources = {
|
|||||||
removeFile: "Remove file attachment",
|
removeFile: "Remove file attachment",
|
||||||
openGithub: "افتح{{kind}}#{{number}}",
|
openGithub: "افتح{{kind}}#{{number}}",
|
||||||
removeGithub: "إزالة{{kind}}#{{number}}",
|
removeGithub: "إزالة{{kind}}#{{number}}",
|
||||||
browserElement: "العنصر ·{{tag}}",
|
element: "عنصر",
|
||||||
openBrowserElement: "افتح مرفق عنصر المتصفح",
|
openBrowserElement: "افتح مرفق عنصر المتصفح",
|
||||||
removeBrowserElement: "إزالة مرفق عنصر المتصفح",
|
removeBrowserElement: "إزالة مرفق عنصر المتصفح",
|
||||||
openReview: "فتح مرفق المراجعة",
|
openReview: "فتح مرفق المراجعة",
|
||||||
@@ -255,9 +255,12 @@ export const ar: TranslationResources = {
|
|||||||
imageUnavailable: "الصورة غير متاحة",
|
imageUnavailable: "الصورة غير متاحة",
|
||||||
imagePreviewUnavailable: "معاينة الصورة غير متاحة.",
|
imagePreviewUnavailable: "معاينة الصورة غير متاحة.",
|
||||||
imagePreviewLoadFailed: "غير قادر على تحميل معاينة الصورة.",
|
imagePreviewLoadFailed: "غير قادر على تحميل معاينة الصورة.",
|
||||||
reviewOne: "مراجعة · تعليق واحد",
|
review: "مراجعة",
|
||||||
reviewMany: "مراجعة · تعليقات{{count}}",
|
commentsOne: "تعليق واحد",
|
||||||
|
commentsMany: "{{count}} تعليقات",
|
||||||
textAttachment: "مرفق النص",
|
textAttachment: "مرفق النص",
|
||||||
|
text: "نص",
|
||||||
|
file: "ملف",
|
||||||
},
|
},
|
||||||
speak: {
|
speak: {
|
||||||
header: "تكلم",
|
header: "تكلم",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const en = {
|
|||||||
removeFile: "Remove file attachment",
|
removeFile: "Remove file attachment",
|
||||||
openGithub: "Open {{kind}} #{{number}}",
|
openGithub: "Open {{kind}} #{{number}}",
|
||||||
removeGithub: "Remove {{kind}} #{{number}}",
|
removeGithub: "Remove {{kind}} #{{number}}",
|
||||||
browserElement: "Element · {{tag}}",
|
element: "Element",
|
||||||
openBrowserElement: "Open browser element attachment",
|
openBrowserElement: "Open browser element attachment",
|
||||||
removeBrowserElement: "Remove browser element attachment",
|
removeBrowserElement: "Remove browser element attachment",
|
||||||
openReview: "Open review attachment",
|
openReview: "Open review attachment",
|
||||||
@@ -254,9 +254,12 @@ export const en = {
|
|||||||
imageUnavailable: "Image unavailable",
|
imageUnavailable: "Image unavailable",
|
||||||
imagePreviewUnavailable: "Image preview unavailable.",
|
imagePreviewUnavailable: "Image preview unavailable.",
|
||||||
imagePreviewLoadFailed: "Unable to load image preview.",
|
imagePreviewLoadFailed: "Unable to load image preview.",
|
||||||
reviewOne: "Review · 1 comment",
|
review: "Review",
|
||||||
reviewMany: "Review · {{count}} comments",
|
commentsOne: "1 comment",
|
||||||
|
commentsMany: "{{count}} comments",
|
||||||
textAttachment: "Text attachment",
|
textAttachment: "Text attachment",
|
||||||
|
text: "Text",
|
||||||
|
file: "File",
|
||||||
},
|
},
|
||||||
speak: {
|
speak: {
|
||||||
header: "Spoke",
|
header: "Spoke",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const es: TranslationResources = {
|
|||||||
removeFile: "Remove file attachment",
|
removeFile: "Remove file attachment",
|
||||||
openGithub: "Abrir{{kind}}#{{number}}",
|
openGithub: "Abrir{{kind}}#{{number}}",
|
||||||
removeGithub: "Quitar{{kind}}#{{number}}",
|
removeGithub: "Quitar{{kind}}#{{number}}",
|
||||||
browserElement: "Elemento ·{{tag}}",
|
element: "Elemento",
|
||||||
openBrowserElement: "Abrir archivo adjunto de elemento del navegador",
|
openBrowserElement: "Abrir archivo adjunto de elemento del navegador",
|
||||||
removeBrowserElement: "Eliminar el archivo adjunto del elemento del navegador",
|
removeBrowserElement: "Eliminar el archivo adjunto del elemento del navegador",
|
||||||
openReview: "Abrir archivo adjunto de reseña",
|
openReview: "Abrir archivo adjunto de reseña",
|
||||||
@@ -258,9 +258,12 @@ export const es: TranslationResources = {
|
|||||||
imageUnavailable: "Imagen no disponible",
|
imageUnavailable: "Imagen no disponible",
|
||||||
imagePreviewUnavailable: "Vista previa de la imagen no disponible.",
|
imagePreviewUnavailable: "Vista previa de la imagen no disponible.",
|
||||||
imagePreviewLoadFailed: "No se puede cargar la vista previa de la imagen.",
|
imagePreviewLoadFailed: "No se puede cargar la vista previa de la imagen.",
|
||||||
reviewOne: "Reseña · 1 comentario",
|
review: "Revisión",
|
||||||
reviewMany: "Revisión · Comentarios{{count}}",
|
commentsOne: "1 comentario",
|
||||||
|
commentsMany: "{{count}} comentarios",
|
||||||
textAttachment: "Adjunto de texto",
|
textAttachment: "Adjunto de texto",
|
||||||
|
text: "Texto",
|
||||||
|
file: "Archivo",
|
||||||
},
|
},
|
||||||
speak: {
|
speak: {
|
||||||
header: "Habló",
|
header: "Habló",
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const fr: TranslationResources = {
|
|||||||
removeFile: "Remove file attachment",
|
removeFile: "Remove file attachment",
|
||||||
openGithub: "Ouvrir{{kind}}#{{number}}",
|
openGithub: "Ouvrir{{kind}}#{{number}}",
|
||||||
removeGithub: "Supprimer{{kind}}#{{number}}",
|
removeGithub: "Supprimer{{kind}}#{{number}}",
|
||||||
browserElement: "Élément ·{{tag}}",
|
element: "Élément",
|
||||||
openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur",
|
openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur",
|
||||||
removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur",
|
removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur",
|
||||||
openReview: "Ouvrir la pièce jointe de l'avis",
|
openReview: "Ouvrir la pièce jointe de l'avis",
|
||||||
@@ -258,9 +258,12 @@ export const fr: TranslationResources = {
|
|||||||
imageUnavailable: "Image indisponible",
|
imageUnavailable: "Image indisponible",
|
||||||
imagePreviewUnavailable: "Aperçu de l'image indisponible.",
|
imagePreviewUnavailable: "Aperçu de l'image indisponible.",
|
||||||
imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.",
|
imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.",
|
||||||
reviewOne: "Avis · 1 commentaire",
|
review: "Revue",
|
||||||
reviewMany: "Révision · Commentaires{{count}}",
|
commentsOne: "1 commentaire",
|
||||||
|
commentsMany: "{{count}} commentaires",
|
||||||
textAttachment: "Texte en pièce jointe",
|
textAttachment: "Texte en pièce jointe",
|
||||||
|
text: "Texte",
|
||||||
|
file: "Fichier",
|
||||||
},
|
},
|
||||||
speak: {
|
speak: {
|
||||||
header: "Rayon",
|
header: "Rayon",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const ru: TranslationResources = {
|
|||||||
removeFile: "Remove file attachment",
|
removeFile: "Remove file attachment",
|
||||||
openGithub: "Открыть{{kind}}#{{number}}",
|
openGithub: "Открыть{{kind}}#{{number}}",
|
||||||
removeGithub: "Удалить{{kind}}#{{number}}",
|
removeGithub: "Удалить{{kind}}#{{number}}",
|
||||||
browserElement: "Элемент ·{{tag}}",
|
element: "Элемент",
|
||||||
openBrowserElement: "Открыть вложение элемента браузера",
|
openBrowserElement: "Открыть вложение элемента браузера",
|
||||||
removeBrowserElement: "Удалить вложение элемента браузера",
|
removeBrowserElement: "Удалить вложение элемента браузера",
|
||||||
openReview: "Открыть прикрепленный файл с отзывом",
|
openReview: "Открыть прикрепленный файл с отзывом",
|
||||||
@@ -257,9 +257,12 @@ export const ru: TranslationResources = {
|
|||||||
imageUnavailable: "Изображение недоступно",
|
imageUnavailable: "Изображение недоступно",
|
||||||
imagePreviewUnavailable: "Предварительный просмотр изображения недоступен.",
|
imagePreviewUnavailable: "Предварительный просмотр изображения недоступен.",
|
||||||
imagePreviewLoadFailed: "Невозможно загрузить предварительный просмотр изображения.",
|
imagePreviewLoadFailed: "Невозможно загрузить предварительный просмотр изображения.",
|
||||||
reviewOne: "Отзыв · 1 комментарий",
|
review: "Ревью",
|
||||||
reviewMany: "Обзор · Комментарии{{count}}",
|
commentsOne: "1 комментарий",
|
||||||
|
commentsMany: "Комментарии: {{count}}",
|
||||||
textAttachment: "Текстовое вложение",
|
textAttachment: "Текстовое вложение",
|
||||||
|
text: "Текст",
|
||||||
|
file: "Файл",
|
||||||
},
|
},
|
||||||
speak: {
|
speak: {
|
||||||
header: "Говорил",
|
header: "Говорил",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const zhCN: TranslationResources = {
|
|||||||
removeFile: "Remove file attachment",
|
removeFile: "Remove file attachment",
|
||||||
openGithub: "打开 {{kind}} #{{number}}",
|
openGithub: "打开 {{kind}} #{{number}}",
|
||||||
removeGithub: "移除 {{kind}} #{{number}}",
|
removeGithub: "移除 {{kind}} #{{number}}",
|
||||||
browserElement: "元素 · {{tag}}",
|
element: "元素",
|
||||||
openBrowserElement: "打开浏览器元素附件",
|
openBrowserElement: "打开浏览器元素附件",
|
||||||
removeBrowserElement: "移除浏览器元素附件",
|
removeBrowserElement: "移除浏览器元素附件",
|
||||||
openReview: "打开 review 附件",
|
openReview: "打开 review 附件",
|
||||||
@@ -255,9 +255,12 @@ export const zhCN: TranslationResources = {
|
|||||||
imageUnavailable: "图片不可用",
|
imageUnavailable: "图片不可用",
|
||||||
imagePreviewUnavailable: "图片预览不可用。",
|
imagePreviewUnavailable: "图片预览不可用。",
|
||||||
imagePreviewLoadFailed: "无法加载图片预览。",
|
imagePreviewLoadFailed: "无法加载图片预览。",
|
||||||
reviewOne: "Review · 1 条评论",
|
review: "审查",
|
||||||
reviewMany: "Review · {{count}} 条评论",
|
commentsOne: "1 条评论",
|
||||||
|
commentsMany: "{{count}} 条评论",
|
||||||
textAttachment: "文本附件",
|
textAttachment: "文本附件",
|
||||||
|
text: "文本",
|
||||||
|
file: "文件",
|
||||||
},
|
},
|
||||||
speak: {
|
speak: {
|
||||||
header: "已朗读",
|
header: "已朗读",
|
||||||
|
|||||||
Reference in New Issue
Block a user