authorgravatar for n.anisimov.23@gmail.comNick Anisimov <n.anisimov.23@gmail.com> 2026-08-29 00:55:42+04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 02:25:42+05:30
log45c0dff8ec33a023c79ccb4e06494309253d1c25
tree1830f11aa8ddd555992732c53e4a1a5578424946
parent84b9d9bc2d8a13a8b13d59b81f1012f5cb57ebd2
signature Signed by PGP key B5690EEEBB952194

fix(mobile): show file actions on Android (#8215)

Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com>

1 files changed, 143 insertions(+), 50 deletions(-)

apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+143-50
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import { useCallback, useEffect, useRef, useState } from "react";3import type { MenuAction } from "@react-native-menu/menu";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { ActivityIndicator, Platform, View } from "react-native";5import { ActivityIndicator, Platform, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
6import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";7import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
...@@ -11,9 +12,10 @@ import {...@@ -11,9 +12,10 @@ import {
11 ThreadId,12 ThreadId,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
1314
14import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";15import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
15import { SymbolView } from "../../components/AppSymbol";16import { SymbolView } from "../../components/AppSymbol";
16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
18import { ControlPillMenu } from "../../components/ControlPill";
17import { EmptyState } from "../../components/EmptyState";19import { EmptyState } from "../../components/EmptyState";
18import { LoadingScreen } from "../../components/LoadingScreen";20import { LoadingScreen } from "../../components/LoadingScreen";
19import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";21import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
...@@ -473,6 +475,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -473,6 +475,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
473 const navigation = useNavigation();475 const navigation = useNavigation();
474 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();476 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
475 const iconColor = useUniwindTheme()["--color-icon"];477 const iconColor = useUniwindTheme()["--color-icon"];
478 const isAndroid = Platform.OS === "android";
476 const params = props.route.params;479 const params = props.route.params;
477 const relativePath = normalizeRoutePath(params.path);480 const relativePath = normalizeRoutePath(params.path);
478 const targetLine = normalizeRouteLine(firstRouteParam(params.line));481 const targetLine = normalizeRouteLine(firstRouteParam(params.line));
...@@ -554,6 +557,91 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -554,6 +557,91 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
554 );557 );
555 useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);558 useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);
556559
560 const fileMenuActions = useMemo(() => {
561 if (relativePath === null) return [];
562 const canToggleMode = canPreview && !isImageFile;
563 return [
564 canToggleMode
565 ? ({
566 id: "preview",
567 title: "Preview",
568 icon: "eye",
569 inline: true,
570 onPress: () => setModeOverride({ path: relativePath, mode: "preview" }),
571 } as const)
572 : null,
573 canToggleMode
574 ? ({
575 id: "source",
576 title: "Source",
577 icon: "doc.text",
578 inline: true,
579 onPress: () => setModeOverride({ path: relativePath, mode: "source" }),
580 } as const)
581 : null,
582 {
583 id: "copy-path",
584 title: "Copy path",
585 icon: "doc.on.doc",
586 inline: false,
587 onPress: () => copyTextWithHaptic(relativePath),
588 } as const,
589 isBrowserFile && typeof assetPreviewUri === "string"
590 ? ({
591 id: "open-browser",
592 title: Platform.OS === "ios" ? "Open in Safari" : "Open in browser",
593 icon: "safari",
594 inline: false,
595 onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"),
596 } as const)
597 : null,
598 resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)
599 ? ({
600 id: "refresh",
601 title: "Refresh",
602 icon: "arrow.clockwise",
603 inline: false,
604 onPress: () => setPreviewRevision((current) => current + 1),
605 } as const)
606 : null,
607 ].filter((action) => action !== null);
608 }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]);
609
610 const androidFileMenuActions = useMemo<MenuAction[]>(
611 () =>
612 fileMenuActions.map((action) => ({
613 id: action.id,
614 title: action.title,
615 image: action.icon,
616 state: action.id === resolvedActiveMode ? "on" : undefined,
617 })),
618 [fileMenuActions, resolvedActiveMode],
619 );
620 const handleAndroidFileMenuAction = useCallback(
621 (event: { nativeEvent: { event: string } }) => {
622 const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event);
623 void action?.onPress();
624 },
625 [fileMenuActions],
626 );
627 const handleReturnToThread = useCallback(() => {
628 if (environmentId !== null && threadId !== null) {
629 navigation.dispatch(
630 StackActions.replace("Thread", {
631 environmentId: String(environmentId),
632 threadId: String(threadId),
633 }),
634 );
635 }
636 }, [environmentId, navigation, threadId]);
637 const handleBack = useCallback(() => {
638 if (navigation.canGoBack()) {
639 navigation.goBack();
640 return;
641 }
642 handleReturnToThread();
643 }, [handleReturnToThread, navigation]);
644
557 if (selectedThread === null || environmentId === null || threadId === null) {645 if (selectedThread === null || environmentId === null || threadId === null) {
558 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;646 return <LoadingScreen message="Opening file..." messagePlacement="above-spinner" />;
559 }647 }
...@@ -582,6 +670,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -582,6 +670,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
582 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid670 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
583 // sheet-colored header — this route's content scrolls internally, so671 // sheet-colored header — this route's content scrolls internally, so
584 // there is nothing for glass to sample). Only dynamic values here.672 // there is nothing for glass to sample). Only dynamic values here.
673 headerShown: !isAndroid,
585 headerTintColor: iconColor,674 headerTintColor: iconColor,
586 headerTitle: basename(relativePath),675 headerTitle: basename(relativePath),
587 title: basename(relativePath),676 title: basename(relativePath),
...@@ -589,19 +678,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -589,19 +678,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
589 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,678 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
590 }}679 }}
591 />680 />
681 {isAndroid ? (
682 <AndroidScreenHeader
683 title={basename(relativePath)}
684 subtitle={headerSubtitle}
685 onBack={handleBack}
686 trailing={
687 <>
688 {fileInspector.supported ? (
689 <AndroidHeaderIconButton
690 accessibilityLabel={
691 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
692 }
693 icon="sidebar.right"
694 onPress={toggleAuxiliaryPane}
695 />
696 ) : null}
697 <ControlPillMenu
698 actions={androidFileMenuActions}
699 isAnchoredToRight
700 title="File actions"
701 onPressAction={handleAndroidFileMenuAction}
702 >
703 <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" />
704 </ControlPillMenu>
705 </>
706 }
707 />
708 ) : null}
592 <WorkspaceSidebarToolbar>709 <WorkspaceSidebarToolbar>
593 {fileInspector.supported ? (710 {fileInspector.supported ? (
594 <NativeHeaderToolbar.Button711 <NativeHeaderToolbar.Button
595 accessibilityLabel="Return to chat"712 accessibilityLabel="Return to chat"
596 icon="chevron.left"713 icon="chevron.left"
597 onPress={() => {714 onPress={handleReturnToThread}
598 navigation.dispatch(
599 StackActions.replace("Thread", {
600 environmentId: String(environmentId),
601 threadId: String(threadId),
602 }),
603 );
604 }}
605 />715 />
606 ) : null}716 ) : null}
607 </WorkspaceSidebarToolbar>717 </WorkspaceSidebarToolbar>
...@@ -617,50 +727,33 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -617,50 +727,33 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
617 />727 />
618 ) : null}728 ) : null}
619 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">729 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
620 {canPreview && !isImageFile ? (730 {fileMenuActions.some(({ inline }) => inline) ? (
621 <NativeHeaderToolbar.Menu inline>731 <NativeHeaderToolbar.Menu inline>
732 {fileMenuActions
733 .filter(({ inline }) => inline)
734 .map((action) => (
735 <NativeHeaderToolbar.MenuAction
736 key={action.id}
737 icon={action.icon}
738 isOn={action.id === resolvedActiveMode}
739 onPress={action.onPress}
740 >
741 {action.title}
742 </NativeHeaderToolbar.MenuAction>
743 ))}
744 </NativeHeaderToolbar.Menu>
745 ) : null}
746 {fileMenuActions
747 .filter(({ inline }) => !inline)
748 .map((action) => (
622 <NativeHeaderToolbar.MenuAction749 <NativeHeaderToolbar.MenuAction
623 icon="eye"750 key={action.id}
624 isOn={resolvedActiveMode === "preview"}751 icon={action.icon}
625 onPress={() => setModeOverride({ path: relativePath, mode: "preview" })}752 onPress={action.onPress}
626 >
627 Preview
628 </NativeHeaderToolbar.MenuAction>
629 <NativeHeaderToolbar.MenuAction
630 icon="doc.text"
631 isOn={resolvedActiveMode === "source"}
632 onPress={() => setModeOverride({ path: relativePath, mode: "source" })}
633 >753 >
634 Source754 {action.title}
635 </NativeHeaderToolbar.MenuAction>755 </NativeHeaderToolbar.MenuAction>
636 </NativeHeaderToolbar.Menu>756 ))}
637 ) : null}
638 <NativeHeaderToolbar.MenuAction
639 icon="doc.on.doc"
640 onPress={() => copyTextWithHaptic(relativePath)}
641 >
642 Copy path
643 </NativeHeaderToolbar.MenuAction>
644 {isBrowserFile && typeof assetPreviewUri === "string" ? (
645 <NativeHeaderToolbar.MenuAction
646 icon="safari"
647 onPress={() => {
648 void tryOpenExternalUrl(assetPreviewUri, "file-preview");
649 }}
650 >
651 Open in Safari
652 </NativeHeaderToolbar.MenuAction>
653 ) : null}
654 {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? (
655 <NativeHeaderToolbar.MenuAction
656 icon="arrow.clockwise"
657 onPress={() => {
658 setPreviewRevision((current) => current + 1);
659 }}
660 >
661 Refresh
662 </NativeHeaderToolbar.MenuAction>
663 ) : null}
664 </NativeHeaderToolbar.Menu>757 </NativeHeaderToolbar.Menu>
665 </NativeHeaderToolbar>758 </NativeHeaderToolbar>
666 <FileContent759 <FileContent