Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,31 @@ import expo.modules.kotlin.modules.ModuleDefinition
import com.facebook.react.common.LifecycleState
import android.util.Base64
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.functions.Queues
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.withContext

class NappletHostModule : Module() {
private var workspaceFullscreen = false

private fun updateWorkspaceBars() {
val window = appContext.currentActivity?.window ?: return
val controller = WindowCompat.getInsetsController(window, window.decorView)
val bars = WindowInsetsCompat.Type.systemBars()
if (workspaceFullscreen) {
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(bars)
} else {
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
controller.show(bars)
}
}

override fun definition() = ModuleDefinition {
Name("HypergolicNappletHost")
Events("onRelayStream", "onRelayLifecycle", "onUploadProgress", "onRoutingLifecycle")
Expand All @@ -32,7 +51,12 @@ class NappletHostModule : Module() {
ResourceHttpsRequestOwner.INSTANCE.setForeground(foreground)
PublishedRelayWssRequestOwner.INSTANCE.setForeground(foreground)
}
AsyncFunction("setWorkspaceFullscreen") { fullscreen: Boolean ->
workspaceFullscreen = fullscreen
updateWorkspaceBars()
}.runOnQueue(Queues.MAIN)
OnActivityEntersForeground {
if (workspaceFullscreen) updateWorkspaceBars()
SelectedFilePicker.foreground(true)
UploadTransport.foreground(true)
CvmTransport.foreground(true)
Expand Down
8 changes: 5 additions & 3 deletions src/shell/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {publishedAppLabel} from '../napplets/published-identity';
import { TrustedBand } from './TrustedBand';
import {useCallback, useEffect, useMemo, useState, useSyncExternalStore} from 'react';
import {WorkspaceFullscreenContext} from './WorkspaceFullscreen';
import {useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore} from 'react';
import {AppState, FlatList, ScrollView, StyleSheet, Text, View} from 'react-native';
import { ShellModal as Modal } from './ShellModal';
import { ShellPressable as Pressable } from './ShellPressable';
Expand Down Expand Up @@ -35,6 +36,7 @@ function NotificationPermission({review, service}: {review: NotifyReview; servic
type CentreEntry = Readonly<{kind: 'card'; card: NotificationCard}> | Readonly<{kind: 'badge'; badge: NotificationBadge}>;
export function Notifications({service, blocked}: {service: NotifyBroker; blocked: boolean}) {
const { styles } = useShellStyles(createStyles);
const fullscreen = useContext(WorkspaceFullscreenContext);
const snapshot = useSyncExternalStore(service.subscribe, service.getSnapshot), [opened, setOpened] = useState(false), insets = useSafeAreaInsets();
useEffect(() => {
const timer = setInterval(() => service.refresh(), 250), listener = AppState.addEventListener('change', state => {if (state !== 'active') setOpened(false); service.refresh();});
Expand All @@ -43,15 +45,15 @@ export function Notifications({service, blocked}: {service: NotifyBroker; blocke
const entries = useMemo<readonly CentreEntry[]>(() => [...snapshot.cards.map(card => ({kind: 'card' as const, card})), ...snapshot.attributedBadges.map(badge => ({kind: 'badge' as const, badge}))], [snapshot.cards, snapshot.attributedBadges]);
const renderEntry = useCallback(({item}: {item: CentreEntry}) => item.kind === 'card' ? <NotificationContent card={item.card} service={service}/> : <View testID={'notify-badge-' + publishedAppLabel(item.badge.origin.appId)} style={styles.card}><Text style={styles.title}>{publishedAppLabel(item.badge.origin.appId)}</Text><Text style={styles.publisher}>{formatNpub(item.badge.origin.publisher)}</Text><Text style={styles.text}>Badge count: {item.badge.count}</Text></View>, [service, styles]);
if (blocked) return null;
const toast = !opened && !snapshot.review ? snapshot.toast : undefined;
const toast = !fullscreen && !opened && !snapshot.review ? snapshot.toast : undefined;
const badge = Object.values(snapshot.badges).reduce((sum, count) => sum + count, 0);
return <>
{toast && <View testID="notify-toast" style={styles.toast}><View style={styles.card}>
<Text numberOfLines={1} style={styles.detail}>From {publishedAppLabel(toast.origin.appId)}</Text><Text numberOfLines={1} style={styles.publisher}>{formatNpub(toast.origin.publisher)}</Text>
<Text numberOfLines={1} style={styles.title}>{toast.title}</Text>{toast.body !== '' && <Text numberOfLines={2} style={styles.text}>{toast.body}</Text>}
<Button id="notify-toast-open" label="View notification" onPress={() => setOpened(true)}/>
</View></View>}
{(snapshot.cards.length > 0 || badge > 0) && !snapshot.review && <View style={styles.launcher}><Button id="notify-centre-open" label={'Notifications (' + snapshot.cards.length + '), badges ' + badge} onPress={() => setOpened(true)}/></View>}
{!fullscreen && (snapshot.cards.length > 0 || badge > 0) && !snapshot.review && <View style={styles.launcher}><Button id="notify-centre-open" label={'Notifications (' + snapshot.cards.length + '), badges ' + badge} onPress={() => setOpened(true)}/></View>}
<Modal visible={opened && !snapshot.review} transparent animationType="fade" onRequestClose={() => setOpened(false)}><View style={[styles.shade, {paddingTop: insets.top, paddingLeft: insets.left, paddingRight: insets.right}]}><FlatList
data={entries}
keyExtractor={item => item.kind === 'card' ? 'card-' + item.card.id : 'badge-' + item.badge.origin.generation}
Expand Down
23 changes: 14 additions & 9 deletions src/shell/Shell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isHostActivityBlocked } from './workspace-activity';
import { WorkspaceFullscreenContext, useWorkspaceFullscreen } from './WorkspaceFullscreen';
import { useWorkspaceDiscovery } from './useWorkspaceDiscovery';
import { DiscoveryAccessReview } from './DiscoveryAccessReview';
import { WorkspaceHeader } from './WorkspaceHeader';
Expand Down Expand Up @@ -81,6 +82,7 @@ export function Shell({ blocked = false, hostActivityBlocked = blocked, initialW
const owner = useContext(OwnerContext);
const [localWorkspace, setLocalWorkspace] = useState(() => initialWorkspace ?? initialTestWorkspace());
const workspace = controlledWorkspace ?? localWorkspace;
const fullscreen = useWorkspaceFullscreen(workspace);
const current = useRef(workspace);
useLayoutEffect(() => { current.current = workspace; }, [workspace]);
const change = useCallback((next: Workspace) => {
Expand All @@ -102,7 +104,7 @@ export function Shell({ blocked = false, hostActivityBlocked = blocked, initialW
const focusedIndex = Math.max(0, workspace.sessions.findIndex(session => session.id === workspace.focusedId));
const focused = workspace.sessions.find(session => session.id === workspace.focusedId);
const { session: menuSession, visible: menuVisible, anchor: menuAnchor, dismiss: dismissMenu, open: openMenu, clear: clearMenu } =
useWorkspaceMenu(focused, workspace.overview, { blocked, busy: busyRef, dragging: handleDragging, closeChecks });
useWorkspaceMenu(focused, workspace.overview, { blocked, fullscreen, busy: busyRef, dragging: handleDragging, closeChecks });
const navigationBlocked = blocked || busy || closeCheck !== null;
const progress = useSharedValue(workspace.overview ? 1 : 0);
const position = useSharedValue(focusedIndex);
Expand Down Expand Up @@ -141,7 +143,7 @@ export function Shell({ blocked = false, hostActivityBlocked = blocked, initialW
}, []);
useEffect(() => {
if (!busyRef.current) { position.set(focusedIndex); progress.set(workspace.overview ? 1 : 0); }
}, [focusedIndex, position, progress, workspace.overview]);
}, [focusedIndex, fullscreen, position, progress, workspace.overview]);

const focusSession = useCallback((id: string) => {
if (blocked || busyRef.current || closeChecks.isPending()) return;
Expand All @@ -160,10 +162,10 @@ export function Shell({ blocked = false, hostActivityBlocked = blocked, initialW
change(showOverview(current.current));
runAnimation(progress, 1, () => overview.current?.focusCard(current.current.focusedId));
}, [blocked, change, closeChecks, progress, runAnimation]);
useWorkspaceBack({ workspace, blocked, menuVisible, busy: busyRef, keyboard: keyboardVisible,
useWorkspaceBack({ workspace, blocked, fullscreen, menuVisible, busy: busyRef, keyboard: keyboardVisible,
closeChecks, dismissMenu, openOverview, focusSession });
const onHandleEnd = (side: HandleSide, result: 'overview' | 'switch' | 'cancel') => handleWorkspaceGestureEnd({
side, result, blocked, busyRef, focusedIndex, current, position, openOverview, runAnimation, change,
side, result, blocked, fullscreen, busyRef, focusedIndex, current, position, openOverview, runAnimation, change,
setHandleDragging,
});
function closeSession(id: string) {
Expand Down Expand Up @@ -208,15 +210,15 @@ export function Shell({ blocked = false, hostActivityBlocked = blocked, initialW
<ShellBackdrop mood={workspace.overview ? 'shelf' : 'running'} />
<ReviewFocus sessionId={workspace.focusedId} overview={workspace.overview} busy={busy} dragging={handleDragging} blocked={blocked} settings={obscured} closing={closingId !== null} />
<View accessibilityElementsHidden={obscured} importantForAccessibility={obscured ? 'no-hide-descendants' : 'auto'} style={styles.content}>
<WorkspaceHeader overview={workspace.overview} focused={focused} identity={identity} disabled={navigationBlocked || handleDragging}
<WorkspaceHeader fullscreen={fullscreen} overview={workspace.overview} focused={focused} identity={identity} disabled={navigationBlocked || handleDragging}
menuAnchor={menuAnchor} onSettings={openSettings} onOverview={openOverview} onMenu={openMenu} />
<WorkspaceStage onZoomTarget={registerZoomTarget} closeCheck={closeCheck} workspace={workspace} progress={progress} position={position} scrollY={scrollY} swipeY={swipeY} swipeId={swipeId} busy={busy} blocked={navigationBlocked || obscured} hostActivityBlocked={isHostActivityBlocked(hostActivityBlocked, obscured)} interactive={!handleDragging}
<WorkspaceStage fullscreen={fullscreen} onZoomTarget={registerZoomTarget} closeCheck={closeCheck} workspace={workspace} progress={progress} position={position} scrollY={scrollY} swipeY={swipeY} swipeId={swipeId} busy={busy} blocked={navigationBlocked || obscured} hostActivityBlocked={isHostActivityBlocked(hostActivityBlocked, obscured)} interactive={!handleDragging}
pending={pendingApprovals ?? noPending} ref={overview} onHostEvent={receiveHostEvent} onFocus={focusSession} onClose={requestClose} onOpen={openDiscovery}
onDragEnd={(id, close) => { runAnimation(swipeY, 0, () => { swipeId.set(null); if (close) requestClose(id); }, 140); }}
onHandleStart={() => { setHandleDragging(true); Keyboard.dismiss(); cancelAnimation(position); }}
onHandleEnd={onHandleEnd} />
</View>
{footer}
<WorkspaceFullscreenContext value={fullscreen}>{footer}</WorkspaceFullscreenContext>
<DiscoveryAccessReview review={discovery.review} decide={discovery.decide} />
{menuSession && <WorkspaceMenu title={menuSession.title} disabled={navigationBlocked} onNappletSettings={openNappletSettings}
onResetZoom={() => resetZoom(menuSession)} zoomResetDisabled={!zoomResetAvailable}
Expand Down Expand Up @@ -245,10 +247,11 @@ interface ShellSettingsProps {
openDiscovery: () => void;
}

function handleWorkspaceGestureEnd({ side, result, blocked, busyRef, focusedIndex, current, position, openOverview, runAnimation, change, setHandleDragging }: {
function handleWorkspaceGestureEnd({ side, result, blocked, fullscreen, busyRef, focusedIndex, current, position, openOverview, runAnimation, change, setHandleDragging }: {
side: HandleSide;
result: 'overview' | 'switch' | 'cancel';
blocked: boolean;
fullscreen: boolean;
busyRef: { current: boolean };
focusedIndex: number;
current: { current: Workspace };
Expand All @@ -259,7 +262,9 @@ function handleWorkspaceGestureEnd({ side, result, blocked, busyRef, focusedInde
setHandleDragging: (dragging: boolean) => void;
}) {
setHandleDragging(false);
if (blocked || busyRef.current) return;
if (busyRef.current) return;
if (fullscreen) { position.set(focusedIndex); return; }
if (blocked) return;
if (result === 'overview') { position.set(focusedIndex); openOverview(); return; }
const id = result === 'switch' ? adjacentNapplet(current.current, directionForHandle(side)) : null;
const destination = id ? current.current.sessions.findIndex(session => session.id === id) : focusedIndex;
Expand Down
23 changes: 23 additions & 0 deletions src/shell/WorkspaceFullscreen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { requireNativeModule } from 'expo';
import { createContext, useEffect } from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import { isWorkspaceFullscreen } from './workspace-activity';
import type { Workspace } from './workspace';

export const WorkspaceFullscreenContext = createContext(false);

const androidChrome = Platform.OS === 'android'
? requireNativeModule<{setWorkspaceFullscreen(fullscreen: boolean): Promise<void>}>('HypergolicNappletHost')
: null;

export function useWorkspaceFullscreen(workspace: Workspace): boolean {
const fullscreen = isWorkspaceFullscreen(workspace, useWindowDimensions());
useEffect(() => {
if (!fullscreen || !androidChrome) return;
const chrome = androidChrome;
const setFullscreen = (enabled: boolean) => { void chrome.setWorkspaceFullscreen(enabled).catch(() => undefined); };
setFullscreen(true);
return () => setFullscreen(false);
}, [fullscreen]);
return fullscreen;
}
12 changes: 6 additions & 6 deletions src/shell/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import type { Ref } from 'react';
import { StatusBar, type ViewInstance } from 'react-native';
import { Platform, StatusBar, type ViewInstance } from 'react-native';
import { Header, type ShellIdentity } from './Header';
import type { NappletDescriptor } from './workspace';
import { useShellStyles } from './theme';

const noStyles = () => ({});
export function WorkspaceHeader({ overview, focused, identity, disabled, menuAnchor, onSettings, onOverview, onMenu }: {
overview: boolean; focused: NappletDescriptor | undefined; identity?: ShellIdentity; disabled: boolean;
export function WorkspaceHeader({ fullscreen, overview, focused, identity, disabled, menuAnchor, onSettings, onOverview, onMenu }: {
fullscreen: boolean; overview: boolean; focused: NappletDescriptor | undefined; identity?: ShellIdentity; disabled: boolean;
menuAnchor: Ref<ViewInstance>; onSettings(): void; onOverview(): void; onMenu(): void;
}) {
const { isDark } = useShellStyles(noStyles);
const compact = !overview && focused !== undefined;
return <>
<StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} />
<Header title={overview ? 'Hypergolic' : focused?.title ?? 'Hypergolic'} identity={identity} disabled={disabled}
<StatusBar hidden={Platform.OS === 'ios' ? fullscreen : undefined} barStyle={isDark ? 'light-content' : 'dark-content'} />
{!fullscreen && <Header title={overview ? 'Hypergolic' : focused?.title ?? 'Hypergolic'} identity={identity} disabled={disabled}
compact={compact} menuAnchor={menuAnchor} onSettings={onSettings}
onOverview={compact ? onOverview : undefined} onMenu={compact ? onMenu : undefined} />
onOverview={compact ? onOverview : undefined} onMenu={compact ? onMenu : undefined} />}
</>;
}
Loading
Loading