diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 22edbc11a3c..f6f82cb5fbf 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -1198,8 +1198,21 @@ "meetings.tabs.next": "Next", "meetings.tabs.past": "Past", "meetings.list.today": "Today", + "meetings.list.onGoing": "Ongoing", + "meetings.list.onGoing.header": "Now", "meetings.list.tomorrow": "Tomorrow", + "meetings.meetingStatus.participating": "Attending", + "meetings.meetingStatus.startingIn": "Starting in {countdown}", + "meetings.meetingStatus.startedAt": "Started at {time}", "meetings.startMeetingHelp": "Start a meeting with team members, guests, or external parties. Your communication is always end-to-end encrypted, offering the highest level of security.", + "meetings.action.meetNow": "Meet Now", + "meetings.action.scheduleMeeting": "Schedule Meeting", + "meetings.action.startMeeting": "Start meeting", + "meetings.action.createConversation": "Create conversation", + "meetings.action.copyLink": "Copy link", + "meetings.action.editMeeting": "Edit meeting", + "meetings.action.deleteMeetingForMe": "Delete meeting for me", + "meetings.action.deleteMeetingForAll": "Delete meeting for everyone", "mlsConversationRecovered": "You haven't used this device for a while, or an issue has occurred. Some older messages may not appear here.", "mlsSignature": "MLS with {signature} Signature", "mlsThumbprint": "MLS Thumbprint", diff --git a/src/script/components/Meeting/ClockContext.tsx b/src/script/components/Meeting/ClockContext.tsx new file mode 100644 index 00000000000..2baafa95eb4 --- /dev/null +++ b/src/script/components/Meeting/ClockContext.tsx @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {createContext, PropsWithChildren, useEffect, useState} from 'react'; + +import {TIME_IN_MILLIS} from 'Util/TimeUtil'; + +/** + * A lightweight context that updates once per second(cna be changed by providing tickMS from outside) with the current time (in ms). + */ +export const ClockContext = createContext(Date.now()); + +export interface ClockProviderProps extends PropsWithChildren { + tickMs?: number; +} + +export const ClockProvider = ({children, tickMs = TIME_IN_MILLIS.SECOND}: ClockProviderProps) => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), tickMs); + return () => window.clearInterval(id); + }, [tickMs]); + + return {children}; +}; diff --git a/src/script/components/Meeting/EmptyMeetingList/EmptyMeetingList.tsx b/src/script/components/Meeting/EmptyMeetingList/EmptyMeetingList.tsx index 84d8a5c357e..6574b9de801 100644 --- a/src/script/components/Meeting/EmptyMeetingList/EmptyMeetingList.tsx +++ b/src/script/components/Meeting/EmptyMeetingList/EmptyMeetingList.tsx @@ -41,10 +41,10 @@ export const EmptyMeetingList = ({text, helperText, showCallingButton = true}: E {showCallingButton && (
)} diff --git a/src/script/components/Meeting/MeetNowMultiActionButton/MeetNowMultiActionButton.tsx b/src/script/components/Meeting/MeetNowMultiActionButton/MeetNowMultiActionButton.tsx index 9c648d98cbe..038c34379a2 100644 --- a/src/script/components/Meeting/MeetNowMultiActionButton/MeetNowMultiActionButton.tsx +++ b/src/script/components/Meeting/MeetNowMultiActionButton/MeetNowMultiActionButton.tsx @@ -25,6 +25,7 @@ import { callingButtonGroupStyles, dropdownIconStyles, } from 'Components/Meeting/MeetNowMultiActionButton/MeetNowMultiActionButton.styles'; +import {t} from 'Util/LocalizerUtil'; import {showContextMenu} from '../../../ui/ContextMenu'; @@ -40,16 +41,16 @@ export const MeetNowMultiActionButton = () => { offset: 0, entries: [ { - title: 'Meet Now', - label: 'Meet Now', + title: t('meetings.action.meetNow'), + label: t('meetings.action.meetNow'), click: () => { handleMeetingButton(); resetIconInversion(); }, }, { - title: 'Schedule Meeting', - label: 'Schedule Meeting', + title: t('meetings.action.scheduleMeeting'), + label: t('meetings.action.scheduleMeeting'), click: () => { // add scheduling functionality here resetIconInversion(); @@ -75,7 +76,7 @@ export const MeetNowMultiActionButton = () => { icon={} onClick={handleMeetingButton} > - Meet now + {t('meetings.action.meetNow')} { - // Temporary mocked data to visualize the UI until the backend is wired - const meetingsToday: Meeting[] = [ - { - start_date: '2025-06-03T07:30:00', - end_date: '2025-06-03T07:40:00', - schedule: 'Single', - conversation_id: '1', - title: 'Meeting 1', - }, - { - start_date: '2025-06-03T07:45:00', - end_date: '2025-06-03T10:15:00', - schedule: 'Single', - conversation_id: '2', - title: 'Meeting 2', - }, - { - start_date: '2025-06-03T08:00:00', - end_date: '2025-06-03T10:15:00', - schedule: 'Daily', - conversation_id: '3', - title: 'Meeting 3', - }, - ]; - - const meetingsTomorrow: Meeting[] = [ - { - start_date: '2025-06-04T07:00:00', - end_date: '2025-06-04T15:15:00', - schedule: 'Single', - conversation_id: '4', - title: 'Meeting 4', - }, - { - start_date: '2025-06-04T08:00:00', - end_date: '2025-06-04T08:15:00', - schedule: 'Monthly', - conversation_id: '5', - title: 'Meeting 5', - }, - { - start_date: '2025-06-04T17:00:00', - end_date: '2025-06-04T18:15:00', - schedule: 'Monthly', - conversation_id: '6', - title: 'Meeting 6', - }, - { - start_date: '2025-06-04T09:00:00', - end_date: '2025-06-04T10:15:00', - schedule: 'Monthly', - conversation_id: '7', - title: 'Meeting 7', - }, - ]; - - const [activeTab, setActiveTab] = useState(MeetingTabsTitle.NEXT); + const [activeTab, setActiveTab] = useState(MEETING_TABS_TITLE.UPCOMING); const {today, tomorrow} = getTodayTomorrowLabels(); + const headerForOnGoing = `${t('meetings.list.onGoing.header')}`; const headerForToday = `${t('meetings.list.today')} (${today})`; const headerForTomorrow = `${t('meetings.list.tomorrow')} (${tomorrow})`; - const groupedMeetingsToday = groupByStartHour(meetingsToday); - const groupedMeetingsTomorrow = groupByStartHour(meetingsTomorrow); + const groupedMeetingsTomorrow = groupByStartHour(MEETINGS_TOMORROW); - const hasMeetingsToday = meetingsToday.length > 0; - const hasMeetingsTomorrow = meetingsTomorrow.length > 0; - const isNextTab = activeTab === MeetingTabsTitle.NEXT; + const hasMeetingsToday = MEETINGS_TODAY.length > 0; + const hasMeetingsTomorrow = MEETINGS_TOMORROW.length > 0; + const isUpcomingTab = activeTab === MEETING_TABS_TITLE.UPCOMING; + + const handleTabChange = useCallback((tab: MeetingTab) => setActiveTab(tab), []); let content: ReactNode; @@ -125,40 +82,49 @@ export const MeetingList = () => { ); } - if (isNextTab) { - // Next tab - content = hasMeetingsToday ? ( + if (isUpcomingTab) { + if (!hasMeetingsToday) { + return ( +
+ +
+ ); + } + + content = ( <> - + +
- ) : ( -
- -
); } else { - // Past tab - content = hasMeetingsTomorrow ? ( - - ) : ( -
- -
- ); + if (!hasMeetingsTomorrow) { + return ( +
+ +
+ ); + } + + content = ; } return (
- - {content} + + {content}
); }; diff --git a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingAction/MeetingAction.tsx b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingAction/MeetingAction.tsx index dce6685cc99..7876d2e37aa 100644 --- a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingAction/MeetingAction.tsx +++ b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingAction/MeetingAction.tsx @@ -36,6 +36,7 @@ import { iconContainerStyle, iconStyles, } from 'Components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingAction/MeetingAction.styles'; +import {t} from 'Util/LocalizerUtil'; import {showContextMenu} from '../../../../../../ui/ContextMenu'; @@ -46,29 +47,29 @@ export const MeetingAction = () => { entries: [ { icon: () => , - label: 'Start meeting', + label: t('meetings.action.startMeeting'), }, { icon: () => , - label: 'Create conversation', + label: t('meetings.action.createConversation'), }, { icon: () => , - label: 'Copy link', + label: t('meetings.action.copyLink'), }, { icon: () => , - label: 'Edit meeting', + label: t('meetings.action.editMeeting'), }, { css: contextMenuDangerItemStyles, icon: () => , - label: 'Remove meeting for me', + label: t('meetings.action.deleteMeetingForMe'), }, { css: contextMenuDangerItemStyles, icon: () => , - label: 'Delete meeting for everyone', + label: t('meetings.action.deleteMeetingForAll'), }, ], identifier: 'message-options-menu', diff --git a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.styles.ts b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.styles.ts index 9860ca2956f..a0e439c535f 100644 --- a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.styles.ts +++ b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.styles.ts @@ -20,6 +20,7 @@ import {CSSObject} from '@emotion/react'; export const itemStyles: CSSObject = { + fontSize: 'var(--font-size-medium)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -42,6 +43,11 @@ export const itemStyles: CSSObject = { }, }; +export const onGoingMeetingStyles: CSSObject = { + background: 'var(--accent-color-highlight)', + border: '1px solid var(--accent-color)', +}; + export const leftStyles: CSSObject = { display: 'flex', alignItems: 'center', @@ -54,7 +60,7 @@ export const titleStyles: CSSObject = { export const metaStyles: CSSObject = { color: 'var(--secondary-text-color)', - fontSize: 12, + fontSize: 'var(--font-size-small)', marginTop: 4, display: 'flex', alignItems: 'center', diff --git a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.tsx b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.tsx index d8d80ed9e79..1fd8b9715eb 100644 --- a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.tsx +++ b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.tsx @@ -17,9 +17,12 @@ * */ +import {memo, useContext, useMemo} from 'react'; + import {CalendarIcon, CallIcon} from '@wireapp/react-ui-kit'; -import {Meeting} from 'Components/Meeting/MeetingList/MeetingList'; +import {ClockContext} from 'Components/Meeting/ClockContext'; +import {MeetingEntity} from 'Components/Meeting/MeetingList/MeetingList'; import {MeetingAction} from 'Components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingAction/MeetingAction'; import { badgeWrapperStyles, @@ -27,23 +30,66 @@ import { itemStyles, leftStyles, metaStyles, + onGoingMeetingStyles, rightStyles, titleStyles, } from 'Components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem.styles'; +import {MeetingStatus} from 'Components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus'; +import {getMeetingStatusAt, MEETING_STATUS} from 'Components/Meeting/utils/MeetingStatusUtil'; +import {t} from 'Util/LocalizerUtil'; import {formatLocale} from 'Util/TimeUtil'; -export const MeetingListItem = ({title, start_date, end_date, schedule}: Meeting) => { - const start = new Date(start_date); - const end = new Date(end_date); +const MeetingListItemComponent = ({title, start_date, end_date, schedule, attending}: MeetingEntity) => { + const nowMs = useContext(ClockContext); + + const {time, showCalendarIcon} = useMemo(() => { + const start = new Date(start_date); + const end = new Date(end_date); + const startMs = start.getTime(); + const endMs = end.getTime(); + const isPast = nowMs > endMs; + const isOngoing = nowMs >= startMs && nowMs < endMs; + + if (isPast) { + const dayOfWeek = formatLocale(start, 'EEEE'); + const month = formatLocale(start, 'MMMM'); + const day = formatLocale(start, 'd'); + const time = formatLocale(start, 'h:mm a'); + return { + time: `${dayOfWeek}, ${month} ${day} • ${t('meetings.meetingStatus.startedAt', {time})}`, + showCalendarIcon: false, + }; + } + + if (isOngoing) { + const time = formatLocale(start, 'h:mm a'); + return { + time: t('meetings.meetingStatus.startedAt', {time}), + showCalendarIcon: false, + }; + } - const sameMeridiem = formatLocale(start, 'a') === formatLocale(end, 'a'); + const sameMeridiem = formatLocale(start, 'a') === formatLocale(end, 'a'); + const timeRange = sameMeridiem + ? `${formatLocale(start, 'h:mm')} – ${formatLocale(end, 'h:mm a')}` + : `${formatLocale(start, 'h:mm a')} – ${formatLocale(end, 'h:mm a')}`; + return { + time: timeRange, + showCalendarIcon: true, + }; + }, [start_date, end_date, nowMs]); - const time = sameMeridiem - ? `${formatLocale(start, 'h:mm')} – ${formatLocale(end, 'h:mm a')}` - : `${formatLocale(start, 'h:mm a')} – ${formatLocale(end, 'h:mm a')}`; + const meetingStatus = useMemo( + () => getMeetingStatusAt(nowMs, start_date, end_date, attending), + [nowMs, start_date, end_date, attending], + ); + + const isOngoing = meetingStatus === MEETING_STATUS.ON_GOING || meetingStatus === MEETING_STATUS.PARTICIPATING; + + const meetingItemStyles = isOngoing ? [itemStyles, onGoingMeetingStyles] : [itemStyles]; return ( -
+
@@ -51,7 +97,8 @@ export const MeetingListItem = ({title, start_date, end_date, schedule}: Meeting
{title}
- {time} + {showCalendarIcon && } + {time} {schedule && (
{schedule} @@ -61,8 +108,12 @@ export const MeetingListItem = ({title, start_date, end_date, schedule}: Meeting
+
); }; + +export const MeetingListItem = memo(MeetingListItemComponent); +MeetingListItem.displayName = 'MeetingListItem'; diff --git a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus.styles.ts b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus.styles.ts new file mode 100644 index 00000000000..5961c6c7fda --- /dev/null +++ b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus.styles.ts @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react/dist/emotion-react.cjs'; + +export const participatingStatusStyles = { + color: 'var(--accent-color)', + fontSize: 'var(--font-size-medium)', + fontWeight: 'var(--font-weight-semibold)', + display: 'flex', + alignItems: 'center', +}; + +export const startingSoonStatusStyles: CSSObject = { + color: 'var(--accent-color)', + textTransform: 'uppercase', + fontWeight: 'var(--font-weight-semibold)', +}; + +export const participatingStatusIconStyles = { + marginRight: '8px', + fill: 'var(--accent-color)', +}; + +export const joinButtonContainerStyles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}; + +export const joinButtonStyles = { + height: '32px', + borderRadius: '8px', + fontSize: 'var(--font-size-medium)', + fontWeight: 'var(--font-weight-semibold)', + minWidth: '83px', + color: 'var(--white)', +}; + +export const joinButtonIconStyles: CSSObject = { + marginRight: '8px', + fill: 'var(--white)', +}; diff --git a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus.tsx b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus.tsx new file mode 100644 index 00000000000..0f6591a1b8c --- /dev/null +++ b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus.tsx @@ -0,0 +1,80 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {memo, useContext, useMemo} from 'react'; + +import {Button, ButtonVariant, CallIcon} from '@wireapp/react-ui-kit'; + +import {ClockContext} from 'Components/Meeting/ClockContext'; +import { + joinButtonContainerStyles, + joinButtonIconStyles, + joinButtonStyles, + participatingStatusIconStyles, + participatingStatusStyles, + startingSoonStatusStyles, +} from 'Components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingStatus/MeetingStatus.styles'; +import {getCountdownSeconds, getMeetingStatusAt, MEETING_STATUS} from 'Components/Meeting/utils/MeetingStatusUtil'; +import {t} from 'Util/LocalizerUtil'; +import {formatSeconds} from 'Util/TimeUtil'; + +export interface MeetingStatusProps { + start_date: string; + end_date: string; + attending?: boolean; +} + +const MeetingStatusComponent = ({start_date, end_date, attending}: MeetingStatusProps) => { + const nowMs = useContext(ClockContext); + + const meetingStatus = useMemo( + () => getMeetingStatusAt(nowMs, start_date, end_date, attending), + [nowMs, start_date, end_date, attending], + ); + + switch (meetingStatus) { + case MEETING_STATUS.PARTICIPATING: + return ( +
+ {t('meetings.meetingStatus.participating')} +
+ ); + + case MEETING_STATUS.ON_GOING: + return ( +
+ +
+ ); + + case MEETING_STATUS.STARTING_SOON: { + const seconds = getCountdownSeconds(nowMs, start_date); + const countdown = formatSeconds(seconds); + return
{t('meetings.meetingStatus.startingIn', {countdown})}
; + } + + default: + return null; + } +}; + +export const MeetingStatus = memo(MeetingStatusComponent); +MeetingStatus.displayName = 'MeetingStatus'; diff --git a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.styles.ts b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.styles.ts index 756ad365aa7..9ccd228604f 100644 --- a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.styles.ts +++ b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.styles.ts @@ -31,7 +31,7 @@ export const sectionHeaderStyles: CSSObject = { export const hourLabelStyles: CSSObject = { color: 'var(--secondary-text-color)', - fontSize: '12px', + fontSize: 'var(--font-size-small)', marginTop: 12, marginBottom: 8, }; diff --git a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.tsx b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.tsx index dea978edc1f..f063a346f1c 100644 --- a/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.tsx +++ b/src/script/components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup.tsx @@ -17,9 +17,11 @@ * */ +import {memo} from 'react'; + import {set} from 'date-fns'; -import {Meeting, MeetingTabsTitle} from 'Components/Meeting/MeetingList/MeetingList'; +import {MEETING_TABS_TITLE, MeetingEntity} from 'Components/Meeting/MeetingList/MeetingList'; import {MeetingListItem} from 'Components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItem/MeetingListItem'; import { hourLabelStyles, @@ -31,7 +33,7 @@ import {t} from 'Util/LocalizerUtil'; interface MeetingListItemGroupProps { header?: string; - groupedMeetings: Record; + groupedMeetings: Record; view?: MeetingTab; } @@ -40,12 +42,12 @@ export enum MeetingGroupBy { HOUR = 'hour', } -export const MeetingListItemGroup = ({ +const MeetingListItemGroupComponent = ({ header, groupedMeetings, - view = MeetingTabsTitle.NEXT, + view = MEETING_TABS_TITLE.UPCOMING, }: MeetingListItemGroupProps) => { - const groupBy = view === MeetingTabsTitle.PAST ? MeetingGroupBy.NONE : MeetingGroupBy.HOUR; + const groupBy = view === MEETING_TABS_TITLE.PAST ? MeetingGroupBy.NONE : MeetingGroupBy.HOUR; const formatHourLabel = (date: string) => set(new Date(date), {minutes: 0, seconds: 0, milliseconds: 0}).toLocaleTimeString([], { @@ -53,8 +55,8 @@ export const MeetingListItemGroup = ({ minute: '2-digit', }); - // Sort by hour key (string -> number), then drop empty buckets - const groups = Object.entries(groupedMeetings).sort(([a], [b]) => +a - +b); + // Sort by hour key + const groups = Object.entries(groupedMeetings).sort(([meetingA], [meetingB]) => +meetingA - +meetingB); const nonEmptyGroups = groups.filter(([, items]) => items?.length); const isEmpty = nonEmptyGroups.length === 0; @@ -95,3 +97,6 @@ export const MeetingListItemGroup = ({ ); }; + +export const MeetingListItemGroup = memo(MeetingListItemGroupComponent); +MeetingListItemGroup.displayName = 'MeetingListItemGroup'; diff --git a/src/script/components/Meeting/MeetingList/MeetingTabs/MeetingTabs.tsx b/src/script/components/Meeting/MeetingList/MeetingTabs/MeetingTabs.tsx index 230363ae48f..fbf41189e34 100644 --- a/src/script/components/Meeting/MeetingList/MeetingTabs/MeetingTabs.tsx +++ b/src/script/components/Meeting/MeetingList/MeetingTabs/MeetingTabs.tsx @@ -17,11 +17,11 @@ * */ -import {MeetingTabsTitle} from 'Components/Meeting/MeetingList/MeetingList'; +import {MEETING_TABS_TITLE} from 'Components/Meeting/MeetingList/MeetingList'; import {tabStyles, tabsWrapperStyles} from 'Components/Meeting/MeetingList/MeetingTabs/MeetingTabs.styles'; import {t} from 'Util/LocalizerUtil'; -export type MeetingTab = MeetingTabsTitle.NEXT | MeetingTabsTitle.PAST; +export type MeetingTab = MEETING_TABS_TITLE.UPCOMING | MEETING_TABS_TITLE.PAST; interface MeetingTabsProps { active: MeetingTab; @@ -32,21 +32,21 @@ export const MeetingTabs = ({active, onChange}: MeetingTabsProps) => (
onChange(MeetingTabsTitle.NEXT)} - onKeyDown={event => (event.key === 'Enter' || event.key === ' ') && onChange(MeetingTabsTitle.NEXT)} + css={tabStyles(active === MEETING_TABS_TITLE.UPCOMING)} + onClick={() => onChange(MEETING_TABS_TITLE.UPCOMING)} + onKeyDown={event => (event.key === 'Enter' || event.key === ' ') && onChange(MEETING_TABS_TITLE.UPCOMING)} > {t('meetings.tabs.next')}
onChange(MeetingTabsTitle.PAST)} - onKeyDown={event => (event.key === 'Enter' || event.key === ' ') && onChange(MeetingTabsTitle.PAST)} + css={tabStyles(active === MEETING_TABS_TITLE.PAST)} + onClick={() => onChange(MEETING_TABS_TITLE.PAST)} + onKeyDown={event => (event.key === 'Enter' || event.key === ' ') && onChange(MEETING_TABS_TITLE.PAST)} > {t('meetings.tabs.past')}
diff --git a/src/script/components/Meeting/MeetingList/TodayAndOngoingSection/TodayAndOngoingSection.tsx b/src/script/components/Meeting/MeetingList/TodayAndOngoingSection/TodayAndOngoingSection.tsx new file mode 100644 index 00000000000..bf9f1de1c0c --- /dev/null +++ b/src/script/components/Meeting/MeetingList/TodayAndOngoingSection/TodayAndOngoingSection.tsx @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {memo, useContext, useMemo} from 'react'; + +import {ClockContext} from 'Components/Meeting/ClockContext'; +import {MEETING_TABS_TITLE, TodayAndOngoingSectionProps} from 'Components/Meeting/MeetingList/MeetingList'; +import {MeetingListItemGroup} from 'Components/Meeting/MeetingList/MeetingListItemGroup/MeetingListItemGroup'; +import {groupByStartHour} from 'Components/Meeting/utils/MeetingDatesUtil'; +import {getOnGoingMeetingsAt} from 'Components/Meeting/utils/MeetingStatusUtil'; + +const TodayAndOngoingSectionComponent = ({ + meetingsToday, + headerForOnGoing, + headerForToday, +}: TodayAndOngoingSectionProps) => { + const nowMs = useContext(ClockContext); + + const onGoingMeetings = useMemo(() => getOnGoingMeetingsAt(meetingsToday, nowMs), [meetingsToday, nowMs]); + const ongoingIds = useMemo(() => new Set(onGoingMeetings.map(meeting => meeting.conversation_id)), [onGoingMeetings]); + + // Exclude ongoing items from today's grouped list + const todayNotOngoing = useMemo( + () => meetingsToday.filter(meeting => !ongoingIds.has(meeting.conversation_id)), + [meetingsToday, ongoingIds], + ); + + const groupedMeetingsToday = useMemo(() => groupByStartHour(todayNotOngoing), [todayNotOngoing]); + + if (meetingsToday.length === 0) { + return ; + } + + const meetingForToday = ; + + if (onGoingMeetings.length === 0) { + return meetingForToday; + } + + return ( + <> + + {meetingForToday} + + ); +}; + +export const TodayAndOngoingSection = memo(TodayAndOngoingSectionComponent); +TodayAndOngoingSection.displayName = 'TodayAndOngoingSection'; diff --git a/src/script/components/Meeting/Meeting.tsx b/src/script/components/Meeting/Meetings.tsx similarity index 96% rename from src/script/components/Meeting/Meeting.tsx rename to src/script/components/Meeting/Meetings.tsx index fff40470e1f..7f978e5f801 100644 --- a/src/script/components/Meeting/Meeting.tsx +++ b/src/script/components/Meeting/Meetings.tsx @@ -21,7 +21,7 @@ import {contentStyles} from 'Components/Meeting/Meeting.styles'; import {MeetingHeader} from 'Components/Meeting/MeetingHeader/MeetingHeader'; import {MeetingList} from 'Components/Meeting/MeetingList/MeetingList'; -export const Meeting = () => ( +export const Meetings = () => ( <>
diff --git a/src/script/components/Meeting/mocks/MeetingMocks.ts b/src/script/components/Meeting/mocks/MeetingMocks.ts new file mode 100644 index 00000000000..fcdee2f7594 --- /dev/null +++ b/src/script/components/Meeting/mocks/MeetingMocks.ts @@ -0,0 +1,130 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +// This whole file is just for mocking purposes, so we can use the MeetingList component +// Once the backend is ready, we can remove this file + +import {MeetingEntity} from 'Components/Meeting/MeetingList/MeetingList'; + +const now = new Date(); +const toISO = (d: Date) => d.toISOString(); + +const addMinutes = (d: Date, m: number) => { + const copy = new Date(d); + copy.setMinutes(copy.getMinutes() + m); + return copy; +}; + +const addSeconds = (d: Date, s: number) => { + const copy = new Date(d); + copy.setSeconds(copy.getSeconds() + s); + return copy; +}; + +const addHours = (d: Date, s: number) => { + const copy = new Date(d); + copy.setHours(copy.getHours() + s); + return copy; +}; + +const addDay = (d: Date, s: number) => { + const copy = new Date(d); + copy.setDate(copy.getDate() + s); + return copy; +}; + +export const MEETINGS_TODAY: MeetingEntity[] = [ + { + start_date: toISO(addSeconds(now, 5)), + end_date: toISO(addMinutes(now, 33)), + schedule: 'Single', + conversation_id: '1', + title: 'Daily stand‑up', + }, + { + start_date: toISO(addHours(now, -2)), + end_date: toISO(addMinutes(now, 28)), + schedule: 'Single', + conversation_id: '2', + title: 'Sprint planning', + attending: true, + }, + { + start_date: toISO(addHours(now, 2)), + end_date: toISO(addHours(now, 3)), + schedule: 'Single', + conversation_id: '3', + title: 'Client sync', + }, + { + start_date: toISO(addHours(now, -3)), + end_date: toISO(addHours(now, -2)), + schedule: 'Single', + conversation_id: '4', + title: 'Retrospective passed Today', + }, +]; + +export const MEETINGS_TOMORROW: MeetingEntity[] = [ + { + start_date: toISO(addDay(addMinutes(now, -120), 1)), + end_date: toISO(addDay(addMinutes(now, -30), 1)), + schedule: 'Single', + conversation_id: '4', + title: 'Meetings 4', + }, + { + start_date: toISO(addDay(now, 1)), + end_date: toISO(addDay(addMinutes(now, 20), 1)), + schedule: 'Monthly', + conversation_id: '5', + title: 'Meetings 5', + }, + { + start_date: toISO(addDay(addMinutes(now, 180), 1)), + end_date: toISO(addDay(addMinutes(now, 240), 1)), + schedule: 'Monthly', + conversation_id: '6', + title: 'Meetings 6', + }, + { + start_date: toISO(addDay(addMinutes(now, 300), 1)), + end_date: toISO(addDay(addMinutes(now, 460), 1)), + schedule: 'Monthly', + conversation_id: '7', + title: 'Meetings 7', + }, +]; + +export const MEETINGS_PAST: MeetingEntity[] = [ + { + start_date: toISO(addDay(addMinutes(now, -120), -1)), + end_date: toISO(addDay(addMinutes(now, -20), -1)), + schedule: 'Single', + conversation_id: '4', + title: 'Retrospective passed Yesterday', + }, + { + start_date: toISO(addDay(addMinutes(now, -60), -1)), + end_date: toISO(addDay(now, -1)), + schedule: 'Monthly', + conversation_id: '5', + title: 'All‑hands', + }, +]; diff --git a/src/script/components/Meeting/utils/MeetingDatesHandler.ts b/src/script/components/Meeting/utils/MeetingDatesHandler.ts deleted file mode 100644 index a8d9dd0e0ac..00000000000 --- a/src/script/components/Meeting/utils/MeetingDatesHandler.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import {Meeting} from 'Components/Meeting/MeetingList/MeetingList'; -import {FnDate, formatLocale} from 'Util/TimeUtil'; - -const formatWeekdayMonthDay = (date: FnDate | string | number) => formatLocale(date, 'EEEE, MMMM d'); - -export const getTodayTomorrowLabels = () => { - const today = new Date(); - const tomorrow = new Date(); - tomorrow.setDate(today.getDate() + 1); - - return { - today: formatWeekdayMonthDay(today), - tomorrow: formatWeekdayMonthDay(tomorrow), - }; -}; - -export const groupByStartHour = (meetings: Meeting[]) => { - const groupedMeetings: Record = {}; - for (const meeting of meetings) { - const hour = new Date(meeting.start_date).getHours(); - (groupedMeetings[hour] ??= []).push(meeting); - } - return groupedMeetings; -}; diff --git a/src/script/components/Meeting/utils/MeetingDatesUtil.ts b/src/script/components/Meeting/utils/MeetingDatesUtil.ts new file mode 100644 index 00000000000..2b583a41000 --- /dev/null +++ b/src/script/components/Meeting/utils/MeetingDatesUtil.ts @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {MeetingEntity} from 'Components/Meeting/MeetingList/MeetingList'; +import {FnDate, formatLocale} from 'Util/TimeUtil'; + +/** + * Formats a given date into a string with the pattern "Weekday, Month Day". + * + * @param {FnDate | string | number} date - The date to format. Can be a `FnDate`, string, or timestamp. + * @returns {string} - The formatted date string. + */ +const formatWeekdayMonthDay = (date: FnDate | string | number): string => formatLocale(date, 'EEEE, MMMM d'); + +/** + * Generates labels for "today" and "tomorrow" with their respective formatted dates. + * + * @returns {{today: string, tomorrow: string}} - An object containing the formatted labels for today and tomorrow. + */ +export const getTodayTomorrowLabels = (): {today: string; tomorrow: string} => { + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + + return { + today: formatWeekdayMonthDay(today), + tomorrow: formatWeekdayMonthDay(tomorrow), + }; +}; + +/** + * Groups a list of meetings by their start hour. + * + * @param {MeetingEntity[]} meetings - The list of meetings to group. + * @returns {Record} - An object where the keys are the start hours (0-23) and the values are arrays of meetings. + */ +export const groupByStartHour = (meetings: MeetingEntity[]): Record => { + const groupedMeetings: Record = {}; + for (const meeting of meetings) { + const hour = new Date(meeting.start_date).getHours(); + (groupedMeetings[hour] ??= []).push(meeting); + } + return groupedMeetings; +}; diff --git a/src/script/components/Meeting/utils/MeetingStatusUtil.ts b/src/script/components/Meeting/utils/MeetingStatusUtil.ts new file mode 100644 index 00000000000..9de5207a6ca --- /dev/null +++ b/src/script/components/Meeting/utils/MeetingStatusUtil.ts @@ -0,0 +1,93 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {MeetingEntity} from 'Components/Meeting/MeetingList/MeetingList'; +import {TIME_IN_MILLIS} from 'Util/TimeUtil'; + +export enum MEETING_STATUS { + ON_GOING = 'on_going', + STARTING_SOON = 'starting_soon', + PARTICIPATING = 'participating', + UPCOMING = 'upcoming', + PAST = 'past', +} + +/** + * Threshold in milliseconds to determine if a meeting is starting soon. + */ +export const STARTING_SOON_THRESHOLD_MS = 5 * TIME_IN_MILLIS.MINUTE; + +/** + * Filters the list of meetings to return only those that are ongoing at the specified time. + * + * @param {MeetingEntity[]} meetings - The list of meetings to filter. + * @param {number} nowMs - The current time in milliseconds. + * @returns {MeetingEntity[]} - The list of ongoing meetings. + */ +export const getOnGoingMeetingsAt = (meetings: MeetingEntity[], nowMs: number): MeetingEntity[] => + meetings.filter(meeting => { + const startMs = new Date(meeting.start_date).getTime(); + const endMs = new Date(meeting.end_date).getTime(); + return nowMs >= startMs && nowMs < endMs; + }); + +/** + * Determines the status of a meeting at a specific time. + * + * @param {number} nowMs - The current time in milliseconds. + * @param {string} start_date - The start date of the meeting in ISO format. + * @param {string} end_date - The end date of the meeting in ISO format. + * @param {boolean} [attending=false] - Whether the user is attending the meeting. + * @returns {MEETING_STATUS} - The status of the meeting. + */ +export const getMeetingStatusAt = ( + nowMs: number, + start_date: string, + end_date: string, + attending: boolean = false, +): MEETING_STATUS => { + const startMs = new Date(start_date).getTime(); + const endMs = new Date(end_date).getTime(); + + if (nowMs > endMs) { + return MEETING_STATUS.PAST; + } + + if (nowMs >= startMs) { + return attending ? MEETING_STATUS.PARTICIPATING : MEETING_STATUS.ON_GOING; + } + + if (startMs <= nowMs + STARTING_SOON_THRESHOLD_MS) { + return MEETING_STATUS.STARTING_SOON; + } + + return MEETING_STATUS.UPCOMING; +}; + +/** + * Calculates the countdown in seconds until a meeting starts. + * + * @param {number} nowMs - The current time in milliseconds. + * @param {string} start_date - The start date of the meeting in ISO format. + * @returns {number} - The countdown in seconds, or 0 if the meeting has already started. + */ +export const getCountdownSeconds = (nowMs: number, start_date: string): number => { + const startMs = new Date(start_date).getTime(); + return Math.max(0, Math.ceil((startMs - nowMs) / TIME_IN_MILLIS.SECOND)); +}; diff --git a/src/script/page/MainContent/MainContent.tsx b/src/script/page/MainContent/MainContent.tsx index 34888e309e7..3e71aee6fbd 100644 --- a/src/script/page/MainContent/MainContent.tsx +++ b/src/script/page/MainContent/MainContent.tsx @@ -29,7 +29,7 @@ import {Conversation} from 'Components/Conversation'; import {HistoryExport} from 'Components/HistoryExport'; import {HistoryImport} from 'Components/HistoryImport'; import * as Icon from 'Components/Icon'; -import {Meeting} from 'Components/Meeting/Meeting'; +import {Meetings} from 'Components/Meeting/Meetings'; import {useLegalHoldModalState} from 'Components/Modals/LegalHoldModal/LegalHoldModal.state'; import {ClientState} from 'Repositories/client/ClientState'; import {ConversationState} from 'Repositories/conversation/ConversationState'; @@ -280,7 +280,7 @@ const MainContent = ({ /> )} - {contentState === ContentState.MEETINGS && } + {contentState === ContentState.MEETINGS && } diff --git a/src/types/i18n.d.ts b/src/types/i18n.d.ts index f0f347e6516..050963b3e3f 100644 --- a/src/types/i18n.d.ts +++ b/src/types/i18n.d.ts @@ -584,6 +584,7 @@ declare module 'I18n/en-US.json' { 'conversationAudioAssetUploadFailed': `Upload failed: {name}`; 'conversationAudioAssetUploading': `Uploading: {name}`; 'conversationButtonSeparator': `or`; + 'conversationCellsConversationEnabled': `File collaboration (Cells beta) is on`; 'conversationClassified': `Security level: VS-NfD`; 'conversationCommonFeature1': `Up to [bold]{capacity}[/bold] people`; 'conversationCommonFeature2': `Video conferencing`; @@ -602,7 +603,6 @@ declare module 'I18n/en-US.json' { 'conversationContextMenuLike': `Like`; 'conversationContextMenuReply': `Reply`; 'conversationContextMenuUnlike': `Unlike`; - 'conversationCellsConversationEnabled': `File collaboration (Cells beta) is on`; 'conversationCreateReceiptsEnabled': `Read receipts are on`; 'conversationCreateTeam': `with [showmore]all team members[/showmore]`; 'conversationCreateTeamGuest': `with [showmore]all team members and one guest[/showmore]`; @@ -625,8 +625,8 @@ declare module 'I18n/en-US.json' { 'conversationDetailsActionArchive': `Archive`; 'conversationDetailsActionBlock': `Block`; 'conversationDetailsActionCancelRequest': `Cancel request`; - 'conversationDetailsActionCellsTitle': `File collaboration (Cells beta version) is on`; 'conversationDetailsActionCellsOption': `Permanently on for this conversation.`; + 'conversationDetailsActionCellsTitle': `File collaboration (Cells beta version) is on`; 'conversationDetailsActionClear': `Clear content`; 'conversationDetailsActionConversationParticipants': `Show all ({number})`; 'conversationDetailsActionCreateGroup': `Create group`; @@ -1202,8 +1202,21 @@ declare module 'I18n/en-US.json' { 'meetings.tabs.next': `Next`; 'meetings.tabs.past': `Past`; 'meetings.list.today': `Today`; + 'meetings.list.onGoing': `Ongoing`; + 'meetings.list.onGoing.header': `Now`; 'meetings.list.tomorrow': `Tomorrow`; + 'meetings.meetingStatus.participating': `Attending`; + 'meetings.meetingStatus.startingIn': `Starting in {countdown}`; + 'meetings.meetingStatus.startedAt': `Started at {time}`; 'meetings.startMeetingHelp': `Start a meeting with team members, guests, or external parties. Your communication is always end-to-end encrypted, offering the highest level of security.`; + 'meetings.action.meetNow': `Meet Now`; + 'meetings.action.scheduleMeeting': `Schedule Meeting`; + 'meetings.action.startMeeting': `Start meeting`; + 'meetings.action.createConversation': `Create conversation`; + 'meetings.action.copyLink': `Copy link`; + 'meetings.action.editMeeting': `Edit meeting`; + 'meetings.action.deleteMeetingForMe': `Delete meeting for me`; + 'meetings.action.deleteMeetingForAll': `Delete meeting for everyone`; 'mlsConversationRecovered': `You haven\'t used this device for a while, or an issue has occurred. Some older messages may not appear here.`; 'mlsSignature': `MLS with {signature} Signature`; 'mlsThumbprint': `MLS Thumbprint`;