From 7f84c828e7f47e464f9868d72dff74d7997a6a85 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Sat, 2 May 2026 09:52:06 -0500 Subject: [PATCH 1/8] add app launcher for embedding urbit apps Lets the user open any installed Urbit desk inside Tlon via webview, with sidebar tabs for switched-to apps. The notes integration's iframe host is generalized into a reusable AppWebView, with %docket charges driving a launcher grid (landscape-style title pills with mix-blend-hard-light) and a session-scoped open-apps store powering the desktop sidebar tabs. Leap is extended to surface active tabs at the top, an "App launcher" entry by default, and individual matching apps when querying. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/client/index.ts | 1 + packages/api/src/client/settingsApi.ts | 10 + .../app/features/apps/AppLauncherScreen.tsx | 49 +++ .../app/features/apps/AppViewerScreen.tsx | 73 ++++ .../features/chat-list/FilteredLeapList.tsx | 391 ++++++++++++++++++ .../app/features/chat-list/GlobalSearch.tsx | 43 +- packages/app/features/top/ActivityScreen.tsx | 1 + packages/app/features/top/ChatListScreen.tsx | 3 + packages/app/features/top/ContactsScreen.tsx | 3 + packages/app/hooks/useOpenApps.ts | 25 ++ packages/app/navigation/RootStack.tsx | 8 + .../app/navigation/desktop/AppsNavigator.tsx | 19 + .../app/navigation/desktop/TopLevelDrawer.tsx | 188 ++++++++- packages/app/navigation/types.ts | 7 + .../app/ui/components/AppLauncherView.tsx | 167 ++++++++ .../ui/components/AppWebView/AppWebView.tsx | 103 +++++ .../appIframeHost.ts} | 8 +- .../appIframeHost.web.ts} | 28 +- .../app/ui/components/AppWebView/index.ts | 1 + packages/app/ui/components/NavBarView.tsx | 10 + .../components/NotesChannel/NotesWebView.tsx | 96 +---- packages/app/ui/index.tsx | 2 + packages/shared/src/store/dbHooks.ts | 46 +++ packages/ui/src/utils/color.ts | 20 + packages/ui/src/utils/index.ts | 1 + 25 files changed, 1184 insertions(+), 119 deletions(-) create mode 100644 packages/app/features/apps/AppLauncherScreen.tsx create mode 100644 packages/app/features/apps/AppViewerScreen.tsx create mode 100644 packages/app/features/chat-list/FilteredLeapList.tsx create mode 100644 packages/app/hooks/useOpenApps.ts create mode 100644 packages/app/navigation/desktop/AppsNavigator.tsx create mode 100644 packages/app/ui/components/AppLauncherView.tsx create mode 100644 packages/app/ui/components/AppWebView/AppWebView.tsx rename packages/app/ui/components/{NotesChannel/notesIframeHost.ts => AppWebView/appIframeHost.ts} (66%) rename packages/app/ui/components/{NotesChannel/notesIframeHost.web.ts => AppWebView/appIframeHost.web.ts} (78%) create mode 100644 packages/app/ui/components/AppWebView/index.ts create mode 100644 packages/ui/src/utils/color.ts diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index dd349cf14a..e4c0518331 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -1,4 +1,5 @@ export { udToDate } from './apiUtils'; +export { normalizeUrbitColor } from './utils'; export * from './channelContentConfig'; export * from './channelsApi'; export * from './chatApi'; diff --git a/packages/api/src/client/settingsApi.ts b/packages/api/src/client/settingsApi.ts index ac50a042b3..b7a0d4b1bc 100644 --- a/packages/api/src/client/settingsApi.ts +++ b/packages/api/src/client/settingsApi.ts @@ -260,6 +260,16 @@ export interface Pikes { [desk: string]: Pike; } +// Returns all installed apps known to %docket, keyed by desk name. Each entry +// includes title, color, image, href and chad (install status). +export async function getCharges(): Promise<{ [desk: string]: Charge }> { + const res = await scry({ + app: 'docket', + path: '/charges', + }); + return res.initial ?? {}; +} + export async function getAppInfo(): Promise { const pikes = await scry({ app: 'hood', diff --git a/packages/app/features/apps/AppLauncherScreen.tsx b/packages/app/features/apps/AppLauncherScreen.tsx new file mode 100644 index 0000000000..c36820e3ec --- /dev/null +++ b/packages/app/features/apps/AppLauncherScreen.tsx @@ -0,0 +1,49 @@ +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import * as store from '@tloncorp/shared/store'; +import { useCallback } from 'react'; + +import { useCurrentUserId } from '../../hooks/useCurrentUser'; +import { useOpenApp } from '../../hooks/useOpenApps'; +import type { RootStackParamList } from '../../navigation/types'; +import { AppLauncherView, NavBarView, View } from '../../ui'; + +export function AppLauncherScreen() { + const navigation = useNavigation>(); + const currentUserId = useCurrentUserId(); + const { data: apps = [], isLoading } = store.useInstalledApps(); + const openApp = useOpenApp(); + + const handleSelectApp = useCallback( + (app: store.InstalledApp) => { + openApp(app.desk); + navigation.navigate('AppViewer', { desk: app.desk }); + }, + [navigation, openApp] + ); + + return ( + + + { + navigation.navigate('Contacts'); + }} + navigateToHome={() => { + navigation.navigate('ChatList'); + }} + navigateToNotifications={() => { + navigation.navigate('Activity'); + }} + navigateToApps={() => { + navigation.navigate('AppLauncher'); + }} + currentRoute="AppLauncher" + currentUserId={currentUserId} + /> + + ); +} diff --git a/packages/app/features/apps/AppViewerScreen.tsx b/packages/app/features/apps/AppViewerScreen.tsx new file mode 100644 index 0000000000..98dd185b9d --- /dev/null +++ b/packages/app/features/apps/AppViewerScreen.tsx @@ -0,0 +1,73 @@ +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import * as db from '@tloncorp/shared/db'; +import * as store from '@tloncorp/shared/store'; +import { useIsWindowNarrow } from '@tloncorp/ui'; +import { useMemo } from 'react'; +import { Platform } from 'react-native'; + +import { + AppWebView, + ScreenHeader, + View, +} from '../../ui'; + +type AppViewerRouteParams = { + AppViewer: { desk: string }; +}; + +export function AppViewerScreen() { + const route = useRoute>(); + const navigation = useNavigation(); + const isWindowNarrow = useIsWindowNarrow(); + const { data: apps = [] } = store.useInstalledApps(); + const shipInfo = db.shipInfo.useValue(); + + const desk = route.params?.desk; + const charge = useMemo( + () => apps.find((app) => app.desk === desk), + [apps, desk] + ); + + // Native needs an absolute URL; web uses a relative path served by the same + // ship that hosts Tlon. + const shipUrl = + Platform.OS === 'web' ? undefined : shipInfo?.shipUrl ?? undefined; + + // Site charges are served from an arbitrary path; glob charges live under + // /apps/{base}/ where base usually matches the desk name. + const path = useMemo(() => { + if (charge && 'site' in charge.href) return charge.href.site; + if (charge && 'glob' in charge.href) { + const base = charge.href.glob.base || desk; + return `/apps/${base}/`; + } + return desk ? `/apps/${desk}/` : null; + }, [charge, desk]); + + if (!desk || !path) { + return ( + + navigation.goBack()} + /> + + ); + } + + return ( + + {isWindowNarrow && ( + navigation.goBack()} + /> + )} + + + ); +} diff --git a/packages/app/features/chat-list/FilteredLeapList.tsx b/packages/app/features/chat-list/FilteredLeapList.tsx new file mode 100644 index 0000000000..7cab0785bb --- /dev/null +++ b/packages/app/features/chat-list/FilteredLeapList.tsx @@ -0,0 +1,391 @@ +import { FlashList, ListRenderItem } from '@shopify/flash-list'; +import * as db from '@tloncorp/shared/db'; +import * as store from '@tloncorp/shared/store'; +import { + Icon, + Image, + Pressable, + SectionListHeader, + Text, + getDarkColor, + isLightColor, +} from '@tloncorp/ui'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { Platform } from 'react-native'; +import { Square, View, XStack, getTokenValue } from 'tamagui'; + +import { useFilteredChats } from '../../hooks/useFilteredChats'; +import { useOpenApps } from '../../hooks/useOpenApps'; +import { useResolvedChats } from '../../hooks/useResolvedChats'; +import { ChatListItem } from '../../ui'; + +const isWeb = Platform.OS === 'web'; +const TITLE_DARK = '#1f2937'; +const TITLE_LIGHT = '#e5e7eb'; +const ICON_SIZE = 28; +const blendStyle = isWeb + ? ({ mixBlendMode: 'hard-light' } as { mixBlendMode: 'hard-light' }) + : undefined; + +type LeapItem = + | { type: 'header'; title: string; key: string } + | { type: 'launcher'; key: string } + | { + type: 'app'; + app: store.InstalledApp; + isOpen: boolean; + key: string; + } + | { type: 'chat'; chat: db.Chat; key: string }; + +const isSelectable = (item: LeapItem) => item.type !== 'header'; + +export interface FilteredLeapListRef { + selectNext: () => void; + selectPrevious: () => void; + pressSelected: () => void; +} + +function AppIcon({ app }: { app: store.InstalledApp }) { + const lightBg = app.color ? isLightColor(app.color) : true; + const fallbackInitialColor = app.color ? getDarkColor(app.color) : 'white'; + const initialColor = isWeb + ? lightBg + ? TITLE_DARK + : TITLE_LIGHT + : fallbackInitialColor; + return ( + + {app.image ? ( + + ) : ( + + {app.title.slice(0, 1).toUpperCase()} + + )} + + ); +} + +function LauncherRow({ + selected, + onPress, +}: { + selected: boolean; + onPress: () => void; +}) { + return ( + + + + + + + + App launcher + + + + Browse + + + + ); +} + +function AppRow({ + app, + hint, + selected, + onPress, +}: { + app: store.InstalledApp; + hint: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + + + + {app.title} + + + + {hint} + + + + ); +} + +export const FilteredLeapList = React.memo( + forwardRef< + FilteredLeapListRef, + { + searchQuery: string; + onPressApp: (desk: string, isOpen: boolean) => void; + onPressChat: (chat: db.Chat) => void; + onPressLauncher: () => void; + } + >(function FilteredLeapList( + { searchQuery, onPressApp, onPressChat, onPressLauncher }, + ref + ) { + const listRef = useRef>(null); + + const { data: chats } = store.useCurrentChats(); + const resolvedChats = useResolvedChats(chats); + const filteredChatsConfig = useMemo( + () => ({ + ...resolvedChats, + pending: [], + searchQuery, + activeTab: 'all' as const, + }), + [resolvedChats, searchQuery] + ); + const chatSections = useFilteredChats(filteredChatsConfig); + + const { data: installed = [] } = store.useInstalledApps(); + const openDesks = useOpenApps(); + const openSet = useMemo(() => new Set(openDesks), [openDesks]); + + const items = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + const matches = (a: store.InstalledApp) => + !q || + a.title.toLowerCase().includes(q) || + a.desk.toLowerCase().includes(q); + + const active: store.InstalledApp[] = []; + const others: store.InstalledApp[] = []; + for (const app of installed) { + if (!matches(app)) continue; + if (openSet.has(app.desk)) active.push(app); + else others.push(app); + } + + const out: LeapItem[] = []; + if (active.length > 0) { + out.push({ type: 'header', title: 'Active', key: 'header:active' }); + for (const app of active) { + out.push({ + type: 'app', + app, + isOpen: true, + key: `app:${app.desk}`, + }); + } + } + + if (!q) { + // No query: collapse the rest of the apps into a single "open + // launcher" entry instead of listing them individually. + out.push({ type: 'launcher', key: 'launcher' }); + } else if (others.length > 0) { + out.push({ type: 'header', title: 'Apps', key: 'header:apps' }); + for (const app of others) { + out.push({ + type: 'app', + app, + isOpen: false, + key: `app:${app.desk}`, + }); + } + } + + for (const section of chatSections) { + if (section.data.length === 0) continue; + out.push({ + type: 'header', + title: section.title, + key: `header:${section.title}`, + }); + for (const chat of section.data) { + out.push({ type: 'chat', chat, key: `chat:${chat.type}:${chat.id}` }); + } + } + return out; + }, [chatSections, installed, openSet, searchQuery]); + + const firstSelectableIndex = useMemo(() => { + const i = items.findIndex(isSelectable); + return i === -1 ? 0 : i; + }, [items]); + + const [selectedIndex, setSelectedIndex] = useState(firstSelectableIndex); + + // Reset selection when items shape changes (e.g., new query). + useEffect(() => { + setSelectedIndex(firstSelectableIndex); + }, [firstSelectableIndex]); + + const updateSelection = useCallback((index: number) => { + setSelectedIndex(index); + listRef.current?.scrollToIndex({ + index, + animated: true, + viewPosition: 0.5, + }); + }, []); + + useImperativeHandle(ref, () => ({ + selectNext: () => { + let next = selectedIndex + 1; + while (next < items.length && !isSelectable(items[next])) next++; + if (next < items.length) updateSelection(next); + }, + selectPrevious: () => { + let prev = selectedIndex - 1; + while (prev >= 0 && !isSelectable(items[prev])) prev--; + if (prev >= 0) updateSelection(prev); + }, + pressSelected: () => { + const item = items[selectedIndex]; + if (!item) return; + if (item.type === 'launcher') onPressLauncher(); + if (item.type === 'app') onPressApp(item.app.desk, item.isOpen); + if (item.type === 'chat') onPressChat(item.chat); + }, + })); + + const renderItem: ListRenderItem = useCallback( + ({ item, index }) => { + if (item.type === 'header') { + return ( + + {item.title} + + ); + } + if (item.type === 'launcher') { + return ( + + ); + } + if (item.type === 'app') { + return ( + onPressApp(item.app.desk, item.isOpen)} + /> + ); + } + return ( + + ); + }, + [onPressApp, onPressChat, onPressLauncher, selectedIndex] + ); + + const getItemType = useCallback((item: LeapItem) => item.type, []); + const keyExtractor = useCallback((item: LeapItem) => item.key, []); + + const contentContainerStyle = useMemo( + () => ({ + padding: getTokenValue('$l', 'size'), + }), + [] + ); + + if (items.length === 0) { + if (searchQuery !== '') { + return ( + + No results found + + ); + } + return null; + } + + return ( + + + + ); + }) +); diff --git a/packages/app/features/chat-list/GlobalSearch.tsx b/packages/app/features/chat-list/GlobalSearch.tsx index 9f03bb0bb3..57e114592c 100644 --- a/packages/app/features/chat-list/GlobalSearch.tsx +++ b/packages/app/features/chat-list/GlobalSearch.tsx @@ -1,7 +1,11 @@ +import { NavigationProp, useNavigation } from '@react-navigation/native'; import type * as db from '@tloncorp/shared/db'; import { useCallback, useEffect, useRef, useState } from 'react'; import { NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native'; +import { Portal } from 'tamagui'; +import { useOpenApp } from '../../hooks/useOpenApps'; +import type { CombinedParamList } from '../../navigation/types'; import { TextInput, TextInputRef, @@ -11,7 +15,7 @@ import { YStack, useGlobalSearch, } from '../../ui'; -import { FilteredChatList, FilteredChatListRef } from './FilteredChatList'; +import { FilteredLeapList, FilteredLeapListRef } from './FilteredLeapList'; export interface GlobalSearchProps { navigateToGroup: (id: string) => void; @@ -25,7 +29,9 @@ export function GlobalSearch({ const { isOpen, setIsOpen } = useGlobalSearch(); const [searchQuery, setSearchQuery] = useState(''); const inputRef = useRef(null); - const listRef = useRef(null); + const listRef = useRef(null); + const navigation = useNavigation>(); + const openApp = useOpenApp(); const onPressItem = useCallback( async (item: db.Chat) => { @@ -39,6 +45,25 @@ export function GlobalSearch({ [navigateToGroup, navigateToChannel, setIsOpen] ); + const onPressApp = useCallback( + (desk: string, alreadyOpen: boolean) => { + if (!alreadyOpen) openApp(desk); + // The drawer 'Apps' route nests an inner stack; on mobile we navigate + // directly to the AppViewer screen registered on the root stack. + navigation.navigate('Apps', { + screen: 'AppViewer', + params: { desk }, + }); + setIsOpen(false); + }, + [navigation, openApp, setIsOpen] + ); + + const onPressLauncher = useCallback(() => { + navigation.navigate('Apps', { screen: 'AppLauncher' }); + setIsOpen(false); + }, [navigation, setIsOpen]); + const handleNavigationKey = useCallback( (key: string) => { switch (key) { @@ -116,7 +141,7 @@ export function GlobalSearch({ if (!isOpen) return null; return ( - <> + { @@ -129,7 +154,7 @@ export function GlobalSearch({ right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', - zIndex: 50, + zIndex: 9999, }} /> @@ -138,7 +163,7 @@ export function GlobalSearch({ top="20%" left="50%" borderRadius="$l" - zIndex={51} + zIndex={10000} backgroundColor="$background" transform="translateX(-50%)" padding="$l" @@ -169,10 +194,12 @@ export function GlobalSearch({ /> {isOpen && ( - )} @@ -210,6 +237,6 @@ export function GlobalSearch({ - + ); } diff --git a/packages/app/features/top/ActivityScreen.tsx b/packages/app/features/top/ActivityScreen.tsx index 48e760818a..4d6c8afa73 100644 --- a/packages/app/features/top/ActivityScreen.tsx +++ b/packages/app/features/top/ActivityScreen.tsx @@ -116,6 +116,7 @@ export function ActivityScreen(props: Props) { navigateToContacts={() => props.navigation.navigate('Contacts')} navigateToHome={() => props.navigation.navigate('ChatList')} navigateToNotifications={() => props.navigation.navigate('Activity')} + navigateToApps={() => props.navigation.navigate('AppLauncher')} currentRoute="Activity" currentUserId={currentUserId} showContactsTab={contactsTabEnabled} diff --git a/packages/app/features/top/ChatListScreen.tsx b/packages/app/features/top/ChatListScreen.tsx index a34f9c8f00..7036842cc1 100644 --- a/packages/app/features/top/ChatListScreen.tsx +++ b/packages/app/features/top/ChatListScreen.tsx @@ -388,6 +388,9 @@ export function ChatListScreenView({ navigateToNotifications={() => { navigation.navigate('Activity'); }} + navigateToApps={() => { + navigation.navigate('AppLauncher'); + }} currentRoute="ChatList" currentUserId={currentUser} /> diff --git a/packages/app/features/top/ContactsScreen.tsx b/packages/app/features/top/ContactsScreen.tsx index 6316929ad0..1209bf7986 100644 --- a/packages/app/features/top/ContactsScreen.tsx +++ b/packages/app/features/top/ContactsScreen.tsx @@ -136,6 +136,9 @@ export default function ContactsScreen(props: Props) { navigateToNotifications={() => { navigate('Activity'); }} + navigateToApps={() => { + navigate('AppLauncher'); + }} currentRoute="Contacts" currentUserId={currentUser} /> diff --git a/packages/app/hooks/useOpenApps.ts b/packages/app/hooks/useOpenApps.ts new file mode 100644 index 0000000000..71a2a10854 --- /dev/null +++ b/packages/app/hooks/useOpenApps.ts @@ -0,0 +1,25 @@ +import create from 'zustand'; + +interface OpenAppsState { + openApps: string[]; + openApp: (desk: string) => void; + closeApp: (desk: string) => void; +} + +// Tracks which app desks the user has opened during this session, in the +// order they were first opened. Used by the desktop sidebar to render a +// per-app shortcut icon below the "Apps" entry. In-memory only — refreshing +// clears the list. +export const useOpenAppsStore = create((set) => ({ + openApps: [], + openApp: (desk) => + set((s) => (s.openApps.includes(desk) + ? s + : { openApps: [...s.openApps, desk] })), + closeApp: (desk) => + set((s) => ({ openApps: s.openApps.filter((d) => d !== desk) })), +})); + +export const useOpenApps = () => useOpenAppsStore((s) => s.openApps); +export const useOpenApp = () => useOpenAppsStore((s) => s.openApp); +export const useCloseApp = () => useOpenAppsStore((s) => s.closeApp); diff --git a/packages/app/navigation/RootStack.tsx b/packages/app/navigation/RootStack.tsx index 5b8a91e27b..c487e8b9ca 100644 --- a/packages/app/navigation/RootStack.tsx +++ b/packages/app/navigation/RootStack.tsx @@ -3,6 +3,8 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { Platform, StatusBar } from 'react-native'; import { InviteUsersScreen } from '../features/InviteUsersScreen'; +import { AppLauncherScreen } from '../features/apps/AppLauncherScreen'; +import { AppViewerScreen } from '../features/apps/AppViewerScreen'; import { ChannelMembersScreen } from '../features/channels/ChannelMembersScreen'; import { ChannelMetaScreen } from '../features/channels/ChannelMetaScreen'; import { ChannelTemplateScreen } from '../features/channels/ChannelTemplateScreen'; @@ -87,6 +89,12 @@ export function RootStack() { gestureEnabled: false, }} /> + + {/* individual screens */} diff --git a/packages/app/navigation/desktop/AppsNavigator.tsx b/packages/app/navigation/desktop/AppsNavigator.tsx new file mode 100644 index 0000000000..7aa57247cb --- /dev/null +++ b/packages/app/navigation/desktop/AppsNavigator.tsx @@ -0,0 +1,19 @@ +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import { AppLauncherScreen } from '../../features/apps/AppLauncherScreen'; +import { AppViewerScreen } from '../../features/apps/AppViewerScreen'; +import { AppsDrawerParamList } from '../types'; + +const AppsStack = createNativeStackNavigator(); + +export const AppsNavigator = () => { + return ( + + + + + ); +}; diff --git a/packages/app/navigation/desktop/TopLevelDrawer.tsx b/packages/app/navigation/desktop/TopLevelDrawer.tsx index f09bb298c9..279c5b1b46 100644 --- a/packages/app/navigation/desktop/TopLevelDrawer.tsx +++ b/packages/app/navigation/desktop/TopLevelDrawer.tsx @@ -5,11 +5,20 @@ import { import { DrawerNavigationState } from '@react-navigation/native'; import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; -import { useCallback, useRef, useState } from 'react'; -import { getVariableValue, useTheme } from 'tamagui'; +import { + Image, + Pressable, + Text, + getDarkColor, + isLightColor, +} from '@tloncorp/ui'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Platform } from 'react-native'; +import { ScrollView, Square, View, getVariableValue, useTheme } from 'tamagui'; import { GlobalSearch } from '../../features/chat-list/GlobalSearch'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; +import { useCloseApp, useOpenApps } from '../../hooks/useOpenApps'; import { AvatarNavIcon, DESKTOP_TOPLEVEL_SIDEBAR_WIDTH, @@ -23,6 +32,7 @@ import { PersonalInviteSheet } from '../../ui/components/PersonalInviteSheet'; import { RootDrawerParamList } from '../types'; import { useRootNavigation } from '../utils'; import { ActivityNavigator } from './ActivityNavigator'; +import { AppsNavigator } from './AppsNavigator'; import { HomeNavigator } from './HomeNavigator'; import { MessagesNavigator } from './MessagesNavigator'; import { ProfileNavigator } from './ProfileNavigator'; @@ -30,6 +40,104 @@ import { SettingsNavigator } from './SettingsNavigator'; const Drawer = createDrawerNavigator(); +const APP_TILE_SIZE = 32; +const CLOSE_BUTTON_SIZE = 14; +const isWeb = Platform.OS === 'web'; +const TITLE_DARK = '#1f2937'; +const TITLE_LIGHT = '#e5e7eb'; +const blendStyle = isWeb + ? ({ mixBlendMode: 'hard-light' } as { mixBlendMode: 'hard-light' }) + : undefined; + +function OpenAppNavIcon({ + app, + isActive, + onPress, + onClose, +}: { + app: store.InstalledApp; + isActive: boolean; + onPress: () => void; + onClose: () => void; +}) { + const [hovered, setHovered] = useState(false); + const lightBg = app.color ? isLightColor(app.color) : true; + const fallbackInitialColor = app.color ? getDarkColor(app.color) : 'white'; + const initialColor = isWeb + ? lightBg + ? TITLE_DARK + : TITLE_LIGHT + : fallbackInitialColor; + return ( + setHovered(true)} + onHoverOut={() => setHovered(false)} + > + + + {app.image ? ( + + ) : ( + + {app.title.slice(0, 1).toUpperCase()} + + )} + + + {hovered && ( + + + × + + + )} + + ); +} + const DrawerContent = (props: DrawerContentComponentProps) => { const userId = useCurrentUserId(); // const { data: baseUnread } = store.useBaseUnread(); @@ -84,6 +192,49 @@ const DrawerContent = (props: DrawerContentComponentProps) => { setPersonalInviteOpen(true); }, []); + const openAppDesks = useOpenApps(); + const closeApp = useCloseApp(); + const { data: installedApps = [] } = store.useInstalledApps(); + const openAppEntries = useMemo(() => { + return openAppDesks + .map((desk) => installedApps.find((a) => a.desk === desk)) + .filter((a): a is store.InstalledApp => !!a); + }, [openAppDesks, installedApps]); + + // Read the currently-focused desk from the nested Apps stack so we can + // highlight the matching sidebar tile. + const activeDesk = useMemo(() => { + const appsRoute = props.state.routes.find((r) => r.name === 'Apps'); + const stack = appsRoute?.state; + if (!stack || stack.index == null) return null; + const focused = stack.routes[stack.index]; + if (focused?.name !== 'AppViewer') return null; + const params = focused.params as { desk?: string } | undefined; + return params?.desk ?? null; + }, [props.state]); + + const goToOpenApp = useCallback( + (desk: string) => { + saveHomeState(); + props.navigation.navigate('Apps', { + screen: 'AppViewer', + params: { desk }, + }); + }, + [props.navigation, saveHomeState] + ); + + const handleCloseApp = useCallback( + (desk: string) => { + closeApp(desk); + // If the closed app was the focused screen, fall back to the launcher. + if (activeDesk === desk) { + props.navigation.navigate('Apps', { screen: 'AppLauncher' }); + } + }, + [closeApp, activeDesk, props.navigation] + ); + return ( @@ -145,8 +296,38 @@ const DrawerContent = (props: DrawerContentComponentProps) => { shouldShowUnreads={false} /> )} + { + saveHomeState(); + props.navigation.reset({ + index: 0, + routes: [{ name: 'Apps' }], + }); + }} + /> - + + + {openAppEntries.map((app) => ( + goToOpenApp(app.desk)} + onClose={() => handleCloseApp(app.desk)} + /> + ))} + + + { + diff --git a/packages/app/navigation/types.ts b/packages/app/navigation/types.ts index 726cf86a6f..0f458a2026 100644 --- a/packages/app/navigation/types.ts +++ b/packages/app/navigation/types.ts @@ -11,6 +11,8 @@ export type RootStackParamList = { ChatList: { previewGroupId: string } | undefined; Activity: undefined; Settings: undefined; + AppLauncher: undefined; + AppViewer: { desk: string }; DM: { channelId: string; selectedPostId?: string | null; @@ -113,6 +115,7 @@ export type RootStackNavigationProp = NavigationProp; export type RootDrawerParamList = { Home: NavigatorScreenParams; Messages: NavigatorScreenParams; + Apps: NavigatorScreenParams; } & Pick; // hack: adding the true contacts types causes lots of tsc failures that need @@ -146,6 +149,10 @@ export type ProfileDrawerParamList = Pick< 'Contacts' | 'AddContacts' | 'UserProfile' >; +export type AppsDrawerParamList = { + AppLauncher: undefined; +} & Pick; + export type SettingsDrawerParamList = Pick< RootStackParamList, | 'AppSettings' diff --git a/packages/app/ui/components/AppLauncherView.tsx b/packages/app/ui/components/AppLauncherView.tsx new file mode 100644 index 0000000000..5705ac3190 --- /dev/null +++ b/packages/app/ui/components/AppLauncherView.tsx @@ -0,0 +1,167 @@ +import type { InstalledApp } from '@tloncorp/shared/store'; +import { + Image, + Pressable, + Text, + getDarkColor, + isLightColor, + useIsWindowNarrow, +} from '@tloncorp/ui'; +import { Platform } from 'react-native'; +import { ScrollView, View, YStack } from 'tamagui'; + +const TILE_SIZE = 140; +const TILE_CELL_PADDING = 10; +const NARROW_CELL_PADDING = 8; + +const isWeb = Platform.OS === 'web'; +// On web, mix-blend-hard-light (matching landscape's tile title) handles +// contrast against any tile color: paint the title pill the same color as +// the tile and let the blend mode do the work. The base text color is +// picked based on tile luminance — gray-800 over light tiles, gray-200 +// over dark tiles — so the blend stays in the readable contrast range. +const TITLE_DARK = '#1f2937'; +const TITLE_LIGHT = '#e5e7eb'; +const blendStyle = isWeb + ? ({ mixBlendMode: 'hard-light' } as { mixBlendMode: 'hard-light' }) + : undefined; + +function AppTile({ + app, + onPress, +}: { + app: InstalledApp; + onPress: (app: InstalledApp) => void; +}) { + const lightBg = app.color ? isLightColor(app.color) : true; + // Native lacks mix-blend-mode; fall back to landscape's HSL invert. + const fallbackTitleColor = app.color ? getDarkColor(app.color) : 'white'; + const titleColor = isWeb + ? lightBg + ? TITLE_DARK + : TITLE_LIGHT + : fallbackTitleColor; + const pillBackground = app.color || '$secondaryBackground'; + return ( + onPress(app)} + pressStyle={{ opacity: 0.7 }} + hoverStyle={{ opacity: 0.85 }} + borderRadius="$xl" + width="100%" + aspectRatio={1} + > + + {app.image && ( + + )} + {app.title && ( + + + {app.title} + + + )} + + + ); +} + +export function AppLauncherView({ + apps, + isLoading, + onSelectApp, +}: { + apps: InstalledApp[]; + isLoading: boolean; + onSelectApp: (app: InstalledApp) => void; +}) { + const isWindowNarrow = useIsWindowNarrow(); + + if (!isLoading && apps.length === 0) { + return ( + + + No apps installed yet. Install apps from the Urbit network to see them + here. + + + ); + } + + // On narrow windows, force a 2-column grid where tiles fill the available + // width via percentage cells. On wide windows tiles stay at the fixed + // TILE_SIZE and wrap as many per row as fit. + const cellPadding = isWindowNarrow ? NARROW_CELL_PADDING : TILE_CELL_PADDING; + const wideCellWidth = TILE_SIZE + TILE_CELL_PADDING * 2; + const containerPadding = isWindowNarrow ? 8 : 12; + + return ( + + + {apps.map((app) => + isWindowNarrow ? ( + + + + ) : ( + + + + ) + )} + + + ); +} diff --git a/packages/app/ui/components/AppWebView/AppWebView.tsx b/packages/app/ui/components/AppWebView/AppWebView.tsx new file mode 100644 index 0000000000..4d9a0b0dff --- /dev/null +++ b/packages/app/ui/components/AppWebView/AppWebView.tsx @@ -0,0 +1,103 @@ +import { LoadingSpinner } from '@tloncorp/ui'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Platform } from 'react-native'; +import { WebView } from 'react-native-webview'; +import { View } from 'tamagui'; + +import { mountAppIframe } from './appIframeHost'; + +interface AppWebViewProps { + // Required on native (the WebView needs an absolute URL); on web we use the + // current origin since the Tlon web app is served by the same ship. + shipUrl?: string; + // Path to load on the ship, relative to its origin (e.g. '/apps/landscape/' + // or '/notes/?notebook=...&embed=1'). Should already include a query string + // if needed. + path: string; + // Identity for iframe caching on web. Two AppWebViews with the same key + // will share an iframe across mount/unmount cycles, preserving state. + cacheKey: string; +} + +function LoadingOverlay() { + return ( + + + + ); +} + +function AppWebViewWeb({ path, cacheKey }: AppWebViewProps) { + const slotRef = useRef(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + const slot = slotRef.current; + if (!slot) return; + const handle = mountAppIframe(cacheKey, path, slot); + setLoaded(handle.isLoaded()); + const unsubscribe = handle.onLoad(() => setLoaded(true)); + return () => { + unsubscribe(); + handle.detach(); + }; + }, [cacheKey, path]); + + return ( + +
+ {!loaded && } + + ); +} + +function AppWebViewNative({ shipUrl, path }: AppWebViewProps) { + const [loaded, setLoaded] = useState(false); + const handleLoad = useCallback(() => setLoaded(true), []); + + if (!shipUrl) { + return ( + + + + ); + } + + const base = shipUrl.replace(/\/$/, ''); + const url = `${base}${path.startsWith('/') ? path : `/${path}`}`; + return ( + + + request.url.startsWith(base) + } + /> + {!loaded && } + + ); +} + +export function AppWebView(props: AppWebViewProps) { + return Platform.OS === 'web' ? ( + + ) : ( + + ); +} diff --git a/packages/app/ui/components/NotesChannel/notesIframeHost.ts b/packages/app/ui/components/AppWebView/appIframeHost.ts similarity index 66% rename from packages/app/ui/components/NotesChannel/notesIframeHost.ts rename to packages/app/ui/components/AppWebView/appIframeHost.ts index 60a191e0bf..df2c163e53 100644 --- a/packages/app/ui/components/NotesChannel/notesIframeHost.ts +++ b/packages/app/ui/components/AppWebView/appIframeHost.ts @@ -1,16 +1,16 @@ // Native stub. The persistent-iframe technique only applies to web; native // uses a real WebView that's mounted/unmounted per screen. -export interface NotesIframeHandle { +export interface AppIframeHandle { isLoaded: () => boolean; onLoad: (cb: () => void) => () => void; detach: () => void; } -export function mountNotesIframe( +export function mountAppIframe( _key: string, _src: string, _slot: unknown -): NotesIframeHandle { - throw new Error('mountNotesIframe is web-only'); +): AppIframeHandle { + throw new Error('mountAppIframe is web-only'); } diff --git a/packages/app/ui/components/NotesChannel/notesIframeHost.web.ts b/packages/app/ui/components/AppWebView/appIframeHost.web.ts similarity index 78% rename from packages/app/ui/components/NotesChannel/notesIframeHost.web.ts rename to packages/app/ui/components/AppWebView/appIframeHost.web.ts index 8906a22b7d..d56f649971 100644 --- a/packages/app/ui/components/NotesChannel/notesIframeHost.web.ts +++ b/packages/app/ui/components/AppWebView/appIframeHost.web.ts @@ -1,10 +1,10 @@ // Persistent iframe host (web only). // -// Mounting and unmounting the notes WebView inside a screen would force the -// iframe to reload on every channel visit. Instead, we keep one iframe per -// notebook flag alive in a top-level fixed-position container and sync its -// bounding box to a placeholder slot rendered by the channel screen. When the -// screen unmounts, we just hide the iframe — its state is preserved. +// Mounting and unmounting an embedded Urbit app inside a screen would force +// the iframe to reload on every visit. Instead, we keep one iframe per cache +// key alive in a top-level fixed-position container and sync its bounding box +// to a placeholder slot rendered by the screen. When the screen unmounts, we +// just hide the iframe — its state is preserved. interface CachedIframe { iframe: HTMLIFrameElement; @@ -19,24 +19,27 @@ let hostContainer: HTMLDivElement | null = null; function getHost(): HTMLDivElement { if (!hostContainer) { hostContainer = document.createElement('div'); - hostContainer.setAttribute('data-notes-iframe-host', ''); + hostContainer.setAttribute('data-app-iframe-host', ''); hostContainer.style.position = 'fixed'; hostContainer.style.top = '0'; hostContainer.style.left = '0'; hostContainer.style.width = '0'; hostContainer.style.height = '0'; hostContainer.style.pointerEvents = 'none'; + // Iframes are appended to as siblings of the React root, so we + // pin them low in the stacking order; in-app overlays (Leap, sheets) + // can claim higher z-index values. + hostContainer.style.zIndex = '0'; document.body.appendChild(hostContainer); } return hostContainer; } function getOrCreate(key: string, src: string): CachedIframe { - let entry = iframes.get(key); + const entry = iframes.get(key); if (entry && entry.src === src) { return entry; } - // If src changed for the same key, drop and recreate. if (entry && entry.iframe.parentNode) { entry.iframe.parentNode.removeChild(entry.iframe); } @@ -47,6 +50,7 @@ function getOrCreate(key: string, src: string): CachedIframe { iframe.style.border = 'none'; iframe.style.background = 'transparent'; iframe.style.display = 'none'; + iframe.style.zIndex = '0'; getHost().appendChild(iframe); const created: CachedIframe = { @@ -63,17 +67,17 @@ function getOrCreate(key: string, src: string): CachedIframe { return created; } -export interface NotesIframeHandle { +export interface AppIframeHandle { isLoaded: () => boolean; onLoad: (cb: () => void) => () => void; detach: () => void; } -export function mountNotesIframe( +export function mountAppIframe( key: string, src: string, slot: HTMLElement -): NotesIframeHandle { +): AppIframeHandle { const entry = getOrCreate(key, src); const { iframe } = entry; @@ -93,7 +97,6 @@ export function mountNotesIframe( const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(sync) : null; ro?.observe(slot); - // Also observe the body so layout shifts elsewhere keep the iframe aligned. ro?.observe(document.body); window.addEventListener('resize', sync); window.addEventListener('scroll', sync, true); @@ -103,7 +106,6 @@ export function mountNotesIframe( isLoaded: () => entry.loaded, onLoad: (cb) => { if (entry.loaded) { - // Fire on next tick so callers can complete their setup first. const id = window.setTimeout(cb, 0); return () => window.clearTimeout(id); } diff --git a/packages/app/ui/components/AppWebView/index.ts b/packages/app/ui/components/AppWebView/index.ts new file mode 100644 index 0000000000..49819ea495 --- /dev/null +++ b/packages/app/ui/components/AppWebView/index.ts @@ -0,0 +1 @@ +export { AppWebView } from './AppWebView'; diff --git a/packages/app/ui/components/NavBarView.tsx b/packages/app/ui/components/NavBarView.tsx index aa8d4a500e..1072600690 100644 --- a/packages/app/ui/components/NavBarView.tsx +++ b/packages/app/ui/components/NavBarView.tsx @@ -10,12 +10,14 @@ export const NavBarView = ({ navigateToHome, navigateToNotifications, navigateToContacts, + navigateToApps, currentRoute, currentUserId, }: { navigateToHome: () => void; navigateToNotifications: () => void; navigateToContacts?: () => void; + navigateToApps?: () => void; currentRoute: string; currentUserId: string; showContactsTab?: boolean; @@ -67,6 +69,14 @@ export const NavBarView = ({ isActive={isRouteActive('Activity')} onPress={navigateToNotifications} /> + {navigateToApps && ( + + )} - - - ); -} - -function NotesWebViewWeb({ notebookFlag, hideHeader }: NotesWebViewProps) { - const slotRef = useRef(null); - const [loaded, setLoaded] = useState(false); - // Persist one iframe per notebook flag (and per query shape) across screen - // mounts so navigating away and back doesn't trigger a fresh reload. - const cacheKey = notebookFlag ?? '__no_notebook__'; - const src = `/notes/${buildQuery({ notebookFlag, hideHeader })}`; - - useEffect(() => { - const slot = slotRef.current; - if (!slot) return; - const handle = mountNotesIframe(cacheKey, src, slot); - setLoaded(handle.isLoaded()); - const unsubscribe = handle.onLoad(() => setLoaded(true)); - return () => { - unsubscribe(); - handle.detach(); - }; - }, [cacheKey, src]); - - return ( - -
- {!loaded && } - - ); -} - -function NotesWebViewNative(props: NotesWebViewProps) { - const query = useMemo(() => buildQuery(props), [props]); - const [loaded, setLoaded] = useState(false); - const handleLoad = useCallback(() => setLoaded(true), []); - - if (!props.shipUrl) { - return ( - - - - ); - } - - const base = props.shipUrl.replace(/\/$/, ''); - const url = `${base}/notes/${query}`; - return ( - - - request.url.startsWith(base) - } - /> - {!loaded && } - - ); -} - export function NotesWebView(props: NotesWebViewProps) { - return Platform.OS === 'web' ? ( - - ) : ( - + const path = `/notes/${buildQuery(props)}`; + const cacheKey = `notes:${props.notebookFlag ?? '__none__'}`; + return ( + ); } diff --git a/packages/app/ui/index.tsx b/packages/app/ui/index.tsx index 89174db5f9..062f322a13 100644 --- a/packages/app/ui/index.tsx +++ b/packages/app/ui/index.tsx @@ -1,7 +1,9 @@ export * from './components/ActionSheet'; export * from './components/Activity/ActivityScreenView'; export * from './components/AddContactsView'; +export * from './components/AppLauncherView'; export * from './components/AppSetting'; +export * from './components/AppWebView'; export * from './components/ArvosDiscussing'; export * from './components/Avatar'; export { GroupAvatar } from './components/GroupAvatar'; diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index 958c9f4699..ea9f120270 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -45,6 +45,52 @@ export const useCurrentChats = ( }); }; +// Scry %docket for the list of apps installed on the user's ship. Excludes +// the Tlon desks themselves since they're already represented by the host UI, +// and apps in a broken install state. +const HIDDEN_DESKS = new Set(['groups', 'talk', 'landscape']); + +export interface InstalledApp { + desk: string; + title: string; + info?: string; + color: string | null; + image?: string; + version: string; + href: api.DocketHref; +} + +export const useInstalledApps = () => { + return useQuery({ + queryKey: ['installedApps'], + queryFn: async (): Promise => { + try { + const charges = await api.getCharges(); + return Object.entries(charges) + .filter(([desk, charge]) => { + if (HIDDEN_DESKS.has(desk)) return false; + // Skip apps in clearly-broken states; allow glob/site/install + // through so the user sees the same set landscape would. + return !('hung' in charge.chad || 'suspend' in charge.chad); + }) + .map(([desk, charge]) => ({ + desk, + title: charge.title, + info: charge.info, + color: api.normalizeUrbitColor(charge.color), + image: charge.image, + version: charge.version, + href: charge.href, + })) + .sort((a, b) => a.title.localeCompare(b.title)); + } catch (e) { + return []; + } + }, + staleTime: 60_000, + }); +}; + // Scry %notes once to detect whether the notes desk is installed on the // user's ship. Used to gate notes-specific UI (channel-creation option, // 'Bulletin' rename, etc.). Defaults to false until the scry resolves. diff --git a/packages/ui/src/utils/color.ts b/packages/ui/src/utils/color.ts new file mode 100644 index 0000000000..128734b23f --- /dev/null +++ b/packages/ui/src/utils/color.ts @@ -0,0 +1,20 @@ +import { hsla, parseToHsla, readableColorIsBlack } from 'color2k'; + +// Mirror of landscape's `getDarkColor`. Returns the input color with its HSL +// lightness inverted, which yields a contrasting tone in the same hue. Used +// for app titles drawn over a charge color so they harmonize with the tile +// background. +export function getDarkColor(color: string): string { + const [h, s, l] = parseToHsla(color); + return hsla(h, s, 1 - l, 1); +} + +// True when the supplied color is light enough that black text reads on it +// better than white. Wraps color2k for callers that don't depend on it. +export function isLightColor(color: string): boolean { + try { + return readableColorIsBlack(color); + } catch { + return true; + } +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 2e16c04e27..f958f64a8e 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './color'; export * from './storage'; export * from './haptics'; export * from './formatUtils'; From 943394adf421b87fc17cfd58d3242f644ec48c12 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Sat, 2 May 2026 15:13:21 -0500 Subject: [PATCH 2/8] track recents, surface in leap, smooth out leap navigation Adds a generic recents log (scope/kind/target_id/last_visited_at/count) populated from AppViewer + ChannelScreen on focus and from contact mention selection in BareChatInput. Leap surfaces the visit log as a "Recents" section above the launcher entry, falling back to per-app matches once the user types. Also smooths out a few rough edges around the embedded webviews: RAF-throttles the iframe layout sync to avoid main-thread thrashing during drawer transitions, pauses the iframe entirely when AppViewer loses focus, defers Leap-driven navigation by a frame so unmount and mount don't share a commit, and lazy-mounts the FlashList one frame after the panel paints. Also fixes the Apps sidebar icon to navigate explicitly to AppLauncher (popping any open AppViewer) so its highlight state matches what's rendered. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/features/apps/AppViewerScreen.tsx | 18 ++- .../features/chat-list/FilteredLeapList.tsx | 116 ++++++++++++------ .../app/features/chat-list/GlobalSearch.tsx | 63 +++++++--- packages/app/features/top/ChannelScreen.tsx | 1 + .../app/navigation/desktop/TopLevelDrawer.tsx | 8 +- .../ui/components/AppWebView/AppWebView.tsx | 11 +- .../AppWebView/appIframeHost.web.ts | 19 ++- .../app/ui/components/BareChatInput/index.tsx | 7 ++ ...ive_slayback.sql => 0000_soft_lockjaw.sql} | 9 ++ .../src/db/migrations/meta/0000_snapshot.json | 57 ++++++++- .../src/db/migrations/meta/_journal.json | 4 +- .../shared/src/db/migrations/migrations.js | 2 +- packages/shared/src/db/queries.ts | 53 ++++++++ packages/shared/src/db/schema.ts | 22 ++++ packages/shared/src/store/dbHooks.ts | 15 +++ packages/shared/src/store/index.ts | 1 + packages/shared/src/store/recents.ts | 20 +++ 17 files changed, 356 insertions(+), 70 deletions(-) rename packages/shared/src/db/migrations/{0000_reflective_slayback.sql => 0000_soft_lockjaw.sql} (98%) create mode 100644 packages/shared/src/store/recents.ts diff --git a/packages/app/features/apps/AppViewerScreen.tsx b/packages/app/features/apps/AppViewerScreen.tsx index 98dd185b9d..1d095bf641 100644 --- a/packages/app/features/apps/AppViewerScreen.tsx +++ b/packages/app/features/apps/AppViewerScreen.tsx @@ -1,8 +1,14 @@ -import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { + RouteProp, + useFocusEffect, + useIsFocused, + useNavigation, + useRoute, +} from '@react-navigation/native'; import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import { useIsWindowNarrow } from '@tloncorp/ui'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { Platform } from 'react-native'; import { @@ -19,6 +25,7 @@ export function AppViewerScreen() { const route = useRoute>(); const navigation = useNavigation(); const isWindowNarrow = useIsWindowNarrow(); + const isFocused = useIsFocused(); const { data: apps = [] } = store.useInstalledApps(); const shipInfo = db.shipInfo.useValue(); @@ -28,6 +35,12 @@ export function AppViewerScreen() { [apps, desk] ); + useFocusEffect( + useCallback(() => { + if (desk) store.recordVisit({ kind: 'app', id: desk }); + }, [desk]) + ); + // Native needs an absolute URL; web uses a relative path served by the same // ship that hosts Tlon. const shipUrl = @@ -67,6 +80,7 @@ export function AppViewerScreen() { shipUrl={shipUrl} path={path} cacheKey={`app:${desk}`} + paused={!isFocused} /> ); diff --git a/packages/app/features/chat-list/FilteredLeapList.tsx b/packages/app/features/chat-list/FilteredLeapList.tsx index 7cab0785bb..dfa49b2547 100644 --- a/packages/app/features/chat-list/FilteredLeapList.tsx +++ b/packages/app/features/chat-list/FilteredLeapList.tsx @@ -204,48 +204,84 @@ export const FilteredLeapList = React.memo( const { data: installed = [] } = store.useInstalledApps(); const openDesks = useOpenApps(); const openSet = useMemo(() => new Set(openDesks), [openDesks]); + const { data: recents = [] } = store.useRecents({ + scope: 'visit', + limit: 8, + }); + + // Index installed apps and chats by id so we can resolve a recents row + // (which only carries a target_id) into a renderable model. Chats that + // no longer exist (left channel, etc.) and apps that aren't installed + // anymore are silently skipped. + const appByDesk = useMemo(() => { + const map = new Map(); + for (const app of installed) map.set(app.desk, app); + return map; + }, [installed]); + const chatById = useMemo(() => { + const map = new Map(); + const all = [ + ...resolvedChats.pinned, + ...resolvedChats.unpinned, + ]; + for (const chat of all) map.set(chat.id, chat); + return map; + }, [resolvedChats]); const items = useMemo(() => { const q = searchQuery.trim().toLowerCase(); - const matches = (a: store.InstalledApp) => - !q || - a.title.toLowerCase().includes(q) || - a.desk.toLowerCase().includes(q); - - const active: store.InstalledApp[] = []; - const others: store.InstalledApp[] = []; - for (const app of installed) { - if (!matches(app)) continue; - if (openSet.has(app.desk)) active.push(app); - else others.push(app); - } - const out: LeapItem[] = []; - if (active.length > 0) { - out.push({ type: 'header', title: 'Active', key: 'header:active' }); - for (const app of active) { - out.push({ - type: 'app', - app, - isOpen: true, - key: `app:${app.desk}`, - }); - } - } if (!q) { - // No query: collapse the rest of the apps into a single "open - // launcher" entry instead of listing them individually. + // No query: lead with the recents log, then a launcher entry, then + // the full chat list. + const recentItems: LeapItem[] = []; + for (const r of recents) { + if (r.kind === 'app') { + const app = appByDesk.get(r.targetId); + if (!app) continue; + recentItems.push({ + type: 'app', + app, + isOpen: openSet.has(app.desk), + key: `recent:app:${app.desk}`, + }); + } else if (r.kind === 'channel') { + const chat = chatById.get(r.targetId); + if (!chat) continue; + recentItems.push({ + type: 'chat', + chat, + key: `recent:chat:${chat.type}:${chat.id}`, + }); + } + } + if (recentItems.length > 0) { + out.push({ type: 'header', title: 'Recents', key: 'header:recents' }); + out.push(...recentItems); + } out.push({ type: 'launcher', key: 'launcher' }); - } else if (others.length > 0) { - out.push({ type: 'header', title: 'Apps', key: 'header:apps' }); - for (const app of others) { - out.push({ - type: 'app', - app, - isOpen: false, - key: `app:${app.desk}`, - }); + } else { + // Query present: surface individual matching apps so they can be + // launched/switched directly. + const matches = (a: store.InstalledApp) => + a.title.toLowerCase().includes(q) || + a.desk.toLowerCase().includes(q); + + const matched: store.InstalledApp[] = []; + for (const app of installed) { + if (matches(app)) matched.push(app); + } + if (matched.length > 0) { + out.push({ type: 'header', title: 'Apps', key: 'header:apps' }); + for (const app of matched) { + out.push({ + type: 'app', + app, + isOpen: openSet.has(app.desk), + key: `app:${app.desk}`, + }); + } } } @@ -261,7 +297,15 @@ export const FilteredLeapList = React.memo( } } return out; - }, [chatSections, installed, openSet, searchQuery]); + }, [ + appByDesk, + chatById, + chatSections, + installed, + openSet, + recents, + searchQuery, + ]); const firstSelectableIndex = useMemo(() => { const i = items.findIndex(isSelectable); diff --git a/packages/app/features/chat-list/GlobalSearch.tsx b/packages/app/features/chat-list/GlobalSearch.tsx index 57e114592c..d974f78471 100644 --- a/packages/app/features/chat-list/GlobalSearch.tsx +++ b/packages/app/features/chat-list/GlobalSearch.tsx @@ -33,36 +33,46 @@ export function GlobalSearch({ const navigation = useNavigation>(); const openApp = useOpenApp(); + // Close Leap immediately, then fire navigation on the next frame so + // React can finish unmounting the heavy FlashList before the destination + // screen starts mounting. Without this defer, both happen in the same + // commit and feel like a 100–300ms hitch on Enter. + const deferNavigate = useCallback((fn: () => void) => { + setIsOpen(false); + requestAnimationFrame(fn); + }, [setIsOpen]); + const onPressItem = useCallback( - async (item: db.Chat) => { - if (item.type === 'group') { - navigateToGroup(item.group.id); - } else { - navigateToChannel(item.channel); - } - setIsOpen(false); + (item: db.Chat) => { + deferNavigate(() => { + if (item.type === 'group') { + navigateToGroup(item.group.id); + } else { + navigateToChannel(item.channel); + } + }); }, - [navigateToGroup, navigateToChannel, setIsOpen] + [navigateToGroup, navigateToChannel, deferNavigate] ); const onPressApp = useCallback( (desk: string, alreadyOpen: boolean) => { if (!alreadyOpen) openApp(desk); - // The drawer 'Apps' route nests an inner stack; on mobile we navigate - // directly to the AppViewer screen registered on the root stack. - navigation.navigate('Apps', { - screen: 'AppViewer', - params: { desk }, + deferNavigate(() => { + navigation.navigate('Apps', { + screen: 'AppViewer', + params: { desk }, + }); }); - setIsOpen(false); }, - [navigation, openApp, setIsOpen] + [navigation, openApp, deferNavigate] ); const onPressLauncher = useCallback(() => { - navigation.navigate('Apps', { screen: 'AppLauncher' }); - setIsOpen(false); - }, [navigation, setIsOpen]); + deferNavigate(() => { + navigation.navigate('Apps', { screen: 'AppLauncher' }); + }); + }, [navigation, deferNavigate]); const handleNavigationKey = useCallback( (key: string) => { @@ -138,6 +148,19 @@ export function GlobalSearch({ } }, [isOpen]); + // The FlashList of recents/apps/chats is the slowest part of Leap on first + // open. Defer mounting it one frame so the panel chrome paints first and + // the input is immediately usable. + const [listMounted, setListMounted] = useState(false); + useEffect(() => { + if (!isOpen) { + setListMounted(false); + return; + } + const id = requestAnimationFrame(() => setListMounted(true)); + return () => cancelAnimationFrame(id); + }, [isOpen]); + if (!isOpen) return null; return ( @@ -147,6 +170,7 @@ export function GlobalSearch({ onPress={() => { setIsOpen(false); }} + pointerEvents="auto" style={{ position: 'fixed', top: 0, @@ -160,6 +184,7 @@ export function GlobalSearch({ - {isOpen && ( + {isOpen && listMounted && ( { testID="AppsNavIcon" onPress={() => { saveHomeState(); - props.navigation.reset({ - index: 0, - routes: [{ name: 'Apps' }], - }); + // Explicit nested navigation pops any open AppViewer back to + // the launcher so the icon's active state matches what's + // actually rendered. + props.navigation.navigate('Apps', { screen: 'AppLauncher' }); }} /> diff --git a/packages/app/ui/components/AppWebView/AppWebView.tsx b/packages/app/ui/components/AppWebView/AppWebView.tsx index 4d9a0b0dff..ead59377bd 100644 --- a/packages/app/ui/components/AppWebView/AppWebView.tsx +++ b/packages/app/ui/components/AppWebView/AppWebView.tsx @@ -17,6 +17,10 @@ interface AppWebViewProps { // Identity for iframe caching on web. Two AppWebViews with the same key // will share an iframe across mount/unmount cycles, preserving state. cacheKey: string; + // When true, detach the iframe (display: none + observers off). Used while + // the host screen is unfocused on desktop so ResizeObserver bursts during + // drawer transitions don't thrash layout. The cached iframe is preserved. + paused?: boolean; } function LoadingOverlay() { @@ -37,11 +41,12 @@ function LoadingOverlay() { ); } -function AppWebViewWeb({ path, cacheKey }: AppWebViewProps) { +function AppWebViewWeb({ path, cacheKey, paused }: AppWebViewProps) { const slotRef = useRef(null); const [loaded, setLoaded] = useState(false); useEffect(() => { + if (paused) return; const slot = slotRef.current; if (!slot) return; const handle = mountAppIframe(cacheKey, path, slot); @@ -51,12 +56,12 @@ function AppWebViewWeb({ path, cacheKey }: AppWebViewProps) { unsubscribe(); handle.detach(); }; - }, [cacheKey, path]); + }, [cacheKey, path, paused]); return (
- {!loaded && } + {!loaded && !paused && } ); } diff --git a/packages/app/ui/components/AppWebView/appIframeHost.web.ts b/packages/app/ui/components/AppWebView/appIframeHost.web.ts index d56f649971..52dcdc40e2 100644 --- a/packages/app/ui/components/AppWebView/appIframeHost.web.ts +++ b/packages/app/ui/components/AppWebView/appIframeHost.web.ts @@ -84,7 +84,12 @@ export function mountAppIframe( iframe.style.display = 'block'; iframe.style.pointerEvents = 'auto'; - const sync = () => { + // Layout sync is RAF-throttled. Without this, ResizeObserver bursts during + // a drawer transition cause synchronous getBoundingClientRect + style + // writes on every event, thrashing layout on the main thread. + let rafId = 0; + const doSync = () => { + rafId = 0; if (!slot.isConnected) return; const rect = slot.getBoundingClientRect(); iframe.style.left = `${rect.left}px`; @@ -92,7 +97,13 @@ export function mountAppIframe( iframe.style.width = `${rect.width}px`; iframe.style.height = `${rect.height}px`; }; - sync(); + const sync = () => { + if (rafId !== 0) return; + rafId = window.requestAnimationFrame(doSync); + }; + // Initial position is synchronous so the iframe doesn't flash at a wrong + // location before the first frame runs. + doSync(); const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(sync) : null; @@ -115,6 +126,10 @@ export function mountAppIframe( detach: () => { if (detached) return; detached = true; + if (rafId !== 0) { + window.cancelAnimationFrame(rafId); + rafId = 0; + } ro?.disconnect(); window.removeEventListener('resize', sync); window.removeEventListener('scroll', sync, true); diff --git a/packages/app/ui/components/BareChatInput/index.tsx b/packages/app/ui/components/BareChatInput/index.tsx index 0b8b45520a..f2fe733135 100644 --- a/packages/app/ui/components/BareChatInput/index.tsx +++ b/packages/app/ui/components/BareChatInput/index.tsx @@ -12,6 +12,7 @@ import { import * as db from '@tloncorp/shared/db'; import type * as domain from '@tloncorp/shared/domain'; import * as logic from '@tloncorp/shared/logic'; +import * as sharedStore from '@tloncorp/shared/store'; import { HEADER_HEIGHT, LoadingSpinner, @@ -405,6 +406,12 @@ function BareChatInput( return; } + // Track recently-mentioned contacts so future mention autocomplete can + // surface them. Skip group mentions (roles, "All"). + if (option.type === 'contact') { + sharedStore.recordMention({ kind: 'contact', id: option.id }); + } + setControlledText(newText); // Force focus back to input after mention selection diff --git a/packages/shared/src/db/migrations/0000_reflective_slayback.sql b/packages/shared/src/db/migrations/0000_soft_lockjaw.sql similarity index 98% rename from packages/shared/src/db/migrations/0000_reflective_slayback.sql rename to packages/shared/src/db/migrations/0000_soft_lockjaw.sql index d1967f934d..39c1c01b0c 100644 --- a/packages/shared/src/db/migrations/0000_reflective_slayback.sql +++ b/packages/shared/src/db/migrations/0000_soft_lockjaw.sql @@ -378,6 +378,15 @@ CREATE INDEX `posts_channel_id` ON `posts` (`channel_id`,`id`);--> statement-bre CREATE INDEX `posts_group_id` ON `posts` (`group_id`,`id`);--> statement-breakpoint CREATE INDEX `posts_author_id_index` ON `posts` (`author_id`);--> statement-breakpoint CREATE INDEX `posts_parent_id_index` ON `posts` (`parent_id`);--> statement-breakpoint +CREATE TABLE `recents` ( + `scope` text NOT NULL, + `kind` text NOT NULL, + `target_id` text NOT NULL, + `last_visited_at` integer NOT NULL, + `count` integer DEFAULT 1 NOT NULL, + PRIMARY KEY(`scope`, `kind`, `target_id`) +); +--> statement-breakpoint CREATE TABLE `settings` ( `id` text PRIMARY KEY DEFAULT 'settings' NOT NULL, `theme` text, diff --git a/packages/shared/src/db/migrations/meta/0000_snapshot.json b/packages/shared/src/db/migrations/meta/0000_snapshot.json index 6a2be9ca59..64c434f63c 100644 --- a/packages/shared/src/db/migrations/meta/0000_snapshot.json +++ b/packages/shared/src/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "8de9c80f-1dfd-4589-9f31-128786ba4c58", + "id": "bd363824-c7d3-4b04-b4f7-9f5421710d4d", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "activity_event_contact_group_pins": { @@ -2555,6 +2555,61 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "recents": { + "name": "recents", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "recents_scope_kind_target_id_pk": { + "columns": [ + "scope", + "kind", + "target_id" + ], + "name": "recents_scope_kind_target_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "settings": { "name": "settings", "columns": { diff --git a/packages/shared/src/db/migrations/meta/_journal.json b/packages/shared/src/db/migrations/meta/_journal.json index a8dd75e000..fa88593075 100644 --- a/packages/shared/src/db/migrations/meta/_journal.json +++ b/packages/shared/src/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1770685230541, - "tag": "0000_reflective_slayback", + "when": 1777734422621, + "tag": "0000_soft_lockjaw", "breakpoints": true } ] diff --git a/packages/shared/src/db/migrations/migrations.js b/packages/shared/src/db/migrations/migrations.js index 8b1c9e94e9..f550c697ed 100644 --- a/packages/shared/src/db/migrations/migrations.js +++ b/packages/shared/src/db/migrations/migrations.js @@ -1,7 +1,7 @@ // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo import journal from './meta/_journal.json'; -import m0000 from './0000_reflective_slayback.sql'; +import m0000 from './0000_soft_lockjaw.sql'; export default { journal, diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 654061b3bc..95d6af0901 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -76,6 +76,7 @@ import { groupUnreads as $groupUnreads, groups as $groups, pins as $pins, + recents as $recents, postReactions as $postReactions, posts as $posts, settings as $settings, @@ -769,6 +770,58 @@ export const getPins = createReadQuery( ['pins'] ); +export interface RecentArgs { + scope: string; + kind: string; + targetId: string; +} + +export const setRecent = createWriteQuery( + 'setRecent', + async (args: RecentArgs, ctx: QueryCtx) => { + const now = Date.now(); + return ctx.db + .insert($recents) + .values({ + scope: args.scope, + kind: args.kind, + targetId: args.targetId, + lastVisitedAt: now, + count: 1, + }) + .onConflictDoUpdate({ + target: [$recents.scope, $recents.kind, $recents.targetId], + set: { + lastVisitedAt: now, + count: sql`${$recents.count} + 1`, + }, + }); + }, + ['recents'] +); + +export const getRecents = createReadQuery( + 'getRecents', + async ( + args: { scope: string; kind?: string; limit?: number }, + ctx: QueryCtx + ) => { + return ctx.db.query.recents.findMany({ + where(fields, { and, eq }) { + if (args.kind) { + return and(eq(fields.scope, args.scope), eq(fields.kind, args.kind)); + } + return eq(fields.scope, args.scope); + }, + orderBy(fields, { desc }) { + return desc(fields.lastVisitedAt); + }, + limit: args.limit ?? 10, + }); + }, + ['recents'] +); + export const getAllChannels = createReadQuery( 'getAllChannels', async (ctx: QueryCtx) => { diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 1aa2dfca86..c77507a2cf 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -1242,3 +1242,25 @@ export const postReactionsRelations = relations(postReactions, ({ one }) => ({ references: [contacts.id], }), })); + +// Generic recency log used by Leap (recently-visited chats/channels/apps) +// and by mention autocomplete (recently-mentioned contacts). `scope` +// distinguishes the use case ('visit', 'mention'); `kind` further +// disambiguates within a scope ('app', 'channel', 'contact', ...). +export const recents = sqliteTable( + 'recents', + { + scope: text('scope').notNull(), + kind: text('kind').notNull(), + targetId: text('target_id').notNull(), + lastVisitedAt: integer('last_visited_at').notNull(), + count: integer('count').notNull().default(1), + }, + (table) => { + return { + pk: primaryKey({ + columns: [table.scope, table.kind, table.targetId], + }), + }; + } +); diff --git a/packages/shared/src/store/dbHooks.ts b/packages/shared/src/store/dbHooks.ts index ea9f120270..900569510e 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -91,6 +91,21 @@ export const useInstalledApps = () => { }); }; +// Recents log used by Leap (recently-visited channels/apps) and by mention +// autocomplete (recently-mentioned contacts). Pure recency ordering, with +// frequency tracked alongside if we want to layer in frecency later. +export const useRecents = (args: { + scope: string; + kind?: string; + limit?: number; +}) => { + const deps = useKeyFromQueryDeps(db.getRecents); + return useQuery({ + queryKey: ['recents', deps, args.scope, args.kind, args.limit ?? 10], + queryFn: () => db.getRecents(args), + }); +}; + // Scry %notes once to detect whether the notes desk is installed on the // user's ship. Used to gate notes-specific UI (channel-creation option, // 'Bulletin' rename, etc.). Defaults to false until the scry resolves. diff --git a/packages/shared/src/store/index.ts b/packages/shared/src/store/index.ts index f784e697a9..4e0ad5f052 100644 --- a/packages/shared/src/store/index.ts +++ b/packages/shared/src/store/index.ts @@ -19,6 +19,7 @@ export * from './contactActions'; export * from './clientActions'; export * from './lure'; export * from './presence'; +export * from './recents'; export * from './inviteActions'; export * from './hostingActions'; export * from './lanyardActions'; diff --git a/packages/shared/src/store/recents.ts b/packages/shared/src/store/recents.ts new file mode 100644 index 0000000000..f573242ee6 --- /dev/null +++ b/packages/shared/src/store/recents.ts @@ -0,0 +1,20 @@ +import * as db from '../db'; + +export type VisitKind = 'app' | 'channel'; +export type MentionKind = 'contact'; + +export async function recordVisit(args: { + kind: VisitKind; + id: string; +}): Promise { + if (!args.id) return; + await db.setRecent({ scope: 'visit', kind: args.kind, targetId: args.id }); +} + +export async function recordMention(args: { + kind: MentionKind; + id: string; +}): Promise { + if (!args.id) return; + await db.setRecent({ scope: 'mention', kind: args.kind, targetId: args.id }); +} From 6c254155b56b5dcc2b82fd37eecc6fba9b002047 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Sat, 2 May 2026 17:28:11 -0500 Subject: [PATCH 3/8] loading spinner, active tab focus, embedded cmd+k, glob bump Adds a real loading overlay for the embedded app iframe via a react-dom portal mounted directly on , sized to the slot via getBoundingClientRect, kept visible on the first paint by running the setup in useLayoutEffect, and held for a 400ms minimum. The iframe itself is opacity:0 until its load event so the spinner isn't covered by the embedded app's blank-document paint. Theme tokens are resolved upstream and passed in as plain colors so the portal contents don't depend on the tamagui theme being reachable. Drives the desktop sidebar's active-tab highlight from a focusedDesk field on the open-apps store, set by AppViewerScreen's useFocusEffect. This is more reliable than reading the drawer's nested state, which doesn't always reflect navigations issued from outside the drawer (e.g., launching from Leap). Captures Cmd/Ctrl+K inside the embedded app's document via a capture- phase keydown listener and re-fires it on the host document so the embedded app's own Cmd+K binding never sees the event. Bumps the desk glob to 0v7.g6a9p.0jabl.udi37.8ni1d.cedah. Co-Authored-By: Claude Opus 4.7 (1M context) --- desk/desk.docket-0 | 2 +- .../app/features/apps/AppViewerScreen.tsx | 9 +- packages/app/hooks/useOpenApps.ts | 21 ++- .../app/navigation/desktop/TopLevelDrawer.tsx | 21 ++- .../ui/components/AppWebView/AppWebView.tsx | 133 ++++++++++++++++-- .../AppWebView/appIframeHost.web.ts | 45 ++++++ 6 files changed, 200 insertions(+), 31 deletions(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 5b0a7b1007..6b2883ff83 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0vqah2o.gu71c.phvtk.igt95.5jhnn.glob' 0vqah2o.gu71c.phvtk.igt95.5jhnn] + glob-http+['https://bootstrap.urbit.org/glob-0v7.g6a9p.0jabl.udi37.8ni1d.cedah.glob' 0v7.g6a9p.0jabl.udi37.8ni1d.cedah] base+'groups' version+[11 2 1] website+'https://tlon.io' diff --git a/packages/app/features/apps/AppViewerScreen.tsx b/packages/app/features/apps/AppViewerScreen.tsx index 1d095bf641..7214da6745 100644 --- a/packages/app/features/apps/AppViewerScreen.tsx +++ b/packages/app/features/apps/AppViewerScreen.tsx @@ -11,6 +11,7 @@ import { useIsWindowNarrow } from '@tloncorp/ui'; import { useCallback, useMemo } from 'react'; import { Platform } from 'react-native'; +import { useSetFocusedDesk } from '../../hooks/useOpenApps'; import { AppWebView, ScreenHeader, @@ -35,10 +36,14 @@ export function AppViewerScreen() { [apps, desk] ); + const setFocusedDesk = useSetFocusedDesk(); useFocusEffect( useCallback(() => { - if (desk) store.recordVisit({ kind: 'app', id: desk }); - }, [desk]) + if (!desk) return; + store.recordVisit({ kind: 'app', id: desk }); + setFocusedDesk(desk); + return () => setFocusedDesk(null); + }, [desk, setFocusedDesk]) ); // Native needs an absolute URL; web uses a relative path served by the same diff --git a/packages/app/hooks/useOpenApps.ts b/packages/app/hooks/useOpenApps.ts index 71a2a10854..12c8ef1b77 100644 --- a/packages/app/hooks/useOpenApps.ts +++ b/packages/app/hooks/useOpenApps.ts @@ -1,25 +1,36 @@ import create from 'zustand'; interface OpenAppsState { + // Desks the user has opened during this session, in first-open order. openApps: string[]; + // The desk currently being rendered in AppViewer (null when on the + // launcher or any non-app screen). Used to drive sidebar active state + // without having to introspect nested drawer navigation state — the + // AppViewer screen sets this via useFocusEffect. + focusedDesk: string | null; openApp: (desk: string) => void; closeApp: (desk: string) => void; + setFocusedDesk: (desk: string | null) => void; } -// Tracks which app desks the user has opened during this session, in the -// order they were first opened. Used by the desktop sidebar to render a -// per-app shortcut icon below the "Apps" entry. In-memory only — refreshing -// clears the list. export const useOpenAppsStore = create((set) => ({ openApps: [], + focusedDesk: null, openApp: (desk) => set((s) => (s.openApps.includes(desk) ? s : { openApps: [...s.openApps, desk] })), closeApp: (desk) => - set((s) => ({ openApps: s.openApps.filter((d) => d !== desk) })), + set((s) => ({ + openApps: s.openApps.filter((d) => d !== desk), + focusedDesk: s.focusedDesk === desk ? null : s.focusedDesk, + })), + setFocusedDesk: (desk) => set({ focusedDesk: desk }), })); export const useOpenApps = () => useOpenAppsStore((s) => s.openApps); export const useOpenApp = () => useOpenAppsStore((s) => s.openApp); export const useCloseApp = () => useOpenAppsStore((s) => s.closeApp); +export const useFocusedDesk = () => useOpenAppsStore((s) => s.focusedDesk); +export const useSetFocusedDesk = () => + useOpenAppsStore((s) => s.setFocusedDesk); diff --git a/packages/app/navigation/desktop/TopLevelDrawer.tsx b/packages/app/navigation/desktop/TopLevelDrawer.tsx index c34bb35a34..42ce6e4c62 100644 --- a/packages/app/navigation/desktop/TopLevelDrawer.tsx +++ b/packages/app/navigation/desktop/TopLevelDrawer.tsx @@ -18,7 +18,11 @@ import { ScrollView, Square, View, getVariableValue, useTheme } from 'tamagui'; import { GlobalSearch } from '../../features/chat-list/GlobalSearch'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; -import { useCloseApp, useOpenApps } from '../../hooks/useOpenApps'; +import { + useCloseApp, + useFocusedDesk, + useOpenApps, +} from '../../hooks/useOpenApps'; import { AvatarNavIcon, DESKTOP_TOPLEVEL_SIDEBAR_WIDTH, @@ -201,17 +205,10 @@ const DrawerContent = (props: DrawerContentComponentProps) => { .filter((a): a is store.InstalledApp => !!a); }, [openAppDesks, installedApps]); - // Read the currently-focused desk from the nested Apps stack so we can - // highlight the matching sidebar tile. - const activeDesk = useMemo(() => { - const appsRoute = props.state.routes.find((r) => r.name === 'Apps'); - const stack = appsRoute?.state; - if (!stack || stack.index == null) return null; - const focused = stack.routes[stack.index]; - if (focused?.name !== 'AppViewer') return null; - const params = focused.params as { desk?: string } | undefined; - return params?.desk ?? null; - }, [props.state]); + // Driven by AppViewerScreen's useFocusEffect — more reliable than reading + // the nested drawer state, which doesn't always reflect changes made via + // navigation calls from outside the drawer (e.g. from Leap). + const activeDesk = useFocusedDesk(); const goToOpenApp = useCallback( (desk: string) => { diff --git a/packages/app/ui/components/AppWebView/AppWebView.tsx b/packages/app/ui/components/AppWebView/AppWebView.tsx index ead59377bd..1dc987f024 100644 --- a/packages/app/ui/components/AppWebView/AppWebView.tsx +++ b/packages/app/ui/components/AppWebView/AppWebView.tsx @@ -1,11 +1,21 @@ import { LoadingSpinner } from '@tloncorp/ui'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { Platform } from 'react-native'; +import { createPortal } from 'react-dom'; import { WebView } from 'react-native-webview'; -import { View } from 'tamagui'; +import { Spinner, View, useTheme } from 'tamagui'; import { mountAppIframe } from './appIframeHost'; +// Minimum time the loading spinner stays visible after a fresh iframe mount. +// Avoids a sub-frame flash when the embedded app's document loads very fast. +const SPINNER_MIN_DURATION_MS = 400; + interface AppWebViewProps { // Required on native (the WebView needs an absolute URL); on web we use the // current origin since the Tlon web app is served by the same ship. @@ -23,7 +33,44 @@ interface AppWebViewProps { paused?: boolean; } -function LoadingOverlay() { +function PortalLoadingOverlay({ + rect, + background, + spinnerColor, +}: { + rect: { left: number; top: number; width: number; height: number } | null; + background: string; + spinnerColor: string; +}) { + if (!rect) return null; + // Mounted as a direct child of so we don't get trapped inside any + // tamagui portal target / drawer transform stacking context. The body- + // level iframe host sits at zIndex 0; this beats it cleanly. Theme + // tokens are resolved upstream and passed in as plain colors so the + // portal contents don't depend on the theme being reachable. + return createPortal( +
+ +
, + document.body + ); +} + +function InlineLoadingOverlay() { return ( (null); - const [loaded, setLoaded] = useState(false); + const [showSpinner, setShowSpinner] = useState(false); + const [overlayRect, setOverlayRect] = useState<{ + left: number; + top: number; + width: number; + height: number; + } | null>(null); + const theme = useTheme(); + const overlayBackground = theme.background?.val ?? '#ffffff'; + const spinnerColor = + theme.tertiaryText?.val ?? theme.color?.val ?? '#6b7280'; - useEffect(() => { - if (paused) return; + // Run synchronously after DOM commit but before paint, so the spinner is + // present on the very first visible frame instead of waiting for a + // post-paint useEffect to fire. + useLayoutEffect(() => { + if (paused) { + setShowSpinner(false); + setOverlayRect(null); + return; + } const slot = slotRef.current; if (!slot) return; const handle = mountAppIframe(cacheKey, path, slot); - setLoaded(handle.isLoaded()); - const unsubscribe = handle.onLoad(() => setLoaded(true)); + if (handle.isLoaded()) { + setShowSpinner(false); + setOverlayRect(null); + return () => handle.detach(); + } + + const startedAt = Date.now(); + const sync = () => { + if (!slot.isConnected) return; + const r = slot.getBoundingClientRect(); + setOverlayRect({ + left: r.left, + top: r.top, + width: r.width, + height: r.height, + }); + }; + sync(); + setShowSpinner(true); + + let hideTimer: ReturnType | null = null; + const ro = + typeof ResizeObserver !== 'undefined' ? new ResizeObserver(sync) : null; + ro?.observe(slot); + ro?.observe(document.body); + window.addEventListener('resize', sync); + window.addEventListener('scroll', sync, true); + + const unsubscribe = handle.onLoad(() => { + const remaining = Math.max( + 0, + SPINNER_MIN_DURATION_MS - (Date.now() - startedAt) + ); + hideTimer = setTimeout(() => { + setShowSpinner(false); + setOverlayRect(null); + }, remaining); + }); + return () => { unsubscribe(); handle.detach(); + ro?.disconnect(); + window.removeEventListener('resize', sync); + window.removeEventListener('scroll', sync, true); + if (hideTimer) clearTimeout(hideTimer); }; }, [cacheKey, path, paused]); return (
- {!loaded && !paused && } + {showSpinner && ( + + )} ); } @@ -73,7 +184,7 @@ function AppWebViewNative({ shipUrl, path }: AppWebViewProps) { if (!shipUrl) { return ( - + ); } @@ -94,7 +205,7 @@ function AppWebViewNative({ shipUrl, path }: AppWebViewProps) { request.url.startsWith(base) } /> - {!loaded && } + {!loaded && } ); } diff --git a/packages/app/ui/components/AppWebView/appIframeHost.web.ts b/packages/app/ui/components/AppWebView/appIframeHost.web.ts index 52dcdc40e2..4eacda443a 100644 --- a/packages/app/ui/components/AppWebView/appIframeHost.web.ts +++ b/packages/app/ui/components/AppWebView/appIframeHost.web.ts @@ -51,6 +51,12 @@ function getOrCreate(key: string, src: string): CachedIframe { iframe.style.background = 'transparent'; iframe.style.display = 'none'; iframe.style.zIndex = '0'; + // Hide the iframe until its document fires `load`. The embedded page's + // body usually paints solid white before content renders, which would + // otherwise cover the spinner the React side shows during loading. + // opacity:0 (vs display:none) keeps the document loading in browsers + // that throttle hidden iframes. + iframe.style.opacity = '0'; getHost().appendChild(iframe); const created: CachedIframe = { @@ -61,12 +67,50 @@ function getOrCreate(key: string, src: string): CachedIframe { }; iframe.addEventListener('load', () => { created.loaded = true; + iframe.style.opacity = '1'; + forwardLeapShortcut(iframe); created.loadListeners.forEach((fn) => fn()); }); iframes.set(key, created); return created; } +// Cmd/Ctrl+K pressed inside the embedded app's document needs to open Leap +// in the parent. Tlon's iframes are same-origin (served by the same ship), +// so we attach a capture-phase keydown listener on the iframe's document +// and stop propagation so the embedded app's own Cmd+K binding (e.g. +// Landscape's Leap) never sees the event. The captured event is re-fired +// on the host document so Tlon's GlobalSearch listener picks it up. The +// try/catch protects against any future cross-origin iframe. +function forwardLeapShortcut(iframe: HTMLIFrameElement) { + try { + const innerDoc = iframe.contentDocument; + if (!innerDoc) return; + innerDoc.addEventListener( + 'keydown', + (e) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'k', + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + bubbles: true, + cancelable: true, + }) + ); + } + }, + true + ); + } catch { + // cross-origin: nothing we can do + } +} + export interface AppIframeHandle { isLoaded: () => boolean; onLoad: (cb: () => void) => () => void; @@ -83,6 +127,7 @@ export function mountAppIframe( iframe.style.display = 'block'; iframe.style.pointerEvents = 'auto'; + iframe.style.opacity = entry.loaded ? '1' : '0'; // Layout sync is RAF-throttled. Without this, ResizeObserver bursts during // a drawer transition cause synchronous getBoundingClientRect + style From e71c6a9a1a6b96cd19fce21c7fd40f7a2d0998d9 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Sat, 2 May 2026 18:07:24 -0500 Subject: [PATCH 4/8] sidebar reorg, app titles, contacts invite button, glob bump Moves the sigil/contacts entry into the bottom sidebar group where the top-level invite-friend icon used to live, drops the top-level invite icon, and adds a left-aligned invite-friend button to the contacts sidebar header. The PersonalInviteSheet stays mounted at the desktop top-level drawer; inner sidebars open it via a small zustand store. Pushes per-screen browser titles through navigation.setOptions so the documentTitle formatter in tlon-web honors them: AppLauncher reports "Apps" and AppViewer reports the embedded document's title (with the charge title and desk slug as fallbacks). The formatters in app.tsx now check options.title first before falling back to the friendly route name, which fixes the previous "App Viewer" override. Bumps the desk glob to 0v2.1esln.ce9af.n51qq.i247a.dd3d4. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/tlon-web/src/app.tsx | 9 ++++- desk/desk.docket-0 | 2 +- .../app/features/apps/AppLauncherScreen.tsx | 5 ++- .../app/features/apps/AppViewerScreen.tsx | 16 +++++++- packages/app/hooks/usePersonalInviteSheet.ts | 22 +++++++++++ .../navigation/desktop/ProfileNavigator.tsx | 8 ++++ .../app/navigation/desktop/TopLevelDrawer.tsx | 38 +++++++++---------- .../ui/components/AppWebView/AppWebView.tsx | 16 +++++++- .../ui/components/AppWebView/appIframeHost.ts | 1 + .../AppWebView/appIframeHost.web.ts | 10 +++++ 10 files changed, 99 insertions(+), 28 deletions(-) create mode 100644 packages/app/hooks/usePersonalInviteSheet.ts diff --git a/apps/tlon-web/src/app.tsx b/apps/tlon-web/src/app.tsx index 3472389849..55d11fb78d 100644 --- a/apps/tlon-web/src/app.tsx +++ b/apps/tlon-web/src/app.tsx @@ -209,7 +209,11 @@ function AppRoutes() { }, []); const documentTitleFormatterMobile = useCallback( - (_options: any, route: Route) => { + (options: any, route: Route) => { + // Honor any explicit title set via navigation.setOptions on the + // focused screen — that way screens like AppViewer / AppLauncher can + // manage their own title without us hardcoding cases here. + if (options?.title) return options.title; if (!route?.name) return 'Tlon'; if (route.name === 'GroupChannels') { @@ -256,7 +260,8 @@ function AppRoutes() { ); const documentTitleFormatterDesktop = useCallback( - (_options: any, route: Route) => { + (options: any, route: Route) => { + if (options?.title) return options.title; if (!route?.name) return 'Tlon'; // For channel routes diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 6b2883ff83..ff31ea460d 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v7.g6a9p.0jabl.udi37.8ni1d.cedah.glob' 0v7.g6a9p.0jabl.udi37.8ni1d.cedah] + glob-http+['https://bootstrap.urbit.org/glob-0v2.1esln.ce9af.n51qq.i247a.dd3d4.glob' 0v2.1esln.ce9af.n51qq.i247a.dd3d4] base+'groups' version+[11 2 1] website+'https://tlon.io' diff --git a/packages/app/features/apps/AppLauncherScreen.tsx b/packages/app/features/apps/AppLauncherScreen.tsx index c36820e3ec..68e3b3ff72 100644 --- a/packages/app/features/apps/AppLauncherScreen.tsx +++ b/packages/app/features/apps/AppLauncherScreen.tsx @@ -1,6 +1,6 @@ import { NavigationProp, useNavigation } from '@react-navigation/native'; import * as store from '@tloncorp/shared/store'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import { useCurrentUserId } from '../../hooks/useCurrentUser'; import { useOpenApp } from '../../hooks/useOpenApps'; @@ -12,6 +12,9 @@ export function AppLauncherScreen() { const currentUserId = useCurrentUserId(); const { data: apps = [], isLoading } = store.useInstalledApps(); const openApp = useOpenApp(); + useEffect(() => { + navigation.setOptions({ title: 'Apps' }); + }, [navigation]); const handleSelectApp = useCallback( (app: store.InstalledApp) => { diff --git a/packages/app/features/apps/AppViewerScreen.tsx b/packages/app/features/apps/AppViewerScreen.tsx index 7214da6745..b5f94706d4 100644 --- a/packages/app/features/apps/AppViewerScreen.tsx +++ b/packages/app/features/apps/AppViewerScreen.tsx @@ -8,7 +8,7 @@ import { import * as db from '@tloncorp/shared/db'; import * as store from '@tloncorp/shared/store'; import { useIsWindowNarrow } from '@tloncorp/ui'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Platform } from 'react-native'; import { useSetFocusedDesk } from '../../hooks/useOpenApps'; @@ -46,6 +46,19 @@ export function AppViewerScreen() { }, [desk, setFocusedDesk]) ); + // Title falls back to the charge title; once the iframe loads we override + // with the embedded document's title (e.g., the page title set by the + // app's router). Pushed via `setOptions` so the documentTitle formatter + // (in app.tsx) honors it instead of defaulting to the screen name. + const [iframeTitle, setIframeTitle] = useState(null); + useEffect(() => { + setIframeTitle(null); + }, [desk]); + const screenTitle = iframeTitle || charge?.title || desk || null; + useEffect(() => { + if (screenTitle) navigation.setOptions({ title: screenTitle }); + }, [navigation, screenTitle]); + // Native needs an absolute URL; web uses a relative path served by the same // ship that hosts Tlon. const shipUrl = @@ -86,6 +99,7 @@ export function AppViewerScreen() { path={path} cacheKey={`app:${desk}`} paused={!isFocused} + onTitleChange={setIframeTitle} /> ); diff --git a/packages/app/hooks/usePersonalInviteSheet.ts b/packages/app/hooks/usePersonalInviteSheet.ts new file mode 100644 index 0000000000..9509d560b0 --- /dev/null +++ b/packages/app/hooks/usePersonalInviteSheet.ts @@ -0,0 +1,22 @@ +import * as db from '@tloncorp/shared/db'; +import create from 'zustand'; + +interface PersonalInviteSheetState { + open: boolean; + setOpen: (open: boolean) => void; +} + +// Backs the PersonalInviteSheet that's rendered once at the desktop top-level +// drawer. Inner sidebars trigger it without prop drilling by calling +// `openPersonalInviteSheet`. +export const usePersonalInviteSheetStore = create( + (set) => ({ + open: false, + setOpen: (open) => set({ open }), + }) +); + +export function openPersonalInviteSheet() { + db.hasViewedPersonalInvite.setValue(true); + usePersonalInviteSheetStore.getState().setOpen(true); +} diff --git a/packages/app/navigation/desktop/ProfileNavigator.tsx b/packages/app/navigation/desktop/ProfileNavigator.tsx index a9bdf13192..01e7f85936 100644 --- a/packages/app/navigation/desktop/ProfileNavigator.tsx +++ b/packages/app/navigation/desktop/ProfileNavigator.tsx @@ -13,6 +13,7 @@ import { AddContactsScreen } from '../../features/contacts/AddContactsScreen'; import { AttestationScreen } from '../../features/profile/AttestationScreen'; import { EditProfileScreen } from '../../features/settings/EditProfileScreen'; import { UserProfileScreen } from '../../features/top/UserProfileScreen'; +import { openPersonalInviteSheet } from '../../hooks/usePersonalInviteSheet'; import { ContactsScreenView, DESKTOP_SIDEBAR_WIDTH, @@ -72,6 +73,13 @@ function DrawerContent(props: DrawerContentComponentProps) { + } rightControls={ { const lastHomeStateRef = useRef | null>(null); const { isOpen, setIsOpen } = useGlobalSearch(); - const [personalInviteOpen, setPersonalInviteOpen] = useState(false); + const personalInviteOpen = usePersonalInviteSheetStore((s) => s.open); + const setPersonalInviteOpen = usePersonalInviteSheetStore((s) => s.setOpen); const isRouteActive = useCallback( (routeName: keyof RootDrawerParamList) => { @@ -192,8 +196,7 @@ const DrawerContent = (props: DrawerContentComponentProps) => { }, [props.navigation, isRouteActive]); const handlePersonalInvitePress = useCallback(() => { - db.hasViewedPersonalInvite.setValue(true); - setPersonalInviteOpen(true); + openPersonalInviteSheet(); }, []); const openAppDesks = useOpenApps(); @@ -273,17 +276,6 @@ const DrawerContent = (props: DrawerContentComponentProps) => { }); }} /> - { - saveHomeState(); - props.navigation.reset({ - index: 0, - routes: [{ name: 'Contacts' }], - }); - }} - /> {webAppNeedsUpdate && ( { - { + saveHomeState(); + props.navigation.reset({ + index: 0, + routes: [{ name: 'Contacts' }], + }); + }} /> void; } function PortalLoadingOverlay({ @@ -88,7 +91,12 @@ function InlineLoadingOverlay() { ); } -function AppWebViewWeb({ path, cacheKey, paused }: AppWebViewProps) { +function AppWebViewWeb({ + path, + cacheKey, + paused, + onTitleChange, +}: AppWebViewProps) { const slotRef = useRef(null); const [showSpinner, setShowSpinner] = useState(false); const [overlayRect, setOverlayRect] = useState<{ @@ -117,6 +125,8 @@ function AppWebViewWeb({ path, cacheKey, paused }: AppWebViewProps) { if (handle.isLoaded()) { setShowSpinner(false); setOverlayRect(null); + const cachedTitle = handle.getTitle()?.trim(); + if (cachedTitle && onTitleChange) onTitleChange(cachedTitle); return () => handle.detach(); } @@ -147,6 +157,8 @@ function AppWebViewWeb({ path, cacheKey, paused }: AppWebViewProps) { 0, SPINNER_MIN_DURATION_MS - (Date.now() - startedAt) ); + const loadedTitle = handle.getTitle()?.trim(); + if (loadedTitle && onTitleChange) onTitleChange(loadedTitle); hideTimer = setTimeout(() => { setShowSpinner(false); setOverlayRect(null); @@ -161,7 +173,7 @@ function AppWebViewWeb({ path, cacheKey, paused }: AppWebViewProps) { window.removeEventListener('scroll', sync, true); if (hideTimer) clearTimeout(hideTimer); }; - }, [cacheKey, path, paused]); + }, [cacheKey, path, paused, onTitleChange]); return ( diff --git a/packages/app/ui/components/AppWebView/appIframeHost.ts b/packages/app/ui/components/AppWebView/appIframeHost.ts index df2c163e53..fff239ff84 100644 --- a/packages/app/ui/components/AppWebView/appIframeHost.ts +++ b/packages/app/ui/components/AppWebView/appIframeHost.ts @@ -4,6 +4,7 @@ export interface AppIframeHandle { isLoaded: () => boolean; onLoad: (cb: () => void) => () => void; + getTitle: () => string | null; detach: () => void; } diff --git a/packages/app/ui/components/AppWebView/appIframeHost.web.ts b/packages/app/ui/components/AppWebView/appIframeHost.web.ts index 4eacda443a..b8c9d17592 100644 --- a/packages/app/ui/components/AppWebView/appIframeHost.web.ts +++ b/packages/app/ui/components/AppWebView/appIframeHost.web.ts @@ -114,6 +114,9 @@ function forwardLeapShortcut(iframe: HTMLIFrameElement) { export interface AppIframeHandle { isLoaded: () => boolean; onLoad: (cb: () => void) => () => void; + // Title of the embedded document, or null if unavailable (cross-origin or + // not yet loaded). + getTitle: () => string | null; detach: () => void; } @@ -168,6 +171,13 @@ export function mountAppIframe( entry.loadListeners.add(cb); return () => entry.loadListeners.delete(cb); }, + getTitle: () => { + try { + return iframe.contentDocument?.title ?? null; + } catch { + return null; + } + }, detach: () => { if (detached) return; detached = true; From 90753d42a68422800dbd671370a5f045d00c5d44 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 5 May 2026 10:08:25 -0500 Subject: [PATCH 5/8] update glob: 0v4.junkn.13ihh.tcgar.f8c5c.0o0nr Co-Authored-By: Claude Opus 4.7 (1M context) --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index ff31ea460d..c4502279d5 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v2.1esln.ce9af.n51qq.i247a.dd3d4.glob' 0v2.1esln.ce9af.n51qq.i247a.dd3d4] + glob-http+['https://bootstrap.urbit.org/glob-0v4.junkn.13ihh.tcgar.f8c5c.0o0nr.glob' 0v4.junkn.13ihh.tcgar.f8c5c.0o0nr] base+'groups' version+[11 2 1] website+'https://tlon.io' From bb84619b79b52607cb710448ad4c79580946e1b2 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 11 May 2026 16:47:33 -0500 Subject: [PATCH 6/8] ops: update glob --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 8c17693c33..11d27a9290 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0vpacgc.pb5rc.j1j91.lqas1.aovu1.glob' 0vpacgc.pb5rc.j1j91.lqas1.aovu1] + glob-http+['https://bootstrap.urbit.org/glob-0v4.2orpp.tcqhq.8m9dl.q1qpa.43f33.glob' 0v4.2orpp.tcqhq.8m9dl.q1qpa.43f33] base+'groups' version+[11 2 1] website+'https://tlon.io' From 3f986471aef69ce48ec5d6b1fbaa8a76d33231d0 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Tue, 12 May 2026 10:32:16 -0500 Subject: [PATCH 7/8] ops: update glob Co-Authored-By: Claude Opus 4.7 (1M context) --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 11d27a9290..435b5ff90f 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v4.2orpp.tcqhq.8m9dl.q1qpa.43f33.glob' 0v4.2orpp.tcqhq.8m9dl.q1qpa.43f33] + glob-http+['https://bootstrap.urbit.org/glob-0v4.0qg5h.egrd3.89d6g.arg5o.egrh5.glob' 0v4.0qg5h.egrd3.89d6g.arg5o.egrh5] base+'groups' version+[11 2 1] website+'https://tlon.io' From 3f2e31bd35934c12bfd2deeb79ead2d28bea7876 Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Mon, 18 May 2026 15:01:39 -0500 Subject: [PATCH 8/8] update glob: [skip actions] --- desk/desk.docket-0 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desk/desk.docket-0 b/desk/desk.docket-0 index 435b5ff90f..683fb25327 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,7 +2,7 @@ info+'Start, host, and cultivate communities. Own your communications, organize your resources, and share documents. Tlon is a decentralized platform that offers a full, communal suite of tools for messaging, writing and sharing media with others.' color+0xde.dede image+'https://bootstrap.urbit.org/tlon.svg?v=1' - glob-http+['https://bootstrap.urbit.org/glob-0v4.0qg5h.egrd3.89d6g.arg5o.egrh5.glob' 0v4.0qg5h.egrd3.89d6g.arg5o.egrh5] + glob-http+['https://bootstrap.urbit.org/glob-0v5.pilpm.p2v4i.6o0f3.vpi93.vnctl.glob' 0v5.pilpm.p2v4i.6o0f3.vpi93.vnctl] base+'groups' version+[11 2 1] website+'https://tlon.io'