Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"start:dev": "APP_VARIANT=development expo start",
"start:preview": "APP_VARIANT=preview expo start",
"start:prod": "APP_VARIANT=production expo start",
"showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear",
"showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear",
"screenshots": "node ../../scripts/mobile-showcase.ts",
"android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
"android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android",
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati
import { RegistryContext } from "@effect/atom-react";
import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { IdentityClaimGate } from "./features/identity/IdentityClaimGate";
import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
import {
Expand Down Expand Up @@ -88,6 +89,7 @@ export default function App() {
/>
</IncomingShareProvider>
<ConfirmDialogHost />
<IdentityClaimGate />
</BlurTargetView>
{/* Anchored-menu overlays render here — in-window, so the
keyboard stays up while a dropdown is open. */}
Expand Down
3 changes: 1 addition & 2 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re
import { useResolveClassNames } from "uniwind";

import { AppText as Text } from "./components/AppText";
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
Expand Down Expand Up @@ -394,7 +393,7 @@ export const RootStack = createNativeStackNavigator({
...GLASS_HEADER_OPTIONS,
contentStyle: { backgroundColor: "transparent" },
headerBackVisible: false,
...getCompactBrandHeaderOptions(),
title: "Threads",
},
}),
Board: createNativeStackScreen({
Expand Down
57 changes: 12 additions & 45 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import { SymbolView } from "./AppSymbol";
import { Image } from "expo-image";
import { useLayoutEffect, useMemo, useState } from "react";
import { useState } from "react";
import { View } from "react-native";
import type { EnvironmentId } from "@t3tools/contracts";
import {
getProjectFaviconCacheKey,
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
import { useThemeColor } from "../lib/useThemeColor";
import { useAssetUrl } from "../state/assets";
import {
beginProjectFaviconRequest,
createProjectFaviconRequest,
hasLoadedProjectFavicon,
markProjectFaviconFailed,
markProjectFaviconLoaded,
} from "./projectFaviconCache";

/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
const loadedFaviconUrls = new Set<string>();

/* ─── Component ──────────────────────────────────────────────────────── */
export function ProjectFavicon(props: {
Expand All @@ -33,15 +26,10 @@ export function ProjectFavicon(props: {
: { _tag: "project-favicon", cwd: props.workspaceRoot },
);
const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
const cacheKey =
renderableFaviconUrl && props.workspaceRoot
? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
: null;

return (
<ProjectFaviconImage
key={cacheKey}
cacheKey={cacheKey}
key={faviconUrl}
faviconUrl={renderableFaviconUrl}
open={props.open}
projectTitle={props.projectTitle}
Expand All @@ -51,32 +39,18 @@ export function ProjectFavicon(props: {
}

function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
readonly faviconUrl: string | null;
readonly open?: boolean;
readonly projectTitle: string;
readonly size: number;
}) {
const iconMuted = useThemeColor("--color-icon-subtle");
const faviconRequest = useMemo(
() => createProjectFaviconRequest(props.cacheKey, props.faviconUrl),
[props.cacheKey, props.faviconUrl],
);
const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null);
useLayoutEffect(() => {
if (faviconRequest === null) return;

const endRequest = beginProjectFaviconRequest(faviconRequest);
setActiveFaviconRequest(faviconRequest);
return endRequest;
}, [faviconRequest]);

const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading",
props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading",
);

const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
const showImage = requestIsActive && status === "loaded";
const showImage = props.faviconUrl !== null && status === "loaded";

return (
<View
Expand All @@ -98,15 +72,11 @@ function ProjectFaviconImage(props: {
) : null}

{/* Favicon image (hidden until loaded) */}
{requestIsActive ? (
{props.faviconUrl ? (
<Image
key={faviconRequest.faviconUrl}
source={{
uri: faviconRequest.faviconUrl,
cacheKey: faviconRequest.cacheKey,
uri: props.faviconUrl,
}}
cachePolicy="memory-disk"
recyclingKey={faviconRequest.cacheKey}
accessibilityLabel={`${props.projectTitle} favicon`}
style={{
width: props.size,
Expand All @@ -116,13 +86,10 @@ function ProjectFaviconImage(props: {
}}
contentFit="contain"
onLoad={() => {
if (!markProjectFaviconLoaded(faviconRequest)) return;
if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl);
setStatus("loaded");
}}
onError={() => {
if (!markProjectFaviconFailed(faviconRequest)) return;
setStatus("error");
}}
onError={() => setStatus("error")}
/>
) : null}
</View>
Expand Down
14 changes: 3 additions & 11 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { relativeTime } from "../../lib/time";
import { useThemeColor } from "../../lib/useThemeColor";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import {
createNativeMailSearchToolbarItem,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";

export interface ArchivedThreadsHeaderEnvironment {
Expand Down Expand Up @@ -73,8 +70,7 @@ function ArchivedThreadsHeader(props: {
const searchIconColor = useThemeColor("--color-icon");
const searchTextColor = useThemeColor("--color-foreground");
const usesNativeChrome = Platform.OS === "ios";
const usesCompactMailToolbar =
Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const usesCompactMailToolbar = Platform.OS === "ios" && width < 700;
const androidFilterActions = useMemo<MenuAction[]>(
() => [
{
Expand Down Expand Up @@ -276,11 +272,7 @@ function ArchivedThreadsHeader(props: {
...(usesNativeChrome
? {
allowToolbarIntegration: true,
// "integratedButton" is an iOS 26 search-bar placement;
// pre-glass iOS keeps the default pull-down placement.
...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
? { placement: "integratedButton" as const }
: null),
placement: "integratedButton" as const,
}
: {
placement: "stacked" as const,
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/features/board/BoardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
import { EmptyState } from "../../components/EmptyState";
import { ProjectFavicon } from "../../components/ProjectFavicon";
import { SymbolView } from "../../components/AppSymbol";
import { ThreadIdentityLeading } from "../identity/ParticipantStack";
import { relativeTime } from "../../lib/time";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { useThemeColor } from "../../lib/useThemeColor";
Expand Down Expand Up @@ -173,9 +174,19 @@ function BoardCard(props: {
/>
) : null}
<View className="min-w-0 flex-1 gap-1">
<Text className="text-base font-t3-medium text-foreground" numberOfLines={2}>
{props.thread.title}
</Text>
<View className="flex-row items-center gap-1.5">
<ThreadIdentityLeading
environmentId={props.thread.environmentId}
originChannel={props.thread.originSource?.channel}
participants={props.thread.participantSummaries}
/>
<Text
className="min-w-0 flex-1 text-base font-t3-medium text-foreground"
numberOfLines={2}
>
{props.thread.title}
</Text>
</View>
{subtitleParts.length > 0 ? (
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{subtitleParts.join(" · ")}
Expand Down
9 changes: 1 addition & 8 deletions apps/mobile/src/features/cloud/linkEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,7 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
case "RelayEnvironmentLinkProofInvalidError":
return `Relay rejected the environment link proof (${error.reason}).`;
case "RelayEnvironmentConnectNotAuthorizedError":
// "Not authorized" covers non-auth causes too; surface the reason so a
// missing link doesn't read as a credential problem.
if (error.reason === "environment_link_not_found") {
return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
}
return error.reason
? `Relay rejected the environment connection request (${error.reason}).`
: "Relay rejected the environment connection request.";
return "Relay rejected the environment connection request.";
case "RelayEnvironmentEndpointUnavailableError":
return `Relay could not reach the environment endpoint (${error.reason}).`;
case "RelayEnvironmentEndpointTimedOutError":
Expand Down
21 changes: 0 additions & 21 deletions apps/mobile/src/features/connection/pairing.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,11 @@
import { describe, expect, it } from "vite-plus/test";

import {
buildPairingUrl,
extractPairingUrlFromQrPayload,
PairingQrPayloadEmptyError,
parsePairingUrl,
} from "./pairing";

describe("buildPairingUrl", () => {
it("uses HTTP for a schemeless IP address", () => {
expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe(
"http://192.168.1.100:3773/#token=pairing-token",
);
});

it("keeps HTTPS as the default for a schemeless hostname", () => {
expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe(
"https://remote.example.com/#token=pairing-token",
);
});

it("preserves an explicit scheme for an IP address", () => {
expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe(
"https://192.168.1.100:3773/#token=pairing-token",
);
});
});

describe("extractPairingUrlFromQrPayload", () => {
it("trims raw pairing urls from qr payloads", () => {
expect(
Expand Down
17 changes: 1 addition & 16 deletions apps/mobile/src/features/connection/pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,6 @@ import * as Schema from "effect/Schema";

const MOBILE_PAIRING_URL_PARAM = "pairingUrl";

function isIpLiteral(host: string): boolean {
try {
const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, "");
if (hostname.includes(":")) return true;

const octets = hostname.split(".");
return (
octets.length === 4 &&
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
);
} catch {
return false;
}
}

export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass<PairingQrPayloadEmptyError>()(
"PairingQrPayloadEmptyError",
{},
Expand All @@ -34,7 +19,7 @@ export function buildPairingUrl(host: string, code: string): string {
if (!c) return h;

try {
const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`);
const url = new URL(h.includes("://") ? h : `https://${h}`);
url.hash = new URLSearchParams([["token", c]]).toString();
return url.toString();
} catch {
Expand Down
8 changes: 2 additions & 6 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@ import {
useAdaptiveWorkspacePaneRole,
useRegisterWorkspaceInspector,
} from "../layout/AdaptiveWorkspaceLayout";
import {
createNativeMailSearchToolbarItem,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider";
import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
Expand Down Expand Up @@ -357,8 +354,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
);
}

const usesCompactMailToolbar =
Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView;

return (
<>
Expand Down
Loading
Loading