feat: bookmarks, message scheduling, image compression, room insights
CI / Build & Quality Checks (push) Failing after 5m48s
CI / Build & Quality Checks (push) Failing after 5m48s
P3-1: Message Bookmarks — right-click any message to bookmark; saved to io.lotus.bookmarks account data (max 500, syncs across devices); star icon in sidebar opens BookmarksPanel with filter, Jump-to-message, and remove buttons; reactive to AccountData events P3-2: Message Scheduling (MSC4140) — clock button next to send opens ScheduleMessageModal with datetime-local picker; validates ≥1 min future; calls PUT org.matrix.msc4140 delayed event API; collapsible ScheduledMessagesTray above composer lists pending messages with cancel; local Jotai atom tracks scheduled messages per room P3-3: File Upload Compression — opt-in checkbox per JPEG/PNG file ≥200KB in upload preview; canvas API compresses at 0.82 quality; shows before/ after size estimate; compressed blob used in upload when checked P3-7: Room Insights — new Insights tab in room settings; top 5 active members (bar chart), top 5 reactions (chips), media breakdown (4 tiles), 24-hour activity heatmap (CSS bar chart); all from local cache only with disclaimer banner; never the first tab shown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react';
|
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '../../state/room/roomInputDrafts';
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
|
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
|
||||||
|
|
||||||
type PreviewImageProps = {
|
type PreviewImageProps = {
|
||||||
fileItem: TUploadItem;
|
fileItem: TUploadItem;
|
||||||
@@ -97,6 +98,105 @@ function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CompressionCheckboxProps = {
|
||||||
|
fileItem: TUploadItem;
|
||||||
|
metadata: TUploadMetadata;
|
||||||
|
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
||||||
|
};
|
||||||
|
function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionCheckboxProps) {
|
||||||
|
const originalFile = fileItem.originalFile as File;
|
||||||
|
const [compressing, setCompressing] = useState(false);
|
||||||
|
const compressPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
if (!isCompressible(originalFile)) return null;
|
||||||
|
|
||||||
|
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
if (!checked) {
|
||||||
|
setMetadata(fileItem, { ...metadata, compressImage: false, compressionResult: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistically mark as enabled; kick off compression in background
|
||||||
|
setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: undefined });
|
||||||
|
setCompressing(true);
|
||||||
|
|
||||||
|
const p = compressImage(originalFile).then((result) => {
|
||||||
|
setCompressing(false);
|
||||||
|
setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: result });
|
||||||
|
});
|
||||||
|
compressPromiseRef.current = p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checked = !!metadata.compressImage;
|
||||||
|
const result = metadata.compressionResult;
|
||||||
|
|
||||||
|
const savingPct =
|
||||||
|
result && result.originalSize > 0
|
||||||
|
? Math.round(((result.originalSize - result.compressedSize) / result.originalSize) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
marginTop: '4px',
|
||||||
|
padding: '6px 8px',
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid var(--bg-surface-border)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<input
|
||||||
|
id={`compress-${originalFile.name}-${originalFile.size}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{ cursor: 'pointer', accentColor: 'var(--bg-secondary)' }}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`compress-${originalFile.name}-${originalFile.size}`}
|
||||||
|
style={{ cursor: 'pointer', color: 'var(--text-primary)', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
Compress before uploading
|
||||||
|
</label>
|
||||||
|
{compressing && (
|
||||||
|
<Text size="T200" style={{ color: 'var(--text-secondary)', marginLeft: '4px' }}>
|
||||||
|
estimating…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{checked && !compressing && result !== undefined && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
color: result
|
||||||
|
? savingPct !== null && savingPct > 0
|
||||||
|
? 'var(--tc-success-normal, #2e7d32)'
|
||||||
|
: 'var(--text-secondary)'
|
||||||
|
: 'var(--tc-danger-normal)',
|
||||||
|
paddingLeft: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result
|
||||||
|
? savingPct !== null && savingPct > 0
|
||||||
|
? `→ ~${formatFileSize(result.compressedSize)} (${savingPct}% smaller)`
|
||||||
|
: `→ ${formatFileSize(result.compressedSize)} (no significant saving)`
|
||||||
|
: 'Compression not available for this file'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{checked && !compressing && result === undefined && (
|
||||||
|
<Text size="T200" style={{ color: 'var(--text-secondary)', paddingLeft: '20px' }}>
|
||||||
|
Original: {formatFileSize(originalFile.size)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type UploadCardRendererProps = {
|
type UploadCardRendererProps = {
|
||||||
isEncrypted?: boolean;
|
isEncrypted?: boolean;
|
||||||
fileItem: TUploadItem;
|
fileItem: TUploadItem;
|
||||||
@@ -204,6 +304,11 @@ export function UploadCardRenderer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CompressionCheckbox
|
||||||
|
fileItem={fileItem}
|
||||||
|
metadata={metadata}
|
||||||
|
setMetadata={setMetadata}
|
||||||
|
/>
|
||||||
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import React, { ChangeEvent, useState } from 'react';
|
||||||
|
import { Box, Button, Header, Icon, IconButton, Icons, Input, Scroll, Text, config } from 'folds';
|
||||||
|
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
|
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`;
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 5) return `${weeks}w ago`;
|
||||||
|
return new Date(ts).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookmarkItemProps = {
|
||||||
|
bookmark: Bookmark;
|
||||||
|
onJump: (roomId: string, eventId: string) => void;
|
||||||
|
onRemove: (eventId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = mx.getRoom(bookmark.roomId);
|
||||||
|
const displayRoomName = room?.name ?? bookmark.roomName;
|
||||||
|
|
||||||
|
const handleJump = () => {
|
||||||
|
onJump(bookmark.roomId, bookmark.eventId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
onRemove(bookmark.eventId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
borderBottomStyle: 'solid',
|
||||||
|
borderBottomColor: 'var(--bg-surface-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="L400" truncate>
|
||||||
|
{displayRoomName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="T300"
|
||||||
|
priority="300"
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bookmark.previewText || '(no preview)'}
|
||||||
|
</Text>
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Saved {formatTimeAgo(bookmark.savedAt)}
|
||||||
|
</Text>
|
||||||
|
<Box gap="100" shrink="No">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleJump}
|
||||||
|
before={<Icon size="100" src={Icons.ArrowRight} />}
|
||||||
|
>
|
||||||
|
<Text size="T300">Jump</Text>
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleRemove}
|
||||||
|
aria-label="Remove bookmark"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookmarksPanelProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||||
|
const { bookmarks, removeBookmark } = useBookmarks();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
const handleJump = (roomId: string, eventId: string) => {
|
||||||
|
navigateRoom(roomId, eventId);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilter(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered: Bookmark[] =
|
||||||
|
filter.trim().length === 0
|
||||||
|
? bookmarks
|
||||||
|
: bookmarks.filter((bk) => {
|
||||||
|
const q = filter.toLowerCase();
|
||||||
|
return bk.previewText.toLowerCase().includes(q) || bk.roomName.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
width: '266px',
|
||||||
|
height: '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
borderLeftWidth: config.borderWidth.B300,
|
||||||
|
borderLeftStyle: 'solid',
|
||||||
|
borderLeftColor: 'var(--bg-surface-border)',
|
||||||
|
backgroundColor: 'var(--bg-background)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Background"
|
||||||
|
size="600"
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Icon src={Icons.Star} size="200" />
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H5">Saved Messages</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton variant="Background" aria-label="Close saved messages" onClick={onClose}>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
borderBottomStyle: 'solid',
|
||||||
|
borderBottomColor: 'var(--bg-surface-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
placeholder="Filter saved messages..."
|
||||||
|
value={filter}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
before={<Icon size="200" src={Icons.Search} />}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll variant="Background" size="300" style={{ flexGrow: 1, minHeight: 0 }}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: config.space.S500 }}
|
||||||
|
>
|
||||||
|
<Icon size="600" src={Icons.Star} />
|
||||||
|
<Text size="T300" priority="300" align="Center">
|
||||||
|
{bookmarks.length === 0
|
||||||
|
? 'No saved messages yet. Right-click any message to bookmark it.'
|
||||||
|
: 'No bookmarks match your filter.'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box direction="Column">
|
||||||
|
{filtered.map((bk) => (
|
||||||
|
<BookmarkItem
|
||||||
|
key={bk.eventId}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Avatar, Box, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
|
||||||
|
import { EventType } from 'matrix-js-sdk';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section header ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionHeader({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
size="L400"
|
||||||
|
style={{
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stat tile ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatTile({ emoji, count, label }: { emoji: string; count: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 64,
|
||||||
|
padding: `${config.space.S300} ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H4">{emoji}</Text>
|
||||||
|
<Text size="H4" style={{ fontWeight: 700 }}>
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" align="Center">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type RoomInsightsProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const events = room.getLiveTimeline().getEvents();
|
||||||
|
|
||||||
|
// ── A. Message count by member ──────────────────────────────────────────
|
||||||
|
const msgCounts = new Map<string, number>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.getType() === EventType.RoomMessage && !ev.isDecryptionFailure()) {
|
||||||
|
const sender = ev.getSender();
|
||||||
|
if (sender) msgCounts.set(sender, (msgCounts.get(sender) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const top5 = [...msgCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||||
|
|
||||||
|
// ── B. Top 5 reactions ──────────────────────────────────────────────────
|
||||||
|
const reactionCounts = new Map<string, number>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.getType() === EventType.Reaction) {
|
||||||
|
const key = ev.getContent()['m.relates_to']?.key as string | undefined;
|
||||||
|
if (key) reactionCounts.set(key, (reactionCounts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const top5Reactions = [...reactionCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||||
|
|
||||||
|
// ── C. Media breakdown ──────────────────────────────────────────────────
|
||||||
|
const mediaCounts = { image: 0, video: 0, audio: 0, file: 0 };
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
|
const msgtype = ev.getContent().msgtype as string | undefined;
|
||||||
|
if (msgtype === 'm.image') mediaCounts.image++;
|
||||||
|
else if (msgtype === 'm.video') mediaCounts.video++;
|
||||||
|
else if (msgtype === 'm.audio') mediaCounts.audio++;
|
||||||
|
else if (msgtype === 'm.file') mediaCounts.file++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── D. Activity heatmap — messages per hour ─────────────────────────────
|
||||||
|
const hourBuckets = new Array<number>(24).fill(0);
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.getType() === EventType.RoomMessage) {
|
||||||
|
hourBuckets[new Date(ev.getTs()).getHours()]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── E. Summary stats ────────────────────────────────────────────────────
|
||||||
|
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
|
||||||
|
const uniqueParticipants = msgCounts.size;
|
||||||
|
|
||||||
|
const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage);
|
||||||
|
const allTs = msgEvents.map((ev) => ev.getTs());
|
||||||
|
const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null;
|
||||||
|
const newestTs = allTs.length > 0 ? Math.max(...allTs) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
top5,
|
||||||
|
top5Reactions,
|
||||||
|
mediaCounts,
|
||||||
|
hourBuckets,
|
||||||
|
totalMessages,
|
||||||
|
uniqueParticipants,
|
||||||
|
oldestTs,
|
||||||
|
newestTs,
|
||||||
|
totalCached: events.length,
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
|
||||||
|
const maxHour = Math.max(...stats.hourBuckets, 1);
|
||||||
|
const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Icon src={Icons.Info} size="200" />
|
||||||
|
<Text as="h2" size="H3" truncate>
|
||||||
|
Insights
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="500">
|
||||||
|
{/* ── Disclaimer banner ── */}
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${color.Warning.Main}`,
|
||||||
|
background: color.Warning.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Warning} size="200" />
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="T300" style={{ color: color.Warning.OnContainer }}>
|
||||||
|
<strong>
|
||||||
|
Based on {stats.totalMessages} locally cached message
|
||||||
|
{stats.totalMessages !== 1 ? 's' : ''}
|
||||||
|
</strong>
|
||||||
|
</Text>
|
||||||
|
{stats.oldestTs !== null && stats.newestTs !== null && (
|
||||||
|
<Text size="T200" style={{ color: color.Warning.OnContainer, opacity: 0.8 }}>
|
||||||
|
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Summary row ── */}
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<SectionHeader label="Summary" />
|
||||||
|
<Box gap="200" wrap="Wrap">
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 80,
|
||||||
|
padding: `${config.space.S300} ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H4" style={{ fontWeight: 700 }}>
|
||||||
|
{stats.totalMessages}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" align="Center">
|
||||||
|
Messages
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 80,
|
||||||
|
padding: `${config.space.S300} ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H4" style={{ fontWeight: 700 }}>
|
||||||
|
{stats.uniqueParticipants}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" align="Center">
|
||||||
|
Participants
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 80,
|
||||||
|
padding: `${config.space.S300} ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H4" style={{ fontWeight: 700 }}>
|
||||||
|
{stats.totalCached}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" align="Center">
|
||||||
|
Cached events
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Media shared ── */}
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<SectionHeader label="Media Shared" />
|
||||||
|
<Box gap="200" wrap="Wrap">
|
||||||
|
<StatTile emoji="🖼️" count={stats.mediaCounts.image} label="Images" />
|
||||||
|
<StatTile emoji="🎬" count={stats.mediaCounts.video} label="Videos" />
|
||||||
|
<StatTile emoji="🎵" count={stats.mediaCounts.audio} label="Audio" />
|
||||||
|
<StatTile emoji="📎" count={stats.mediaCounts.file} label="Files" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Most active members ── */}
|
||||||
|
{stats.top5.length > 0 && (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<SectionHeader label="Most Active Members" />
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
{stats.top5.map(([userId, count], index) => {
|
||||||
|
const displayName =
|
||||||
|
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
|
const avatarUrl = avatarMxc
|
||||||
|
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ??
|
||||||
|
undefined)
|
||||||
|
: undefined;
|
||||||
|
const barWidth = `${Math.max(4, (count / maxMsgCount) * 100)}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={userId} alignItems="Center" gap="200">
|
||||||
|
{/* Rank */}
|
||||||
|
<Text
|
||||||
|
size="T300"
|
||||||
|
priority="300"
|
||||||
|
style={{ width: 16, flexShrink: 0, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<Box shrink="No">
|
||||||
|
<Avatar size="200" radii="300">
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={displayName}
|
||||||
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Name + bar */}
|
||||||
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Text size="T300" truncate style={{ lineHeight: 1 }}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
width: barWidth,
|
||||||
|
background: color.Primary.Main,
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Top reactions ── */}
|
||||||
|
{stats.top5Reactions.length > 0 && (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<SectionHeader label="Top Reactions" />
|
||||||
|
<Box gap="200" wrap="Wrap">
|
||||||
|
{stats.top5Reactions.map(([emoji, count]) => (
|
||||||
|
<Box
|
||||||
|
key={emoji}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H5">{emoji}</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Activity by hour ── */}
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<SectionHeader label="Activity by Hour" />
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Bars */}
|
||||||
|
<Box
|
||||||
|
alignItems="End"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.hourBuckets.map((count, h) => (
|
||||||
|
<Box
|
||||||
|
key={h}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
title={`${h}:00 — ${count} message${count !== 1 ? 's' : ''}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: `${Math.max(2, (count / maxHour) * 48)}px`,
|
||||||
|
background:
|
||||||
|
count > 0 && count === maxHour
|
||||||
|
? color.Primary.Main
|
||||||
|
: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: '2px 2px 0 0',
|
||||||
|
transition: 'height 0.2s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hour labels: show 0, 6, 12, 18 */}
|
||||||
|
<Box style={{ gap: 0 }}>
|
||||||
|
{stats.hourBuckets.map((_, h) => (
|
||||||
|
<Box key={h} justifyContent="Center" style={{ flex: 1 }}>
|
||||||
|
{h % 6 === 0 ? (
|
||||||
|
<Text size="T200" priority="300" align="Center" style={{ fontSize: 9 }}>
|
||||||
|
{h}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Hour of day (local time, 0 = midnight)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bottom padding */}
|
||||||
|
<Box style={{ height: config.space.S200 }} />
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { DeveloperTools } from '../common-settings/developer-tools';
|
|||||||
import { ExportRoomHistory } from './ExportRoomHistory';
|
import { ExportRoomHistory } from './ExportRoomHistory';
|
||||||
import { RoomActivityLog } from './RoomActivityLog';
|
import { RoomActivityLog } from './RoomActivityLog';
|
||||||
import { RoomServerACL } from './RoomServerACL';
|
import { RoomServerACL } from './RoomServerACL';
|
||||||
|
import { RoomInsights } from './RoomInsights';
|
||||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
@@ -66,6 +67,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
|
|||||||
name: 'Activity',
|
name: 'Activity',
|
||||||
icon: Icons.RecentClock,
|
icon: Icons.RecentClock,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: RoomSettingsPage.InsightsPage,
|
||||||
|
name: 'Insights',
|
||||||
|
icon: Icons.Info,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
|
const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
|
||||||
@@ -218,6 +224,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||||||
{activePage === RoomSettingsPage.ServerACLPage && (
|
{activePage === RoomSettingsPage.ServerACLPage && (
|
||||||
<RoomServerACL requestClose={handlePageRequestClose} />
|
<RoomServerACL requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === RoomSettingsPage.InsightsPage && (
|
||||||
|
<RoomInsights requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
|
import { compressImage } from '../../utils/imageCompression';
|
||||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||||
@@ -124,6 +125,9 @@ import { useComposingCheck } from '../../hooks/useComposingCheck';
|
|||||||
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
||||||
import { PollCreator } from './PollCreator';
|
import { PollCreator } from './PollCreator';
|
||||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||||
|
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||||
|
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||||
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||||
|
|
||||||
const GifPicker = React.lazy(() =>
|
const GifPicker = React.lazy(() =>
|
||||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||||
@@ -167,6 +171,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setCharCount(0);
|
setCharCount(0);
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
const [pollOpen, setPollOpen] = useState(false);
|
const [pollOpen, setPollOpen] = useState(false);
|
||||||
|
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||||
|
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
|
||||||
|
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||||||
|
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||||
@@ -401,16 +408,55 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
if (!fileItem) throw new Error('Broken upload');
|
if (!fileItem) throw new Error('Broken upload');
|
||||||
|
|
||||||
|
// Resolve the MXC URL to use — may be overridden if compression is enabled
|
||||||
|
let mxc = upload.mxc;
|
||||||
|
|
||||||
|
if (
|
||||||
|
fileItem.metadata.compressImage &&
|
||||||
|
fileItem.originalFile.type.startsWith('image') &&
|
||||||
|
(fileItem.originalFile.type === 'image/jpeg' ||
|
||||||
|
fileItem.originalFile.type === 'image/png')
|
||||||
|
) {
|
||||||
|
// Use the cached compression result if available, otherwise compute it now
|
||||||
|
let compressionResult = fileItem.metadata.compressionResult;
|
||||||
|
if (compressionResult === undefined) {
|
||||||
|
compressionResult = await compressImage(fileItem.originalFile as File);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compressionResult) {
|
||||||
|
const originalFile = fileItem.originalFile as File;
|
||||||
|
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
||||||
|
type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||||
|
name: originalFile.name,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
});
|
||||||
|
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||||
|
if (compressedMxc) {
|
||||||
|
mxc = compressedMxc;
|
||||||
|
// Build a synthetic fileItem that refers to the compressed file so
|
||||||
|
// getImageMsgContent picks up the correct dimensions and type.
|
||||||
|
const compressedItem = {
|
||||||
|
...fileItem,
|
||||||
|
file: compressedFile,
|
||||||
|
originalFile: compressedFile,
|
||||||
|
};
|
||||||
|
return getImageMsgContent(mx, compressedItem, mxc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (fileItem.file.type.startsWith('image')) {
|
if (fileItem.file.type.startsWith('image')) {
|
||||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
return getImageMsgContent(mx, fileItem, mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('video')) {
|
if (fileItem.file.type.startsWith('video')) {
|
||||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
return getVideoMsgContent(mx, fileItem, mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('audio')) {
|
if (fileItem.file.type.startsWith('audio')) {
|
||||||
return getAudioMsgContent(fileItem, upload.mxc);
|
return getAudioMsgContent(fileItem, mxc);
|
||||||
}
|
}
|
||||||
return getFileMsgContent(fileItem, upload.mxc);
|
return getFileMsgContent(fileItem, mxc);
|
||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
@@ -501,6 +547,80 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a text message content object from the current editor state.
|
||||||
|
* Returns null if the editor is empty or the input is a command.
|
||||||
|
*/
|
||||||
|
const buildCurrentTextContent = useCallback((): IContent | null => {
|
||||||
|
const commandName = getBeginCommand(editor);
|
||||||
|
// Don't schedule commands
|
||||||
|
if (commandName) return null;
|
||||||
|
|
||||||
|
const plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
|
const customHtml = trimCustomHtml(
|
||||||
|
toMatrixCustomHTML(editor.children, {
|
||||||
|
allowTextFormatting: true,
|
||||||
|
allowBlockMarkdown: isMarkdown,
|
||||||
|
allowInlineMarkdown: isMarkdown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (plainText === '') return null;
|
||||||
|
|
||||||
|
const body = plainText;
|
||||||
|
const formattedBody = customHtml;
|
||||||
|
const mentionData = getMentions(mx, roomId, editor);
|
||||||
|
|
||||||
|
const content: IContent = {
|
||||||
|
msgtype: MsgType.Text,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
|
||||||
|
mentionData.users.add(replyDraft.userId);
|
||||||
|
}
|
||||||
|
content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
||||||
|
|
||||||
|
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
||||||
|
content.format = 'org.matrix.custom.html';
|
||||||
|
content.formatted_body = formattedBody;
|
||||||
|
}
|
||||||
|
if (replyDraft) {
|
||||||
|
content['m.relates_to'] = {
|
||||||
|
'm.in_reply_to': { event_id: replyDraft.eventId },
|
||||||
|
};
|
||||||
|
if (replyDraft.relation?.rel_type === RelationType.Thread) {
|
||||||
|
content['m.relates_to'].event_id = replyDraft.relation.event_id;
|
||||||
|
content['m.relates_to'].rel_type = RelationType.Thread;
|
||||||
|
content['m.relates_to'].is_falling_back = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}, [editor, isMarkdown, mx, roomId, replyDraft]);
|
||||||
|
|
||||||
|
const handleScheduleClick = useCallback(() => {
|
||||||
|
const content = buildCurrentTextContent();
|
||||||
|
if (!content) return;
|
||||||
|
setScheduleContent(content);
|
||||||
|
setScheduleOpen(true);
|
||||||
|
}, [buildCurrentTextContent]);
|
||||||
|
|
||||||
|
const handleScheduled = useCallback(
|
||||||
|
(delayId: string, sendAt: number, content: IContent) => {
|
||||||
|
setScheduledMessages((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(roomId) ?? [];
|
||||||
|
next.set(roomId, [...current, { delayId, roomId, content, sendAt }]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
resetEditor(editor);
|
||||||
|
resetEditorHistory(editor);
|
||||||
|
localStorage.removeItem(`draft-msg-${roomId}`);
|
||||||
|
setReplyDraft(undefined);
|
||||||
|
sendTypingStatus(false);
|
||||||
|
},
|
||||||
|
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
|
||||||
|
);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (
|
if (
|
||||||
@@ -750,6 +870,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
<ScheduledMessagesTray roomId={roomId} />
|
||||||
<CustomEditor
|
<CustomEditor
|
||||||
editableName="RoomInput"
|
editableName="RoomInput"
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -1019,6 +1140,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{charCount}
|
{charCount}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={handleScheduleClick}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-label="Schedule message"
|
||||||
|
title="Schedule message"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Clock} size="100" />
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -1040,6 +1171,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||||
|
{scheduleOpen && scheduleContent && (
|
||||||
|
<ScheduleMessageModal
|
||||||
|
roomId={roomId}
|
||||||
|
content={scheduleContent}
|
||||||
|
onScheduled={handleScheduled}
|
||||||
|
onClose={() => {
|
||||||
|
setScheduleOpen(false);
|
||||||
|
setScheduleContent(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { IContent } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||||
|
|
||||||
|
interface ScheduleMessageModalProps {
|
||||||
|
roomId: string;
|
||||||
|
content: IContent;
|
||||||
|
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
if (hours > 0 && minutes > 0) return `in ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `in ${hours}h`;
|
||||||
|
if (minutes > 0) return `in ${minutes}m`;
|
||||||
|
return 'in less than a minute';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSendAt(sendAt: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const isToday =
|
||||||
|
sendAt.getFullYear() === now.getFullYear() &&
|
||||||
|
sendAt.getMonth() === now.getMonth() &&
|
||||||
|
sendAt.getDate() === now.getDate();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const isTomorrow =
|
||||||
|
sendAt.getFullYear() === tomorrow.getFullYear() &&
|
||||||
|
sendAt.getMonth() === tomorrow.getMonth() &&
|
||||||
|
sendAt.getDate() === tomorrow.getDate();
|
||||||
|
|
||||||
|
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (isToday) return `Today at ${timeStr}`;
|
||||||
|
if (isTomorrow) return `Tomorrow at ${timeStr}`;
|
||||||
|
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDatetimeValue(date: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return (
|
||||||
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||||
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleMessageModal({
|
||||||
|
roomId,
|
||||||
|
content,
|
||||||
|
onScheduled,
|
||||||
|
onClose,
|
||||||
|
}: ScheduleMessageModalProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Default: 1 hour from now, rounded to nearest 5 minutes
|
||||||
|
const defaultDate = () => {
|
||||||
|
const d = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [datetimeValue, setDatetimeValue] = useState<string>(() =>
|
||||||
|
toLocalDatetimeValue(defaultDate()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
|
||||||
|
|
||||||
|
const updatePreview = useCallback((value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
setPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sendAt = new Date(value);
|
||||||
|
const now = Date.now();
|
||||||
|
const diffMs = sendAt.getTime() - now;
|
||||||
|
if (Number.isNaN(sendAt.getTime()) || diffMs < 60_000) {
|
||||||
|
setPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updatePreview(datetimeValue);
|
||||||
|
}, [datetimeValue, updatePreview]);
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
|
||||||
|
if (!datetimeValue) {
|
||||||
|
setError('Please select a date and time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sendAt = new Date(datetimeValue);
|
||||||
|
if (Number.isNaN(sendAt.getTime())) {
|
||||||
|
setError('Invalid date/time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diffMs = sendAt.getTime() - Date.now();
|
||||||
|
if (diffMs < 60_000) {
|
||||||
|
setError('Scheduled time must be at least 1 minute in the future.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const delayId = await scheduleMessage(mx, roomId, content, sendAt.getTime());
|
||||||
|
onScheduled(delayId, sendAt.getTime(), content);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="schedule-message-title"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
background: color.Surface.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: color.Other.Shadow,
|
||||||
|
width: '100vw',
|
||||||
|
maxWidth: 400,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Header
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Icon src={Icons.Clock} size="100" />
|
||||||
|
<Text id="schedule-message-title" size="H4">
|
||||||
|
Schedule Message
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||||
|
{/* Message preview */}
|
||||||
|
{typeof content.body === 'string' && content.body.trim() !== '' && (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: config.space.S200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="L400">Message</Text>
|
||||||
|
<Text
|
||||||
|
size="T300"
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content.body as string}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Datetime picker */}
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" htmlFor="schedule-datetime" size="L400">
|
||||||
|
Send at
|
||||||
|
</Text>
|
||||||
|
<input
|
||||||
|
id="schedule-datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
value={datetimeValue}
|
||||||
|
onChange={(e) => setDatetimeValue(e.target.value)}
|
||||||
|
style={{
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{preview ? (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="T300" style={{ opacity: 0.7 }}>
|
||||||
|
{preview.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" style={{ opacity: 0.5 }}>
|
||||||
|
({preview.relative})
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
datetimeValue && (
|
||||||
|
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||||
|
Must be at least 1 minute in the future
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<Text size="T300" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box
|
||||||
|
gap="300"
|
||||||
|
justifyContent="End"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="Primary"
|
||||||
|
radii="300"
|
||||||
|
disabled={submitting || !preview}
|
||||||
|
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
|
||||||
|
>
|
||||||
|
<Text size="B400">Schedule</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { Box, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||||
|
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
|
||||||
|
|
||||||
|
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 [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||||
|
|
||||||
|
// Remove scheduled messages whose time has passed
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length === 0) return undefined;
|
||||||
|
|
||||||
|
const nearestSendAt = Math.min(...messages.map((m) => m.sendAt));
|
||||||
|
const delay = nearestSendAt - Date.now();
|
||||||
|
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setScheduledMessages((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(roomId) ?? [];
|
||||||
|
const remaining = current.filter((m) => m.sendAt > now);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
next.delete(roomId);
|
||||||
|
} else {
|
||||||
|
next.set(roomId, remaining);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Math.max(0, delay) + 2000,
|
||||||
|
); // 2s grace after scheduled time
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [messages, roomId, setScheduledMessages]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(
|
||||||
|
async (msg: ScheduledMessage) => {
|
||||||
|
if (cancelling.has(msg.delayId)) return;
|
||||||
|
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||||
|
try {
|
||||||
|
await cancelScheduledMessage(mx, msg.delayId);
|
||||||
|
} catch {
|
||||||
|
// If cancellation fails on the server, still remove locally
|
||||||
|
// since the user intends to remove it
|
||||||
|
} finally {
|
||||||
|
setScheduledMessages((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(roomId) ?? [];
|
||||||
|
const remaining = current.filter((m) => m.delayId !== msg.delayId);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
next.delete(roomId);
|
||||||
|
} else {
|
||||||
|
next.set(roomId, remaining);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCancelling((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(msg.delayId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, roomId, cancelling, setScheduledMessages],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messages.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Tray header */}
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S100} ${config.space.S300}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
as="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Clock} size="50" />
|
||||||
|
<Text size="T200" style={{ flex: 1, fontWeight: 600 }}>
|
||||||
|
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Tray items */}
|
||||||
|
{expanded && (
|
||||||
|
<Box direction="Column">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<Box
|
||||||
|
key={msg.delayId}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S100} ${config.space.S300}`,
|
||||||
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{formatSendAt(msg.sendAt)}
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
aria-label="Cancel scheduled message"
|
||||||
|
disabled={cancelling.has(msg.delayId)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCancel(msg);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="50" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ import { PowerIcon } from '../../../components/power';
|
|||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID from '../../../../util/colorMXID';
|
||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||||
|
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||||
|
|
||||||
// Delivery status indicator for own messages
|
// Delivery status indicator for own messages
|
||||||
function DeliveryStatus({
|
function DeliveryStatus({
|
||||||
@@ -792,6 +793,7 @@ export const Message = React.memo(
|
|||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
|
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
|
||||||
const [forwardOpen, setForwardOpen] = useState(false);
|
const [forwardOpen, setForwardOpen] = useState(false);
|
||||||
|
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
|
||||||
|
|
||||||
const senderDisplayName =
|
const senderDisplayName =
|
||||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||||
@@ -1128,6 +1130,48 @@ export const Message = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{!mEvent.isRedacted() && mEvent.getId() && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={Icons.Star}
|
||||||
|
filled={isBookmarked(mEvent.getId()!)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
const eventId = mEvent.getId()!;
|
||||||
|
if (isBookmarked(eventId)) {
|
||||||
|
removeBookmark(eventId);
|
||||||
|
} else {
|
||||||
|
const content = mEvent.getContent();
|
||||||
|
const body: string =
|
||||||
|
(content?.body as string | undefined) ?? '';
|
||||||
|
addBookmark({
|
||||||
|
roomId: room.roomId,
|
||||||
|
eventId,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
previewText: body.slice(0, 120),
|
||||||
|
roomName: room.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={css.MessageMenuItemText}
|
||||||
|
as="span"
|
||||||
|
size="T300"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
{isBookmarked(mEvent.getId()!)
|
||||||
|
? 'Remove Bookmark'
|
||||||
|
: 'Bookmark Message'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{!isThreadedMessage && (
|
{!isThreadedMessage && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||||
|
|
||||||
|
export type Bookmark = {
|
||||||
|
roomId: string;
|
||||||
|
eventId: string;
|
||||||
|
savedAt: number;
|
||||||
|
previewText: string;
|
||||||
|
roomName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
|
||||||
|
const MAX_BOOKMARKS = 500;
|
||||||
|
|
||||||
|
type BookmarksContent = {
|
||||||
|
bookmarks: Bookmark[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function readBookmarks(mx: MatrixClient): Bookmark[] {
|
||||||
|
return (
|
||||||
|
(mx.getAccountData(BOOKMARKS_KEY as any)?.getContent() as BookmarksContent | undefined)
|
||||||
|
?.bookmarks ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBookmarks(): {
|
||||||
|
bookmarks: Bookmark[];
|
||||||
|
addBookmark: (b: Bookmark) => Promise<void>;
|
||||||
|
removeBookmark: (eventId: string) => Promise<void>;
|
||||||
|
isBookmarked: (eventId: string) => boolean;
|
||||||
|
} {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx));
|
||||||
|
|
||||||
|
useAccountDataCallback(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (evt.getType() === BOOKMARKS_KEY) {
|
||||||
|
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setBookmarks],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-read on mx change
|
||||||
|
useEffect(() => {
|
||||||
|
setBookmarks(readBookmarks(mx));
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const addBookmark = useCallback(
|
||||||
|
async (b: Bookmark) => {
|
||||||
|
const current = readBookmarks(mx);
|
||||||
|
// Avoid duplicates
|
||||||
|
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||||
|
let next = [b, ...filtered];
|
||||||
|
if (next.length > MAX_BOOKMARKS) {
|
||||||
|
next = next.slice(0, MAX_BOOKMARKS);
|
||||||
|
}
|
||||||
|
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeBookmark = useCallback(
|
||||||
|
async (eventId: string) => {
|
||||||
|
const current = readBookmarks(mx);
|
||||||
|
const next = current.filter((bk) => bk.eventId !== eventId);
|
||||||
|
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isBookmarked = useCallback(
|
||||||
|
(eventId: string) => bookmarks.some((bk) => bk.eventId === eventId),
|
||||||
|
[bookmarks],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { bookmarks, addBookmark, removeBookmark, isBookmarked };
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Box } from 'folds';
|
import { Box, Line } from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
||||||
|
import { BookmarksPanel } from '../../features/bookmarks/BookmarksPanel';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
|
||||||
type ClientLayoutProps = {
|
type ClientLayoutProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
||||||
|
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
|
||||||
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
@@ -37,6 +44,12 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
|||||||
<Box grow="Yes" as="main" id="main-content">
|
<Box grow="Yes" as="main" id="main-content">
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
{bookmarksOpen && screenSize === ScreenSize.Desktop && (
|
||||||
|
<>
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
SettingsTab,
|
SettingsTab,
|
||||||
UnverifiedTab,
|
UnverifiedTab,
|
||||||
SearchTab,
|
SearchTab,
|
||||||
|
BookmarksTab,
|
||||||
} from './sidebar';
|
} from './sidebar';
|
||||||
import { CreateTab } from './sidebar/CreateTab';
|
import { CreateTab } from './sidebar/CreateTab';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export function SidebarNav() {
|
|||||||
<SidebarStackSeparator />
|
<SidebarStackSeparator />
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<SearchTab />
|
<SearchTab />
|
||||||
|
<BookmarksTab />
|
||||||
<UnverifiedTab />
|
<UnverifiedTab />
|
||||||
<InboxTab />
|
<InboxTab />
|
||||||
<SettingsTab />
|
<SettingsTab />
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon, Icons } from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
||||||
|
import { bookmarksPanelAtom } from '../../../state/bookmarksPanel';
|
||||||
|
|
||||||
|
export function BookmarksTab() {
|
||||||
|
const [opened, setOpen] = useAtom(bookmarksPanelAtom);
|
||||||
|
|
||||||
|
const toggle = () => setOpen((v) => !v);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem active={opened}>
|
||||||
|
<SidebarItemTooltip tooltip="Saved Messages">
|
||||||
|
{(triggerRef) => (
|
||||||
|
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={toggle}>
|
||||||
|
<Icon src={Icons.Star} filled={opened} />
|
||||||
|
</SidebarAvatar>
|
||||||
|
)}
|
||||||
|
</SidebarItemTooltip>
|
||||||
|
</SidebarItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export * from './ExploreTab';
|
|||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
export * from './UnverifiedTab';
|
export * from './UnverifiedTab';
|
||||||
export * from './SearchTab';
|
export * from './SearchTab';
|
||||||
|
export * from './BookmarksTab';
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const bookmarksPanelAtom = atom<boolean>(false);
|
||||||
@@ -6,10 +6,15 @@ import { IEventRelation } from 'matrix-js-sdk';
|
|||||||
import { createUploadAtomFamily } from '../upload';
|
import { createUploadAtomFamily } from '../upload';
|
||||||
import { TUploadContent } from '../../utils/matrix';
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
import { createListAtom } from '../list';
|
import { createListAtom } from '../list';
|
||||||
|
import { CompressionResult } from '../../utils/imageCompression';
|
||||||
|
|
||||||
export type TUploadMetadata = {
|
export type TUploadMetadata = {
|
||||||
markedAsSpoiler: boolean;
|
markedAsSpoiler: boolean;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
|
/** User has opted in to compressing this image before upload */
|
||||||
|
compressImage?: boolean;
|
||||||
|
/** Cached compression result (populated in the background when compressImage is set to true) */
|
||||||
|
compressionResult?: CompressionResult | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TUploadItem = {
|
export type TUploadItem = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export enum RoomSettingsPage {
|
|||||||
ExportPage,
|
ExportPage,
|
||||||
ActivityLogPage,
|
ActivityLogPage,
|
||||||
ServerACLPage,
|
ServerACLPage,
|
||||||
|
InsightsPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoomSettingsState = {
|
export type RoomSettingsState = {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
import { IContent } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export type ScheduledMessage = {
|
||||||
|
delayId: string;
|
||||||
|
roomId: string;
|
||||||
|
content: IContent;
|
||||||
|
sendAt: number; // Unix timestamp ms
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global atom: Map<roomId, ScheduledMessage[]>
|
||||||
|
* Stores all locally-tracked scheduled messages across rooms.
|
||||||
|
* MSC4140 has no list endpoint, so we track them ourselves.
|
||||||
|
*/
|
||||||
|
export const scheduledMessagesAtom = atom<Map<string, ScheduledMessage[]>>(new Map());
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type CompressionResult = {
|
||||||
|
blob: Blob;
|
||||||
|
originalSize: number;
|
||||||
|
compressedSize: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPRESSIBLE_TYPES = ['image/jpeg', 'image/png'];
|
||||||
|
const COMPRESSION_SKIP_THRESHOLD = 200 * 1024; // 200 KB
|
||||||
|
|
||||||
|
/** Returns true if this file type can be compressed AND the file is large enough to bother. */
|
||||||
|
export function isCompressible(file: File): boolean {
|
||||||
|
return COMPRESSIBLE_TYPES.includes(file.type) && file.size >= COMPRESSION_SKIP_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress an image file via canvas.toBlob.
|
||||||
|
* Returns null if the file type is not compressible (GIF, SVG, WebP, video, audio, etc.).
|
||||||
|
*/
|
||||||
|
export async function compressImage(file: File, quality = 0.82): Promise<CompressionResult | null> {
|
||||||
|
if (!COMPRESSIBLE_TYPES.includes(file.type)) return null;
|
||||||
|
|
||||||
|
const img = await loadImage(file);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
blob,
|
||||||
|
originalSize: file.size,
|
||||||
|
compressedSize: blob.size,
|
||||||
|
width: img.naturalWidth,
|
||||||
|
height: img.naturalHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
quality,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(file: File): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a message via MSC4140 (delayed messages).
|
||||||
|
* @param mx - Matrix client instance
|
||||||
|
* @param roomId - The room to send the message in
|
||||||
|
* @param content - The message event content
|
||||||
|
* @param sendAtMs - Unix timestamp (ms) when the message should be sent
|
||||||
|
* @returns The delay_id returned by the server (use to cancel/restart)
|
||||||
|
*/
|
||||||
|
export async function scheduleMessage(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
content: IContent,
|
||||||
|
sendAtMs: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const delayMs = sendAtMs - Date.now();
|
||||||
|
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
const path = `/_matrix/client/unstable/org.matrix.msc4140/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}?delay=${Math.max(1000, Math.round(delayMs))}`;
|
||||||
|
const res = (await mx.http.authedRequest(Method.Put, path, undefined, content, {
|
||||||
|
prefix: '',
|
||||||
|
})) as { delay_id: string };
|
||||||
|
return res.delay_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a scheduled message via MSC4140.
|
||||||
|
* @param mx - Matrix client instance
|
||||||
|
* @param delayId - The delay_id from scheduleMessage
|
||||||
|
*/
|
||||||
|
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
||||||
|
const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`;
|
||||||
|
await mx.http.authedRequest(Method.Post, path, undefined, { action: 'cancel' }, { prefix: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart (refresh heartbeat) a scheduled message via MSC4140.
|
||||||
|
* Resets the delay timer from now.
|
||||||
|
* @param mx - Matrix client instance
|
||||||
|
* @param delayId - The delay_id from scheduleMessage
|
||||||
|
*/
|
||||||
|
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
||||||
|
const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`;
|
||||||
|
await mx.http.authedRequest(Method.Post, path, undefined, { action: 'restart' }, { prefix: '' });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user