Skip to content
Merged
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
15 changes: 15 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { ReviewSheet } from "./features/review/ReviewSheet";
// T3-CUSTOM(expbkt3): native plan review screens.
import { PlanReviewSheet } from "./features/planreview/PlanReviewSheet";
import { PlanReviewCommentSheet } from "./features/planreview/PlanReviewCommentSheet";
// T3-CUSTOM(expbkt3): thread member tagging.
import { ThreadMembersSheet } from "./features/members/ThreadMembersSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
Expand Down Expand Up @@ -524,6 +526,19 @@ export const RootStack = createNativeStackNavigator({
headerShown: false,
},
}),
ThreadMembers: createNativeStackScreen({
screen: ThreadMembersSheet,
linking: `${THREAD_LINKING_PREFIX}/members`,
options: {
// Same Android constraint as the other keyboard-driven sheets.
...(Platform.OS === "android"
? { presentation: "fullScreenModal" as const }
: FORM_SHEET_PRESENTATION_OPTIONS),
sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.6, 0.95],
sheetGrabberVisible: Platform.OS !== "android",
headerShown: false,
},
}),
// T3-CUSTOM(expbkt3): END native plan review
ThreadFiles: createNativeStackScreen({
screen: ThreadFilesTreeScreen,
Expand Down
207 changes: 207 additions & 0 deletions apps/mobile/src/features/members/ThreadMembersSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// T3-CUSTOM(expbkt3): tag users on a thread, from the phone.
//
// The commands (`addMember`, `removeMember`, `transferOwnership`) already live in
// client-runtime and are what web's ThreadMembersControl calls, so this is the
// directory plus a list. Ordering and filtering are in threadMembers.ts, kept
// pure so they are testable without a renderer.
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
import type { EnvironmentId, OrchestrationUser, ThreadId, UserId } from "@t3tools/contracts";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
import { useThemeColor } from "../../lib/useThemeColor";
import { useEnvironmentQuery } from "../../state/query";
import { environmentSession } from "../../state/session";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useThreadShell } from "../../state/entities";
import {
buildThreadMemberEntries,
canRemoveThreadMember,
filterThreadMemberEntries,
threadMemberInitial,
threadMemberLabel,
type ThreadMemberEntry,
} from "./threadMembers";

type ThreadMembersSheetProps = StaticScreenProps<{
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
}>;

function MemberRow(props: {
readonly entry: ThreadMemberEntry;
readonly isBusy: boolean;
readonly onToggle: (entry: ThreadMemberEntry) => void;
readonly onTransferOwnership: (entry: ThreadMemberEntry) => void;
}) {
const { entry } = props;
const iconTint = String(useThemeColor("--color-icon"));
const handleToggle = useCallback(() => props.onToggle(entry), [entry, props]);
const handleTransfer = useCallback(() => props.onTransferOwnership(entry), [entry, props]);

return (
<View className="flex-row items-center gap-3 px-4 py-2.5">
<View className="h-8 w-8 items-center justify-center rounded-full bg-primary/70">
<Text className="text-xs font-t3-bold text-primary-foreground">
{threadMemberInitial(entry.user)}
</Text>
</View>
<View className="min-w-0 flex-1">
<Text className="text-sm text-foreground" numberOfLines={1}>
{threadMemberLabel(entry.user)}
</Text>
{entry.isOwner ? (
<Text className="text-[10px] uppercase text-muted-foreground">Owner</Text>
) : entry.user.email === null ? null : (
<Text className="text-[10px] text-muted-foreground" numberOfLines={1}>
{entry.user.email}
</Text>
)}
</View>

{props.isBusy ? (
<ActivityIndicator size="small" />
) : entry.isOwner ? null : (
<View className="flex-row items-center gap-3">
{entry.isMember ? (
<Pressable hitSlop={8} onPress={handleTransfer}>
<Text className="text-xs font-t3-bold text-primary">Make owner</Text>
</Pressable>
) : null}
<Pressable hitSlop={8} onPress={handleToggle}>
<SymbolView
name={canRemoveThreadMember(entry) ? "minus.circle" : "plus.circle"}
size={20}
tintColor={iconTint}
type="monochrome"
/>
</Pressable>
</View>
)}
</View>
);
}

export function ThreadMembersSheet(props: ThreadMembersSheetProps) {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { environmentId, threadId } = props.route.params;
const [query, setQuery] = useState("");
const [pendingUserIds, setPendingUserIds] = useState<ReadonlySet<string>>(() => new Set());
const [error, setError] = useState<string | null>(null);

const thread = useThreadShell({ environmentId, threadId });
// The org directory: UserIds that can own or be tagged on a thread.
const directoryQuery = useEnvironmentQuery(
environmentSession.orchestrationUsersAtom(environmentId),
);
const addMember = useAtomCommand(threadEnvironment.addMember, "thread add member");
const removeMember = useAtomCommand(threadEnvironment.removeMember, "thread remove member");
const transferOwnership = useAtomCommand(
threadEnvironment.transferOwnership,
"thread transfer ownership",
);

const users: ReadonlyArray<OrchestrationUser> = directoryQuery.data?.users ?? [];
const entries = useMemo(
() =>
filterThreadMemberEntries(
buildThreadMemberEntries({
users,
ownerUserId: thread?.ownerUserId ?? null,
memberUserIds: thread?.memberUserIds ?? [],
}),
query,
),
[query, thread?.memberUserIds, thread?.ownerUserId, users],
);

const withPending = useCallback(
(userId: UserId, run: () => Promise<{ readonly _tag: string }>) => {
setPendingUserIds((current) => new Set(current).add(userId));
setError(null);
void run()
.then((result) => {
if (result._tag === "Failure") setError("That change could not be saved. Try again.");
})
.finally(() => {
setPendingUserIds((current) => {
const next = new Set(current);
next.delete(userId);
return next;
});
});
},
[],
);

const handleToggle = useCallback(
(entry: ThreadMemberEntry) => {
const userId = entry.user.id;
withPending(userId, () =>
canRemoveThreadMember(entry)
? removeMember({ environmentId, input: { threadId, userId } })
: addMember({ environmentId, input: { threadId, userId } }),
);
},
[addMember, environmentId, removeMember, threadId, withPending],
);

const handleTransferOwnership = useCallback(
(entry: ThreadMemberEntry) => {
const userId = entry.user.id;
withPending(userId, () => transferOwnership({ environmentId, input: { threadId, userId } }));
},
[environmentId, threadId, transferOwnership, withPending],
);

return (
<View className="flex-1 bg-background" style={{ paddingBottom: insets.bottom }}>
<View className="flex-row items-center justify-between border-b border-border px-4 py-3">
<Pressable hitSlop={8} onPress={() => navigation.goBack()}>
<Text className="text-base text-muted-foreground">Done</Text>
</Pressable>
<Text className="text-base font-t3-bold text-foreground">People</Text>
<View className="w-12" />
</View>

<TextInput
autoCapitalize="none"
className="mx-4 mt-3 rounded-lg bg-muted/40 px-3 py-2 text-sm text-foreground"
onChangeText={setQuery}
placeholder="Search people"
value={query}
/>

{error === null ? null : <Text className="px-4 pt-2 text-xs text-destructive">{error}</Text>}

<ScrollView className="mt-2 flex-1" keyboardShouldPersistTaps="handled">
{directoryQuery.isPending && users.length === 0 ? (
<View className="items-center py-8">
<ActivityIndicator />
</View>
) : entries.length === 0 ? (
<Text className="px-4 py-8 text-center text-sm text-muted-foreground">
{users.length === 0
? "This environment has no user directory."
: "Nobody matches that search."}
</Text>
) : (
entries.map((entry) => (
<MemberRow
entry={entry}
isBusy={pendingUserIds.has(entry.user.id)}
key={entry.user.id}
onToggle={handleToggle}
onTransferOwnership={handleTransferOwnership}
/>
))
)}
</ScrollView>
</View>
);
}
114 changes: 114 additions & 0 deletions apps/mobile/src/features/members/threadMembers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// T3-CUSTOM(expbkt3): fork-owned coverage for thread member tagging.
import { describe, expect, it } from "@effect/vitest";
import type { OrchestrationUser, UserId } from "@t3tools/contracts";

import {
buildThreadMemberEntries,
canRemoveThreadMember,
filterThreadMemberEntries,
threadMemberInitial,
threadMemberLabel,
} from "./threadMembers";

const user = (id: string, overrides: Partial<OrchestrationUser> = {}): OrchestrationUser =>
({
id,
name: null,
email: null,
imageUrl: null,
isAdmin: false,
...overrides,
}) as OrchestrationUser;

describe("threadMemberLabel", () => {
it("prefers a display name, then an email, then the raw id", () => {
expect(threadMemberLabel(user("u1", { name: "Tushar" }))).toBe("Tushar");
expect(threadMemberLabel(user("u2", { email: "a@b.com" }))).toBe("a@b.com");
expect(threadMemberLabel(user("u3"))).toBe("u3");
});
});

describe("threadMemberInitial", () => {
it("uses the first letter of the label", () => {
expect(threadMemberInitial(user("u1", { name: "tushar" }))).toBe("T");
});

it("falls back rather than rendering an empty circle", () => {
expect(threadMemberInitial(user("", { name: " " }))).toBe("?");
});
});

describe("buildThreadMemberEntries", () => {
const users = [
user("zoe", { name: "Zoe" }),
user("owner", { name: "Owner" }),
user("amy", { name: "Amy" }),
user("member", { name: "Member" }),
];

it("puts the owner first, then members, then everyone else", () => {
const entries = buildThreadMemberEntries({
users,
ownerUserId: "owner" as UserId,
memberUserIds: ["member" as UserId],
});
expect(entries.map((entry) => entry.user.id)).toEqual(["owner", "member", "amy", "zoe"]);
});

it("marks ownership and membership", () => {
const entries = buildThreadMemberEntries({
users,
ownerUserId: "owner" as UserId,
memberUserIds: ["member" as UserId],
});
expect(entries[0]).toMatchObject({ isOwner: true });
expect(entries[1]).toMatchObject({ isOwner: false, isMember: true });
expect(entries[2]).toMatchObject({ isOwner: false, isMember: false });
});

it("sorts alphabetically when nobody is tagged", () => {
const entries = buildThreadMemberEntries({ users, ownerUserId: null, memberUserIds: [] });
expect(entries.map((entry) => entry.user.id)).toEqual(["amy", "member", "owner", "zoe"]);
});
});

describe("filterThreadMemberEntries", () => {
const entries = buildThreadMemberEntries({
users: [
user("a", { name: "Tushar Bhardwaj", email: "tushar@beknown.work" }),
user("b", { name: "Someone Else", email: "else@example.com" }),
],
ownerUserId: null,
memberUserIds: [],
});

it("keeps everyone for an empty query", () => {
expect(filterThreadMemberEntries(entries, " ")).toHaveLength(2);
});

it("matches a partial name, case-insensitively", () => {
expect(filterThreadMemberEntries(entries, "bhard").map((e) => e.user.id)).toEqual(["a"]);
});

it("matches an email prefix", () => {
expect(filterThreadMemberEntries(entries, "else@").map((e) => e.user.id)).toEqual(["b"]);
});

it("returns nothing when nobody matches", () => {
expect(filterThreadMemberEntries(entries, "zzz")).toEqual([]);
});
});

describe("canRemoveThreadMember", () => {
it("refuses to remove the owner", () => {
expect(canRemoveThreadMember({ user: user("o"), isOwner: true, isMember: true })).toBe(false);
});

it("allows removing a plain member", () => {
expect(canRemoveThreadMember({ user: user("m"), isOwner: false, isMember: true })).toBe(true);
});

it("has nothing to remove for a non-member", () => {
expect(canRemoveThreadMember({ user: user("x"), isOwner: false, isMember: false })).toBe(false);
});
});
Loading
Loading