Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cd5041f
fix(mobile): match react version to react-native 0.85.3 vendored rend…
KrzysztofMoch Jul 27, 2026
2cdb37d
Add OTA update checks to mobile settings (#4686)
juliusmarminge Jul 27, 2026
b1c2f2b
fix(mobile): threads load snapped to bottom on iOS (#4689)
t3dotgg Jul 28, 2026
1119546
Make mobile Thread List v2 the default (#4717)
juliusmarminge Jul 28, 2026
c3443ac
Fix mobile showcase workflow without Clerk (#4718)
juliusmarminge Jul 28, 2026
58e6e23
Settle merged PR threads immediately (#4704)
t3dotgg Jul 28, 2026
9d54648
Fix Android showcase capture and rebuild v2 queued rows (#4730)
juliusmarminge Jul 28, 2026
a610653
refactor(client): share filesystem browse navigation (#4797)
juliusmarminge Jul 28, 2026
4119fc0
fix(mobile): defer filesystem navigation (#4799)
juliusmarminge Jul 28, 2026
8cafaf2
Fix Connect sign-in settings label (#4806)
juliusmarminge Jul 29, 2026
3fd825c
fix(mobile): reduce thread feed scroll jank (#4874)
gabrielelpidio Jul 30, 2026
616f2b6
fix(clients): disable add project while disconnected (#4834)
StiensWout Jul 30, 2026
01f401a
fix(mobile): stop shared content errors in Personal Team builds (#4943)
t3dotgg Jul 30, 2026
ae9c403
perf(mobile): sends respond instantly, thread opens stop freezing (#4…
t3dotgg Jul 30, 2026
e37d3a6
fix(mobile): support dragged images in the composer (#4953)
t3dotgg Jul 30, 2026
3aa7bfb
fix(mobile): stop long iOS threads from jumping while scrolling up (#…
t3dotgg Jul 30, 2026
6b7836d
perf(mobile): reconnect environments immediately on resume (#4878)
t3dotgg Jul 30, 2026
8c88d3d
Check for mobile app updates on launch (#4958)
juliusmarminge Jul 30, 2026
857a5d8
fix(mobile): support pre-Liquid-Glass iOS bottom toolbar (#4984)
gabrielelpidio Jul 30, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ private final class ComposerTextView: UITextView {
replace(textRange, withText: "")
}

private func loadImages(from providers: [NSItemProvider]) {
func loadImages(from providers: [NSItemProvider]) {
let group = DispatchGroup()
let lock = NSLock()
var images = [UIImage?](repeating: nil, count: providers.count)
Expand Down Expand Up @@ -282,7 +282,7 @@ private final class ComposerTextView: UITextView {
}
}

public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDropDelegate {
private let textView = ComposerTextView()
private let placeholderLabel = UILabel()
private var value = ""
Expand Down Expand Up @@ -326,6 +326,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {

clipsToBounds = false
textView.delegate = self
textView.textDropDelegate = self
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
Expand Down Expand Up @@ -500,6 +501,41 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate {
return true
}

public func textDroppableView(
_ textDroppableView: UIView & UITextDroppable,
proposalForDrop drop: UITextDropRequest
) -> UITextDropProposal {
guard droppedImageProviders(in: drop) != nil else {
return drop.suggestedProposal
}

// The composer owns image drops so UIKit does not insert NSTextAttachments
// that the controlled plain-text value cannot represent.
let proposal = UITextDropProposal(operation: .copy)
proposal.dropAction = .insert
proposal.dropPerformer = .delegate
return proposal
}

public func textDroppableView(
_ textDroppableView: UIView & UITextDroppable,
willPerformDrop drop: UITextDropRequest
) {
guard let imageProviders = droppedImageProviders(in: drop) else {
return
}
textView.loadImages(from: imageProviders)
}

private func droppedImageProviders(in drop: UITextDropRequest) -> [NSItemProvider]? {
let providers = drop.dropSession.items.map(\.itemProvider)
guard !providers.isEmpty,
providers.allSatisfy({ $0.canLoadObject(ofClass: UIImage.self) }) else {
return nil
Comment on lines +531 to +534

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle images in mixed drag payloads

When an iPadOS drop contains both an image item and a non-image item, allSatisfy rejects the entire drop and falls back to UIKit's suggested performer. UIKit can then insert the image as a regular NSTextAttachment; serializedText cannot preserve that attachment and onComposerPasteImages is never emitted, so the dragged image disappears from the controlled composer. Intercept the image providers even when the same drop also contains other item types, while explicitly preserving or inserting the remaining content.

Useful? React with 👍 / 👎.

}
return providers
}

public func textViewDidBeginEditing(_ textView: UITextView) {
onComposerFocus()
}
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/metro-runtime": "~56.0.15",
"@expo/ui": "~56.0.18",
"@legendapp/list": "3.2.0",
"@legendapp/list": "3.3.3",
"@noble/curves": "catalog:",
"@noble/hashes": "catalog:",
"@pierre/diffs": "catalog:",
Expand Down Expand Up @@ -101,8 +101,8 @@
"expo-web-browser": "~56.0.5",
"expo-widgets": "~56.0.19",
"punycode": "^2.3.1",
"react": "19.2.6",
"react-dom": "19.2.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-image-viewing": "^0.2.2",
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,15 @@ function workspacePathFromState(state: NavigationState): string {
return path.startsWith("/") ? path : `/${path}`;
}

// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
// each enqueue, shell change, or reconnect.
function ThreadOutboxDrainWorker() {
useThreadOutboxDrain();
return null;
}

function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
Expand All @@ -294,7 +303,6 @@ function RootStackLayout(props: {
const { pendingShare, revivedShareId, clearRevivedShareId } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
useAgentNotificationNavigation();
useThreadOutboxDrain();
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Launcher app shortcuts: routes shortcut taps and tracks opened threads.
Expand Down Expand Up @@ -326,6 +334,7 @@ function RootStackLayout(props: {

return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
{SHOWCASE_ENABLED ? <ShowcaseCaptureCoordinator pathname={pathname} /> : null}
<ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/src/connection/app-state-wakeups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "@effect/vitest";

import {
MOBILE_BACKGROUND_RECONNECT_AFTER_MS,
mobileApplicationActiveWakeup,
} from "./app-state-wakeups";

describe("mobileApplicationActiveWakeup", () => {
it("uses a fast probe after a short interruption", () => {
expect(mobileApplicationActiveWakeup(null, 20_000)).toBe("application-active-probe");
expect(
mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS - 1),
).toBe("application-active-probe");
});

it("replaces the session after a meaningful background suspension", () => {
expect(
mobileApplicationActiveWakeup(20_000, 20_000 + MOBILE_BACKGROUND_RECONNECT_AFTER_MS),
).toBe("application-active-reconnect");
});
});
18 changes: 18 additions & 0 deletions apps/mobile/src/connection/app-state-wakeups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Wakeups } from "@t3tools/client-runtime/connection";

export const MOBILE_BACKGROUND_RECONNECT_AFTER_MS = 10_000;

export type MobileApplicationActiveWakeup = Extract<
Wakeups.ConnectionWakeup,
"application-active-probe" | "application-active-reconnect"
>;

export function mobileApplicationActiveWakeup(
backgroundedAtMs: number | null,
activeAtMs: number,
): MobileApplicationActiveWakeup {
return backgroundedAtMs !== null &&
activeAtMs - backgroundedAtMs >= MOBILE_BACKGROUND_RECONNECT_AFTER_MS
? "application-active-reconnect"
: "application-active-probe";
}
49 changes: 38 additions & 11 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as MobileStorage from "../persistence/mobile-storage";
import { appAtomRegistry } from "../state/atom-registry";
import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
import { connectionStorageLayer } from "./storage";

function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
Expand All @@ -54,27 +55,53 @@ const connectivityLayer = Connectivity.layer({
),
changes: Stream.callback((queue) =>
Effect.acquireRelease(
Effect.sync(() =>
Network.addNetworkStateListener((state) => {
Effect.sync(() => {
let active = true;
const networkSubscription = Network.addNetworkStateListener((state) => {
Queue.offerUnsafe(queue, networkStatus(state));
}),
),
(subscription) => Effect.sync(() => subscription.remove()),
});
const appStateSubscription = AppState.addEventListener("change", (state) => {
if (state !== "active") {
return;
}
void Network.getNetworkStateAsync()
.then((current) => {
if (active) {
Queue.offerUnsafe(queue, networkStatus(current));
}
})
.catch(() => undefined);
});
return {
close: () => {
active = false;
networkSubscription.remove();
appStateSubscription.remove();
},
};
}),
({ close }) => Effect.sync(close),
).pipe(Effect.asVoid),
),
});

const wakeupsLayer = Wakeups.layer({
changes: Stream.merge(
Stream.callback<"application-active">((queue) =>
Stream.callback<"application-active-probe" | "application-active-reconnect">((queue) =>
Effect.acquireRelease(
Effect.sync(() =>
AppState.addEventListener("change", (state) => {
Effect.sync(() => {
let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null;
return AppState.addEventListener("change", (state) => {
if (state === "background") {
backgroundedAtMs = Date.now();
return;
}
if (state === "active") {
Queue.offerUnsafe(queue, "application-active");
Queue.offerUnsafe(queue, mobileApplicationActiveWakeup(backgroundedAtMs, Date.now()));
backgroundedAtMs = null;
}
}),
),
});
}),
(subscription) => Effect.sync(() => subscription.remove()),
).pipe(Effect.asVoid),
),
Expand Down
14 changes: 11 additions & 3 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ 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 } from "../layout/native-mail-search-toolbar";
import {
createNativeMailSearchToolbarItem,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";

export interface ArchivedThreadsHeaderEnvironment {
Expand Down Expand Up @@ -70,7 +73,8 @@ 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;
const usesCompactMailToolbar =
Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const androidFilterActions = useMemo<MenuAction[]>(
() => [
{
Expand Down Expand Up @@ -272,7 +276,11 @@ function ArchivedThreadsHeader(props: {
...(usesNativeChrome
? {
allowToolbarIntegration: true,
placement: "integratedButton" as const,
// "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: "stacked" as const,
Expand Down
35 changes: 25 additions & 10 deletions apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,7 @@ import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironment
import { ConnectionStatusDot } from "./ConnectionStatusDot";
import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";

/**
* "T3 Connect" section: every environment published to the signed-in account,
* with connect switches, availability status, refresh, and loading/error
* states. Shared between the Settings environments screen and the T3 Connect
* onboarding sheet.
*/
export function CloudEnvironmentRows(props: {
interface CloudEnvironmentRowsProps {
readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
Expand All @@ -41,8 +35,31 @@ export function CloudEnvironmentRows(props: {
* pull-to-refresh).
*/
readonly showHeader?: boolean;
}) {
}

/**
* "T3 Connect" section: every environment published to the signed-in account,
* with connect switches, availability status, refresh, and loading/error
* states. Shared between the Settings environments screen and the T3 Connect
* onboarding sheet.
*/
export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
// Showcase captures run without a Clerk publishable key, so `ClerkProvider`
// is never mounted and any `useAuth` call throws — the fixture states whether
// the rows are signed in instead of asking Clerk.
if (props.showcaseSignedIn !== undefined) {
return props.showcaseSignedIn ? <CloudEnvironmentRowsContent {...props} /> : null;
}
return <SignedInCloudEnvironmentRows {...props} />;
}

function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
if (!isSignedIn) return null;
return <CloudEnvironmentRowsContent {...props} />;
}

function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) {
const controller = useConnectionController();
const iconColor = useThemeColor("--color-icon");
const availableCloudEnvironments =
Expand All @@ -67,8 +84,6 @@ export function CloudEnvironmentRows(props: {

const showHeader = props.showHeader ?? true;

if (!(props.showcaseSignedIn ?? isSignedIn)) return null;

return (
<View collapsable={false} className={cn("gap-3", showHeader && "mt-5")}>
{showHeader ? (
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import {
useAdaptiveWorkspacePaneRole,
useRegisterWorkspaceInspector,
} from "../layout/AdaptiveWorkspaceLayout";
import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar";
import {
createNativeMailSearchToolbarItem,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} 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 @@ -354,7 +357,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
);
}

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

return (
<>
Expand Down
Loading
Loading