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
23 changes: 19 additions & 4 deletions src/assets/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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%;
Expand Down
12 changes: 12 additions & 0 deletions src/components/dashboard-page/__tests__/ChatPane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 4 additions & 59 deletions src/components/dashboard-page/calls/CallsSidebar.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { CheckCheck, EyeOff, Info, Phone, PhoneMissed, Plus, UserRound, UsersRound, Video } from 'lucide-vue-next';
import { Info, Phone, PhoneMissed, Plus, UserRound, UsersRound, Video } from 'lucide-vue-next';
import { type Component, computed, ref } from 'vue';

import PaneHeader from '@/components/dashboard-page/PaneHeader.vue';
import SectionBadge from '@/components/dashboard-page/SectionBadge.vue';
import { Avatar, AvatarMosaic, type MosaicFace } from '@/components/ui/avatar';
import { ListHeading, ListRow } from '@/components/ui/list-row';
import { LoadingRipple } from '@/components/ui/loading';
Expand All @@ -23,8 +22,6 @@ import type { MeetingPerson, MeetingSession } from '@/services/social-api';

const props = defineProps<{
panel: CallsPanel;
/** Missed calls not read yet. */
badge: number;
meetings: MeetingSession[];
isLoading: boolean;
error: string;
Expand All @@ -41,7 +38,6 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'open', meeting: MeetingSession): void;
(event: 'call', meeting: MeetingSession): void;
(event: 'toggle-read', meeting: MeetingSession): void;
(event: 'load-more'): void;
(event: 'start-meeting'): void;
}>();
Expand Down Expand Up @@ -112,7 +108,6 @@ function initials(name: string) {
aria-labelledby="calls-heading"
>
<PaneHeader v-model:query="query" title="Calls" heading-id="calls-heading" search-label="Search calls">
<template #badge><SectionBadge :count="badge" singular="missed call" plural="missed calls" /></template>
<template #trailing>
<RoundButton tone="accent" aria-label="Start a meeting" title="Start a meeting" @click="emit('start-meeting')">
<!-- A new call: the handset with a plus in its empty top-right corner. -->
Expand Down Expand Up @@ -154,59 +149,16 @@ function initials(name: string) {
</p>
<section v-else>
<ListHeading class="pt-1">Recent</ListHeading>
<!-- A missed call reads in red; a swipe left shows the call's details. -->
<SwipeableRow
v-for="meeting in visibleMeetings"
:id="`call-${meeting.id}`"
:key="meeting.id"
:leading-width="meeting.missed ? SWIPE_ACTION_WIDTH : 0"
:leading-width="0"
:trailing-width="SWIPE_ACTION_WIDTH"
:full-swipe-leading="meeting.missed"
:full-swipe-trailing="meeting.unread"
@full-swipe-leading="emit('toggle-read', meeting)"
@full-swipe-trailing="emit('toggle-read', meeting)"
>
<template #leading="{ armed, close }">
<template #trailing="{ close }">
<button
v-if="meeting.missed"
type="button"
data-swipe-action
class="flex h-full w-full items-center justify-start text-white transition-colors"
:class="armed ? 'bg-harbor-ink-soft' : 'bg-harbor-icon'"
:aria-label="meeting.unread ? 'Mark call read' : 'Mark call unread'"
@click="
close();
emit('toggle-read', meeting);
"
>
<span
class="flex w-[4.75rem] shrink-0 flex-col items-center justify-center gap-1 text-[11px] font-semibold"
><CheckCheck v-if="meeting.unread" class="size-4" /><EyeOff v-else class="size-4" />
{{ meeting.unread ? 'Read' : 'Unread' }}</span
>
</button>
</template>
<!-- Swiping an unread missed call left marks it read; read calls offer their details. -->
<template #trailing="{ armed, close }">
<button
v-if="meeting.unread"
type="button"
data-swipe-action
data-call-read-action
class="flex h-full w-full items-center justify-end text-white transition-colors"
:class="armed ? 'bg-harbor-ink-soft' : 'bg-harbor-icon'"
aria-label="Mark call read"
@click="
close();
emit('toggle-read', meeting);
"
>
<span
class="flex w-[4.75rem] shrink-0 flex-col items-center justify-center gap-1 text-[11px] font-semibold"
><CheckCheck class="size-4" />Read</span
>
</button>
<button
v-else
type="button"
data-swipe-action
class="flex h-full w-full items-center justify-end bg-harbor-hover text-harbor-ink"
Expand Down Expand Up @@ -258,13 +210,6 @@ function initials(name: string) {
</template>
<template #trailing>
<LoadingRipple v-if="callingId === meeting.id" class="size-4 text-harbor-icon" />
<span
v-else-if="meeting.unread"
data-call-unread
class="size-2.5 shrink-0 rounded-full bg-harbor-accent"
role="img"
aria-label="Unread"
/>
</template>
<!-- Desktop only: call back and details on hover; touch opens the details, which can call back. -->
<template #actions>
Expand Down
41 changes: 12 additions & 29 deletions src/components/dashboard-page/calls/__tests__/CallsSidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ function mountSidebar(props: Partial<InstanceType<typeof CallsSidebar>['$props']
},
props: {
panel: 'collapsed',
badge: 0,
meetings,
isLoading: false,
error: '',
Expand All @@ -68,13 +67,9 @@ describe('CallsSidebar', () => {
expect(wrapper.emitted('toggle')).toBeUndefined();
});

it('shows missed calls beside the title while collapsed', () => {
expect(mountSidebar().find('[data-section-badge]').exists()).toBe(false);

const badge = mountSidebar({ badge: 3 }).get('#calls-heading [data-section-badge]');
expect(badge.text()).toBe('3');
expect(badge.attributes('aria-label')).toBe('3 missed calls');
expect(mountSidebar({ badge: 150 }).get('[data-section-badge]').text()).toBe('99+');
it('shows no missed-call count beside the title', () => {
const wrapper = mountSidebar({ meetings: [missedCall] });
expect(wrapper.find('[data-section-badge]').exists()).toBe(false);
});

it('lists calls when expanded and opens one on press', async () => {
Expand All @@ -87,35 +82,23 @@ describe('CallsSidebar', () => {
expect(wrapper.emitted('open')?.[0]).toEqual([meetings[0]]);
});

it('marks unread missed calls with a dot and a missed label', () => {
it('marks a missed call in red with a missed label, and no unread dot', () => {
const wrapper = mountSidebar({ panel: 'top', meetings: [missedCall, meetings[0]!] });
const [missed, answered] = wrapper.findAll('[data-call-item]');

expect(missed!.find('[data-call-unread]').exists()).toBe(true);
expect(missed!.get('[data-call-missed]').text()).toContain('Missed');
expect(answered!.find('[data-call-unread]').exists()).toBe(false);
expect(answered!.find('[data-call-missed]').exists()).toBe(false);
});

it('drops the dot once a missed call is read', () => {
const wrapper = mountSidebar({ panel: 'top', meetings: [{ ...missedCall, unread: false }] });
expect(missed!.find('.text-harbor-danger').exists()).toBe(true);
expect(wrapper.find('[data-call-unread]').exists()).toBe(false);
expect(wrapper.find('[data-call-missed]').exists()).toBe(true);
expect(answered!.find('[data-call-missed]').exists()).toBe(false);
});

it('toggles a missed call read state without exposing call again', async () => {
const wrapper = mountSidebar({ panel: 'top', meetings: [missedCall, meetings[0]!] });

await wrapper.get('[data-call-read-action]').trigger('click');
expect(wrapper.emitted('toggle-read')?.[0]).toEqual([missedCall]);
expect(wrapper.findAll('[data-call-read-action]')).toHaveLength(1);
expect(wrapper.find('[aria-label="Call details"]').exists()).toBe(true);
});
it('offers only call details on a swipe, with no read or unread action', async () => {
const wrapper = mountSidebar({ panel: 'top', meetings: [missedCall] });
expect(wrapper.find('[data-call-read-action]').exists()).toBe(false);
expect(wrapper.find('[aria-label="Mark call unread"]').exists()).toBe(false);

it('marks a read missed call unread from the right swipe action', async () => {
const wrapper = mountSidebar({ panel: 'top', meetings: [{ ...missedCall, unread: false }] });
await wrapper.get('[aria-label="Mark call unread"]').trigger('click');
expect(wrapper.emitted('toggle-read')?.[0]).toEqual([{ ...missedCall, unread: false }]);
await wrapper.get('[aria-label="Call details"]').trigger('click');
expect(wrapper.emitted('open')?.[0]).toEqual([missedCall]);
});

it('searches from the round button beside the title', () => {
Expand Down
20 changes: 17 additions & 3 deletions src/components/dashboard-page/chat/ChatPane.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { useElementSize } from '@vueuse/core';
import { useElementSize, useMediaQuery } from '@vueuse/core';
import { ArrowLeft, CircleUserRound, Ellipsis, MessageCircleMore, Phone, UsersRound, Video, X } from 'lucide-vue-next';
import { AnimatePresence, motion } from 'motion-v';
import { computed, ref, watch } from 'vue';
import { computed, onBeforeUnmount, ref, watch } from 'vue';

import ChatThread from '@/components/dashboard-page/chat/ChatThread.vue';
import type { SenderAvatar } from '@/components/dashboard-page/chat/voice-message';
Expand All @@ -17,6 +17,7 @@ import { LoadingRipple } from '@/components/ui/loading';
import { PresenceDot } from '@/components/ui/presence-dot';
import { userStatusOption } from '@/config/user-status.config';
import type { Conversation, ConversationMessage, Friend, GifSearchResult, GroupMember } from '@/services/social-api';
import { coverScreenTop } from '@/services/system-bars';

const props = defineProps<{
conversation: Conversation | null;
Expand Down Expand Up @@ -74,6 +75,16 @@ const content = defineModel<string>('content', { required: true });
const replyTo = defineModel<ConversationMessage | null>('replyTo', { default: null });
const attachments = defineModel<File[]>('attachments', { default: () => [] });
const thread = ref<InstanceType<typeof ChatThread> | null>(null);
// On a phone an open chat fills the screen, so in the OpenMeet app it reaches under the status bar too; the header
// pads for it with env(safe-area-inset-top), and its fade colours the area.
const isPhoneLayout = useMediaQuery('(max-width: 639px)');
watch(
() => !!(props.conversation || props.pendingFriend) && isPhoneLayout.value,
(open) => coverScreenTop('chat', open),
{ immediate: true },
);
onBeforeUnmount(() => coverScreenTop('chat', false));

const header = ref<HTMLElement | null>(null);
const { height: headerHeight } = useElementSize(header, undefined, { box: 'border-box' });
// The header's lower part is only its fade, so the first message starts inside it and slides under the blur.
Expand Down Expand Up @@ -183,7 +194,10 @@ defineExpose({
class="relative flex h-full min-h-0 flex-col"
>
<!-- The header floats over the messages, which fade and blur as they scroll under it. -->
<header ref="header" class="pointer-events-none absolute inset-x-0 top-0 z-20 px-3 pb-12 pt-3 sm:px-5">
<header
ref="header"
class="pointer-events-none absolute inset-x-0 top-0 z-20 px-3 pb-12 pt-[calc(0.75rem+env(safe-area-inset-top))] sm:px-5"
>
<!-- The header lets touches through, so it marks its fade for the status bar colour to find. -->
<span class="harbor-chat-header-fade absolute inset-0 -z-10" aria-hidden="true" data-system-bar-layer />
<div class="flex items-center gap-2 sm:gap-3">
Expand Down
12 changes: 11 additions & 1 deletion src/components/dashboard-page/chat/ChatThread.vue
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ const mediaInput = ref<HTMLInputElement | null>(null);
const previewUrls = new Map<File, string>();
let pendingBottomScroll: ScrollBehavior | null = null;
const composerCaret = ref(0);
// Typing on a phone brings up the keyboard over the home indicator area (see the composer's padding).
const isComposerFocused = ref(false);
const commandSelection = ref(0);
const mentionSelection = ref(0);
const gifSelection = ref(0);
Expand Down Expand Up @@ -803,7 +805,15 @@ defineExpose({ scrollToBottom, getScrollState, isNearBottom, restoreScroll, focu
:attachment-urls="attachmentUrls"
@close="previewAttachments = []"
/>
<div class="bg-harbor-canvas px-2 pb-2.5 pt-2 sm:px-4">
<!-- The composer sits on the home indicator area, so it pads for it with a little room to spare, except while
typing: the keyboard covers that area then, and the padding would only leave a gap above it. -->
<div
class="bg-harbor-canvas px-2 pt-2 sm:px-4"
:class="isComposerFocused ? 'pb-2.5' : 'pb-[max(0.625rem,calc(env(safe-area-inset-bottom)+0.375rem))]'"
data-composer-bar
@focusin="isComposerFocused = true"
@focusout="isComposerFocused = false"
>
<p v-if="footerNote" class="mb-2 text-xs text-harbor-muted">{{ footerNote }}</p>
<AnimatePresence>
<motion.div
Expand Down
2 changes: 1 addition & 1 deletion src/components/dashboard-page/details/InfoPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const emit = defineEmits<{ (event: 'update:open', value: boolean): void }>();
</header>
<DialogDescription class="sr-only">{{ description }}</DialogDescription>
<div
class="min-h-0 flex-1 overflow-y-auto overscroll-contain pt-[calc(4.5rem+env(safe-area-inset-top))]"
class="min-h-0 flex-1 overflow-y-auto overscroll-contain pt-[calc(5.5rem+env(safe-area-inset-top))]"
:aria-busy="busy"
>
<div class="mx-auto max-w-3xl px-4 pb-[calc(2rem+env(safe-area-inset-bottom))] sm:px-6">
Expand Down
3 changes: 2 additions & 1 deletion src/components/layout/IncomingCallBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ const { t } = useI18n();
</span>
<LoadingRipple v-if="isAnswering" class="mr-3 size-5 text-harbor-icon" />
<template v-else>
<!-- Decline is the white disc with a red icon, so Answer keeps the visual weight. -->
<RoundButton
tone="danger"
tone="destructive"
data-incoming-decline
:aria-label="t('dashboard.decline')"
:title="t('dashboard.decline')"
Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/TheNavbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const mobileNavBottomClass = computed(() =>
isMeetingPage.value
? 'bottom-[calc(var(--meeting-control-size)+2.5rem)]'
: showWorkspaceTabs.value
? 'bottom-[calc(max(1rem,env(safe-area-inset-bottom))+var(--liquid-bar-height)+12px)]'
? 'bottom-[calc(var(--liquid-bar-bottom)+var(--liquid-bar-height)+12px)]'
: isDashboardPage.value
? 'bottom-4'
: 'bottom-6',
Expand Down Expand Up @@ -747,7 +747,7 @@ watch(mobileAccountActionsRef, (element, previous) => {
above its backdrop, so the sheet sits on top of the bar instead of covering it. -->
<div
v-if="showWorkspaceTabs"
class="marketing-font pointer-events-none fixed inset-x-0 bottom-[max(1rem,env(safe-area-inset-bottom))] flex justify-center px-4"
class="marketing-font pointer-events-none fixed inset-x-0 bottom-[var(--liquid-bar-bottom)] flex justify-center px-4"
:class="mobileMenuOpen ? 'z-[60]' : 'z-40'"
>
<LiquidTabBar
Expand Down
3 changes: 3 additions & 0 deletions src/components/layout/__tests__/IncomingCallBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ describe('IncomingCallBar', () => {
expect(wrapper.get('[data-incoming-call]').text()).toContain('1 more');

await wrapper.get('[data-incoming-accept]').trigger('click');
// Decline is the white disc with a red icon, not a red fill.
expect(wrapper.get('[data-incoming-decline]').attributes('data-tone')).toBe('destructive');
expect(wrapper.get('[data-incoming-decline]').classes()).not.toContain('liquid-glass-danger');
await wrapper.get('[data-incoming-decline]').trigger('click');
expect(wrapper.emitted('accept')).toHaveLength(1);
expect(wrapper.emitted('decline')).toHaveLength(1);
Expand Down
Loading