922 lines
27 KiB
TypeScript
922 lines
27 KiB
TypeScript
|
|
import type { RunActivityRow, SearchHit, SpaceBot, SpaceGroup } from "@rakazo/contracts";
|
||
|
|
import { botAvatarImageSrc, groupBotsForSidebar } from "@rakazo/core";
|
||
|
|
import { Redirect, useFocusEffect, useRouter } from "expo-router";
|
||
|
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
|
import {
|
||
|
|
ActivityIndicator,
|
||
|
|
Alert,
|
||
|
|
AppState,
|
||
|
|
FlatList,
|
||
|
|
Pressable,
|
||
|
|
RefreshControl,
|
||
|
|
StyleSheet,
|
||
|
|
Text,
|
||
|
|
TextInput,
|
||
|
|
View,
|
||
|
|
} from "react-native";
|
||
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||
|
|
import { BotAvatar } from "../components/bot-avatar";
|
||
|
|
import { BotOrganizeModal } from "../components/bot-organize-modal";
|
||
|
|
import { GroupAvatar } from "../components/group-avatar";
|
||
|
|
import { NativeSymbol } from "../components/native-symbol";
|
||
|
|
import {
|
||
|
|
activityStatusLabel,
|
||
|
|
fetchSpaceActivity,
|
||
|
|
formatActivityRelativeTime,
|
||
|
|
} from "../lib/activity";
|
||
|
|
import { loadActivityMode, saveActivityMode } from "../lib/activity-mode";
|
||
|
|
import {
|
||
|
|
currentApiBase,
|
||
|
|
loadSessionToken,
|
||
|
|
type MobileBot,
|
||
|
|
type MobileBotSection,
|
||
|
|
type MobileGroup,
|
||
|
|
type MobileMe,
|
||
|
|
type MobileSpace,
|
||
|
|
type MobileSpaceNavigation,
|
||
|
|
rpc,
|
||
|
|
selectedSpaceId,
|
||
|
|
selectInitialSpace,
|
||
|
|
selectSpace,
|
||
|
|
} from "../lib/api";
|
||
|
|
import { botTag, filterBots, formatThreadTime, userInitials } from "../lib/inbox";
|
||
|
|
import { dismissThreadNotifications, resumeLiveNotifications } from "../lib/live-notifications";
|
||
|
|
import { native } from "../lib/native";
|
||
|
|
import { previewSnippet } from "../lib/preview";
|
||
|
|
import { registerPushToken } from "../lib/push";
|
||
|
|
import { querySpaceSearch } from "../lib/search";
|
||
|
|
import { mobileSearchDestination } from "../lib/search-destination";
|
||
|
|
|
||
|
|
const FALLBACK_COLOR = "#9B5CF6";
|
||
|
|
|
||
|
|
type InboxItem =
|
||
|
|
| { type: "bot"; bot: MobileBot | SpaceBot }
|
||
|
|
| { type: "group"; group: MobileGroup | SpaceGroup }
|
||
|
|
| { type: "search"; hit: SearchHit }
|
||
|
|
| { type: "heading"; key: string; title: string };
|
||
|
|
|
||
|
|
async function openMobileSpace(spaceId: string | undefined, open: () => void) {
|
||
|
|
if (spaceId && !(await selectSpace(spaceId))) {
|
||
|
|
Alert.alert("Could not switch spaces", "Try again.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
open();
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function Home() {
|
||
|
|
const [bots, setBots] = useState<MobileBot[]>([]);
|
||
|
|
const [groups, setGroups] = useState<MobileGroup[]>([]);
|
||
|
|
const [botSections, setBotSections] = useState<MobileBotSection[]>([]);
|
||
|
|
const [spaces, setSpaces] = useState<MobileSpace[]>([]);
|
||
|
|
const [me, setMe] = useState<MobileMe | null>(null);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [ready, setReady] = useState(false);
|
||
|
|
const [hasSession, setHasSession] = useState(false);
|
||
|
|
const [refreshing, setRefreshing] = useState(false);
|
||
|
|
const [query, setQuery] = useState("");
|
||
|
|
const [searching, setSearching] = useState(false);
|
||
|
|
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
||
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
||
|
|
const [organizeTarget, setOrganizeTarget] = useState<{
|
||
|
|
kind: "bot" | "group";
|
||
|
|
id: string;
|
||
|
|
} | null>(null);
|
||
|
|
const [activityMode, setActivityMode] = useState(false);
|
||
|
|
const [activity, setActivity] = useState<{ active: RunActivityRow[]; recent: RunActivityRow[] }>({
|
||
|
|
active: [],
|
||
|
|
recent: [],
|
||
|
|
});
|
||
|
|
const activityRequestId = useRef(0);
|
||
|
|
const inboxRequestId = useRef(0);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void loadActivityMode().then(setActivityMode);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const toggleActivityMode = useCallback(() => {
|
||
|
|
setActivityMode((on) => {
|
||
|
|
const next = !on;
|
||
|
|
void saveActivityMode(next);
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const loadBots = useCallback(async () => {
|
||
|
|
const requestId = ++inboxRequestId.current;
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const [navigation, nextMe] = await Promise.all([
|
||
|
|
rpc<MobileSpaceNavigation>("spaces/list"),
|
||
|
|
rpc<MobileMe>("me"),
|
||
|
|
]);
|
||
|
|
if (requestId !== inboxRequestId.current) return;
|
||
|
|
if (!(await selectInitialSpace(nextMe.spaceId))) {
|
||
|
|
throw new Error("Could not save the default space");
|
||
|
|
}
|
||
|
|
if (requestId !== inboxRequestId.current) return;
|
||
|
|
setBots(navigation.current.bots);
|
||
|
|
setBotSections(navigation.current.botSections);
|
||
|
|
setGroups(navigation.current.groups);
|
||
|
|
setSpaces(navigation.spaces);
|
||
|
|
setMe(nextMe);
|
||
|
|
} catch (err) {
|
||
|
|
if (requestId !== inboxRequestId.current) return;
|
||
|
|
setError(err instanceof Error ? err.message : "Could not load bots");
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const refreshBots = useCallback(async () => {
|
||
|
|
setRefreshing(true);
|
||
|
|
try {
|
||
|
|
await loadBots();
|
||
|
|
} finally {
|
||
|
|
setRefreshing(false);
|
||
|
|
}
|
||
|
|
}, [loadBots]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void loadSessionToken().then((token) => {
|
||
|
|
setHasSession(Boolean(token));
|
||
|
|
setReady(true);
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!hasSession) return;
|
||
|
|
void registerPushToken().catch(() => undefined);
|
||
|
|
}, [hasSession]);
|
||
|
|
|
||
|
|
useFocusEffect(
|
||
|
|
useCallback(() => {
|
||
|
|
if (!hasSession) return;
|
||
|
|
let cancelled = false;
|
||
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
|
|
const tick = async () => {
|
||
|
|
if (AppState.currentState === "active") await loadBots();
|
||
|
|
if (!cancelled) timer = setTimeout(() => void tick(), 5_000);
|
||
|
|
};
|
||
|
|
void tick();
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
if (timer !== undefined) clearTimeout(timer);
|
||
|
|
};
|
||
|
|
}, [hasSession, loadBots]),
|
||
|
|
);
|
||
|
|
|
||
|
|
const loadActivity = useCallback(async () => {
|
||
|
|
if (!hasSession || !activityMode || searching || query.trim()) {
|
||
|
|
activityRequestId.current += 1;
|
||
|
|
setActivity({ active: [], recent: [] });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const requestId = ++activityRequestId.current;
|
||
|
|
try {
|
||
|
|
const next = await fetchSpaceActivity();
|
||
|
|
if (requestId !== activityRequestId.current) return;
|
||
|
|
setActivity(next);
|
||
|
|
} catch {
|
||
|
|
// Keep the last good snapshot on transient RPC failures; only drop stale responses.
|
||
|
|
if (requestId !== activityRequestId.current) return;
|
||
|
|
}
|
||
|
|
}, [activityMode, hasSession, query, searching]);
|
||
|
|
|
||
|
|
useFocusEffect(
|
||
|
|
useCallback(() => {
|
||
|
|
if (!hasSession || !activityMode || searching || query.trim()) return;
|
||
|
|
let cancelled = false;
|
||
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
|
|
|
||
|
|
const tick = async () => {
|
||
|
|
await loadActivity();
|
||
|
|
if (!cancelled) {
|
||
|
|
timer = setTimeout(() => void tick(), 15_000);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
void tick();
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
activityRequestId.current += 1;
|
||
|
|
if (timer !== undefined) clearTimeout(timer);
|
||
|
|
};
|
||
|
|
}, [activityMode, hasSession, loadActivity, query, searching]),
|
||
|
|
);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const trimmed = query.trim();
|
||
|
|
if (!searching || !trimmed) {
|
||
|
|
setSearchHits([]);
|
||
|
|
setSearchLoading(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const abort = new AbortController();
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
setSearchLoading(true);
|
||
|
|
void querySpaceSearch(trimmed)
|
||
|
|
.then((hits) => {
|
||
|
|
if (!abort.signal.aborted) setSearchHits(hits);
|
||
|
|
})
|
||
|
|
.catch(() => {
|
||
|
|
if (!abort.signal.aborted) setSearchHits([]);
|
||
|
|
})
|
||
|
|
.finally(() => {
|
||
|
|
if (!abort.signal.aborted) setSearchLoading(false);
|
||
|
|
});
|
||
|
|
}, 200);
|
||
|
|
return () => {
|
||
|
|
abort.abort();
|
||
|
|
clearTimeout(timer);
|
||
|
|
};
|
||
|
|
}, [query, searching]);
|
||
|
|
|
||
|
|
const visible = useMemo(() => filterBots(bots, query), [bots, query]);
|
||
|
|
const visibleGroups = useMemo(() => {
|
||
|
|
const needle = query.trim().toLowerCase();
|
||
|
|
if (!needle) return groups;
|
||
|
|
return groups.filter((group) =>
|
||
|
|
`${group.name} ${group.preview}`.toLowerCase().includes(needle),
|
||
|
|
);
|
||
|
|
}, [groups, query]);
|
||
|
|
const listData = useMemo((): InboxItem[] => {
|
||
|
|
if (query.trim() && searching) {
|
||
|
|
return searchHits.map((hit) => ({ type: "search", hit }));
|
||
|
|
}
|
||
|
|
const sidebarSpaces =
|
||
|
|
spaces.length > 0
|
||
|
|
? spaces.map((space) =>
|
||
|
|
space.id === me?.spaceId
|
||
|
|
? { ...space, bots: visible, groups: visibleGroups, botSections }
|
||
|
|
: {
|
||
|
|
...space,
|
||
|
|
bots: filterBots(space.bots, query),
|
||
|
|
groups: space.groups.filter((group) =>
|
||
|
|
`${group.name} ${group.preview}`
|
||
|
|
.toLowerCase()
|
||
|
|
.includes(query.trim().toLowerCase()),
|
||
|
|
),
|
||
|
|
},
|
||
|
|
)
|
||
|
|
: me
|
||
|
|
? [
|
||
|
|
{
|
||
|
|
id: me.spaceId,
|
||
|
|
name: "Personal",
|
||
|
|
isDefault: true,
|
||
|
|
bots: visible,
|
||
|
|
groups: visibleGroups,
|
||
|
|
botSections,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
: [];
|
||
|
|
const showSpaceNames = sidebarSpaces.length > 1;
|
||
|
|
return sidebarSpaces.flatMap((space) => {
|
||
|
|
const chats = [
|
||
|
|
...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
|
||
|
|
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
|
||
|
|
];
|
||
|
|
return groupBotsForSidebar(chats, space.botSections).flatMap((group) => [
|
||
|
|
...(group.title || showSpaceNames
|
||
|
|
? [
|
||
|
|
{
|
||
|
|
type: "heading" as const,
|
||
|
|
key: `${space.id}:${group.key}`,
|
||
|
|
title: showSpaceNames
|
||
|
|
? `🔒 ${space.name}${group.title ? ` · ${group.title}` : ""}`
|
||
|
|
: (group.title ?? ""),
|
||
|
|
},
|
||
|
|
]
|
||
|
|
: []),
|
||
|
|
...group.bots,
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
}, [botSections, me, spaces, query, searching, searchHits, visible, visibleGroups]);
|
||
|
|
const initials = userInitials(me?.name ?? "");
|
||
|
|
const organizeChat = organizeTarget
|
||
|
|
? organizeTarget.kind === "bot"
|
||
|
|
? bots.find((bot) => bot.id === organizeTarget.id)
|
||
|
|
: groups.find((group) => group.id === organizeTarget.id)
|
||
|
|
: null;
|
||
|
|
const insets = useSafeAreaInsets();
|
||
|
|
const router = useRouter();
|
||
|
|
|
||
|
|
if (!ready) {
|
||
|
|
return (
|
||
|
|
<View style={[styles.screen, styles.centered]}>
|
||
|
|
<ActivityIndicator color={native.secondaryLabel} />
|
||
|
|
</View>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (!hasSession) return <Redirect href="/sign-in" />;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<View style={[styles.screen, { paddingTop: Math.max(insets.top, 20) }]}>
|
||
|
|
<View style={styles.header}>
|
||
|
|
<CircleButton accessibilityLabel="Account" onPress={() => router.push("/account")}>
|
||
|
|
<Text style={styles.profileInitials}>{initials}</Text>
|
||
|
|
</CircleButton>
|
||
|
|
<View style={styles.headerActions}>
|
||
|
|
<CircleButton
|
||
|
|
accessibilityLabel="Activity"
|
||
|
|
active={activityMode}
|
||
|
|
accent
|
||
|
|
onPress={toggleActivityMode}
|
||
|
|
>
|
||
|
|
<NativeSymbol
|
||
|
|
ios={activityMode ? "bell.fill" : "bell"}
|
||
|
|
android={activityMode ? "notifications" : "notifications-outline"}
|
||
|
|
size={17}
|
||
|
|
color={activityMode ? "#FFFFFF" : "#8E8E93"}
|
||
|
|
/>
|
||
|
|
</CircleButton>
|
||
|
|
<CircleButton
|
||
|
|
accessibilityLabel="Search"
|
||
|
|
active={searching}
|
||
|
|
onPress={() =>
|
||
|
|
setSearching((open) => {
|
||
|
|
if (open) setQuery("");
|
||
|
|
return !open;
|
||
|
|
})
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<NativeSymbol ios="magnifyingglass" android="search" size={17} />
|
||
|
|
</CircleButton>
|
||
|
|
<CircleButton
|
||
|
|
accessibilityLabel="Create"
|
||
|
|
onPress={() =>
|
||
|
|
Alert.alert("Create", undefined, [
|
||
|
|
{ text: "New bot", onPress: () => router.push("/new") },
|
||
|
|
{ text: "New group", onPress: () => router.push("/new-group") },
|
||
|
|
{ text: "New space", onPress: () => router.push("/new-space") },
|
||
|
|
{ text: "Cancel", style: "cancel" },
|
||
|
|
])
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<NativeSymbol ios="plus" android="add" size={18} />
|
||
|
|
</CircleButton>
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
{searching ? (
|
||
|
|
<TextInput
|
||
|
|
autoFocus
|
||
|
|
value={query}
|
||
|
|
onChangeText={setQuery}
|
||
|
|
placeholder="Search"
|
||
|
|
placeholderTextColor="#6C6C70"
|
||
|
|
autoCorrect={false}
|
||
|
|
autoCapitalize="none"
|
||
|
|
returnKeyType="search"
|
||
|
|
keyboardAppearance="dark"
|
||
|
|
clearButtonMode="while-editing"
|
||
|
|
style={styles.searchField}
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||
|
|
|
||
|
|
<FlatList<InboxItem>
|
||
|
|
data={listData}
|
||
|
|
keyExtractor={(item) => {
|
||
|
|
if (item.type === "heading") return `heading-${item.key}`;
|
||
|
|
if (item.type === "bot") return item.bot.id;
|
||
|
|
if (item.type === "group") return `group-${item.group.id}`;
|
||
|
|
const hit = item.hit;
|
||
|
|
return `${hit.kind}-${hit.botId ?? hit.groupId}-${hit.messageId ?? hit.artifactId ?? hit.routineId ?? hit.url}`;
|
||
|
|
}}
|
||
|
|
keyboardDismissMode="interactive"
|
||
|
|
keyboardShouldPersistTaps="handled"
|
||
|
|
indicatorStyle="white"
|
||
|
|
contentContainerStyle={styles.list}
|
||
|
|
refreshControl={
|
||
|
|
<RefreshControl
|
||
|
|
refreshing={refreshing}
|
||
|
|
onRefresh={() => {
|
||
|
|
void refreshBots();
|
||
|
|
void loadActivity();
|
||
|
|
}}
|
||
|
|
tintColor={native.secondaryLabel}
|
||
|
|
colors={["#8E8E93"]}
|
||
|
|
progressBackgroundColor="#1C1C1E"
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
ListHeaderComponent={
|
||
|
|
activityMode &&
|
||
|
|
!searching &&
|
||
|
|
!query.trim() &&
|
||
|
|
(activity.active.length > 0 || activity.recent.length > 0) ? (
|
||
|
|
<ActivitySection activity={activity} />
|
||
|
|
) : null
|
||
|
|
}
|
||
|
|
ListEmptyComponent={
|
||
|
|
<Text style={styles.empty}>
|
||
|
|
{query.trim() && searching
|
||
|
|
? searchLoading
|
||
|
|
? "Searching…"
|
||
|
|
: "No results"
|
||
|
|
: query.trim()
|
||
|
|
? "No matching bots"
|
||
|
|
: searching
|
||
|
|
? "Search conversations, files, and routines"
|
||
|
|
: "Tap + to create a bot"}
|
||
|
|
</Text>
|
||
|
|
}
|
||
|
|
renderItem={({ item }) =>
|
||
|
|
item.type === "search" ? (
|
||
|
|
<SearchRow
|
||
|
|
hit={item.hit}
|
||
|
|
onPress={() => {
|
||
|
|
setQuery("");
|
||
|
|
setSearchHits([]);
|
||
|
|
router.push(mobileSearchDestination(item.hit));
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
) : item.type === "heading" ? (
|
||
|
|
<Text style={styles.sectionHeading}>{item.title}</Text>
|
||
|
|
) : item.type === "group" ? (
|
||
|
|
<GroupRow
|
||
|
|
group={item.group}
|
||
|
|
onPress={() => {
|
||
|
|
void openMobileSpace(item.group.spaceId, () =>
|
||
|
|
router.push({
|
||
|
|
pathname: "/group-thread",
|
||
|
|
params: { groupId: item.group.id, name: item.group.name },
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
onLongPress={
|
||
|
|
item.group.spaceId === me?.spaceId
|
||
|
|
? () => setOrganizeTarget({ kind: "group", id: item.group.id })
|
||
|
|
: undefined
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<BotRow
|
||
|
|
bot={item.bot}
|
||
|
|
onPress={() => {
|
||
|
|
void openMobileSpace(item.bot.spaceId, () =>
|
||
|
|
router.push({
|
||
|
|
pathname: "/thread",
|
||
|
|
params: { botId: item.bot.id, name: item.bot.name },
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
onLongPress={
|
||
|
|
item.bot.spaceId === me?.spaceId
|
||
|
|
? () => setOrganizeTarget({ kind: "bot", id: item.bot.id })
|
||
|
|
: undefined
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
{organizeChat && organizeTarget ? (
|
||
|
|
<BotOrganizeModal
|
||
|
|
bot={organizeChat}
|
||
|
|
sections={botSections}
|
||
|
|
onClose={() => setOrganizeTarget(null)}
|
||
|
|
onUpdate={async (update) => {
|
||
|
|
await rpc(`${organizeTarget.kind}s/update`, {
|
||
|
|
[`${organizeTarget.kind}Id`]: organizeChat.id,
|
||
|
|
...update,
|
||
|
|
});
|
||
|
|
if (organizeTarget.kind === "bot" && update.notifyOnFinish !== undefined) {
|
||
|
|
await resumeLiveNotifications(
|
||
|
|
currentApiBase(),
|
||
|
|
await loadSessionToken(),
|
||
|
|
selectedSpaceId() ?? "",
|
||
|
|
).catch(() => undefined);
|
||
|
|
if (!update.notifyOnFinish && "threadId" in organizeChat) {
|
||
|
|
await dismissThreadNotifications({ threadId: organizeChat.threadId }).catch(
|
||
|
|
() => undefined,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
await loadBots();
|
||
|
|
}}
|
||
|
|
onCreateSection={async (name) => {
|
||
|
|
await rpc("botSections/create", {
|
||
|
|
[`${organizeTarget.kind}Id`]: organizeChat.id,
|
||
|
|
name,
|
||
|
|
});
|
||
|
|
await loadBots();
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
</View>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ActivitySection({
|
||
|
|
activity,
|
||
|
|
}: {
|
||
|
|
activity: { active: RunActivityRow[]; recent: RunActivityRow[] };
|
||
|
|
}) {
|
||
|
|
const router = useRouter();
|
||
|
|
const openRun = (run: RunActivityRow) => {
|
||
|
|
if (run.groupId) {
|
||
|
|
router.push({
|
||
|
|
pathname: "/group-thread",
|
||
|
|
params: { groupId: run.groupId, name: run.groupName ?? "Group" },
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<View style={styles.activitySection}>
|
||
|
|
{activity.active.length > 0 ? (
|
||
|
|
<>
|
||
|
|
<Text style={styles.sectionHeading}>Now</Text>
|
||
|
|
{activity.active.map((run) => (
|
||
|
|
<ActivityRow key={run.runId} run={run} onPress={() => openRun(run)} />
|
||
|
|
))}
|
||
|
|
</>
|
||
|
|
) : null}
|
||
|
|
{activity.recent.length > 0 ? (
|
||
|
|
<>
|
||
|
|
<Text style={[styles.sectionHeading, activity.active.length > 0 && styles.activityGap]}>
|
||
|
|
Recent
|
||
|
|
</Text>
|
||
|
|
{activity.recent.map((run) => (
|
||
|
|
<ActivityRow key={run.runId} run={run} onPress={() => openRun(run)} />
|
||
|
|
))}
|
||
|
|
</>
|
||
|
|
) : null}
|
||
|
|
</View>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ActivityRow({ run, onPress }: { run: RunActivityRow; onPress: () => void }) {
|
||
|
|
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
||
|
|
const status = activityStatusLabel(run.status);
|
||
|
|
const preview = run.promptSnippet ? `${run.promptSnippet} · ${status}` : status;
|
||
|
|
const activityLabel = `${title}, ${status}`;
|
||
|
|
return (
|
||
|
|
<Pressable
|
||
|
|
accessibilityRole="button"
|
||
|
|
accessibilityLabel={activityLabel}
|
||
|
|
onPress={onPress}
|
||
|
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||
|
|
>
|
||
|
|
<View style={styles.activityDot} />
|
||
|
|
<View style={styles.rowBody}>
|
||
|
|
<View style={styles.rowTop}>
|
||
|
|
<Text style={styles.name} numberOfLines={1}>
|
||
|
|
{title}
|
||
|
|
</Text>
|
||
|
|
<Text style={styles.time}>{formatActivityRelativeTime(run.updatedAt)}</Text>
|
||
|
|
</View>
|
||
|
|
<Text style={styles.preview} numberOfLines={1}>
|
||
|
|
{preview}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
</Pressable>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function CircleButton({
|
||
|
|
children,
|
||
|
|
onPress,
|
||
|
|
accessibilityLabel,
|
||
|
|
active = false,
|
||
|
|
accent = false,
|
||
|
|
}: {
|
||
|
|
children: ReactNode;
|
||
|
|
onPress: () => void;
|
||
|
|
accessibilityLabel: string;
|
||
|
|
active?: boolean;
|
||
|
|
accent?: boolean;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<Pressable
|
||
|
|
accessibilityRole="button"
|
||
|
|
accessibilityLabel={accessibilityLabel}
|
||
|
|
accessibilityState={{ selected: active }}
|
||
|
|
onPress={onPress}
|
||
|
|
hitSlop={4}
|
||
|
|
style={({ pressed }) => [
|
||
|
|
styles.circleButton,
|
||
|
|
accent && active ? styles.circleAccent : (active || pressed) && styles.circlePressed,
|
||
|
|
]}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</Pressable>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function SearchRow({ hit, onPress }: { hit: SearchHit; onPress: () => void }) {
|
||
|
|
return (
|
||
|
|
<Pressable
|
||
|
|
onPress={onPress}
|
||
|
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||
|
|
>
|
||
|
|
<View style={styles.rowBody}>
|
||
|
|
<View style={styles.rowTop}>
|
||
|
|
<Text style={styles.name} numberOfLines={1}>
|
||
|
|
{hit.title}
|
||
|
|
</Text>
|
||
|
|
<Text style={styles.time}>{hit.kind}</Text>
|
||
|
|
</View>
|
||
|
|
<Text style={styles.preview} numberOfLines={2}>
|
||
|
|
{hit.groupName ?? hit.botName} · {hit.snippet}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
</Pressable>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function BotRow({
|
||
|
|
bot,
|
||
|
|
onPress,
|
||
|
|
onLongPress,
|
||
|
|
}: {
|
||
|
|
bot: MobileBot | SpaceBot;
|
||
|
|
onPress: () => void;
|
||
|
|
onLongPress?: () => void;
|
||
|
|
}) {
|
||
|
|
const preview = previewSnippet(bot.preview, 40);
|
||
|
|
const time = bot.updatedAt ? formatThreadTime(bot.updatedAt) : "";
|
||
|
|
const tag = botTag(bot.title, bot.name);
|
||
|
|
// Spelled out because an explicit label replaces the one built from the row's children.
|
||
|
|
const label = [
|
||
|
|
bot.name,
|
||
|
|
tag,
|
||
|
|
bot.notifyOnFinish ? null : "notifications silenced",
|
||
|
|
bot.unread ? "unread" : null,
|
||
|
|
time,
|
||
|
|
preview,
|
||
|
|
]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(", ");
|
||
|
|
return (
|
||
|
|
<Pressable
|
||
|
|
accessibilityLabel={label}
|
||
|
|
accessibilityHint={
|
||
|
|
onLongPress ? "Long press to pin, move, or silence notifications" : undefined
|
||
|
|
}
|
||
|
|
onPress={onPress}
|
||
|
|
onLongPress={onLongPress}
|
||
|
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||
|
|
>
|
||
|
|
<BotAvatar
|
||
|
|
color={bot.color || FALLBACK_COLOR}
|
||
|
|
identity={bot.id}
|
||
|
|
status={bot.status}
|
||
|
|
muted={!bot.notifyOnFinish}
|
||
|
|
shape={bot.avatarShape}
|
||
|
|
imageSrc={botAvatarImageSrc(bot)}
|
||
|
|
/>
|
||
|
|
<View style={styles.rowBody}>
|
||
|
|
<View style={styles.rowTop}>
|
||
|
|
<View style={styles.titleRow}>
|
||
|
|
<Text style={styles.name} numberOfLines={1} ellipsizeMode="tail">
|
||
|
|
{bot.name}
|
||
|
|
</Text>
|
||
|
|
{tag ? (
|
||
|
|
<View style={styles.tag}>
|
||
|
|
<Text style={styles.tagLabel} numberOfLines={1} ellipsizeMode="tail">
|
||
|
|
{tag}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
) : null}
|
||
|
|
</View>
|
||
|
|
<View style={styles.rowMeta}>
|
||
|
|
{time ? <Text style={styles.time}>{time}</Text> : null}
|
||
|
|
{bot.unread ? <View accessibilityElementsHidden style={styles.unreadDot} /> : null}
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
{preview ? (
|
||
|
|
<Text
|
||
|
|
style={[styles.preview, bot.unread && styles.unreadPreview]}
|
||
|
|
numberOfLines={1}
|
||
|
|
ellipsizeMode="tail"
|
||
|
|
>
|
||
|
|
{preview}
|
||
|
|
</Text>
|
||
|
|
) : null}
|
||
|
|
</View>
|
||
|
|
</Pressable>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function GroupRow({
|
||
|
|
group,
|
||
|
|
onPress,
|
||
|
|
onLongPress,
|
||
|
|
}: {
|
||
|
|
group: MobileGroup | SpaceGroup;
|
||
|
|
onPress: () => void;
|
||
|
|
onLongPress?: () => void;
|
||
|
|
}) {
|
||
|
|
const preview =
|
||
|
|
previewSnippet(group.preview, 40) || group.members.map((member) => member.name).join(", ");
|
||
|
|
const time = group.updatedAt ? formatThreadTime(group.updatedAt) : "";
|
||
|
|
return (
|
||
|
|
<Pressable
|
||
|
|
accessibilityLabel={[group.name, group.unread ? "unread" : null, time, preview]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(", ")}
|
||
|
|
onPress={onPress}
|
||
|
|
onLongPress={onLongPress}
|
||
|
|
accessibilityHint={onLongPress ? "Long press to pin or move to a section" : undefined}
|
||
|
|
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||
|
|
>
|
||
|
|
<GroupAvatar members={group.members} size={54} />
|
||
|
|
<View style={styles.rowBody}>
|
||
|
|
<View style={styles.rowTop}>
|
||
|
|
<Text style={styles.name} numberOfLines={1}>
|
||
|
|
{group.name}
|
||
|
|
</Text>
|
||
|
|
<View style={styles.rowMeta}>
|
||
|
|
{time ? <Text style={styles.time}>{time}</Text> : null}
|
||
|
|
{group.unread ? <View accessibilityElementsHidden style={styles.unreadDot} /> : null}
|
||
|
|
</View>
|
||
|
|
</View>
|
||
|
|
<Text style={[styles.preview, group.unread && styles.unreadPreview]} numberOfLines={1}>
|
||
|
|
{preview}
|
||
|
|
</Text>
|
||
|
|
</View>
|
||
|
|
</Pressable>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const styles = StyleSheet.create({
|
||
|
|
screen: {
|
||
|
|
flex: 1,
|
||
|
|
backgroundColor: native.page,
|
||
|
|
},
|
||
|
|
centered: {
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "center",
|
||
|
|
},
|
||
|
|
header: {
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "space-between",
|
||
|
|
paddingHorizontal: 16,
|
||
|
|
paddingTop: 8,
|
||
|
|
paddingBottom: 10,
|
||
|
|
},
|
||
|
|
headerActions: {
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
gap: 12,
|
||
|
|
},
|
||
|
|
circleButton: {
|
||
|
|
width: 40,
|
||
|
|
height: 40,
|
||
|
|
borderRadius: 20,
|
||
|
|
backgroundColor: "#2C2C2E",
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "center",
|
||
|
|
overflow: "hidden",
|
||
|
|
},
|
||
|
|
circlePressed: {
|
||
|
|
backgroundColor: "#3A3A3C",
|
||
|
|
},
|
||
|
|
circleAccent: {
|
||
|
|
backgroundColor: "#4C8DFF",
|
||
|
|
},
|
||
|
|
profileInitials: {
|
||
|
|
color: native.label,
|
||
|
|
fontSize: 15,
|
||
|
|
fontWeight: "600",
|
||
|
|
},
|
||
|
|
searchField: {
|
||
|
|
marginHorizontal: 16,
|
||
|
|
marginBottom: 8,
|
||
|
|
height: 36,
|
||
|
|
borderRadius: 10,
|
||
|
|
backgroundColor: native.fill,
|
||
|
|
color: native.label,
|
||
|
|
paddingHorizontal: 12,
|
||
|
|
fontSize: 17,
|
||
|
|
writingDirection: "auto",
|
||
|
|
},
|
||
|
|
error: {
|
||
|
|
color: native.secondaryLabel,
|
||
|
|
paddingHorizontal: 20,
|
||
|
|
paddingBottom: 8,
|
||
|
|
},
|
||
|
|
list: {
|
||
|
|
flexGrow: 1,
|
||
|
|
paddingBottom: 32,
|
||
|
|
},
|
||
|
|
empty: {
|
||
|
|
color: native.secondaryLabel,
|
||
|
|
fontSize: 16,
|
||
|
|
paddingHorizontal: 20,
|
||
|
|
paddingTop: 28,
|
||
|
|
},
|
||
|
|
row: {
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
paddingHorizontal: 16,
|
||
|
|
paddingVertical: 10,
|
||
|
|
gap: 12,
|
||
|
|
},
|
||
|
|
rowPressed: {
|
||
|
|
opacity: 0.55,
|
||
|
|
},
|
||
|
|
rowBody: {
|
||
|
|
flex: 1,
|
||
|
|
minWidth: 0,
|
||
|
|
gap: 2,
|
||
|
|
},
|
||
|
|
rowTop: {
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
gap: 8,
|
||
|
|
},
|
||
|
|
titleRow: {
|
||
|
|
flex: 1,
|
||
|
|
minWidth: 0,
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
gap: 6,
|
||
|
|
},
|
||
|
|
rowMeta: {
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
gap: 7,
|
||
|
|
},
|
||
|
|
name: {
|
||
|
|
flexShrink: 1,
|
||
|
|
color: native.label,
|
||
|
|
fontSize: 17,
|
||
|
|
fontWeight: "600",
|
||
|
|
writingDirection: "auto",
|
||
|
|
},
|
||
|
|
tag: {
|
||
|
|
flexShrink: 1,
|
||
|
|
borderRadius: 999,
|
||
|
|
backgroundColor: native.fill,
|
||
|
|
paddingHorizontal: 7,
|
||
|
|
paddingVertical: 2,
|
||
|
|
},
|
||
|
|
tagLabel: {
|
||
|
|
color: native.secondaryLabel,
|
||
|
|
fontSize: 11,
|
||
|
|
fontWeight: "500",
|
||
|
|
writingDirection: "auto",
|
||
|
|
},
|
||
|
|
time: {
|
||
|
|
color: native.secondaryLabel,
|
||
|
|
fontSize: 15,
|
||
|
|
},
|
||
|
|
preview: {
|
||
|
|
color: native.secondaryLabel,
|
||
|
|
fontSize: 15,
|
||
|
|
lineHeight: 20,
|
||
|
|
writingDirection: "auto",
|
||
|
|
},
|
||
|
|
unreadPreview: {
|
||
|
|
color: native.label,
|
||
|
|
fontWeight: "600",
|
||
|
|
},
|
||
|
|
unreadDot: {
|
||
|
|
width: 8,
|
||
|
|
height: 8,
|
||
|
|
borderRadius: 4,
|
||
|
|
backgroundColor: "#8B5CF6",
|
||
|
|
},
|
||
|
|
sectionHeading: {
|
||
|
|
color: native.secondaryLabel,
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: "600",
|
||
|
|
paddingHorizontal: 16,
|
||
|
|
paddingTop: 12,
|
||
|
|
paddingBottom: 4,
|
||
|
|
},
|
||
|
|
activitySection: {
|
||
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||
|
|
borderBottomColor: "#2C2C2E",
|
||
|
|
marginBottom: 4,
|
||
|
|
paddingBottom: 4,
|
||
|
|
},
|
||
|
|
activityGap: {
|
||
|
|
paddingTop: 16,
|
||
|
|
},
|
||
|
|
activityDot: {
|
||
|
|
width: 8,
|
||
|
|
height: 8,
|
||
|
|
borderRadius: 4,
|
||
|
|
backgroundColor: "#8B5CF6",
|
||
|
|
marginTop: 6,
|
||
|
|
},
|
||
|
|
groupAvatar: {
|
||
|
|
width: 48,
|
||
|
|
height: 48,
|
||
|
|
borderRadius: 24,
|
||
|
|
backgroundColor: "#232326",
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "center",
|
||
|
|
},
|
||
|
|
groupAvatarLabel: {
|
||
|
|
color: "#C9C9CE",
|
||
|
|
fontSize: 16,
|
||
|
|
fontWeight: "600",
|
||
|
|
},
|
||
|
|
});
|