diff --git a/src/assets/index.css b/src/assets/index.css
index 56876f2..689042e 100644
--- a/src/assets/index.css
+++ b/src/assets/index.css
@@ -255,6 +255,12 @@
gap: 0.75rem;
}
+/* A notification takes touches even while a dialog is open: the dialog turns off pointer events on the page, and
+ the notifications would inherit that, so they could not be swiped away. */
+.harbor-toaster [data-sonner-toast]:not([data-visible='false']) {
+ pointer-events: auto;
+}
+
.harbor-toaster [data-sonner-toast] [data-title] {
color: rgb(var(--harbor-ink));
font-size: 0.9375rem;
@@ -393,6 +399,9 @@
/* Whole bars: the tabs plus the bar's padding on both sides. */
--liquid-bar-height: calc(var(--ui-tab-size) + 12px);
--liquid-bar-height-sm: calc(var(--ui-tab-size-sm) + 8px);
+ /* The phone tab bar's gap to the screen bottom: 1rem, or a little inside the home indicator area where the page
+ covers it (the OpenMeet app), so the bar sits close to the edge. */
+ --liquid-bar-bottom: max(1rem, calc(env(safe-area-inset-bottom) - 0.5rem));
/* Meeting controls and the meeting's top row buttons, in normal and full screen. */
/* Large screens stop growing them at 52px, so the bar stays compact on big monitors. */
--meeting-control-size: clamp(44px, calc(30px + 2.6vmin), 52px);
@@ -1720,9 +1729,9 @@
}
}
- /* The call screen is always dark, so it and its chrome (controls, phone bars, chat, camera preview) use a dark
- Harbor palette. `meeting-immersive` sits on the document while a call is on screen, because the chat and the
- camera preview open in portals. */
+ /* The call screen is always dark, so it and its chrome (controls, phone bars, chat, camera preview, and the
+ notifications shown during the call) use a dark Harbor palette. `meeting-immersive` sits on the document while
+ a call is on screen, because the chat, the camera preview, and the notifications render outside it. */
.meeting-controls-dark,
.call-screen-dark,
.meeting-immersive
@@ -1731,7 +1740,8 @@
[data-call-phone-top-bar],
.harbor-chat-panel,
[data-camera-preview],
- [data-meeting-invite]
+ [data-meeting-invite],
+ .harbor-toaster
) {
--harbor-ink: 233 237 239;
--harbor-ink-soft: 209 215 219;
@@ -1792,6 +1802,11 @@
border-top: 1px solid rgb(var(--harbor-line));
}
+ /* A closed expander has no height, so it draws no divider either. */
+ .call-panel-group > [data-expand][data-state='closed'] {
+ border-top-width: 0;
+ }
+
.call-panel-row {
display: flex;
width: 100%;
diff --git a/src/components/dashboard-page/__tests__/ChatPane.test.ts b/src/components/dashboard-page/__tests__/ChatPane.test.ts
index 61f7872..c3e40a4 100644
--- a/src/components/dashboard-page/__tests__/ChatPane.test.ts
+++ b/src/components/dashboard-page/__tests__/ChatPane.test.ts
@@ -121,6 +121,18 @@ describe('ChatPane', () => {
expect(wrapper.find('[data-presence-dot]').exists()).toBe(false);
});
+ it('pads the header for the status bar and the composer for the home indicator, except while typing', async () => {
+ const wrapper = mountPane({ conversation });
+ expect(wrapper.get('header').classes().join(' ')).toContain('env(safe-area-inset-top)');
+ const bar = wrapper.get('[data-composer-bar]');
+ expect(bar.classes().join(' ')).toContain('env(safe-area-inset-bottom)');
+
+ await bar.trigger('focusin');
+ expect(bar.classes()).toContain('pb-2.5');
+ await bar.trigger('focusout');
+ expect(bar.classes().join(' ')).toContain('env(safe-area-inset-bottom)');
+ });
+
it('starts video and audio calls from the header actions', async () => {
const wrapper = mountPane({
conversation,
diff --git a/src/components/dashboard-page/calls/CallsSidebar.vue b/src/components/dashboard-page/calls/CallsSidebar.vue
index 5b9d871..44e3f00 100644
--- a/src/components/dashboard-page/calls/CallsSidebar.vue
+++ b/src/components/dashboard-page/calls/CallsSidebar.vue
@@ -1,9 +1,8 @@
+
+
+
+
+
+
diff --git a/src/components/meeting-page/CallPanelChoice.vue b/src/components/meeting-page/CallPanelChoice.vue
index 50f1b44..00e0137 100644
--- a/src/components/meeting-page/CallPanelChoice.vue
+++ b/src/components/meeting-page/CallPanelChoice.vue
@@ -2,6 +2,8 @@
import { Check, ChevronDown } from 'lucide-vue-next';
import { computed, useId } from 'vue';
+import { Expand } from '@/components/ui/expand';
+
// One setting in the phone controls panel, such as a camera's source or quality: a row with the setting's name
// over its current value, which expands in place to the choices.
export interface CallPanelOption {
@@ -51,28 +53,30 @@ const current = computed(() => props.options.find((option) => option.value === p
aria-hidden="true"
/>
-
-
{{ emptyText }}
-
-
+
+
+
{{ emptyText }}
+
+
+
diff --git a/src/components/meeting-page/CallPhoneControls.vue b/src/components/meeting-page/CallPhoneControls.vue
index fc1bef2..e4a4fce 100644
--- a/src/components/meeting-page/CallPhoneControls.vue
+++ b/src/components/meeting-page/CallPhoneControls.vue
@@ -25,6 +25,11 @@ defineProps<{
/** The call could not get the microphone or camera; its button shows a warning that asks again. */
audioWarning?: boolean;
videoWarning?: boolean;
+ /**
+ * Full screen sits the bar 0.25rem higher, so it clears the screen's edge with a little more room. Over the home
+ * indicator area (the OpenMeet app) the bar always keeps 0.5rem above the indicator.
+ */
+ fullscreen?: boolean;
}>();
const emit = defineEmits<{
@@ -163,7 +168,12 @@ function onLeave() {
{
aria-hidden="true"
/>
-
-
-
-
+
diff --git a/src/components/meeting-page/MeetingControls.vue b/src/components/meeting-page/MeetingControls.vue
index dfb83f0..344ec28 100644
--- a/src/components/meeting-page/MeetingControls.vue
+++ b/src/components/meeting-page/MeetingControls.vue
@@ -76,8 +76,11 @@ function onLeave() {
{
:controls-hidden="!showsControls"
:hide-own-camera="!showOwnCamera"
@toggle-pin="handleTogglePin"
+ @show-grid="handleSetViewMode('grid')"
@toggle-fullscreen="handleToggleFullscreen"
@long-press="handleVideoLongPress"
@swipe="handleVideoSwipe"
@@ -1196,6 +1212,7 @@ const handleReconnect = () => {
:show-own-camera="showOwnCamera"
:audio-warning="audioWarning"
:video-warning="videoWarning"
+ :fullscreen="isFullscreen"
@toggle-mute="handleToggleMute"
@toggle-video="handleToggleVideo"
@request-access="handleRequestAccess"
diff --git a/src/services/__tests__/system-alerts.test.ts b/src/services/__tests__/system-alerts.test.ts
index fa50e47..0eb1578 100644
--- a/src/services/__tests__/system-alerts.test.ts
+++ b/src/services/__tests__/system-alerts.test.ts
@@ -290,4 +290,34 @@ describe('SystemAlerts in the native app', () => {
expect(ringtoneStart).toHaveBeenCalledOnce();
alerts.stop();
});
+
+ it('rings only in the app bar while the app is visible, even when its webview reports no focus', async () => {
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
+ const ringtoneStart = vi.spyOn(Ringtone.prototype, 'start').mockImplementation(() => undefined);
+ const { alerts } = createAlerts();
+ await alerts.start();
+
+ alerts.show(callAlert);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(nativeNotifications.showNativeCallNotification).not.toHaveBeenCalled();
+ expect(ringtoneStart).toHaveBeenCalled();
+ alerts.stop();
+ });
+
+ it('swaps a call notification for the app bar when the app comes to the front', async () => {
+ const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
+ const ringtoneStart = vi.spyOn(Ringtone.prototype, 'start').mockImplementation(() => undefined);
+ const { alerts } = createAlerts();
+ await alerts.start();
+ alerts.show(callAlert);
+ await vi.waitFor(() => expect(nativeNotifications.showNativeCallNotification).toHaveBeenCalled());
+
+ visibility.mockReturnValue('visible');
+ document.dispatchEvent(new Event('visibilitychange'));
+
+ expect(nativeNotifications.dismissNativeCallNotification).toHaveBeenCalledWith('call-1', 'session');
+ expect(ringtoneStart).toHaveBeenCalled();
+ alerts.stop();
+ });
});
diff --git a/src/services/__tests__/system-bars.test.ts b/src/services/__tests__/system-bars.test.ts
index c22daea..751bd98 100644
--- a/src/services/__tests__/system-bars.test.ts
+++ b/src/services/__tests__/system-bars.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { startSystemBarSync } from '@/services/system-bars';
+import { coverScreenTop, deviceCornerRadius, startSystemBarSync } from '@/services/system-bars';
const tauri = vi.hoisted(() => ({ isTauri: vi.fn(() => false), invoke: vi.fn(() => Promise.resolve()) }));
vi.mock('@tauri-apps/api/core', () => tauri);
@@ -148,4 +148,23 @@ describe('system bars', () => {
expect(tauri.invoke).toHaveBeenCalledTimes(1);
});
+
+ it('covers the status bar area while any reason holds, and tells the app only when that changes', () => {
+ tauri.isTauri.mockReturnValue(true);
+ coverScreenTop('chat', true);
+ coverScreenTop('call', true);
+ coverScreenTop('chat', false);
+ coverScreenTop('call', false);
+
+ expect(tauri.invoke.mock.calls).toEqual([
+ ['set_screen_edges', { top: true, bottom: true }],
+ ['set_screen_edges', { top: false, bottom: true }],
+ ]);
+ });
+
+ it('knows the corner radius of rounded iPhone screens by size, in either orientation', () => {
+ expect(deviceCornerRadius(393, 852)).toBe(55);
+ expect(deviceCornerRadius(852, 393)).toBe(55);
+ expect(deviceCornerRadius(375, 667)).toBe(0);
+ });
});
diff --git a/src/services/system-alerts.ts b/src/services/system-alerts.ts
index 4d211eb..6b2aeb6 100644
--- a/src/services/system-alerts.ts
+++ b/src/services/system-alerts.ts
@@ -35,6 +35,8 @@ interface RingingCall {
callKind: AlertCallKind;
alertedAt: number;
webNotification: Notification | null;
+ /** The OpenMeet app shows a system notification for it, because the call came while the app was away. */
+ nativeShown: boolean;
}
function blobToDataUrl(blob: Blob) {
@@ -46,8 +48,11 @@ function blobToDataUrl(blob: Blob) {
});
}
+// The OpenMeet app is in front whenever its page is visible: its webview does not always report focus, and a
+// missed focus would show a system notification on top of the app's own incoming call bar. A browser tab can be
+// visible in a window behind another one, so there it must also have focus.
function isAppInFront() {
- return document.visibilityState === 'visible' && document.hasFocus();
+ return document.visibilityState === 'visible' && (hasNativeNotifications() || document.hasFocus());
}
/**
@@ -65,8 +70,22 @@ export class SystemAlerts {
constructor(private readonly options: SystemAlertsOptions) {}
+ // Coming back to the app while a call rings: the incoming call bar takes over from the system notification.
+ private readonly handleVisibility = () => {
+ if (document.visibilityState !== 'visible') return;
+ for (const [callId, call] of this.calls) {
+ if (!call.nativeShown) continue;
+ call.nativeShown = false;
+ dismissNativeCallNotification(callId, call.callKind).catch((error) =>
+ console.error('[SystemAlerts] Failed to dismiss call notification:', error),
+ );
+ this.ringtone.start();
+ }
+ };
+
async start() {
if (!this.native || this.nativeSession) return;
+ document.addEventListener('visibilitychange', this.handleVisibility);
try {
this.nativeSession = await startNativeNotifications(
this.actionLabels(),
@@ -81,6 +100,7 @@ export class SystemAlerts {
}
stop() {
+ document.removeEventListener('visibilitychange', this.handleVisibility);
this.generation += 1;
this.dismissAllCalls();
this.nativeSession?.stop();
@@ -173,7 +193,12 @@ export class SystemAlerts {
const body = alert.conversationTitle
? translate('notifications.groupCallBody', { name: alert.callerName })
: translate('notifications.callBody');
- const call: RingingCall = { callKind: alert.callKind, alertedAt: Date.now(), webNotification: null };
+ const call: RingingCall = {
+ callKind: alert.callKind,
+ alertedAt: Date.now(),
+ webNotification: null,
+ nativeShown: false,
+ };
this.calls.set(alert.callId, call);
const generation = this.generation;
@@ -188,6 +213,12 @@ export class SystemAlerts {
void this.avatarBytes(alert.avatarUrl)
.then((avatar) => {
if (generation !== this.generation || !this.nativeSession || !isRinging()) return;
+ // The app came to the front while the avatar loaded: its own bar shows the call, so it rings in the app.
+ if (isAppInFront()) {
+ this.ringtone.start();
+ return;
+ }
+ call.nativeShown = true;
return showNativeCallNotification({ callId: alert.callId, callKind: alert.callKind, title, body, avatar });
})
.catch((error) => console.error('[SystemAlerts] Failed to show call notification:', error));
diff --git a/src/services/system-bars.ts b/src/services/system-bars.ts
index d17b8a2..66a62d9 100644
--- a/src/services/system-bars.ts
+++ b/src/services/system-bars.ts
@@ -175,8 +175,52 @@ function paint(colors: SystemBarColors) {
}
}
+// What wants the page to cover the status bar area, such as an open chat or a full screen call in portrait.
+const topCovers = new Set();
+
+/**
+ * Lets the page reach under the status bar for `reason` (an open chat, a full screen call), or stops. The OpenMeet
+ * iOS app always lets the page cover the home indicator area, and covers the status bar area while any reason
+ * holds (`set_screen_edges`); browsers ignore it. What the page draws there pads itself with
+ * `env(safe-area-inset-*)`, which reads the real insets of the covered areas.
+ */
+export function coverScreenTop(reason: string, cover: boolean) {
+ const covered = topCovers.size > 0;
+ if (cover) topCovers.add(reason);
+ else topCovers.delete(reason);
+ if (!isTauri() || covered === topCovers.size > 0) return;
+ invoke('set_screen_edges', { top: topCovers.size > 0, bottom: true }).catch((error) =>
+ console.error('[SystemBars] Failed to change the screen edges:', error),
+ );
+}
+
+// The screen's corner radius on iPhones with rounded displays, by their size in points (portrait width x height).
+// iOS keeps the real value private, so this follows Apple's published screen sizes; where two models share a size,
+// the smaller radius is used, which only rounds a little less than the glass.
+const IPHONE_CORNER_RADII: Record = {
+ '375x812': 39,
+ '414x896': 39,
+ '390x844': 47.33,
+ '428x926': 53.33,
+ '393x852': 55,
+ '430x932': 55,
+ '402x874': 62,
+ '440x956': 62,
+};
+
+/** The corner radius of a phone screen this size, in CSS pixels, or 0 for square corners or an unknown size. */
+export function deviceCornerRadius(width: number, height: number): number {
+ const [short, long] = width < height ? [width, height] : [height, width];
+ return IPHONE_CORNER_RADII[`${Math.round(short)}x${Math.round(long)}`] ?? 0;
+}
+
/** Starts following the page background; returns a function that stops. */
export function startSystemBarSync(): () => void {
+ // Lets a full screen call round its video like the glass around it (see VideoGrid).
+ if (isTauri()) {
+ const radius = deviceCornerRadius(window.screen.width, window.screen.height);
+ if (radius) document.documentElement.style.setProperty('--device-corner-radius', `${radius}px`);
+ }
let painted: SystemBarColors | null = null;
let frame: number | null = null;