From 50cc78788f8b1da50898b9863fdba9b714550e52 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:11:33 +0530 Subject: [PATCH] Jump to time option in room timeline (#2377) * add time and date picker components * add time utils * add jump to time in room timeline * fix typo causing crash in safari --- src/app/components/time-date/DatePicker.tsx | 129 +++++++++ src/app/components/time-date/PickerColumn.tsx | 23 ++ src/app/components/time-date/TimePicker.tsx | 132 +++++++++ src/app/components/time-date/index.ts | 2 + src/app/components/time-date/styles.css.ts | 16 ++ src/app/features/room/RoomViewHeader.tsx | 30 ++ .../features/room/jump-to-time/JumpToTime.tsx | 256 ++++++++++++++++++ src/app/features/room/jump-to-time/index.ts | 1 + src/app/utils/time.ts | 48 ++++ 9 files changed, 637 insertions(+) create mode 100644 src/app/components/time-date/DatePicker.tsx create mode 100644 src/app/components/time-date/PickerColumn.tsx create mode 100644 src/app/components/time-date/TimePicker.tsx create mode 100644 src/app/components/time-date/index.ts create mode 100644 src/app/components/time-date/styles.css.ts create mode 100644 src/app/features/room/jump-to-time/JumpToTime.tsx create mode 100644 src/app/features/room/jump-to-time/index.ts diff --git a/src/app/components/time-date/DatePicker.tsx b/src/app/components/time-date/DatePicker.tsx new file mode 100644 index 0000000..faa43a3 --- /dev/null +++ b/src/app/components/time-date/DatePicker.tsx @@ -0,0 +1,129 @@ +import React, { forwardRef } from 'react'; +import { Menu, Box, Text, Chip } from 'folds'; +import dayjs from 'dayjs'; +import * as css from './styles.css'; +import { PickerColumn } from './PickerColumn'; +import { dateFor, daysInMonth, daysToMs } from '../../utils/time'; + +type DatePickerProps = { + min: number; + max: number; + value: number; + onChange: (value: number) => void; +}; +export const DatePicker = forwardRef( + ({ min, max, value, onChange }, ref) => { + const selectedYear = dayjs(value).year(); + const selectedMonth = dayjs(value).month() + 1; + const selectedDay = dayjs(value).date(); + + const handleSubmit = (newValue: number) => { + onChange(Math.min(Math.max(min, newValue), max)); + }; + + const handleDay = (day: number) => { + const seconds = daysToMs(day); + const lastSeconds = daysToMs(selectedDay); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const handleMonthAndYear = (month: number, year: number) => { + const mDays = daysInMonth(month, year); + const currentDate = dateFor(selectedYear, selectedMonth, selectedDay); + const time = value - currentDate; + + const newDate = dateFor(year, month, mDays < selectedDay ? mDays : selectedDay); + + const newValue = newDate + time; + handleSubmit(newValue); + }; + + const handleMonth = (month: number) => { + handleMonthAndYear(month, selectedYear); + }; + + const handleYear = (year: number) => { + handleMonthAndYear(selectedMonth, year); + }; + + const minYear = dayjs(min).year(); + const maxYear = dayjs(max).year(); + const yearsRange = maxYear - minYear + 1; + + const minMonth = dayjs(min).month() + 1; + const maxMonth = dayjs(max).month() + 1; + + const minDay = dayjs(min).date(); + const maxDay = dayjs(max).date(); + return ( + + + + {Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys()) + .map((i) => i + 1) + .map((day) => ( + handleDay(day)} + disabled={ + (selectedYear === minYear && selectedMonth === minMonth && day < minDay) || + (selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay) + } + > + {day} + + ))} + + + {Array.from(Array(12).keys()) + .map((i) => i + 1) + .map((month) => ( + handleMonth(month)} + disabled={ + (selectedYear === minYear && month < minMonth) || + (selectedYear === maxYear && month > maxMonth) + } + > + + {dayjs() + .month(month - 1) + .format('MMM')} + + + ))} + + + {Array.from(Array(yearsRange).keys()) + .map((i) => minYear + i) + .map((year) => ( + handleYear(year)} + > + {year} + + ))} + + + + ); + } +); diff --git a/src/app/components/time-date/PickerColumn.tsx b/src/app/components/time-date/PickerColumn.tsx new file mode 100644 index 0000000..c31daf4 --- /dev/null +++ b/src/app/components/time-date/PickerColumn.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react'; +import { Box, Text, Scroll } from 'folds'; +import { CutoutCard } from '../cutout-card'; +import * as css from './styles.css'; + +export function PickerColumn({ title, children }: { title: string; children: ReactNode }) { + return ( + + + {title} + + + + + + {children} + + + + + + ); +} diff --git a/src/app/components/time-date/TimePicker.tsx b/src/app/components/time-date/TimePicker.tsx new file mode 100644 index 0000000..1dd0958 --- /dev/null +++ b/src/app/components/time-date/TimePicker.tsx @@ -0,0 +1,132 @@ +import React, { forwardRef } from 'react'; +import { Menu, Box, Text, Chip } from 'folds'; +import dayjs from 'dayjs'; +import * as css from './styles.css'; +import { PickerColumn } from './PickerColumn'; +import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time'; + +type TimePickerProps = { + min: number; + max: number; + value: number; + onChange: (value: number) => void; +}; +export const TimePicker = forwardRef( + ({ min, max, value, onChange }, ref) => { + const hour24 = dayjs(value).hour(); + + const selectedHour = hour24to12(hour24); + const selectedMinute = dayjs(value).minute(); + const selectedPM = hour24 >= 12; + + const handleSubmit = (newValue: number) => { + onChange(Math.min(Math.max(min, newValue), max)); + }; + + const handleHour = (hour: number) => { + const seconds = hoursToMs(hour12to24(hour, selectedPM)); + const lastSeconds = hoursToMs(hour24); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const handleMinute = (minute: number) => { + const seconds = minutesToMs(minute); + const lastSeconds = minutesToMs(selectedMinute); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const handlePeriod = (pm: boolean) => { + const seconds = hoursToMs(hour12to24(selectedHour, pm)); + const lastSeconds = hoursToMs(hour24); + const newValue = value + (seconds - lastSeconds); + handleSubmit(newValue); + }; + + const minHour24 = dayjs(min).hour(); + const maxHour24 = dayjs(max).hour(); + + const minMinute = dayjs(min).minute(); + const maxMinute = dayjs(max).minute(); + const minPM = minHour24 >= 12; + const maxPM = maxHour24 >= 12; + + const minDay = inSameDay(min, value); + const maxDay = inSameDay(max, value); + + return ( + + + + {Array.from(Array(12).keys()) + .map((i) => { + if (i === 0) return 12; + return i; + }) + .map((hour) => ( + handleHour(hour)} + disabled={ + (minDay && hour12to24(hour, selectedPM) < minHour24) || + (maxDay && hour12to24(hour, selectedPM) > maxHour24) + } + > + {hour < 10 ? `0${hour}` : hour} + + ))} + + + {Array.from(Array(60).keys()).map((minute) => ( + handleMinute(minute)} + disabled={ + (minDay && hour24 === minHour24 && minute < minMinute) || + (maxDay && hour24 === maxHour24 && minute > maxMinute) + } + > + {minute < 10 ? `0${minute}` : minute} + + ))} + + + handlePeriod(false)} + disabled={minDay && minPM} + > + AM + + handlePeriod(true)} + disabled={maxDay && !maxPM} + > + PM + + + + + ); + } +); diff --git a/src/app/components/time-date/index.ts b/src/app/components/time-date/index.ts new file mode 100644 index 0000000..592c5af --- /dev/null +++ b/src/app/components/time-date/index.ts @@ -0,0 +1,2 @@ +export * from './TimePicker'; +export * from './DatePicker'; diff --git a/src/app/components/time-date/styles.css.ts b/src/app/components/time-date/styles.css.ts new file mode 100644 index 0000000..97926d3 --- /dev/null +++ b/src/app/components/time-date/styles.css.ts @@ -0,0 +1,16 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const PickerMenu = style({ + padding: config.space.S200, +}); +export const PickerContainer = style({ + maxHeight: toRem(250), +}); +export const PickerColumnLabel = style({ + padding: config.space.S200, +}); +export const PickerColumnContent = style({ + padding: config.space.S200, + paddingRight: 0, +}); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 352ae4b..63e9d55 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -65,6 +65,8 @@ import { getRoomNotificationModeIcon, useRoomsNotificationPreferencesContext, } from '../../hooks/useRoomsNotificationPreferences'; +import { JumpToTime } from './jump-to-time'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; type RoomMenuProps = { room: Room; @@ -79,6 +81,7 @@ const RoomMenu = forwardRef(({ room, requestClose const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); + const { navigateRoom } = useRoomNavigate(); const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideActivity); @@ -175,6 +178,33 @@ const RoomMenu = forwardRef(({ room, requestClose Room Settings + + {(promptJump, setPromptJump) => ( + <> + setPromptJump(true)} + size="300" + after={} + radii="300" + aria-pressed={promptJump} + > + + Jump to Time + + + {promptJump && ( + { + setPromptJump(false); + navigateRoom(room.roomId, eventId); + requestClose(); + }} + onCancel={() => setPromptJump(false)} + /> + )} + + )} + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx new file mode 100644 index 0000000..8c4e2c0 --- /dev/null +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -0,0 +1,256 @@ +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Dialog, + Overlay, + OverlayCenter, + OverlayBackdrop, + Header, + config, + Box, + Text, + IconButton, + Icon, + Icons, + color, + Button, + Spinner, + Chip, + PopOut, + RectCords, +} from 'folds'; +import { Direction, MatrixError } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useAlive } from '../../../hooks/useAlive'; +import { useStateEvent } from '../../../hooks/useStateEvent'; +import { useRoom } from '../../../hooks/useRoom'; +import { StateEvent } from '../../../../types/matrix/room'; +import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time'; +import { DatePicker, TimePicker } from '../../../components/time-date'; + +type JumpToTimeProps = { + onCancel: () => void; + onSubmit: (eventId: string) => void; +}; +export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { + const mx = useMatrixClient(); + const room = useRoom(); + const alive = useAlive(); + const createStateEvent = useStateEvent(room, StateEvent.RoomCreate); + + const todayTs = getToday(); + const yesterdayTs = getYesterday(); + const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]); + const [ts, setTs] = useState(() => Date.now()); + + const [timePickerCords, setTimePickerCords] = useState(); + const [datePickerCords, setDatePickerCords] = useState(); + + const handleTimePicker: MouseEventHandler = (evt) => { + setTimePickerCords(evt.currentTarget.getBoundingClientRect()); + }; + const handleDatePicker: MouseEventHandler = (evt) => { + setDatePickerCords(evt.currentTarget.getBoundingClientRect()); + }; + + const handleToday = () => { + setTs(todayTs < createTs ? createTs : todayTs); + }; + const handleYesterday = () => { + setTs(yesterdayTs < createTs ? createTs : yesterdayTs); + }; + const handleBeginning = () => setTs(createTs); + + const [timestampState, timestampToEvent] = useAsyncCallback( + useCallback( + async (newTs) => { + const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward); + return result.event_id; + }, + [mx, room] + ) + ); + + const handleSubmit = () => { + timestampToEvent(ts).then((eventId) => { + if (alive()) { + onSubmit(eventId); + } + }); + }; + + return ( + }> + + + +
+ + Jump to Time + + + + +
+ + + + + Time + + + } + onClick={handleTimePicker} + > + {timeHourMinute(ts)} + + setTimePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + +
+ } + /> +
+ + + + Date + + + } + onClick={handleDatePicker} + > + {timeDayMonthYear(ts)} + + setDatePickerCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + } + /> + + + + + Preset + + {createTs < todayTs && ( + + Today + + )} + {createTs < yesterdayTs && ( + + Yesterday + + )} + + Beginning + + + + {timestampState.status === AsyncStatus.Error && ( + + {timestampState.error.message} + + )} + + + + + + + ); +} diff --git a/src/app/features/room/jump-to-time/index.ts b/src/app/features/room/jump-to-time/index.ts new file mode 100644 index 0000000..9bdc2c7 --- /dev/null +++ b/src/app/features/room/jump-to-time/index.ts @@ -0,0 +1 @@ +export * from './JumpToTime'; diff --git a/src/app/utils/time.ts b/src/app/utils/time.ts index 3ee6720..f230e59 100644 --- a/src/app/utils/time.ts +++ b/src/app/utils/time.ts @@ -9,12 +9,26 @@ export const today = (ts: number): boolean => dayjs(ts).isToday(); export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday(); +export const timeHour = (ts: number): string => dayjs(ts).format('hh'); +export const timeMinute = (ts: number): string => dayjs(ts).format('mm'); +export const timeAmPm = (ts: number): string => dayjs(ts).format('A'); +export const timeDay = (ts: number): string => dayjs(ts).format('D'); +export const timeMon = (ts: number): string => dayjs(ts).format('MMM'); +export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM'); +export const timeYear = (ts: number): string => dayjs(ts).format('YYYY'); + export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A'); export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY'); export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY'); +export const daysInMonth = (month: number, year: number): number => + dayjs(`${year}-${month}-1`).daysInMonth(); + +export const dateFor = (year: number, month: number, day: number): number => + dayjs(`${year}-${month}-${day}`).valueOf(); + export const inSameDay = (ts1: number, ts2: number): boolean => { const dt1 = new Date(ts1); const dt2 = new Date(ts2); @@ -33,3 +47,37 @@ export const minuteDifference = (ts1: number, ts2: number): number => { diff /= 60; return Math.abs(Math.round(diff)); }; + +export const hour24to12 = (hour24: number): number => { + const h = hour24 % 12; + + if (h === 0) return 12; + return h; +}; + +export const hour12to24 = (hour: number, pm: boolean): number => { + if (hour === 12) { + return pm ? 12 : 0; + } + return pm ? hour + 12 : hour; +}; + +export const secondsToMs = (seconds: number) => seconds * 1000; + +export const minutesToMs = (minutes: number) => minutes * secondsToMs(60); + +export const hoursToMs = (hour: number) => hour * minutesToMs(60); + +export const daysToMs = (days: number) => days * hoursToMs(24); + +export const getToday = () => { + const nowTs = Date.now(); + const date = dayjs(nowTs); + return dateFor(date.year(), date.month() + 1, date.date()); +}; + +export const getYesterday = () => { + const nowTs = Date.now() - daysToMs(1); + const date = dayjs(nowTs); + return dateFor(date.year(), date.month() + 1, date.date()); +}; -- 2.34.1