Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/
import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsScreen } from "./components/SettingsScreen";
import { ShareTemplatesSection } from "./ShareTemplatesSection";

const FOLLOW_UP_OPTIONS: ReadonlyArray<{
readonly behavior: FollowUpBehavior;
Expand Down Expand Up @@ -62,6 +63,7 @@ export function SettingsFollowUpRouteScreen() {
Long-press the send button to use the other option for a single message. With a hardware
keyboard, hold Command while sending.
</Text>
<ShareTemplatesSection />
</ScrollView>
);

Expand Down
91 changes: 91 additions & 0 deletions apps/mobile/src/features/settings/ShareTemplatesSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/reactivity";
import { useState } from "react";
import { TextInput, View } from "react-native";

import * as Dialog from "../../components/ui/Dialog";
import { IconButton } from "../../components/ui/IconButton";
import { ListActionRow } from "../../components/ui/ListActionRow";
import { uuidv4 } from "../../lib/uuid";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { SHARE_TEMPLATE_PLACEHOLDER, type ShareTemplate } from "../sharing/share-templates";
import { SettingsSection } from "./components/SettingsSection";

export function ShareTemplatesSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const [addedTemplateId, setAddedTemplateId] = useState<string | null>(null);
const templates = AsyncResult.isSuccess(preferencesResult)
? (preferencesResult.value.shareTemplates ?? [])
: null;

const updateTemplates = (
transform: (templates: ReadonlyArray<ShareTemplate>) => ReadonlyArray<ShareTemplate>,
) =>
savePreferences({
transform: (current) => ({ shareTemplates: transform(current.shareTemplates ?? []) }),
});
const addTemplate = () => {
const id = uuidv4();
setAddedTemplateId(id);
updateTemplates((current) => [...current, { id, prompt: "" }]);
};
const removeTemplate = (template: ShareTemplate) => {
const remove = () =>
updateTemplates((current) => current.filter(({ id }) => id !== template.id));
if (!template.prompt.trim()) {
remove();
return;
}
Dialog.confirm({
title: "Delete this template?",
confirmText: "Delete",
destructive: true,
onConfirm: remove,
});
};

return (
<SettingsSection
title="Share templates"
footer={`When you share to T3 Code from another app, choose a template to wrap what you shared. Write ${SHARE_TEMPLATE_PLACEHOLDER} where the shared text goes, or it goes at the end.`}
>
{(templates ?? []).map((template) => (
<View
key={template.id}
className="flex-row items-start gap-1 border-b border-border-subtle py-1 pl-4"
>
{/* Uncontrolled so saving each keystroke never moves the cursor. */}
<TextInput
accessibilityLabel="Template prompt"
className="min-h-12 flex-1 py-2 text-base text-foreground"
placeholderTextColorClassName="accent-foreground-muted"
placeholder={`Review this for bugs:\n\n${SHARE_TEMPLATE_PLACEHOLDER}`}
multiline
autoFocus={template.id === addedTemplateId}
defaultValue={template.prompt}
onChangeText={(prompt) =>
updateTemplates((current) =>
current.map((candidate) =>
candidate.id === template.id ? { ...candidate, prompt } : candidate,
),
)
}
/>
<IconButton
accessibilityLabel="Delete template"
icon="trash"
tintColorClassName="accent-danger-foreground"
onPress={() => removeTemplate(template)}
/>
</View>
))}
<ListActionRow
icon="plus"
label="Add template"
disabled={templates === null}
onPress={addTemplate}
/>
</SettingsSection>
);
}
9 changes: 8 additions & 1 deletion apps/mobile/src/features/settings/settings-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,14 @@ const SETTINGS_SEARCH_TERMS = {
"auto-resume",
"thread behavior",
],
SettingsFollowUp: ["follow-ups", "queue", "steer", "while the agent is running"],
SettingsFollowUp: [
"follow-ups",
"queue",
"steer",
"while the agent is running",
"share templates",
"share sheet",
],
SettingsArchive: ["archive", "archived", "restore", "unarchive"],
SettingsProviderAccounts: [
"provider accounts",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/sharing/IncomingShareProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const incomingShareInbox = new IncomingShareInbox({
? await resolvedPayloadsForFiles()
: [];
const draft = await buildIncomingShareDraft({
attachmentIdPrefix: Crypto.randomUUID(),
payloads,
resolvedPayloads,
fileReader: {
Expand Down
73 changes: 62 additions & 11 deletions apps/mobile/src/features/sharing/incoming-share-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ResolvedSharePayload, SharePayload } from "expo-sharing";

import {
buildIncomingShareDraft,
decodeIncomingShareDraft,
hasIncomingShareContent,
isShareFileUriUnderOwnedRoots,
selectIncomingShareAttachments,
Expand All @@ -20,11 +21,17 @@ describe("incoming native shares", () => {
value: "file:///shared/Screenshot.png",
mimeType: "image/png",
};
const file: SharePayload = {
shareType: "file",
value: "file:///shared/report.pdf",
mimeType: "application/pdf",
};
const payloads: SharePayload[] = [
{ shareType: "text", value: "Please explain this error" },
{ shareType: "url", value: "https://example.com/issue/1" },
{ shareType: "text", value: "Please explain this error" },
image,
file,
];
const resolvedImage: ResolvedSharePayload = {
...image,
Expand All @@ -36,19 +43,38 @@ describe("incoming native shares", () => {
};
const removeOwnedFile = vi.fn(() => Promise.resolve());

const result = await buildIncomingShareDraft({
id: "share-1",
createdAt: "2026-07-15T10:00:00.000Z",
payloads,
resolvedPayloads: [resolvedImage],
fileReader: {
readBase64: async () => "YWJj",
removeOwnedFile,
},
});
const build = (attachmentIdPrefix: string) =>
buildIncomingShareDraft({
attachmentIdPrefix,
id: "share-1",
createdAt: "2026-07-15T10:00:00.000Z",
payloads,
resolvedPayloads: [
resolvedImage,
{
...file,
contentUri: file.value,
contentType: "file",
contentMimeType: "application/pdf",
contentSize: 3,
originalName: "report.pdf",
},
],
fileReader: {
readBase64: async () => "YWJj",
removeOwnedFile,
},
});

const result = await build("share-1");
const repeated = await build("second-handoff");
expect(
new Set([...result.attachments, ...repeated.attachments].map((attachment) => attachment.id))
.size,
).toBe(4);

expect(result).toEqual({
schemaVersion: 1,
schemaVersion: 2,
id: "share-1",
createdAt: "2026-07-15T10:00:00.000Z",
text: "Please explain this error\n\nhttps://example.com/issue/1",
Expand All @@ -62,9 +88,22 @@ describe("incoming native shares", () => {
dataUrl: "data:image/png;base64,YWJj",
previewUri: "data:image/png;base64,YWJj",
},
{
id: "share-1:file:4",
type: "file",
name: "report.pdf",
mimeType: "application/pdf",
sizeBytes: 3,
fileUri: "file:///shared/report.pdf",
},
],
warnings: [],
});
expect(decodeIncomingShareDraft(result)).toEqual(result);
expect(decodeIncomingShareDraft({ ...result, schemaVersion: 1 })).toEqual({
...result,
schemaVersion: 1,
});
expect(removeOwnedFile).toHaveBeenCalledWith(image.value);
expect(hasIncomingShareContent(result)).toBe(true);
});
Expand All @@ -79,6 +118,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(() => Promise.resolve());

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-2",
id: "share-2",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [image],
Expand Down Expand Up @@ -113,6 +153,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-report",
id: "share-report",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
Expand Down Expand Up @@ -155,6 +196,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async () => undefined);

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-video",
id: "share-video",
createdAt: "2026-08-30T10:00:00.000Z",
payloads: [{ ...video, shareType: "video" }],
Expand Down Expand Up @@ -200,6 +242,7 @@ describe("incoming native shares", () => {
};

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-empty",
id: "share-empty",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
Expand All @@ -225,6 +268,7 @@ describe("incoming native shares", () => {
const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? null : 42));

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-android-report",
id: "share-android-report",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
Expand Down Expand Up @@ -263,6 +307,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-empty-copy",
id: "share-empty-copy",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [file],
Expand All @@ -285,6 +330,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async () => undefined);

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-no-copy",
id: "share-no-copy",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [{ shareType: "file", value: sourceUri, mimeType: "application/pdf" }],
Expand All @@ -307,6 +353,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-copy",
id: "share-copy",
createdAt: "2026-07-15T10:00:00.000Z",
payloads: [{ shareType: "file", value: payloadUri, mimeType: "application/pdf" }],
Expand Down Expand Up @@ -389,6 +436,7 @@ describe("incoming native shares", () => {
const readBase64 = vi.fn(async () => "YWJj");

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-3",
id: "share-3",
createdAt: "2026-07-15T10:00:00.000Z",
payloads,
Expand Down Expand Up @@ -434,6 +482,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async () => undefined);

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-duplicates",
id: "share-duplicates",
createdAt: "2026-07-16T08:00:00.000Z",
payloads: [duplicate, duplicate],
Expand Down Expand Up @@ -461,6 +510,7 @@ describe("incoming native shares", () => {
};

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-cleanup-failure",
id: "share-cleanup-failure",
createdAt: "2026-07-16T08:00:00.000Z",
payloads: [image],
Expand All @@ -487,6 +537,7 @@ describe("incoming native shares", () => {
const removeOwnedFile = vi.fn(async (_uri: string) => undefined);

const result = await buildIncomingShareDraft({
attachmentIdPrefix: "share-persist-leak",
id: "share-persist-leak",
createdAt: "2026-07-16T08:00:00.000Z",
payloads: [file],
Expand Down
11 changes: 6 additions & 5 deletions apps/mobile/src/features/sharing/incoming-share-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { DraftComposerAttachment } from "../../lib/composerImages";
import { estimateBase64ByteSize } from "../../lib/base64";

export interface IncomingShareDraft {
readonly schemaVersion: 1;
readonly schemaVersion: 1 | 2;
readonly id: string;
readonly createdAt: string;
readonly destination?: IncomingShareDestination;
Expand All @@ -37,7 +37,7 @@ const IncomingShareDestinationSchema = Schema.Struct({
});

export const IncomingShareDraftSchema = Schema.Struct({
schemaVersion: Schema.Literal(1),
schemaVersion: Schema.Literals([1, 2]),
id: Schema.String,
createdAt: Schema.String,
destination: Schema.optional(IncomingShareDestinationSchema),
Expand Down Expand Up @@ -232,6 +232,7 @@ export async function buildIncomingShareDraft(input: {
readonly resolvedPayloads: ReadonlyArray<ResolvedSharePayload>;
readonly fileReader: IncomingShareFileReader;
readonly id: string;
readonly attachmentIdPrefix: string;
readonly createdAt: string;
}): Promise<IncomingShareDraft> {
const attachments: DraftComposerAttachment[] = [];
Expand Down Expand Up @@ -332,7 +333,7 @@ export async function buildIncomingShareDraft(input: {
}
}
attachments.push({
id: `${input.id}:file:${index}`,
id: `${input.attachmentIdPrefix}:file:${index}`,
type: "file",
name,
mimeType,
Expand Down Expand Up @@ -397,7 +398,7 @@ export async function buildIncomingShareDraft(input: {
}
const dataUrl = `data:${mimeType};base64,${base64}`;
attachments.push({
id: `${input.id}:image:${index}`,
id: `${input.attachmentIdPrefix}:image:${index}`,
type: "image",
name: resolved?.originalName ?? fallbackName(uri, index, mimeType),
mimeType,
Expand All @@ -414,7 +415,7 @@ export async function buildIncomingShareDraft(input: {
}

return {
schemaVersion: 1,
schemaVersion: 2,
id: input.id,
createdAt: input.createdAt,
text: sharedText(input.payloads),
Expand Down
Loading
Loading