Compare commits
2
Commits
8d11a62e14
...
bd8c79e0e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd8c79e0e6 | ||
|
|
4d4a76214a |
@@ -24,32 +24,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time';
|
||||
import { TimestampPrefs, formatTimestamp } from '../../utils/formatTimestamp';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
function formatReadTs(ts: number, hour24Clock: boolean): string {
|
||||
const timeStr = timeHourMinute(ts, hour24Clock);
|
||||
if (today(ts)) return `Today at ${timeStr}`;
|
||||
if (yesterday(ts)) return `Yesterday at ${timeStr}`;
|
||||
const sameYear = timeYear(ts) === timeYear(Date.now());
|
||||
return sameYear
|
||||
? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}`
|
||||
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
|
||||
}
|
||||
const formatReadTs = (ts: number, prefs: TimestampPrefs): string => formatTimestamp(ts, prefs);
|
||||
|
||||
type EventReaderItemProps = {
|
||||
room: Room;
|
||||
readerId: string;
|
||||
hour24Clock: boolean;
|
||||
prefs: TimestampPrefs;
|
||||
lotusTerminal: boolean;
|
||||
onSelect: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
function EventReaderItem({
|
||||
room,
|
||||
readerId,
|
||||
hour24Clock,
|
||||
lotusTerminal,
|
||||
onSelect,
|
||||
}: EventReaderItemProps) {
|
||||
function EventReaderItem({ room, readerId, prefs, lotusTerminal, onSelect }: EventReaderItemProps) {
|
||||
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
|
||||
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||
|
||||
@@ -86,7 +73,7 @@ function EventReaderItem({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatReadTs(receiptTs, hour24Clock)}
|
||||
{formatReadTs(receiptTs, prefs)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -106,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
return (
|
||||
@@ -157,7 +144,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
key={readerId}
|
||||
room={room}
|
||||
readerId={readerId}
|
||||
hour24Clock={hour24Clock}
|
||||
prefs={prefs}
|
||||
lotusTerminal={lotusTerminal}
|
||||
onSelect={(event) => {
|
||||
openProfile(
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as css from './Reply.css';
|
||||
import { ForwardedMeta } from '../../features/room/message/forwardContent';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||
import { formatTimestamp } from '../../utils/formatTimestamp';
|
||||
|
||||
type ForwardedHeaderProps = {
|
||||
mx: MatrixClient;
|
||||
@@ -32,11 +32,7 @@ export const ForwardedHeader = as<'div', ForwardedHeaderProps>(
|
||||
getMxIdLocalPart(meta.sender) ??
|
||||
meta.sender;
|
||||
const ts = meta.origin_server_ts;
|
||||
const when = today(ts)
|
||||
? timeHourMinute(ts, hour24Clock)
|
||||
: yesterday(ts)
|
||||
? `Yesterday ${timeHourMinute(ts, hour24Clock)}`
|
||||
: `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute(ts, hour24Clock)}`;
|
||||
const when = formatTimestamp(ts, { hour24Clock, dateFormatString });
|
||||
const canJump = !!sourceRoom && !!onJump;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { Text, as } from 'folds';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||
import { formatTimestamp } from '../../utils/formatTimestamp';
|
||||
|
||||
export type TimeProps = {
|
||||
compact?: boolean;
|
||||
@@ -12,8 +12,8 @@ export type TimeProps = {
|
||||
/**
|
||||
* Renders a formatted timestamp, supporting compact and full display modes.
|
||||
*
|
||||
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
|
||||
* For older messages, it shows the date and time.
|
||||
* `compact` always shows the clock time; otherwise the shared `formatTimestamp`
|
||||
* rules apply (today → time, yesterday/this week → day word + time, else date + time).
|
||||
*
|
||||
* @param {number} ts - The timestamp to display.
|
||||
* @param {boolean} [compact=false] - If true, always show only the time.
|
||||
@@ -23,18 +23,7 @@ export type TimeProps = {
|
||||
*/
|
||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
||||
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
|
||||
const formattedTime = timeHourMinute(ts, hour24Clock);
|
||||
|
||||
let time = '';
|
||||
if (compact) {
|
||||
time = formattedTime;
|
||||
} else if (today(ts)) {
|
||||
time = formattedTime;
|
||||
} else if (yesterday(ts)) {
|
||||
time = `Yesterday ${formattedTime}`;
|
||||
} else {
|
||||
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
|
||||
}
|
||||
const time = formatTimestamp(ts, { hour24Clock, dateFormatString }, compact ? 'time' : 'auto');
|
||||
|
||||
return (
|
||||
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
|
||||
|
||||
@@ -19,15 +19,13 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useRoomAvatar, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { InviteUserPrompt } from '../invite-user-prompt';
|
||||
import { RoomTopicViewer } from '../room-topic-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
@@ -68,7 +66,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]),
|
||||
);
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||
@@ -135,7 +133,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
<Text size="T200" priority="300">
|
||||
{'Created by '}
|
||||
<b>@{creatorName}</b>
|
||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
|
||||
{` on ${format(ts, 'dateTime')}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -6,9 +6,7 @@ import { SettingTile } from '../setting-tile';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
type UserKickAlertProps = {
|
||||
reason?: string;
|
||||
@@ -16,11 +14,8 @@ type UserKickAlertProps = {
|
||||
ts?: number;
|
||||
};
|
||||
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
return (
|
||||
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||
@@ -28,11 +23,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Kicked User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{kickedBy && (
|
||||
@@ -66,11 +57,8 @@ type UserBanAlertProps = {
|
||||
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
const [unbanState, unban] = useAsyncCallback<undefined, Error, []>(
|
||||
useCallback(async () => {
|
||||
@@ -86,11 +74,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Banned User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{bannedBy && (
|
||||
@@ -141,11 +125,8 @@ type UserInviteAlertProps = {
|
||||
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
||||
useCallback(async () => {
|
||||
@@ -161,11 +142,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Invited User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{invitedBy && (
|
||||
|
||||
@@ -35,19 +35,8 @@ import { nameInitials } from '../../utils/common';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './BookmarksPanel.css';
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
|
||||
// getOnInit reads localStorage synchronously at init so the persisted sort is
|
||||
@@ -110,7 +99,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
|
||||
: undefined;
|
||||
// Prefer a live-resolved author name, then the stored snapshot.
|
||||
const author = senderName ?? bookmark.senderName;
|
||||
const timeAgo = formatTimeAgo(bookmark.savedAt);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const timeAgo = formatRelativeAge(bookmark.savedAt, prefs);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -52,6 +52,7 @@ import { SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||
useMemo(
|
||||
@@ -74,6 +75,7 @@ type EncryptedRoomCachePanelProps = {
|
||||
};
|
||||
function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { format } = useTimestampFormatter();
|
||||
const [loadingRooms, setLoadingRooms] = useState<Set<string>>(new Set());
|
||||
|
||||
const encryptedRooms = useMemo(
|
||||
@@ -140,7 +142,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{msgEvents.length > 0
|
||||
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
|
||||
? `${msgEvents.length} messages cached · oldest: ${format(oldest!.getTs(), 'date')}`
|
||||
: 'No messages cached yet'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -37,9 +37,6 @@ import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
@@ -97,29 +94,11 @@ import { MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||
import { EmojiBoard } from '../../components/emoji-board';
|
||||
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatShortAge } from '../../utils/formatTimestamp';
|
||||
|
||||
const PREVIEW_MAX_CHARS = 48;
|
||||
|
||||
function formatDmTimestamp(ts: number): string {
|
||||
const d = dayjs(ts);
|
||||
const now = dayjs();
|
||||
const diffMinutes = now.diff(d, 'minute');
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes < 1 ? 0 : diffMinutes}m`;
|
||||
}
|
||||
const diffHours = now.diff(d, 'hour');
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
if (d.isYesterday()) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return d.format('D MMM');
|
||||
}
|
||||
|
||||
type RenameRoomDialogProps = {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
@@ -646,6 +625,7 @@ function RoomNavItem_({
|
||||
|
||||
const roomName = useLocalRoomName(room);
|
||||
const hasLocalName = useHasLocalRoomName(room.roomId);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
|
||||
// Whether this room has an unsent message draft. selectAtom maps to a boolean
|
||||
// so the row only re-renders when that flips (the draft atom itself is written
|
||||
@@ -677,7 +657,7 @@ function RoomNavItem_({
|
||||
}
|
||||
if (!body) return null;
|
||||
const preview = body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}…` : body;
|
||||
return { preview, time: formatDmTimestamp(ts) };
|
||||
return { preview, time: formatShortAge(ts, prefs) };
|
||||
})();
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
createDetachedTimelineSet,
|
||||
createTypesFilter,
|
||||
} from '../../utils/detachedTimeline';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,20 +29,6 @@ type StateEventType = (typeof STATE_EVENT_TYPES)[number];
|
||||
|
||||
// ── Timestamp formatting ──────────────────────────────────────────────────────
|
||||
|
||||
function formatRelativeTs(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
const d = new Date(ts);
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event description ─────────────────────────────────────────────────────────
|
||||
|
||||
function getDisplayName(mx: ReturnType<typeof useMatrixClient>, userId: string): string {
|
||||
@@ -296,6 +284,7 @@ type LogEntryProps = {
|
||||
};
|
||||
|
||||
function LogEntry({ ev, desc }: LogEntryProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
return (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
@@ -326,7 +315,7 @@ function LogEntry({ ev, desc }: LogEntryProps) {
|
||||
{desc.text}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{formatRelativeTs(ev.getTs())}
|
||||
{formatRelativeAge(ev.getTs(), prefs)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,21 +9,10 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUpdatedAt(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Throttle window for re-computing stats on new timeline events - avoids
|
||||
// re-running every heatmap/list computation on every single incoming message
|
||||
// during a burst.
|
||||
@@ -74,6 +63,7 @@ type RoomInsightsProps = {
|
||||
};
|
||||
|
||||
export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
const { format } = useTimestampFormatter();
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
@@ -234,11 +224,11 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
</Text>
|
||||
{stats.oldestTs !== null && stats.newestTs !== null && (
|
||||
<Text size="T200" priority="300">
|
||||
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
|
||||
from {format(stats.oldestTs, 'date')} to {format(stats.newestTs, 'date')}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Last updated {formatUpdatedAt(lastUpdated)}
|
||||
Last updated {format(lastUpdated, 'time')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -35,6 +35,8 @@ import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './MediaGallery.css';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
||||
|
||||
@@ -56,7 +58,7 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
|
||||
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
|
||||
|
||||
function useDecryptedMediaUrl(
|
||||
export function useDecryptedMediaUrl(
|
||||
mx: MatrixClient,
|
||||
mxcUrl: string | undefined,
|
||||
encInfo: IEncryptedFile | undefined,
|
||||
@@ -117,18 +119,6 @@ function useDecryptedMediaUrl(
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatRelativeDate(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 2) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(diff / 3600000);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -164,7 +154,7 @@ function getSenderName(room: Room, userId: string): string {
|
||||
// the grid and the lightbox must use this so their positional indices stay in
|
||||
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
|
||||
// lightbox and open the wrong media.
|
||||
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
|
||||
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
|
||||
const c = mEvent.getContent();
|
||||
const isEnc = !!c.file;
|
||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||
@@ -323,6 +313,7 @@ export function Lightbox({
|
||||
onJump: (eventId: string) => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
const item = items[index];
|
||||
const isImage = item?.msgtype === MsgType.Image;
|
||||
@@ -366,11 +357,7 @@ export function Lightbox({
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
const dateStr = format(item.ts, 'date');
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
@@ -614,7 +601,8 @@ function GalleryTile({
|
||||
mimeType,
|
||||
nearViewport,
|
||||
);
|
||||
const relDate = formatRelativeDate(ts);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const relDate = formatRelativeAge(ts, prefs);
|
||||
|
||||
return (
|
||||
<div className={css.GalleryTileWrap}>
|
||||
@@ -724,6 +712,7 @@ type MediaGalleryProps = {
|
||||
};
|
||||
|
||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
@@ -1040,7 +1029,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
if (!url) return null;
|
||||
const body: string = c.body || 'Voice message';
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const relDate = formatRelativeDate(mEvent.getTs());
|
||||
const relDate = formatRelativeAge(mEvent.getTs(), prefs);
|
||||
// Sanitize the mimetype the way MAudio does (e.g. application/ogg →
|
||||
// audio/ogg) so the decrypted blob actually plays.
|
||||
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IContent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -90,6 +91,9 @@ import {
|
||||
reactionOrEditEvent,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
|
||||
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
|
||||
import { Lightbox, getThumbMxc, toLightboxItems } from './MediaGallery';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
@@ -105,7 +109,8 @@ import { markAsRead } from '../../utils/notifications';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
import { inSameDay, minuteDifference } from '../../utils/time';
|
||||
import { formatDayDivider } from '../../utils/formatTimestamp';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
@@ -180,6 +185,9 @@ export const getFirstLinkedTimeline = (
|
||||
return getFirstLinkedTimeline(linkedTm, direction);
|
||||
};
|
||||
|
||||
/** [Gitea #137] Galleries the user asked to see as separate messages (keyed by the group's last event id). */
|
||||
const separatedGalleries = new Set<string>();
|
||||
|
||||
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
|
||||
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
|
||||
const timelines: EventTimeline[] = [];
|
||||
@@ -469,6 +477,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
|
||||
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
|
||||
const [, setSeparatedTick] = useState(0);
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
@@ -528,6 +538,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||
// [Gitea #219] Timeline images open the shared media lightbox at that event.
|
||||
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
|
||||
// [Gitea #137] Opened from a gallery grid: the viewer walks that group in send order.
|
||||
const [lightboxGroup, setLightboxGroup] = useState<MatrixEvent[] | undefined>();
|
||||
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -1166,10 +1178,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderMatrixEvent = useMatrixEventRenderer<
|
||||
[string, MatrixEvent, number, EventTimelineSet, boolean]
|
||||
[
|
||||
string,
|
||||
MatrixEvent,
|
||||
number,
|
||||
EventTimelineSet,
|
||||
boolean,
|
||||
MatrixEvent[] | undefined,
|
||||
string | undefined,
|
||||
]
|
||||
>(
|
||||
{
|
||||
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
||||
[MessageEvent.RoomMessage]: (
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapse,
|
||||
mediaGroup,
|
||||
regroupId,
|
||||
) => {
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
@@ -1262,24 +1290,47 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
) : mediaGroup ? (
|
||||
<MediaGroupGrid
|
||||
events={mediaGroup}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
||||
mEvent={mEvent}
|
||||
onOpen={(id) => {
|
||||
setLightboxGroup(mediaGroup);
|
||||
setLightboxEventId(id);
|
||||
}}
|
||||
onShowSeparately={() => {
|
||||
separatedGalleries.add(mEventId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
||||
mEvent={mEvent}
|
||||
/>
|
||||
{regroupId && (
|
||||
<RegroupChip
|
||||
onClick={() => {
|
||||
separatedGalleries.delete(regroupId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
@@ -2184,30 +2235,100 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
let isPrevRendered = false;
|
||||
let newDivider = false;
|
||||
let dayDivider = false;
|
||||
const eventRenderer = (item: number) => {
|
||||
// Perf-5: O(T) → O(log T) via precomputed segments
|
||||
let eventTimeline: EventTimeline | undefined;
|
||||
let baseIndex = 0;
|
||||
{
|
||||
let lo = 0;
|
||||
let hi = timelineSegments.length - 1;
|
||||
while (lo <= hi) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const [base, len] = timelineSegments[mid];
|
||||
if (item < base) {
|
||||
hi = mid - 1;
|
||||
} else if (item >= base + len) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
eventTimeline = timelineSegments[mid][2];
|
||||
baseIndex = base;
|
||||
break;
|
||||
}
|
||||
// Perf-5: O(T) → O(log T) via precomputed segments
|
||||
const resolveItem = (
|
||||
item: number,
|
||||
): { eventTimeline: EventTimeline; baseIndex: number } | undefined => {
|
||||
let lo = 0;
|
||||
let hi = timelineSegments.length - 1;
|
||||
while (lo <= hi) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const [base, len] = timelineSegments[mid];
|
||||
if (item < base) {
|
||||
hi = mid - 1;
|
||||
} else if (item >= base + len) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
return { eventTimeline: timelineSegments[mid][2], baseIndex: base };
|
||||
}
|
||||
}
|
||||
if (!eventTimeline) return null;
|
||||
const timelineSet = eventTimeline?.getTimelineSet();
|
||||
return undefined;
|
||||
};
|
||||
const eventAt = (
|
||||
item: number,
|
||||
): { mEvent: MatrixEvent; timelineSet: EventTimelineSet } | undefined => {
|
||||
const seg = resolveItem(item);
|
||||
if (!seg) return undefined;
|
||||
const mEvent = getTimelineEvent(
|
||||
seg.eventTimeline,
|
||||
getTimelineRelativeIndex(item, seg.baseIndex),
|
||||
);
|
||||
return mEvent ? { mEvent, timelineSet: seg.eventTimeline.getTimelineSet() } : undefined;
|
||||
};
|
||||
// [Gitea #137] Gallery grouping is planned lazily per render pass: the first
|
||||
// media event we meet plans its whole run (looking both ways, so a virtual
|
||||
// window that starts mid-run still agrees), and the plan is reused for the
|
||||
// run's other members.
|
||||
const groupPlans = new Map<number, GroupPlan | null>();
|
||||
const candidateAt = (index: number): GroupCandidate | undefined => {
|
||||
const found = eventAt(index);
|
||||
if (!found) return undefined;
|
||||
const { mEvent: ev, timelineSet } = found;
|
||||
const sender = ev.getSender() ?? '';
|
||||
const base = { sender, ts: ev.getTs(), hasRelation: false, redacted: false, mustEnd: false };
|
||||
if (
|
||||
reactionOrEditEvent(ev) ||
|
||||
ev.getType() === 'm.room.redaction' ||
|
||||
ignoredUsersSet.has(sender)
|
||||
)
|
||||
return { ...base, kind: 'skip' };
|
||||
if (ev.getType() === StateEvent.RoomMember && hideMembershipEvents)
|
||||
return { ...base, kind: 'skip' };
|
||||
const msgtype = ev.getContent().msgtype;
|
||||
const isMedia =
|
||||
ev.getType() === MessageEvent.RoomMessage &&
|
||||
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
|
||||
!!getThumbMxc(ev);
|
||||
if (!isMedia) return { ...base, kind: 'other' };
|
||||
const id = ev.getId() ?? '';
|
||||
const reactions = getEventReactions(timelineSet, id)?.getSortedAnnotationsByKey();
|
||||
const hasThread =
|
||||
ev.getThread() !== undefined ||
|
||||
ev.getServerAggregatedRelation(RelationType.Thread) !== undefined;
|
||||
return {
|
||||
...base,
|
||||
kind: 'media',
|
||||
hasRelation: !!ev.getContent()['m.relates_to'],
|
||||
redacted: ev.isRedacted(),
|
||||
mustEnd: (reactions?.length ?? 0) > 0 || hasThread,
|
||||
};
|
||||
};
|
||||
const mediaGroupFor = (
|
||||
item: number,
|
||||
): { hidden: boolean; events?: MatrixEvent[]; regroup?: string } | undefined => {
|
||||
if (!groupPlans.has(item)) {
|
||||
if (candidateAt(item)?.kind !== 'media') return undefined;
|
||||
const plans = planMediaGroups(candidateAt, item);
|
||||
plans.forEach((plan, index) => groupPlans.set(index, plan));
|
||||
if (!plans.has(item)) groupPlans.set(item, null);
|
||||
}
|
||||
const plan = groupPlans.get(item);
|
||||
if (!plan) return undefined;
|
||||
const lastId = eventAt(plan.members[plan.members.length - 1])?.mEvent.getId() ?? '';
|
||||
if (separatedGalleries.has(lastId))
|
||||
return plan.renders ? { hidden: false, regroup: lastId } : undefined;
|
||||
if (!plan.renders) return { hidden: true };
|
||||
const events = plan.members
|
||||
.map((index) => eventAt(index)?.mEvent)
|
||||
.filter((ev): ev is MatrixEvent => !!ev);
|
||||
return { hidden: false, events };
|
||||
};
|
||||
const eventRenderer = (item: number) => {
|
||||
const resolved = resolveItem(item);
|
||||
if (!resolved) return null;
|
||||
const { eventTimeline, baseIndex } = resolved;
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
||||
const mEventId = mEvent?.getId();
|
||||
|
||||
@@ -2251,17 +2372,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
);
|
||||
const mediaGroup = mediaGroupFor(item);
|
||||
const eventJSX =
|
||||
reactionOrEditEvent(mEvent) || mediaGroup?.hidden
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
mediaGroup?.events,
|
||||
mediaGroup?.regroup,
|
||||
);
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = !!eventJSX;
|
||||
|
||||
@@ -2282,11 +2407,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<TimelineDivider variant="Surface">
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
<Text size="L400">
|
||||
{(() => {
|
||||
if (today(mEvent.getTs())) return 'Today';
|
||||
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||
return timeDayMonthYear(mEvent.getTs());
|
||||
})()}
|
||||
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
|
||||
</Text>
|
||||
</Badge>
|
||||
</TimelineDivider>
|
||||
@@ -2461,7 +2582,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onClose={() => setEditHistoryEvent(undefined)}
|
||||
/>
|
||||
)}
|
||||
{lightboxEventId && (
|
||||
{lightboxEventId && lightboxGroup && (
|
||||
<Lightbox
|
||||
items={toLightboxItems(room, lightboxGroup)}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
lightboxGroup.findIndex((ev) => ev.getId() === lightboxEventId),
|
||||
)}
|
||||
useAuthentication={useAuthentication}
|
||||
onClose={() => {
|
||||
setLightboxEventId(undefined);
|
||||
setLightboxGroup(undefined);
|
||||
}}
|
||||
onJump={(id) => {
|
||||
setLightboxEventId(undefined);
|
||||
setLightboxGroup(undefined);
|
||||
navigateRoom(room.roomId, id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{lightboxEventId && !lightboxGroup && (
|
||||
<RoomMediaLightbox
|
||||
room={room}
|
||||
eventId={lightboxEventId}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
@@ -101,6 +102,7 @@ export function ScheduleMessageModal({
|
||||
};
|
||||
|
||||
// When editing, seed the pickers from the existing send-time; else default to +1h.
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
|
||||
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
|
||||
@@ -124,10 +126,10 @@ export function ScheduleMessageModal({
|
||||
return;
|
||||
}
|
||||
setPreview({
|
||||
label: formatFriendlyDateTime(sendAt.getTime()),
|
||||
label: formatFriendlyDateTime(sendAt.getTime(), prefs),
|
||||
relative: formatRelativeTime(diffMs),
|
||||
});
|
||||
}, [getSendAt]);
|
||||
}, [getSendAt, prefs]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePreview();
|
||||
|
||||
@@ -6,32 +6,17 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||
|
||||
interface ScheduledMessagesTrayProps {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
function formatSendAt(sendAt: number): string {
|
||||
const date = new Date(sendAt);
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow =
|
||||
date.getFullYear() === tomorrow.getFullYear() &&
|
||||
date.getMonth() === tomorrow.getMonth() &&
|
||||
date.getDate() === tomorrow.getDate();
|
||||
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `Today ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||
return `${date.toLocaleDateString()} ${timeStr}`;
|
||||
}
|
||||
|
||||
export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const formatSendAt = (sendAt: number) => formatFriendlyDateTime(sendAt, prefs);
|
||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -27,7 +27,8 @@ 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 { getToday, getYesterday } from '../../../utils/time';
|
||||
import { formatDate, formatTime } from '../../../utils/formatTimestamp';
|
||||
import { DatePicker, TimePicker } from '../../../components/time-date';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
@@ -50,6 +51,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
const [ts, setTs] = useState(() => Date.now());
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
|
||||
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
|
||||
@@ -131,7 +133,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||
onClick={handleTimePicker}
|
||||
>
|
||||
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
|
||||
<Text size="B300">{formatTime(ts, { hour24Clock })}</Text>
|
||||
</Chip>
|
||||
<PopOut
|
||||
anchor={timePickerCords}
|
||||
@@ -172,7 +174,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||
onClick={handleDatePicker}
|
||||
>
|
||||
<Text size="B300">{timeDayMonthYear(ts)}</Text>
|
||||
<Text size="B300">{formatDate(ts, { hour24Clock, dateFormatString })}</Text>
|
||||
</Chip>
|
||||
<PopOut
|
||||
anchor={datePickerCords}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { sanitizeCustomHtml } from '../../../utils/sanitize';
|
||||
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||
import { formatTimestamp } from '../../../utils/formatTimestamp';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { diffWords } from '../../../utils/textDiff';
|
||||
@@ -229,11 +229,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
|
||||
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 0;
|
||||
|
||||
const formatTs = (ts: number): string => {
|
||||
const time = timeHourMinute(ts, hour24Clock);
|
||||
const date = timeDayMonYear(ts, dateFormatString);
|
||||
return `${date} at ${time}`;
|
||||
};
|
||||
const formatTs = (ts: number): string => formatTimestamp(ts, { hour24Clock, dateFormatString });
|
||||
|
||||
const originalContent = getOriginalContent(mEvent);
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const Wrap = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
width: toRem(480),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Grid = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'grid',
|
||||
gap: toRem(3),
|
||||
width: '100%',
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Cell = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
aspectRatio: '1 / 1',
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
selectors: {
|
||||
'&:focus-visible': {
|
||||
outline: `${config.borderWidth.B600} solid ${color.Primary.Main}`,
|
||||
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const CellImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
transition: 'transform 150ms',
|
||||
selectors: {
|
||||
[`${Cell}:hover &`]: {
|
||||
transform: 'scale(1.03)',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const CellBlur = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const PlayBadge = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(36),
|
||||
height: toRem(36),
|
||||
borderRadius: config.radii.Pill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
color: 'white',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Footer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
marginTop: config.space.S100,
|
||||
},
|
||||
]);
|
||||
|
||||
export const FooterButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: 'transparent',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
textDecorationColor: 'currentColor',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { getThumbMxc, useDecryptedMediaUrl } from '../MediaGallery';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||
import * as css from './MediaGroupGrid.css';
|
||||
|
||||
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 5–6 → 3, 7+ → 4. */
|
||||
export const gridColumns = (n: number): number => {
|
||||
if (n <= 2) return 2;
|
||||
if (n === 3) return 3;
|
||||
if (n === 4) return 2;
|
||||
if (n <= 6) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
/** "5 photos" / "2 videos" / "6 items". */
|
||||
export const describeGroup = (events: MatrixEvent[]): string => {
|
||||
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
|
||||
const n = events.length;
|
||||
if (videos === 0) return `${n} photos`;
|
||||
if (videos === n) return `${n} videos`;
|
||||
return `${n} items`;
|
||||
};
|
||||
|
||||
function Cell({
|
||||
mEvent,
|
||||
load,
|
||||
onOpen,
|
||||
}: {
|
||||
mEvent: MatrixEvent;
|
||||
load: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const content = mEvent.getContent();
|
||||
const isVideo = content.msgtype === MsgType.Video;
|
||||
const thumbMxc = getThumbMxc(mEvent);
|
||||
const info = content.info as Record<string, unknown> | undefined;
|
||||
const encInfo = content.file
|
||||
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
|
||||
: undefined;
|
||||
const mimeType =
|
||||
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
|
||||
(info?.mimetype as string | undefined);
|
||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
|
||||
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
|
||||
const body = typeof content.body === 'string' ? content.body : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.Cell}
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={() => onOpen(mEvent.getId() ?? '')}
|
||||
>
|
||||
{blurHash && media.status !== 'ok' && (
|
||||
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
|
||||
)}
|
||||
{load && media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
|
||||
{isVideo && (
|
||||
<span className={css.PlayBadge}>
|
||||
<Icon src={Icons.Play} size="200" filled />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type MediaGroupGridProps = {
|
||||
events: MatrixEvent[];
|
||||
mediaAutoLoad: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
onShowSeparately: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #137] Several consecutive image/video events from one sender shown
|
||||
* as one grid. Each tile opens the room's shared lightbox at that event, so
|
||||
* ←/→ walk through the group (and beyond). Purely a render-time grouping.
|
||||
*/
|
||||
export function MediaGroupGrid({
|
||||
events,
|
||||
mediaAutoLoad,
|
||||
onOpen,
|
||||
onShowSeparately,
|
||||
}: MediaGroupGridProps) {
|
||||
const [load, setLoad] = useState(mediaAutoLoad);
|
||||
const columns = gridColumns(events.length);
|
||||
|
||||
return (
|
||||
<div className={css.Wrap}>
|
||||
<div
|
||||
className={css.Grid}
|
||||
role="group"
|
||||
aria-label={describeGroup(events)}
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||
onClick={load ? undefined : () => setLoad(true)}
|
||||
>
|
||||
{events.map((ev) => (
|
||||
<Cell
|
||||
key={ev.getId()}
|
||||
mEvent={ev}
|
||||
load={load}
|
||||
onOpen={load ? onOpen : () => setLoad(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.Footer}>
|
||||
<Text size="T200" priority="300">
|
||||
{describeGroup(events)}
|
||||
{!load && ' · tap to load'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
·
|
||||
</Text>
|
||||
<Text
|
||||
as="button"
|
||||
size="T200"
|
||||
priority="300"
|
||||
className={css.FooterButton}
|
||||
onClick={onShowSeparately}
|
||||
>
|
||||
Show separately
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown under the last of a gallery the user split up, to put it back together. */
|
||||
export function RegroupChip({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div className={css.Footer}>
|
||||
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
|
||||
Show as gallery
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1334,7 +1334,12 @@ export const Message = React.memo(
|
||||
})}
|
||||
role="article"
|
||||
aria-label={
|
||||
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||
collapse
|
||||
? messageAriaLabel(senderDisplayName, mEvent.getTs(), {
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { TimestampPrefs, formatTime } from '../../../utils/formatTimestamp';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useReminders } from '../../../hooks/useReminders';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
@@ -34,11 +36,11 @@ type RemindMeDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function getPresets(): Array<{ label: string; ms: number }> {
|
||||
function getPresets(prefs: TimestampPrefs): Array<{ label: string; ms: number }> {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const timeLabel = formatTime(tomorrow.getTime(), prefs);
|
||||
return [
|
||||
{ label: 'In 20 minutes', ms: 20 * 60_000 },
|
||||
{ label: 'In 1 hour', ms: 60 * 60_000 },
|
||||
@@ -58,7 +60,8 @@ function defaultCustomDate(): Date {
|
||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder, removeReminder, reminders } = useReminders();
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const presets = useMemo(() => getPresets(prefs), [prefs]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
@@ -185,7 +188,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
|
||||
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
|
||||
{formatFriendlyDateTime(r.timestamp)}
|
||||
{formatFriendlyDateTime(r.timestamp, prefs)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
@@ -193,7 +196,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={() => handleCancelExisting(r.timestamp)}
|
||||
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
|
||||
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp, prefs)}`}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
|
||||
@@ -3,9 +3,7 @@ import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { MobileTouchTarget } from '../../../styles/mobile.css';
|
||||
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||
|
||||
type ThreadSummaryProps = {
|
||||
@@ -15,17 +13,12 @@ type ThreadSummaryProps = {
|
||||
};
|
||||
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
||||
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
if (!summary || summary.count === 0) return null;
|
||||
|
||||
const { count, latestTs } = summary;
|
||||
const latestStr =
|
||||
latestTs !== undefined
|
||||
? today(latestTs)
|
||||
? timeHourMinute(latestTs, hour24Clock)
|
||||
: timeDayMonthYear(latestTs)
|
||||
: undefined;
|
||||
const latestStr = latestTs !== undefined ? format(latestTs) : undefined;
|
||||
|
||||
return (
|
||||
<Box style={{ marginTop: config.space.S200 }}>
|
||||
|
||||
@@ -76,13 +76,8 @@ import { RoomMediaLightbox } from '../RoomMediaLightbox';
|
||||
import { Image } from '../../../components/media';
|
||||
import { ImageViewer } from '../../../components/image-viewer';
|
||||
import * as css from './ThreadTimeline.css';
|
||||
import {
|
||||
inSameDay,
|
||||
minuteDifference,
|
||||
timeDayMonthYear,
|
||||
today,
|
||||
yesterday,
|
||||
} from '../../../utils/time';
|
||||
import { inSameDay, minuteDifference } from '../../../utils/time';
|
||||
import { formatDayDivider } from '../../../utils/formatTimestamp';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||
@@ -910,11 +905,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
<Text size="L400">
|
||||
{(() => {
|
||||
if (today(mEvent.getTs())) return 'Today';
|
||||
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||
return timeDayMonthYear(mEvent.getTs());
|
||||
})()}
|
||||
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
|
||||
</Text>
|
||||
</Badge>
|
||||
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
isThreadSort,
|
||||
} from '../../../utils/threadList';
|
||||
import { useRoomThreads } from '../../../hooks/useRoomThreads';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
@@ -55,19 +57,6 @@ const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [
|
||||
|
||||
const MAX_PARTICIPANTS = 5;
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Segmented button, mirroring the Bookmarks panel sort control for consistency.
|
||||
function SegButton({
|
||||
label,
|
||||
@@ -115,6 +104,7 @@ type ThreadRowProps = {
|
||||
onOpen: (threadId: string) => void;
|
||||
};
|
||||
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const rootEvent = thread.rootEvent;
|
||||
const rootSender = rootEvent?.getSender() ?? '';
|
||||
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
|
||||
@@ -126,7 +116,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
|
||||
const extra = participants.length - MAX_PARTICIPANTS;
|
||||
const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`;
|
||||
const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${
|
||||
typeof lastTs === 'number' ? `, last reply ${formatTimeAgo(lastTs)}` : ''
|
||||
typeof lastTs === 'number' ? `, last reply ${formatRelativeAge(lastTs, prefs)}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -175,7 +165,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
|
||||
<Icon size="50" src={Icons.Thread} />
|
||||
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
|
||||
{count} {count === 1 ? 'reply' : 'replies'}
|
||||
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
|
||||
{typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
|
||||
</Text>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
|
||||
|
||||
@@ -20,15 +20,13 @@ import FocusTrap from 'focus-trap-react';
|
||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
|
||||
export function DeviceTilePlaceholder() {
|
||||
return (
|
||||
@@ -43,20 +41,14 @@ export function DeviceTilePlaceholder() {
|
||||
}
|
||||
|
||||
function DeviceActiveTime({ ts }: { ts: number }) {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
return (
|
||||
<Text className={BreakWord} size="T200">
|
||||
<Text size="Inherit" as="span" priority="300">
|
||||
{'Last activity: '}
|
||||
</Text>
|
||||
<>
|
||||
{today(ts) && 'Today'}
|
||||
{yesterday(ts) && 'Yesterday'}
|
||||
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
|
||||
{timeHourMinute(ts, hour24Clock)}
|
||||
</>
|
||||
{format(ts)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds';
|
||||
import { IPusherRequest } from 'matrix-js-sdk';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -139,6 +140,7 @@ const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }>
|
||||
// Cross-platform "pause notifications" — sets a snooze instant that the
|
||||
// notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds.
|
||||
function PauseNotifications() {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
|
||||
// While paused, tick so the status flips to "on" the moment the snooze lapses.
|
||||
@@ -155,7 +157,7 @@ function PauseNotifications() {
|
||||
? 'Notifications are on.'
|
||||
: snoozeUntil >= SNOOZE_INDEFINITE
|
||||
? 'Paused until you resume.'
|
||||
: `Paused until ${formatFriendlyDateTime(snoozeUntil)}.`;
|
||||
: `Paused until ${formatFriendlyDateTime(snoozeUntil, prefs)}.`;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import {
|
||||
TimestampStyle,
|
||||
formatShortAge,
|
||||
formatTimestamp,
|
||||
TimestampPrefs,
|
||||
} from '../utils/formatTimestamp';
|
||||
|
||||
/**
|
||||
* [Gitea #139] Timestamp formatting bound to the user's clock/date settings.
|
||||
* `format(ts)` is the everyday "auto" style; pass a style for the others.
|
||||
*/
|
||||
export function useTimestampFormatter() {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const prefs = useMemo<TimestampPrefs>(
|
||||
() => ({ hour24Clock, dateFormatString }),
|
||||
[hour24Clock, dateFormatString],
|
||||
);
|
||||
const format = useCallback(
|
||||
(ts: number, style: TimestampStyle = 'auto') => formatTimestamp(ts, prefs, style),
|
||||
[prefs],
|
||||
);
|
||||
const shortAge = useCallback((ts: number) => formatShortAge(ts, prefs), [prefs]);
|
||||
return { format, shortAge, prefs };
|
||||
}
|
||||
@@ -2,27 +2,29 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import dayjs from 'dayjs';
|
||||
import { messageAriaLabel } from './a11y';
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
|
||||
test('messageAriaLabel composes sender, date and time (24h)', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Alice', ts, true),
|
||||
`Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`,
|
||||
messageAriaLabel('Alice', ts, { hour24Clock: true, dateFormatString: 'D MMM YYYY' }),
|
||||
'Alice, 1 Jul 2026 14:30',
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel honours the 12-hour clock preference', () => {
|
||||
test('messageAriaLabel honours the 12-hour clock and date-format preferences', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Bob', ts, false),
|
||||
`Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`,
|
||||
messageAriaLabel('Bob', ts, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }),
|
||||
'Bob, 07/01/2026 02:30 PM',
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel keeps the sender name verbatim as plain text', () => {
|
||||
const ts = dayjs('2026-07-01T09:05:00').valueOf();
|
||||
const label = messageAriaLabel('@user:example.org', ts, true);
|
||||
const label = messageAriaLabel('@user:example.org', ts, {
|
||||
hour24Clock: true,
|
||||
dateFormatString: '',
|
||||
});
|
||||
assert.ok(label.startsWith('@user:example.org, '));
|
||||
assert.ok(!label.includes('<'));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
import { TimestampPrefs, formatTimestamp } from './formatTimestamp';
|
||||
|
||||
/**
|
||||
* Builds a plain-text accessible label for a message row, used when the
|
||||
@@ -7,8 +7,8 @@ import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
*
|
||||
* @param sender - Sender display name (already resolved to a human string).
|
||||
* @param ts - Message origin timestamp in milliseconds.
|
||||
* @param hour24Clock - Whether to format the time using a 24-hour clock.
|
||||
* @returns A label such as `Alice, 1 July 2026 14:30`.
|
||||
* @param prefs - The user's clock/date preferences.
|
||||
* @returns A label such as `Alice, 1 Jul 2026 14:30`.
|
||||
*/
|
||||
export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string =>
|
||||
`${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;
|
||||
export const messageAriaLabel = (sender: string, ts: number, prefs: TimestampPrefs): string =>
|
||||
`${sender}, ${formatTimestamp(ts, prefs, 'dateTime')}`;
|
||||
|
||||
@@ -55,16 +55,22 @@ test('formatFriendlyDateTime: uses Today/Tomorrow/date prefixes', () => {
|
||||
const tomorrow = new Date(2026, 0, 6, 9, 0).getTime();
|
||||
const nextWeek = new Date(2026, 0, 12, 9, 0).getTime();
|
||||
|
||||
assert.ok(formatFriendlyDateTime(laterToday, now).startsWith('Today at '));
|
||||
assert.ok(formatFriendlyDateTime(tomorrow, now).startsWith('Tomorrow at '));
|
||||
const other = formatFriendlyDateTime(nextWeek, now);
|
||||
assert.ok(!other.startsWith('Today'));
|
||||
assert.ok(!other.startsWith('Tomorrow'));
|
||||
assert.ok(other.includes(' at '));
|
||||
const prefs = { hour24Clock: true, dateFormatString: 'D MMM YYYY' };
|
||||
assert.equal(formatFriendlyDateTime(laterToday, prefs, now), 'Today at 15:30');
|
||||
assert.equal(formatFriendlyDateTime(tomorrow, prefs, now), 'Tomorrow at 09:00');
|
||||
assert.equal(formatFriendlyDateTime(nextWeek, prefs, now), '12 Jan 2026 at 09:00');
|
||||
assert.equal(
|
||||
formatFriendlyDateTime(nextWeek, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }, now),
|
||||
'01/12/2026 at 09:00 AM',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatFriendlyDateTime: Tomorrow rolls over month/year boundaries', () => {
|
||||
const nye = new Date(2026, 11, 31, 23, 0).getTime();
|
||||
const jan1 = new Date(2027, 0, 1, 9, 0).getTime();
|
||||
assert.ok(formatFriendlyDateTime(jan1, nye).startsWith('Tomorrow at '));
|
||||
assert.ok(
|
||||
formatFriendlyDateTime(jan1, { hour24Clock: true, dateFormatString: '' }, nye).startsWith(
|
||||
'Tomorrow at ',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { color as foldsColor, config as foldsConfig } from 'folds';
|
||||
import { TimestampPrefs, dayWord, formatDate, formatTime } from './formatTimestamp';
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
@@ -23,21 +24,17 @@ export function parseLocalDateTime(dateValue: string, timeValue: string): Date |
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
// Human-friendly absolute time: "Today at 3:00 PM", "Tomorrow at 9:00 AM", or
|
||||
// "1/5/2026 at 3:00 PM". `now` is injectable so the relative-day logic is testable.
|
||||
export function formatFriendlyDateTime(ts: number, now: number = Date.now()): string {
|
||||
const date = new Date(ts);
|
||||
const nowDate = new Date(now);
|
||||
const sameDay = (a: Date, b: Date): boolean =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
const tomorrow = new Date(nowDate);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (sameDay(date, nowDate)) return `Today at ${timeStr}`;
|
||||
if (sameDay(date, tomorrow)) return `Tomorrow at ${timeStr}`;
|
||||
return `${date.toLocaleDateString()} at ${timeStr}`;
|
||||
// Human-friendly absolute time for scheduled things: "Today at 03:00 PM",
|
||||
// "Tomorrow at 09:00 AM", "Mon at 09:00", or "12 Jan 2026 at 09:00" — the
|
||||
// shared day-word rules and the user's clock/date preferences (#139). `now` is
|
||||
// injectable so the relative-day logic is testable.
|
||||
export function formatFriendlyDateTime(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const day = dayWord(ts, now) ?? formatDate(ts, prefs);
|
||||
return `${day} at ${formatTime(ts, prefs)}`;
|
||||
}
|
||||
|
||||
// Shared style for date/time <input>s — matches the app's surface tokens and
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatDayDivider,
|
||||
formatRelativeAge,
|
||||
formatShortAge,
|
||||
formatTimestamp,
|
||||
} from './formatTimestamp';
|
||||
|
||||
// Fri 18 Sep 2026 14:05 local.
|
||||
const now = new Date(2026, 8, 18, 14, 5).getTime();
|
||||
const at = (y: number, m: number, d: number, h = 21, min = 14) =>
|
||||
new Date(y, m - 1, d, h, min).getTime();
|
||||
const p12 = { hour24Clock: false, dateFormatString: 'D MMM YYYY' };
|
||||
const p24 = { hour24Clock: true, dateFormatString: 'YYYY-MM-DD' };
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
it('today → time only', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18, 9, 3), p12, 'auto', now), '09:03 AM');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18, 9, 3), p24, 'auto', now), '09:03');
|
||||
});
|
||||
|
||||
it('yesterday / tomorrow → day word + time', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 17), p24, 'auto', now), 'Yesterday 21:14');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 19), p24, 'auto', now), 'Tomorrow 21:14');
|
||||
// Just before midnight yesterday is still yesterday, not "hours ago".
|
||||
assert.equal(formatTimestamp(at(2026, 9, 17, 23, 59), p24, 'auto', now), 'Yesterday 23:59');
|
||||
});
|
||||
|
||||
it('within the last week → weekday + time', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 14), p24, 'auto', now), 'Mon 21:14');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 12), p24, 'auto', now), 'Sat 21:14');
|
||||
});
|
||||
|
||||
it('a week or more → date + time in the user format', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 11), p24, 'auto', now), '2026-09-11 21:14');
|
||||
assert.equal(formatTimestamp(at(2025, 1, 2), p12, 'auto', now), '2 Jan 2025 09:14 PM');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 25), p12, 'auto', now), '25 Sep 2026 09:14 PM');
|
||||
});
|
||||
|
||||
it('autoDate never shows the time', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'autoDate', now), 'Today');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 17), p12, 'autoDate', now), 'Yesterday');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 15), p12, 'autoDate', now), 'Tue');
|
||||
assert.equal(formatTimestamp(at(2026, 1, 15), p12, 'autoDate', now), '15 Jan 2026');
|
||||
});
|
||||
|
||||
it('fixed styles ignore the relative day', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'time', now), '09:14 PM');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'date', now), '18 Sep 2026');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p24, 'dateTime', now), '2026-09-18 21:14');
|
||||
});
|
||||
|
||||
it('falls back to a default date format when the preference is empty', () => {
|
||||
assert.equal(
|
||||
formatTimestamp(at(2026, 1, 15), { hour24Clock: true, dateFormatString: '' }, 'date', now),
|
||||
'15 Jan 2026',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortAge', () => {
|
||||
it('minutes and hours today', () => {
|
||||
assert.equal(formatShortAge(now - 20_000, p12, now), 'now');
|
||||
assert.equal(formatShortAge(now - 5 * 60_000, p12, now), '5m');
|
||||
assert.equal(formatShortAge(now - 3 * 3_600_000, p12, now), '3h');
|
||||
});
|
||||
|
||||
it('day words, then a short date (year dropped when current)', () => {
|
||||
assert.equal(formatShortAge(at(2026, 9, 17, 23, 0), p12, now), 'Yesterday');
|
||||
assert.equal(formatShortAge(at(2026, 9, 14), p12, now), 'Mon');
|
||||
assert.equal(formatShortAge(at(2026, 3, 2), p12, now), '2 Mar');
|
||||
assert.equal(formatShortAge(at(2026, 3, 2), p24, now), '03-02');
|
||||
assert.equal(formatShortAge(at(2025, 3, 2), p12, now), '2 Mar 2025');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeAge', () => {
|
||||
it('steps through minutes, hours, days, then the date', () => {
|
||||
assert.equal(formatRelativeAge(now - 10_000, p12, now), 'just now');
|
||||
assert.equal(formatRelativeAge(now - 7 * 60_000, p12, now), '7m ago');
|
||||
assert.equal(formatRelativeAge(now - 5 * 3_600_000, p12, now), '5h ago');
|
||||
assert.equal(formatRelativeAge(at(2026, 9, 17, 8), p12, now), 'yesterday');
|
||||
assert.equal(formatRelativeAge(at(2026, 9, 15, 8), p12, now), '3d ago');
|
||||
assert.equal(formatRelativeAge(at(2026, 9, 1, 8), p24, now), '2026-09-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDayDivider', () => {
|
||||
it('uses the full weekday inside the last week', () => {
|
||||
assert.equal(formatDayDivider(at(2026, 9, 18), p12, now), 'Today');
|
||||
assert.equal(formatDayDivider(at(2026, 9, 17), p12, now), 'Yesterday');
|
||||
assert.equal(formatDayDivider(at(2026, 9, 14), p12, now), 'Monday');
|
||||
assert.equal(formatDayDivider(at(2026, 9, 11), p12, now), '11 Sep 2026');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* [Gitea #139] One place that turns a timestamp into words, honouring the
|
||||
* user's 12/24 h clock and date-format preferences everywhere.
|
||||
*
|
||||
* Rules for the `auto` styles, relative to `now`:
|
||||
* today → "21:14"
|
||||
* yesterday → "Yesterday 21:14"
|
||||
* tomorrow → "Tomorrow 21:14" (scheduled sends, reminders)
|
||||
* last 6 days → "Mon 21:14"
|
||||
* older / further → "5 Sep 2026 21:14" (date per `dateFormatString`)
|
||||
*/
|
||||
export type TimestampPrefs = {
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
};
|
||||
|
||||
export type TimestampStyle =
|
||||
/** Time for today, day word + time for nearby days, date + time otherwise. */
|
||||
| 'auto'
|
||||
/** Like `auto` but never the time: "Today", "Yesterday", "Mon", "5 Sep 2026". */
|
||||
| 'autoDate'
|
||||
/** Always the clock time. */
|
||||
| 'time'
|
||||
/** Always the date per `dateFormatString`. */
|
||||
| 'date'
|
||||
/** Always date + time. */
|
||||
| 'dateTime';
|
||||
|
||||
const DEFAULT_DATE_FORMAT = 'D MMM YYYY';
|
||||
|
||||
const dateFormat = (prefs: TimestampPrefs) => prefs.dateFormatString || DEFAULT_DATE_FORMAT;
|
||||
|
||||
export const formatTime = (ts: number, prefs: Pick<TimestampPrefs, 'hour24Clock'>): string =>
|
||||
dayjs(ts).format(prefs.hour24Clock ? 'HH:mm' : 'hh:mm A');
|
||||
|
||||
export const formatDate = (ts: number, prefs: TimestampPrefs): string =>
|
||||
dayjs(ts).format(dateFormat(prefs));
|
||||
|
||||
/** Whole days between the start of `ts`'s day and the start of `now`'s day (negative = future). */
|
||||
export const dayDistance = (ts: number, now: number): number =>
|
||||
dayjs(now).startOf('day').diff(dayjs(ts).startOf('day'), 'day');
|
||||
|
||||
/** "Today" / "Yesterday" / "Tomorrow" / "Mon" for nearby days, else undefined. */
|
||||
export const dayWord = (ts: number, now: number): string | undefined => {
|
||||
const d = dayDistance(ts, now);
|
||||
if (d === 0) return 'Today';
|
||||
if (d === 1) return 'Yesterday';
|
||||
if (d === -1) return 'Tomorrow';
|
||||
if (d > 1 && d < 7) return dayjs(ts).format('ddd');
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function formatTimestamp(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
style: TimestampStyle = 'auto',
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const time = formatTime(ts, prefs);
|
||||
if (style === 'time') return time;
|
||||
const date = formatDate(ts, prefs);
|
||||
if (style === 'date') return date;
|
||||
if (style === 'dateTime') return `${date} ${time}`;
|
||||
|
||||
const word = dayWord(ts, now);
|
||||
if (style === 'autoDate') return word ?? date;
|
||||
if (word === 'Today') return time;
|
||||
return `${word ?? date} ${time}`;
|
||||
}
|
||||
|
||||
/** Short relative age for dense lists: "now", "5m", "3h", "Yesterday", "Mon", "5 Sep". */
|
||||
export function formatShortAge(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const diffMin = Math.floor((now - ts) / 60_000);
|
||||
if (diffMin < 60) return diffMin < 1 ? 'now' : `${diffMin}m`;
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
if (diffHours < 24 && dayDistance(ts, now) === 0) return `${diffHours}h`;
|
||||
const word = dayWord(ts, now);
|
||||
if (word && word !== 'Today') return word;
|
||||
// Same year: drop the year from the user's format for brevity.
|
||||
const sameYear = dayjs(ts).year() === dayjs(now).year();
|
||||
const fmt = dateFormat(prefs);
|
||||
return dayjs(ts).format(sameYear ? fmt.replace(/[-/.\s]*Y{2,4}[-/.\s]*/, ' ').trim() : fmt);
|
||||
}
|
||||
|
||||
/** Conversational age for list rows: "just now", "5m ago", "3h ago", "yesterday", "3d ago", then the date. */
|
||||
export function formatRelativeAge(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const diffMin = Math.floor((now - ts) / 60_000);
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const days = dayDistance(ts, now);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return formatDate(ts, prefs);
|
||||
}
|
||||
|
||||
/** Timeline day divider: "Today", "Yesterday", "Monday" (last 6 days), else the date. */
|
||||
export function formatDayDivider(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const d = dayDistance(ts, now);
|
||||
if (d > 1 && d < 7) return dayjs(ts).format('dddd');
|
||||
return formatTimestamp(ts, prefs, 'autoDate', now);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GroupCandidate, findMediaRun, planMediaGroups, splitRun } from './mediaGroups';
|
||||
|
||||
const media = (
|
||||
sender: string,
|
||||
ts: number,
|
||||
extra: Partial<GroupCandidate> = {},
|
||||
): GroupCandidate => ({
|
||||
sender,
|
||||
ts,
|
||||
kind: 'media',
|
||||
hasRelation: false,
|
||||
redacted: false,
|
||||
mustEnd: false,
|
||||
...extra,
|
||||
});
|
||||
const text = (sender: string, ts: number): GroupCandidate => ({
|
||||
...media(sender, ts),
|
||||
kind: 'other',
|
||||
});
|
||||
const skip = (ts: number): GroupCandidate => ({ ...media('x', ts), kind: 'skip' });
|
||||
const seq = (items: GroupCandidate[]) => (i: number) => items[i];
|
||||
|
||||
describe('findMediaRun', () => {
|
||||
it('collects contiguous same-sender media within the gap, from any member', () => {
|
||||
const at = seq([
|
||||
text('a', 0),
|
||||
media('a', 1000),
|
||||
media('a', 2000),
|
||||
media('a', 3000),
|
||||
text('a', 4000),
|
||||
]);
|
||||
assert.deepEqual(findMediaRun(at, 1), [1, 2, 3]);
|
||||
assert.deepEqual(findMediaRun(at, 2), [1, 2, 3]);
|
||||
assert.deepEqual(findMediaRun(at, 3), [1, 2, 3]);
|
||||
});
|
||||
|
||||
it('breaks on a different sender, text in between, or a long gap', () => {
|
||||
const at = seq([
|
||||
media('a', 0),
|
||||
media('b', 1000),
|
||||
media('a', 2000),
|
||||
text('a', 2500),
|
||||
media('a', 3000),
|
||||
media('a', 70_000),
|
||||
]);
|
||||
assert.deepEqual(findMediaRun(at, 0), [0]);
|
||||
assert.deepEqual(findMediaRun(at, 2), [2]);
|
||||
assert.deepEqual(findMediaRun(at, 4), [4]);
|
||||
assert.deepEqual(findMediaRun(at, 5), [5]);
|
||||
});
|
||||
|
||||
it('skips invisible filler such as reactions and edits', () => {
|
||||
const at = seq([media('a', 0), skip(100), skip(200), media('a', 1000)]);
|
||||
assert.deepEqual(findMediaRun(at, 0), [0, 3]);
|
||||
});
|
||||
|
||||
it('never groups replies, thread messages, edits or redacted events', () => {
|
||||
const at = seq([
|
||||
media('a', 0),
|
||||
media('a', 500, { hasRelation: true }),
|
||||
media('a', 1000),
|
||||
media('a', 1500, { redacted: true }),
|
||||
]);
|
||||
assert.deepEqual(findMediaRun(at, 0), [0]);
|
||||
assert.deepEqual(findMediaRun(at, 1), []);
|
||||
assert.deepEqual(findMediaRun(at, 2), [2]);
|
||||
});
|
||||
|
||||
it('measures the gap between consecutive members, not from the first', () => {
|
||||
const at = seq([media('a', 0), media('a', 50_000), media('a', 100_000)]);
|
||||
assert.deepEqual(findMediaRun(at, 0), [0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitRun', () => {
|
||||
it('caps group size and drops singles', () => {
|
||||
const items = Array.from({ length: 12 }, (_, i) => media('a', i * 1000));
|
||||
const groups = splitRun(
|
||||
Array.from({ length: 12 }, (_, i) => i),
|
||||
seq(items),
|
||||
10,
|
||||
);
|
||||
assert.deepEqual(groups, [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
[10, 11],
|
||||
]);
|
||||
assert.deepEqual(splitRun([0, 1, 2, 3], seq(items), 3), [[0, 1, 2]]);
|
||||
});
|
||||
|
||||
it('closes a group at a member with reactions so they stay visible', () => {
|
||||
const items = [
|
||||
media('a', 0),
|
||||
media('a', 1000, { mustEnd: true }),
|
||||
media('a', 2000),
|
||||
media('a', 3000),
|
||||
];
|
||||
assert.deepEqual(splitRun([0, 1, 2, 3], seq(items)), [
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planMediaGroups', () => {
|
||||
it('marks only the last member as the renderer', () => {
|
||||
const at = seq([media('a', 0), media('a', 1000), media('a', 2000)]);
|
||||
const plan = planMediaGroups(at, 1);
|
||||
assert.equal(plan.get(0)?.renders, false);
|
||||
assert.equal(plan.get(1)?.renders, false);
|
||||
assert.equal(plan.get(2)?.renders, true);
|
||||
assert.deepEqual(plan.get(2)?.members, [0, 1, 2]);
|
||||
});
|
||||
|
||||
it('returns an empty plan for a lone image', () => {
|
||||
assert.equal(planMediaGroups(seq([text('a', 0), media('a', 1000)]), 1).size, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* [Gitea #137] Client-side "gallery" grouping. Consecutive image/video events
|
||||
* from one sender, close together in time and with nothing else in between,
|
||||
* render as one grid. Nothing changes on the wire: every file is still its
|
||||
* own standard event, so other clients see N ordinary images.
|
||||
*/
|
||||
|
||||
export type GroupCandidate = {
|
||||
sender: string;
|
||||
ts: number;
|
||||
/** `media` can join; `skip` is invisible filler (reactions, edits…); anything else breaks. */
|
||||
kind: 'media' | 'skip' | 'other';
|
||||
/** Reply / thread / edit relation on the event itself — never grouped. */
|
||||
hasRelation: boolean;
|
||||
redacted: boolean;
|
||||
/** Reactions or a thread hang off this event: it may only be a group's last member. */
|
||||
mustEnd: boolean;
|
||||
};
|
||||
|
||||
export const MEDIA_GROUP_MAX_GAP_MS = 60_000;
|
||||
export const MEDIA_GROUP_CAP = 10;
|
||||
|
||||
const joinable = (c: GroupCandidate | undefined): c is GroupCandidate =>
|
||||
!!c && c.kind === 'media' && !c.hasRelation && !c.redacted;
|
||||
|
||||
/**
|
||||
* The maximal run of groupable media around index `i` (inclusive), as ordered
|
||||
* indices. `at` returns the candidate at an absolute index or undefined past
|
||||
* either end. Returns just `[i]` (or `[]` if `i` itself can't group) when
|
||||
* there is nothing to group with.
|
||||
*/
|
||||
export function findMediaRun(
|
||||
at: (index: number) => GroupCandidate | undefined,
|
||||
i: number,
|
||||
maxGapMs: number = MEDIA_GROUP_MAX_GAP_MS,
|
||||
): number[] {
|
||||
const me = at(i);
|
||||
if (!joinable(me)) return [];
|
||||
|
||||
const extend = (dir: 1 | -1): number[] => {
|
||||
const out: number[] = [];
|
||||
let last = me;
|
||||
let j = i + dir;
|
||||
for (;;) {
|
||||
const c = at(j);
|
||||
if (!c) break;
|
||||
if (c.kind === 'skip') {
|
||||
j += dir;
|
||||
continue;
|
||||
}
|
||||
if (!joinable(c) || c.sender !== me.sender) break;
|
||||
if (Math.abs(c.ts - last.ts) > maxGapMs) break;
|
||||
out.push(j);
|
||||
last = c;
|
||||
j += dir;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return [...extend(-1).reverse(), i, ...extend(1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut a run into groups: at most `cap` members each, and a member that has
|
||||
* reactions/threads (`mustEnd`) closes its group so those stay visible under
|
||||
* the rendered (last) event. Runs of one are dropped — they render normally.
|
||||
*/
|
||||
export function splitRun(
|
||||
run: number[],
|
||||
at: (index: number) => GroupCandidate | undefined,
|
||||
cap: number = MEDIA_GROUP_CAP,
|
||||
): number[][] {
|
||||
const groups: number[][] = [];
|
||||
let current: number[] = [];
|
||||
run.forEach((index) => {
|
||||
current.push(index);
|
||||
if (at(index)?.mustEnd || current.length >= cap) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
}
|
||||
});
|
||||
if (current.length) groups.push(current);
|
||||
return groups.filter((g) => g.length >= 2);
|
||||
}
|
||||
|
||||
export type GroupPlan = {
|
||||
/** Ordered member indices; the last one renders the grid. */
|
||||
members: number[];
|
||||
/** Whether `index` is the member that renders. */
|
||||
renders: boolean;
|
||||
};
|
||||
|
||||
/** Plan every group in the run containing `i`, keyed by member index. */
|
||||
export function planMediaGroups(
|
||||
at: (index: number) => GroupCandidate | undefined,
|
||||
i: number,
|
||||
opts: { maxGapMs?: number; cap?: number } = {},
|
||||
): Map<number, GroupPlan> {
|
||||
const plans = new Map<number, GroupPlan>();
|
||||
const run = findMediaRun(at, i, opts.maxGapMs);
|
||||
splitRun(run, at, opts.cap).forEach((members) => {
|
||||
const last = members[members.length - 1];
|
||||
members.forEach((index) => plans.set(index, { members, renders: index === last }));
|
||||
});
|
||||
return plans;
|
||||
}
|
||||
Reference in New Issue
Block a user