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([]); const [groups, setGroups] = useState([]); const [botSections, setBotSections] = useState([]); const [spaces, setSpaces] = useState([]); const [me, setMe] = useState(null); const [error, setError] = useState(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([]); 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("spaces/list"), rpc("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 | 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 | 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 ( ); } if (!hasSession) return ; return ( router.push("/account")}> {initials} setSearching((open) => { if (open) setQuery(""); return !open; }) } > 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" }, ]) } > {searching ? ( ) : null} {error ? {error} : null} 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={ { void refreshBots(); void loadActivity(); }} tintColor={native.secondaryLabel} colors={["#8E8E93"]} progressBackgroundColor="#1C1C1E" /> } ListHeaderComponent={ activityMode && !searching && !query.trim() && (activity.active.length > 0 || activity.recent.length > 0) ? ( ) : null } ListEmptyComponent={ {query.trim() && searching ? searchLoading ? "Searching…" : "No results" : query.trim() ? "No matching bots" : searching ? "Search conversations, files, and routines" : "Tap + to create a bot"} } renderItem={({ item }) => item.type === "search" ? ( { setQuery(""); setSearchHits([]); router.push(mobileSearchDestination(item.hit)); }} /> ) : item.type === "heading" ? ( {item.title} ) : item.type === "group" ? ( { 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 } /> ) : ( { 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 ? ( 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} ); } 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 ( {activity.active.length > 0 ? ( <> Now {activity.active.map((run) => ( openRun(run)} /> ))} ) : null} {activity.recent.length > 0 ? ( <> 0 && styles.activityGap]}> Recent {activity.recent.map((run) => ( openRun(run)} /> ))} ) : null} ); } 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 ( [styles.row, pressed && styles.rowPressed]} > {title} {formatActivityRelativeTime(run.updatedAt)} {preview} ); } function CircleButton({ children, onPress, accessibilityLabel, active = false, accent = false, }: { children: ReactNode; onPress: () => void; accessibilityLabel: string; active?: boolean; accent?: boolean; }) { return ( [ styles.circleButton, accent && active ? styles.circleAccent : (active || pressed) && styles.circlePressed, ]} > {children} ); } function SearchRow({ hit, onPress }: { hit: SearchHit; onPress: () => void }) { return ( [styles.row, pressed && styles.rowPressed]} > {hit.title} {hit.kind} {hit.groupName ?? hit.botName} · {hit.snippet} ); } 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 ( [styles.row, pressed && styles.rowPressed]} > {bot.name} {tag ? ( {tag} ) : null} {time ? {time} : null} {bot.unread ? : null} {preview ? ( {preview} ) : null} ); } 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 ( [styles.row, pressed && styles.rowPressed]} > {group.name} {time ? {time} : null} {group.unread ? : null} {preview} ); } 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", }, });