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 { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
|
||||
|
||||
type PreviewImageProps = {
|
||||
fileItem: TUploadItem;
|
||||
@@ -97,6 +98,105 @@ function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
|
||||
) : 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 = {
|
||||
isEncrypted?: boolean;
|
||||
fileItem: TUploadItem;
|
||||
@@ -204,6 +304,11 @@ export function UploadCardRenderer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CompressionCheckbox
|
||||
fileItem={fileItem}
|
||||
metadata={metadata}
|
||||
setMetadata={setMetadata}
|
||||
/>
|
||||
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||
<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 { RoomActivityLog } from './RoomActivityLog';
|
||||
import { RoomServerACL } from './RoomServerACL';
|
||||
import { RoomInsights } from './RoomInsights';
|
||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -66,6 +67,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
|
||||
name: 'Activity',
|
||||
icon: Icons.RecentClock,
|
||||
},
|
||||
{
|
||||
page: RoomSettingsPage.InsightsPage,
|
||||
name: 'Insights',
|
||||
icon: Icons.Info,
|
||||
},
|
||||
];
|
||||
|
||||
const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
|
||||
@@ -218,6 +224,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||
{activePage === RoomSettingsPage.ServerACLPage && (
|
||||
<RoomServerACL requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.InsightsPage && (
|
||||
<RoomInsights requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
getMxIdLocalPart,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { compressImage } from '../../utils/imageCompression';
|
||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||
@@ -124,6 +125,9 @@ import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
||||
import { PollCreator } from './PollCreator';
|
||||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -167,6 +171,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
setCharCount(0);
|
||||
}, [roomId]);
|
||||
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 [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||
@@ -401,16 +408,55 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
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')) {
|
||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
||||
return getImageMsgContent(mx, fileItem, mxc);
|
||||
}
|
||||
if (fileItem.file.type.startsWith('video')) {
|
||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||
return getVideoMsgContent(mx, fileItem, mxc);
|
||||
}
|
||||
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);
|
||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||
@@ -501,6 +547,80 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
sendTypingStatus(false);
|
||||
}, [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(
|
||||
(evt) => {
|
||||
if (
|
||||
@@ -750,6 +870,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ScheduledMessagesTray roomId={roomId} />
|
||||
<CustomEditor
|
||||
editableName="RoomInput"
|
||||
editor={editor}
|
||||
@@ -1019,6 +1140,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{charCount}
|
||||
</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
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
@@ -1040,6 +1171,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
/>
|
||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||
{scheduleOpen && scheduleContent && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
content={scheduleContent}
|
||||
onScheduled={handleScheduled}
|
||||
onClose={() => {
|
||||
setScheduleOpen(false);
|
||||
setScheduleContent(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||
|
||||
// Delivery status indicator for own messages
|
||||
function DeliveryStatus({
|
||||
@@ -792,6 +793,7 @@ export const Message = React.memo(
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
|
||||
const [forwardOpen, setForwardOpen] = useState(false);
|
||||
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
|
||||
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
@@ -1128,6 +1130,48 @@ export const Message = React.memo(
|
||||
</Text>
|
||||
</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 && (
|
||||
<MenuItem
|
||||
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 { 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 = {
|
||||
nav: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
||||
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
@@ -37,6 +44,12 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
|
||||
<Box grow="Yes" as="main" id="main-content">
|
||||
{children}
|
||||
</Box>
|
||||
{bookmarksOpen && screenSize === ScreenSize.Desktop && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SettingsTab,
|
||||
UnverifiedTab,
|
||||
SearchTab,
|
||||
BookmarksTab,
|
||||
} from './sidebar';
|
||||
import { CreateTab } from './sidebar/CreateTab';
|
||||
|
||||
@@ -44,6 +45,7 @@ export function SidebarNav() {
|
||||
<SidebarStackSeparator />
|
||||
<SidebarStack>
|
||||
<SearchTab />
|
||||
<BookmarksTab />
|
||||
<UnverifiedTab />
|
||||
<InboxTab />
|
||||
<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 './UnverifiedTab';
|
||||
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 { TUploadContent } from '../../utils/matrix';
|
||||
import { createListAtom } from '../list';
|
||||
import { CompressionResult } from '../../utils/imageCompression';
|
||||
|
||||
export type TUploadMetadata = {
|
||||
markedAsSpoiler: boolean;
|
||||
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 = {
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum RoomSettingsPage {
|
||||
ExportPage,
|
||||
ActivityLogPage,
|
||||
ServerACLPage,
|
||||
InsightsPage,
|
||||
}
|
||||
|
||||
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