diff --git a/apps/tlon-web/src/app.tsx b/apps/tlon-web/src/app.tsx index 8097632497..13768d5d12 100644 --- a/apps/tlon-web/src/app.tsx +++ b/apps/tlon-web/src/app.tsx @@ -218,7 +218,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') { @@ -265,7 +269,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 ab241f7abc..683fb25327 100644 --- a/desk/desk.docket-0 +++ b/desk/desk.docket-0 @@ -2,9 +2,9 @@ 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-0v3.psb1s.ub1nh.a6jma.iv4t5.f297s.glob' 0v3.psb1s.ub1nh.a6jma.iv4t5.f297s] + 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 2] + version+[11 2 1] website+'https://tlon.io' license+'MIT' == diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index f540616360..3f5131e754 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 './a2ui'; export * from './channelContentConfig'; export * from './channelsApi'; 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..68e3b3ff72 --- /dev/null +++ b/packages/app/features/apps/AppLauncherScreen.tsx @@ -0,0 +1,52 @@ +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import * as store from '@tloncorp/shared/store'; +import { useCallback, useEffect } 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(); + useEffect(() => { + navigation.setOptions({ title: 'Apps' }); + }, [navigation]); + + 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..b5f94706d4 --- /dev/null +++ b/packages/app/features/apps/AppViewerScreen.tsx @@ -0,0 +1,106 @@ +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 { useCallback, useEffect, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; + +import { useSetFocusedDesk } from '../../hooks/useOpenApps'; +import { + AppWebView, + ScreenHeader, + View, +} from '../../ui'; + +type AppViewerRouteParams = { + AppViewer: { desk: string }; +}; + +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(); + + const desk = route.params?.desk; + const charge = useMemo( + () => apps.find((app) => app.desk === desk), + [apps, desk] + ); + + const setFocusedDesk = useSetFocusedDesk(); + useFocusEffect( + useCallback(() => { + if (!desk) return; + store.recordVisit({ kind: 'app', id: desk }); + setFocusedDesk(desk); + return () => setFocusedDesk(null); + }, [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 = + 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..dfa49b2547 --- /dev/null +++ b/packages/app/features/chat-list/FilteredLeapList.tsx @@ -0,0 +1,435 @@ +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 { 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 out: LeapItem[] = []; + + if (!q) { + // 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 { + // 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}`, + }); + } + } + } + + 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; + }, [ + appByDesk, + chatById, + chatSections, + installed, + openSet, + recents, + 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..d974f78471 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,20 +29,51 @@ 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(); + + // 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, deferNavigate] + ); + + const onPressApp = useCallback( + (desk: string, alreadyOpen: boolean) => { + if (!alreadyOpen) openApp(desk); + deferNavigate(() => { + navigation.navigate('Apps', { + screen: 'AppViewer', + params: { desk }, + }); + }); }, - [navigateToGroup, navigateToChannel, setIsOpen] + [navigation, openApp, deferNavigate] ); + const onPressLauncher = useCallback(() => { + deferNavigate(() => { + navigation.navigate('Apps', { screen: 'AppLauncher' }); + }); + }, [navigation, deferNavigate]); + const handleNavigationKey = useCallback( (key: string) => { switch (key) { @@ -113,15 +148,29 @@ 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 ( - <> + { setIsOpen(false); }} + pointerEvents="auto" style={{ position: 'fixed', top: 0, @@ -129,16 +178,17 @@ export function GlobalSearch({ right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', - zIndex: 50, + zIndex: 9999, }} /> - {isOpen && ( - )} @@ -210,6 +262,6 @@ export function GlobalSearch({ - + ); } diff --git a/packages/app/features/top/ActivityScreen.tsx b/packages/app/features/top/ActivityScreen.tsx index 2352525373..9e8c8ea9f0 100644 --- a/packages/app/features/top/ActivityScreen.tsx +++ b/packages/app/features/top/ActivityScreen.tsx @@ -124,6 +124,9 @@ export function ActivityScreen(props: Props) { navigateToNotifications={() => props.navigation.navigate('Activity', undefined, { pop: true }) } + navigateToApps={() => + props.navigation.navigate('AppLauncher', undefined, { pop: true }) + } currentRoute="Activity" currentUserId={currentUserId} showContactsTab={contactsTabEnabled} diff --git a/packages/app/features/top/ChannelScreen.tsx b/packages/app/features/top/ChannelScreen.tsx index 8bdc8e749d..0a8333fe9b 100644 --- a/packages/app/features/top/ChannelScreen.tsx +++ b/packages/app/features/top/ChannelScreen.tsx @@ -83,6 +83,7 @@ export default function ChannelScreen(props: Props) { // Mark the channel as visited when we unfocus/leave this screen if (!channelIsPending) { store.markChannelVisited(channelId); + store.recordVisit({ kind: 'channel', id: channelId }); } // Mark wayfinding channels as visited if needed diff --git a/packages/app/features/top/ChatListScreen.tsx b/packages/app/features/top/ChatListScreen.tsx index c01a115e68..6d219bfd20 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', undefined, { pop: true }); }} + 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 589cf49e8b..8357e24d7e 100644 --- a/packages/app/features/top/ContactsScreen.tsx +++ b/packages/app/features/top/ContactsScreen.tsx @@ -139,6 +139,9 @@ export default function ContactsScreen(props: Props) { navigateToNotifications={() => { navigate('Activity', undefined, { pop: true }); }} + 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..12c8ef1b77 --- /dev/null +++ b/packages/app/hooks/useOpenApps.ts @@ -0,0 +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; +} + +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), + 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/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/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/ProfileNavigator.tsx b/packages/app/navigation/desktop/ProfileNavigator.tsx index 4c5c8aa4e2..21b2c2f8cc 100644 --- a/packages/app/navigation/desktop/ProfileNavigator.tsx +++ b/packages/app/navigation/desktop/ProfileNavigator.tsx @@ -14,6 +14,7 @@ import { AttestationScreen } from '../../features/profile/AttestationScreen'; import { EditProfileScreen } from '../../features/settings/EditProfileScreen'; import { UserProfileScreen } from '../../features/top/UserProfileScreen'; import { useMarkMatchesSeen } from '../../hooks/useMarkMatchesSeen'; +import { openPersonalInviteSheet } from '../../hooks/usePersonalInviteSheet'; import { ContactsScreenView, DESKTOP_SIDEBAR_WIDTH, @@ -75,6 +76,13 @@ function DrawerContent(props: DrawerContentComponentProps) { + } rightControls={ (); +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(); @@ -38,7 +153,8 @@ const DrawerContent = (props: DrawerContentComponentProps) => { 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) => { @@ -80,10 +196,45 @@ const DrawerContent = (props: DrawerContentComponentProps) => { }, [props.navigation, isRouteActive]); const handlePersonalInvitePress = useCallback(() => { - db.hasViewedPersonalInvite.setValue(true); - setPersonalInviteOpen(true); + openPersonalInviteSheet(); }, []); + 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]); + + // 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) => { + 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 ( @@ -125,17 +276,6 @@ const DrawerContent = (props: DrawerContentComponentProps) => { }); }} /> - { - saveHomeState(); - props.navigation.reset({ - index: 0, - routes: [{ name: 'Contacts' }], - }); - }} - /> {webAppNeedsUpdate && ( { shouldShowUnreads={false} /> )} - - { + saveHomeState(); + // 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' }); + }} + /> + + + + {openAppEntries.map((app) => ( + goToOpenApp(app.desk)} + onClose={() => handleCloseApp(app.desk)} + /> + ))} + + + + { + saveHomeState(); + props.navigation.reset({ + index: 0, + routes: [{ name: 'Contacts' }], + }); + }} /> { + 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..dcc600e195 --- /dev/null +++ b/packages/app/ui/components/AppWebView/AppWebView.tsx @@ -0,0 +1,231 @@ +import { LoadingSpinner } from '@tloncorp/ui'; +import { + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { Platform } from 'react-native'; +import { createPortal } from 'react-dom'; +import { WebView } from 'react-native-webview'; +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. + 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; + // 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; + // Called with the embedded document's title once it's loaded (web only; + // requires same-origin iframe). + onTitleChange?: (title: string) => void; +} + +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 ( + + + + ); +} + +function AppWebViewWeb({ + path, + cacheKey, + paused, + onTitleChange, +}: AppWebViewProps) { + const slotRef = useRef(null); + 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'; + + // 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); + if (handle.isLoaded()) { + setShowSpinner(false); + setOverlayRect(null); + const cachedTitle = handle.getTitle()?.trim(); + if (cachedTitle && onTitleChange) onTitleChange(cachedTitle); + 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) + ); + const loadedTitle = handle.getTitle()?.trim(); + if (loadedTitle && onTitleChange) onTitleChange(loadedTitle); + 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, onTitleChange]); + + return ( + +
+ {showSpinner && ( + + )} + + ); +} + +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 62% rename from packages/app/ui/components/NotesChannel/notesIframeHost.ts rename to packages/app/ui/components/AppWebView/appIframeHost.ts index 60a191e0bf..fff239ff84 100644 --- a/packages/app/ui/components/NotesChannel/notesIframeHost.ts +++ b/packages/app/ui/components/AppWebView/appIframeHost.ts @@ -1,16 +1,17 @@ // 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; + getTitle: () => string | null; 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/AppWebView/appIframeHost.web.ts b/packages/app/ui/components/AppWebView/appIframeHost.web.ts new file mode 100644 index 0000000000..b8c9d17592 --- /dev/null +++ b/packages/app/ui/components/AppWebView/appIframeHost.web.ts @@ -0,0 +1,195 @@ +// Persistent iframe host (web only). +// +// 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; + src: string; + loaded: boolean; + loadListeners: Set<() => void>; +} + +const iframes = new Map(); +let hostContainer: HTMLDivElement | null = null; + +function getHost(): HTMLDivElement { + if (!hostContainer) { + hostContainer = document.createElement('div'); + 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 { + const entry = iframes.get(key); + if (entry && entry.src === src) { + return entry; + } + if (entry && entry.iframe.parentNode) { + entry.iframe.parentNode.removeChild(entry.iframe); + } + + const iframe = document.createElement('iframe'); + iframe.src = src; + iframe.style.position = 'fixed'; + iframe.style.border = 'none'; + 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 = { + iframe, + src, + loaded: false, + loadListeners: new Set(), + }; + 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; + // Title of the embedded document, or null if unavailable (cross-origin or + // not yet loaded). + getTitle: () => string | null; + detach: () => void; +} + +export function mountAppIframe( + key: string, + src: string, + slot: HTMLElement +): AppIframeHandle { + const entry = getOrCreate(key, src); + const { iframe } = entry; + + 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 + // 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`; + iframe.style.top = `${rect.top}px`; + iframe.style.width = `${rect.width}px`; + iframe.style.height = `${rect.height}px`; + }; + 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; + ro?.observe(slot); + ro?.observe(document.body); + window.addEventListener('resize', sync); + window.addEventListener('scroll', sync, true); + + let detached = false; + return { + isLoaded: () => entry.loaded, + onLoad: (cb) => { + if (entry.loaded) { + const id = window.setTimeout(cb, 0); + return () => window.clearTimeout(id); + } + 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; + if (rafId !== 0) { + window.cancelAnimationFrame(rafId); + rafId = 0; + } + ro?.disconnect(); + window.removeEventListener('resize', sync); + window.removeEventListener('scroll', sync, true); + iframe.style.display = 'none'; + iframe.style.pointerEvents = 'none'; + }, + }; +} 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/BareChatInput/index.tsx b/packages/app/ui/components/BareChatInput/index.tsx index a8c4e6d601..b229f82bcb 100644 --- a/packages/app/ui/components/BareChatInput/index.tsx +++ b/packages/app/ui/components/BareChatInput/index.tsx @@ -11,6 +11,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, @@ -420,6 +421,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/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/components/NotesChannel/notesIframeHost.web.ts b/packages/app/ui/components/NotesChannel/notesIframeHost.web.ts deleted file mode 100644 index 3ae706e9fb..0000000000 --- a/packages/app/ui/components/NotesChannel/notesIframeHost.web.ts +++ /dev/null @@ -1,123 +0,0 @@ -// 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. - -interface CachedIframe { - iframe: HTMLIFrameElement; - src: string; - loaded: boolean; - loadListeners: Set<() => void>; -} - -const iframes = new Map(); -let hostContainer: HTMLDivElement | null = null; - -function getHost(): HTMLDivElement { - if (!hostContainer) { - hostContainer = document.createElement('div'); - hostContainer.setAttribute('data-notes-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'; - document.body.appendChild(hostContainer); - } - return hostContainer; -} - -function getOrCreate(key: string, src: string): CachedIframe { - 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); - } - - const iframe = document.createElement('iframe'); - iframe.src = src; - iframe.style.position = 'fixed'; - iframe.style.border = 'none'; - iframe.style.background = 'transparent'; - iframe.style.display = 'none'; - getHost().appendChild(iframe); - - const created: CachedIframe = { - iframe, - src, - loaded: false, - loadListeners: new Set(), - }; - iframe.addEventListener('load', () => { - created.loaded = true; - created.loadListeners.forEach((fn) => fn()); - }); - iframes.set(key, created); - return created; -} - -export interface NotesIframeHandle { - isLoaded: () => boolean; - onLoad: (cb: () => void) => () => void; - detach: () => void; -} - -export function mountNotesIframe( - key: string, - src: string, - slot: HTMLElement -): NotesIframeHandle { - const entry = getOrCreate(key, src); - const { iframe } = entry; - - iframe.style.display = 'block'; - iframe.style.pointerEvents = 'auto'; - - const sync = () => { - if (!slot.isConnected) return; - const rect = slot.getBoundingClientRect(); - iframe.style.left = `${rect.left}px`; - iframe.style.top = `${rect.top}px`; - iframe.style.width = `${rect.width}px`; - iframe.style.height = `${rect.height}px`; - }; - sync(); - - 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); - - let detached = false; - return { - 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); - } - entry.loadListeners.add(cb); - return () => entry.loadListeners.delete(cb); - }, - detach: () => { - if (detached) return; - detached = true; - ro?.disconnect(); - window.removeEventListener('resize', sync); - window.removeEventListener('scroll', sync, true); - iframe.style.display = 'none'; - iframe.style.pointerEvents = 'none'; - }, - }; -} diff --git a/packages/app/ui/index.tsx b/packages/app/ui/index.tsx index eb6fec2acf..8b19f7b972 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/db/migrations/0000_lovely_solo.sql b/packages/shared/src/db/migrations/0000_careless_the_enforcers.sql similarity index 98% rename from packages/shared/src/db/migrations/0000_lovely_solo.sql rename to packages/shared/src/db/migrations/0000_careless_the_enforcers.sql index 661d9dc9d8..9732099957 100644 --- a/packages/shared/src/db/migrations/0000_lovely_solo.sql +++ b/packages/shared/src/db/migrations/0000_careless_the_enforcers.sql @@ -383,6 +383,15 @@ CREATE INDEX `posts_parent_id_index` ON `posts` (`parent_id`);--> statement-brea CREATE INDEX `posts_cached_index` ON `posts` (`channel_id`,`sent_at`,`author_id`) WHERE sequence_number = 0 AND parent_id IS NULL;--> statement-breakpoint CREATE INDEX `posts_channel_last_preview` ON `posts` (`channel_id`,`received_at`) WHERE type != 'reply' AND (is_deleted IS NULL OR is_deleted = 0);--> statement-breakpoint CREATE INDEX `posts_channel_last_seq` ON `posts` (`channel_id`,`sequence_number`) WHERE type != 'reply' AND sequence_number IS NOT NULL;--> 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 1e6e7933f8..f880a3b97b 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": "5d867ea6-039f-46ef-9dd8-d5eb65eb1838", + "id": "b401844b-72c8-4ac8-9d8a-99fd84dc69a3", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "activity_event_contact_group_pins": { @@ -2598,6 +2598,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 e9d1bceec3..1328ac0c1a 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": 1778110442233, - "tag": "0000_lovely_solo", + "when": 1778599795377, + "tag": "0000_careless_the_enforcers", "breakpoints": true } ] diff --git a/packages/shared/src/db/migrations/migrations.js b/packages/shared/src/db/migrations/migrations.js index 2dfe9f5235..b523e4d928 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_lovely_solo.sql'; +import m0000 from './0000_careless_the_enforcers.sql'; export default { journal, diff --git a/packages/shared/src/db/queries.ts b/packages/shared/src/db/queries.ts index 5d81c705cc..04aad3c7ba 100644 --- a/packages/shared/src/db/queries.ts +++ b/packages/shared/src/db/queries.ts @@ -77,6 +77,7 @@ import { groupUnreads as $groupUnreads, groups as $groups, pins as $pins, + recents as $recents, postReactions as $postReactions, posts as $posts, settings as $settings, @@ -814,6 +815,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 73c19f02d8..4a29417a21 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -1264,3 +1264,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 16c2d9447b..403175fa8b 100644 --- a/packages/shared/src/store/dbHooks.ts +++ b/packages/shared/src/store/dbHooks.ts @@ -45,6 +45,67 @@ 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, + }); +}; + +// 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 }); +} 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';