authorgravatar for 107891771+Michel-Liao@users.noreply.github.comMichel Liao <107891771+Michel-Liao@users.noreply.github.com> 2026-09-15 13:26:06-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 10:26:06-07:00
logd1790aa14639a0361c02fbc5c326d0aa76f95a83
tree8ceb3b89bd9d54c479c103ab468e40034e233c7c
parent2a264adc6f6c65f98d4273acaa1af567eca83f2a
signature Signed by PGP key B5690EEEBB952194

fix(mobile): add missing thread rename action (#11503)


9 files changed, 190 insertions(+), 37 deletions(-)

apps/mobile/src/components/ConfirmDialogHost.tsx+63-20
......@@ -1,5 +1,5 @@
11import { useCallback, useEffect, useState } from "react";
2import { Modal, Pressable, View } from "react-native";
2import { Modal, Pressable, TextInput, View } from "react-native";
33
44import { cn } from "../lib/cn";
55import { AppText } from "./AppText";
......@@ -14,7 +14,20 @@ export type ConfirmDialogRequest = {
1414 readonly onCancel?: () => void;
1515};
1616
17let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
17export type TextInputDialogRequest = {
18 readonly title: string;
19 readonly initialValue: string;
20 readonly cancelText?: string;
21 readonly confirmText: string;
22 readonly onConfirm: (value: string) => void;
23 readonly onCancel?: () => void;
24};
25
26type DialogRequest =
27 | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
28 | { readonly kind: "text-input"; readonly request: TextInputDialogRequest };
29
30let presentRequest: ((request: DialogRequest) => void) | null = null;
1831
1932/**
2033 * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already
......@@ -23,7 +36,11 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
2336 * once. Requires ConfirmDialogHost to be mounted at the app root.
2437 */
2538export function showConfirmDialog(request: ConfirmDialogRequest): void {
26 presentRequest?.(request);
39 presentRequest?.({ kind: "confirm", request });
40}
41
42export function showTextInputDialog(request: TextInputDialogRequest): void {
43 presentRequest?.({ kind: "text-input", request });
2744}
2845
2946/**
......@@ -33,42 +50,64 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void {
3350 * button color and a dimmer message than the title.
3451 */
3552export function ConfirmDialogHost() {
36 const [request, setRequest] = useState<ConfirmDialogRequest | null>(null);
53 const [presented, setPresented] = useState<DialogRequest | null>(null);
54 const [inputValue, setInputValue] = useState("");
3755 useEffect(() => {
38 presentRequest = setRequest;
56 presentRequest = (request) => {
57 setInputValue(request.kind === "text-input" ? request.request.initialValue : "");
58 setPresented(request);
59 };
3960 return () => {
4061 presentRequest = null;
4162 };
4263 }, []);
4364
4465 const handleCancel = useCallback(() => {
45 request?.onCancel?.();
46 setRequest(null);
47 }, [request]);
66 presented?.request.onCancel?.();
67 setPresented(null);
68 }, [presented]);
4869
4970 const handleConfirm = useCallback(() => {
50 request?.onConfirm();
51 setRequest(null);
52 }, [request]);
71 if (presented?.kind === "confirm") {
72 presented.request.onConfirm();
73 } else if (presented?.kind === "text-input") {
74 presented.request.onConfirm(inputValue);
75 }
76 setPresented(null);
77 }, [inputValue, presented]);
78
79 const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0;
5380
5481 return (
5582 <Modal
56 visible={request !== null}
83 visible={presented !== null}
5784 transparent
5885 animationType="fade"
5986 statusBarTranslucent
6087 navigationBarTranslucent
6188 onRequestClose={handleCancel}
6289 >
63 {request === null ? null : (
90 {presented === null ? null : (
6491 <View className="flex-1 items-center justify-center bg-backdrop px-8">
6592 <View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">
66 <AppText className="text-lg font-t3-medium">{request.title}</AppText>
67 {request.message === undefined ? null : (
93 <AppText className="text-lg font-t3-medium">{presented.request.title}</AppText>
94 {presented.kind === "confirm" && presented.request.message !== undefined ? (
6895 <AppText className="mt-2 text-sm text-foreground-secondary">
69 {request.message}
96 {presented.request.message}
7097 </AppText>
71 )}
98 ) : null}
99 {presented.kind === "text-input" ? (
100 <TextInput
101 accessibilityLabel={presented.request.title}
102 autoFocus
103 className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"
104 onChangeText={setInputValue}
105 onSubmitEditing={confirmDisabled ? undefined : handleConfirm}
106 returnKeyType="done"
107 selectTextOnFocus
108 value={inputValue}
109 />
110 ) : null}
72111 <View className="mt-5 flex-row justify-end gap-1">
73112 <View className="overflow-hidden rounded-full">
74113 <Pressable
......@@ -77,23 +116,27 @@ export function ConfirmDialogHost() {
77116 onPress={handleCancel}
78117 >
79118 <AppText className="text-base font-t3-medium">
80 {request.cancelText ?? "Cancel"}
119 {presented.request.cancelText ?? "Cancel"}
81120 </AppText>
82121 </Pressable>
83122 </View>
84123 <View className="overflow-hidden rounded-full">
85124 <Pressable
86125 accessibilityRole="button"
126 disabled={confirmDisabled}
87127 className="min-h-10 items-center justify-center px-4 active:bg-subtle"
88128 onPress={handleConfirm}
89129 >
90130 <AppText
91131 className={cn(
92132 "text-base font-t3-medium",
93 request.destructive && "text-danger-foreground",
133 presented.kind === "confirm" &&
134 presented.request.destructive &&
135 "text-danger-foreground",
136 confirmDisabled && "text-foreground-tertiary",
94137 )}
95138 >
96 {request.confirmText}
139 {presented.request.confirmText}
97140 </AppText>
98141 </Pressable>
99142 </View>
apps/mobile/src/features/home/HomeRouteScreen.tsx+2
......@@ -50,6 +50,7 @@ export function HomeRouteScreen() {
5050 pinThread,
5151 unpinThread,
5252 moveThread,
53 renameThread,
5354 regenerateThreadTitle,
5455 unsettleThread,
5556 } = useThreadListActions();
......@@ -200,6 +201,7 @@ export function HomeRouteScreen() {
200201 onPinThread={pinThread}
201202 onUnpinThread={unpinThread}
202203 onMoveThread={moveThread}
204 onRenameThread={renameThread}
203205 onRegenerateThreadTitle={regenerateThreadTitle}
204206 onEnvironmentChange={setSelectedEnvironmentId}
205207 onProjectChange={setSelectedProjectKey}
apps/mobile/src/features/home/HomeScreen.tsx+9
......@@ -126,6 +126,7 @@ interface HomeScreenProps {
126126 thread: EnvironmentThreadShell,
127127 direction: ThreadMoveDestination,
128128 ) => Promise<boolean>;
129 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
129130 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
130131 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
131132 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
......@@ -538,6 +539,10 @@ export function HomeScreen(props: HomeScreenProps) {
538539 },
539540 [props.onRegenerateThreadTitle],
540541 );
542 const handleRenameThread = useCallback(
543 (thread: EnvironmentThreadShell) => props.onRenameThread(thread),
544 [props.onRenameThread],
545 );
541546 const handleDeleteThread = props.onDeleteThread;
542547 const handleUnsettleThread = props.onUnsettleThread;
543548 // The settled tail renders in pages; expansion resets when the filter
......@@ -868,6 +873,7 @@ export function HomeScreen(props: HomeScreenProps) {
868873 onSelectThread={props.onSelectThread}
869874 onDeleteThread={handleDeleteThread}
870875 onArchiveThread={props.onArchiveThread}
876 onRenameThread={handleRenameThread}
871877 onRegenerateThreadTitle={handleRegenerateThreadTitle}
872878 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
873879 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
......@@ -901,6 +907,7 @@ export function HomeScreen(props: HomeScreenProps) {
901907 handleMoveThread,
902908 handlePinThread,
903909 handleRegenerateThreadTitle,
910 handleRenameThread,
904911 handleSettleThread,
905912 handleSnoozeThread,
906913 handleUnpinThread,
......@@ -1025,6 +1032,7 @@ export function HomeScreen(props: HomeScreenProps) {
10251032 searchQuery={props.searchQuery}
10261033 onArchiveThread={props.onArchiveThread}
10271034 onDeleteThread={props.onDeleteThread}
1035 onRenameThread={handleRenameThread}
10281036 onRegenerateThreadTitle={handleRegenerateThreadTitle}
10291037 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
10301038 onSelectThread={props.onSelectThread}
......@@ -1049,6 +1057,7 @@ export function HomeScreen(props: HomeScreenProps) {
10491057 handleSwipeableClose,
10501058 handleSwipeableWillOpen,
10511059 handleRegenerateThreadTitle,
1060 handleRenameThread,
10521061 machineByEnvironmentId,
10531062 queuedThreadKeys,
10541063 props.onArchiveThread,
apps/mobile/src/features/home/useThreadListActions.ts+49-2
......@@ -4,10 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa
44import * as Cause from "effect/Cause";
55import * as Haptics from "expo-haptics";
66import { useCallback, useRef } from "react";
7import { Alert } from "react-native";
7import { Alert, Platform } from "react-native";
88
99import { withThreadDismissal } from "./thread-dismissal";
10import { showConfirmDialog } from "../../components/ConfirmDialogHost";
10import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost";
1111import { scopedThreadKey } from "../../lib/scopedEntities";
1212import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
1313import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
......@@ -27,6 +27,7 @@ import {
2727 threadDropLifecycle,
2828} from "../threads/threadOrder";
2929import { getThreadListV2OrderedSection } from "../threads/threadListV2";
30import { resolveThreadTitleRename } from "../threads/thread-title-rename";
3031
3132/** Version skew: never send settle/unsettle to a server that predates them
3233 (capability defaults false on decode for older servers). */
......@@ -240,6 +241,7 @@ export function useThreadListActions(): {
240241 thread: EnvironmentThreadShell,
241242 direction: ThreadMoveDestination,
242243 ) => Promise<boolean>;
244 readonly renameThread: (thread: EnvironmentThreadShell) => void;
243245 readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
244246} {
245247 const executeAction = useThreadActionExecutor();
......@@ -474,6 +476,50 @@ export function useThreadListActions(): {
474476 },
475477 [updateThreadMetadata],
476478 );
479 const renameThread = useCallback(
480 (thread: EnvironmentThreadShell) => {
481 const commit = (title: string) => {
482 const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title });
483 if (resolution.action === "reject-empty") {
484 Alert.alert("Could not rename thread", "Thread title cannot be empty.");
485 return;
486 }
487 if (resolution.action === "noop") return;
488 selectionHaptic();
489 void updateThreadMetadata({
490 environmentId: thread.environmentId,
491 input: { threadId: thread.id, title: resolution.title },
492 }).then((result) => {
493 if (result._tag === "Success") return;
494 const error = Cause.squash(result.cause);
495 Alert.alert(
496 "Could not rename thread",
497 error instanceof Error && error.message.trim().length > 0
498 ? error.message
499 : "The thread could not be renamed.",
500 );
501 });
502 };
503
504 if (Platform.OS === "ios") {
505 Alert.prompt(
506 "Rename thread",
507 undefined,
508 (title) => commit(title ?? ""),
509 "plain-text",
510 thread.title,
511 );
512 return;
513 }
514 showTextInputDialog({
515 title: "Rename thread",
516 initialValue: thread.title,
517 confirmText: "Rename",
518 onConfirm: commit,
519 });
520 },
521 [updateThreadMetadata],
522 );
477523
478524 // Plan against the complete section so filtering does not change a move.
479525 const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
......@@ -653,6 +699,7 @@ export function useThreadListActions(): {
653699 pinThread,
654700 unpinThread,
655701 moveThread,
702 renameThread,
656703 regenerateThreadTitle,
657704 };
658705}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+4
......@@ -171,6 +171,7 @@ function ThreadNavigationSidebarPane(
171171 pinThread,
172172 unpinThread,
173173 moveThread,
174 renameThread,
174175 regenerateThreadTitle,
175176 } = useThreadListActions();
176177 const threadListV2Enabled = useThreadListV2Enabled();
......@@ -932,6 +933,7 @@ function ThreadNavigationSidebarPane(
932933 onSelectThread={handleSelectThread}
933934 onDeleteThread={confirmDeleteThread}
934935 onArchiveThread={archiveThread}
936 onRenameThread={renameThread}
935937 onRegenerateThreadTitle={regenerateThreadTitle}
936938 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
937939 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
......@@ -1049,6 +1051,7 @@ function ThreadNavigationSidebarPane(
10491051 fullSwipeWidth={props.width - 20}
10501052 onArchiveThread={archiveThread}
10511053 onDeleteThread={confirmDeleteThread}
1054 onRenameThread={renameThread}
10521055 onRegenerateThreadTitle={regenerateThreadTitle}
10531056 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
10541057 onSelectThread={handleSelectThread}
......@@ -1091,6 +1094,7 @@ function ThreadNavigationSidebarPane(
10911094 projectByKey,
10921095 projectTitleByProjectKey,
10931096 regenerateThreadTitle,
1097 renameThread,
10941098 props.onNewThreadInProject,
10951099 props.onNewThreadOnBranch,
10961100 props.searchQuery,
apps/mobile/src/features/threads/thread-list-items.tsx+8-2
......@@ -437,6 +437,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
437437
438438const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [
439439 { id: "archive", title: "Archive", image: "archivebox" },
440 { id: "rename", title: "Rename", image: "square.and.pencil" },
440441 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
441442];
442443
......@@ -458,6 +459,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
458459 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
459460 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
460461 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
462 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
461463 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
462464 readonly titleRegenerationSupported: boolean;
463465 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
......@@ -484,6 +486,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
484486 onSelectThread,
485487 onArchiveThread,
486488 onDeleteThread,
489 onRenameThread,
487490 onRegenerateThreadTitle,
488491 onNewThreadOnBranch,
489492 } = props;
......@@ -525,6 +528,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
525528
526529 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
527530 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
531 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
528532 const handleRegenerateTitle = useCallback(
529533 () => onRegenerateThreadTitle(thread),
530534 [onRegenerateThreadTitle, thread],
......@@ -542,11 +546,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
542546 ]
543547 : []),
544548 THREAD_ROW_MENU_ACTIONS[0]!,
549 THREAD_ROW_MENU_ACTIONS[1]!,
545550 ...buildThreadTitleRegenerationMenuItems({
546551 supported: props.titleRegenerationSupported,
547552 isRegenerating: thread.titleRegeneration != null,
548553 }),
549 THREAD_ROW_MENU_ACTIONS[1]!,
554 THREAD_ROW_MENU_ACTIONS[2]!,
550555 ],
551556 [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration],
552557 );
......@@ -563,10 +568,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
563568 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
564569 if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread);
565570 if (nativeEvent.event === "archive") handleArchive();
571 if (nativeEvent.event === "rename") handleRename();
566572 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
567573 if (nativeEvent.event === "delete") handleDelete();
568574 },
569 [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread],
575 [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread],
570576 );
571577
572578 const statusPill = effectiveStatus ? (
apps/mobile/src/features/threads/thread-list-v2-items.tsx+20-13
......@@ -382,6 +382,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
382382 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
383383 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
384384 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
385 readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
385386 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void;
386387 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
387388 readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void;
......@@ -423,6 +424,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
423424 variant,
424425 onSelectThread,
425426 onDeleteThread,
427 onRenameThread,
426428 onRegenerateThreadTitle,
427429 onNewThreadOnBranch,
428430 onSettleThread,
......@@ -468,6 +470,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
468470 settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread);
469471
470472 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
473 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
471474 const handleRegenerateTitle = useCallback(
472475 () => onRegenerateThreadTitle(thread),
473476 [onRegenerateThreadTitle, thread],
......@@ -560,12 +563,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
560563 variant,
561564 ],
562565 );
563 const titleRegenerationMenuItems = useMemo<MenuAction[]>(
564 () =>
565 buildThreadTitleRegenerationMenuItems({
566 const titleMenuItems = useMemo<MenuAction[]>(
567 () => [
568 { id: "rename", title: "Rename", image: "square.and.pencil" },
569 ...buildThreadTitleRegenerationMenuItems({
566570 supported: props.titleRegenerationSupported,
567571 isRegenerating: thread.titleRegeneration != null,
568572 }),
573 ],
569574 [props.titleRegenerationSupported, thread.titleRegeneration],
570575 );
571576 const snoozableCardMenuActions = useMemo<MenuAction[]>(
......@@ -578,19 +583,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
578583 subactions: snoozePresetActions,
579584 },
580585 ...arrangementMenuItems,
581 ...titleRegenerationMenuItems,
586 ...titleMenuItems,
582587 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
583588 ],
584 [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems],
589 [arrangementMenuItems, snoozePresetActions, titleMenuItems],
585590 );
586591 const cardMenuActions = useMemo<MenuAction[]>(
587592 () => [
588593 CARD_MENU_ACTIONS[0]!,
589594 ...arrangementMenuItems,
590 ...titleRegenerationMenuItems,
595 ...titleMenuItems,
591596 ...CARD_MENU_ACTIONS.slice(1),
592597 ],
593 [arrangementMenuItems, titleRegenerationMenuItems],
598 [arrangementMenuItems, titleMenuItems],
594599 );
595600 const slimMenuActions = useMemo<MenuAction[]>(
596601 () => [
......@@ -598,23 +603,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
598603 ...arrangementMenuItems.filter(
599604 (action) => action.id !== "move-up" && action.id !== "move-down",
600605 ),
601 ...titleRegenerationMenuItems,
606 ...titleMenuItems,
602607 SLIM_MENU_ACTIONS[1]!,
603608 ],
604 [arrangementMenuItems, titleRegenerationMenuItems],
609 [arrangementMenuItems, titleMenuItems],
605610 );
606611 const snoozedMenuActions = useMemo<MenuAction[]>(
607 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
608 [titleRegenerationMenuItems],
612 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!],
613 [titleMenuItems],
609614 );
610615 const legacyMenuActions = useMemo<MenuAction[]>(
611616 () => [
612617 LEGACY_MENU_ACTIONS[0]!,
613618 ...arrangementMenuItems,
614 ...titleRegenerationMenuItems,
619 ...titleMenuItems,
615620 LEGACY_MENU_ACTIONS[1]!,
616621 ],
617 [arrangementMenuItems, titleRegenerationMenuItems],
622 [arrangementMenuItems, titleMenuItems],
618623 );
619624 const handleMenuAction = useCallback(
620625 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
......@@ -628,6 +633,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
628633 if (nativeEvent.event === "move-up") handleMoveUp();
629634 if (nativeEvent.event === "move-down") handleMoveDown();
630635 if (nativeEvent.event === "archive") handleArchive();
636 if (nativeEvent.event === "rename") handleRename();
631637 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
632638 if (nativeEvent.event === "delete") handleDelete();
633639 if (nativeEvent.event === "snooze:custom") {
......@@ -651,6 +657,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
651657 handleArchive,
652658 handleDelete,
653659 handleRegenerateTitle,
660 handleRename,
654661 handleMoveDown,
655662 handleMoveUp,
656663 handlePin,
apps/mobile/src/features/threads/thread-title-rename.test.ts created+21
......@@ -0,0 +1,21 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveThreadTitleRename } from "./thread-title-rename";
4
5describe("resolveThreadTitleRename", () => {
6 it("trims a changed title", () => {
7 expect(resolveThreadTitleRename({ title: " New title ", originalTitle: "Old" })).toEqual({
8 action: "rename",
9 title: "New title",
10 });
11 });
12
13 it("rejects empty and unchanged titles", () => {
14 expect(resolveThreadTitleRename({ title: " ", originalTitle: "Old" })).toEqual({
15 action: "reject-empty",
16 });
17 expect(resolveThreadTitleRename({ title: " Old ", originalTitle: "Old" })).toEqual({
18 action: "noop",
19 });
20 });
21});
apps/mobile/src/features/threads/thread-title-rename.ts created+14
......@@ -0,0 +1,14 @@
1export type ThreadTitleRenameResolution =
2 | { readonly action: "rename"; readonly title: string }
3 | { readonly action: "noop" }
4 | { readonly action: "reject-empty" };
5
6export function resolveThreadTitleRename(input: {
7 readonly title: string;
8 readonly originalTitle: string;
9}): ThreadTitleRenameResolution {
10 const title = input.title.trim();
11 if (title.length === 0) return { action: "reject-empty" };
12 if (title === input.originalTitle) return { action: "noop" };
13 return { action: "rename", title };
14}