feat: P1 features — voice speed, private receipts, room filter, favorites, invite link, poll creation
P1-5: Voice message playback speed toggle (0.75×/1×/1.5×/2×) in AudioContent.tsx P1-10: Private read receipts toggle in Privacy settings; wired to notifications.ts P1-3: Room filter input on Home tab and DMs tab (client-side, clears on tab switch) P1-8: Favorite rooms via m.favourite tag — Favorites section in Home sidebar, star/unstar in right-click menu P1-9: Room invite link + QR code in room settings (Share Room tile, api.qrserver.com QR) P1-6: Poll creation modal in composer (PollCreator.tsx, sends m.poll.start) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,22 @@ export function AudioContent({
|
|||||||
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS),
|
useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.playbackRate = playbackSpeed;
|
||||||
|
}
|
||||||
|
}, [playbackSpeed]);
|
||||||
|
|
||||||
|
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
||||||
|
|
||||||
|
const handleSpeedClick = () => {
|
||||||
|
const currentIndex = SPEED_STEPS.indexOf(playbackSpeed);
|
||||||
|
const nextIndex = (currentIndex + 1) % SPEED_STEPS.length;
|
||||||
|
setPlaybackSpeed(SPEED_STEPS[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (srcState.status === AsyncStatus.Success) {
|
if (srcState.status === AsyncStatus.Success) {
|
||||||
setPlaying(!playing);
|
setPlaying(!playing);
|
||||||
@@ -163,6 +179,15 @@ export function AudioContent({
|
|||||||
<Text size="T200">{`${secondsToMinutesAndSeconds(
|
<Text size="T200">{`${secondsToMinutesAndSeconds(
|
||||||
currentTime,
|
currentTime,
|
||||||
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
|
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
onClick={handleSpeedClick}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="Pill"
|
||||||
|
aria-label={`Playback speed: ${playbackSpeed}×`}
|
||||||
|
>
|
||||||
|
<Text size="B300">{`${playbackSpeed}×`}</Text>
|
||||||
|
</Chip>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
rightControl: (
|
rightControl: (
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { Box, Button, config, Icon, Icons, Text } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { CutoutCard } from '../../../components/cutout-card';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { getMatrixToRoom } from '../../../plugins/matrix-to';
|
||||||
|
|
||||||
|
export function RoomShareInvite() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const domain = mx.getDomain() ?? undefined;
|
||||||
|
const inviteUrl = getMatrixToRoom(room.roomId, domain ? [domain] : undefined);
|
||||||
|
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(inviteUrl)}`;
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(inviteUrl).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}, [inviteUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Share Room"
|
||||||
|
description="Share this invite link so others can join the room."
|
||||||
|
/>
|
||||||
|
<CutoutCard variant="Surface" style={{ padding: config.space.S300 }}>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
userSelect: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inviteUrl}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant={copied ? 'Success' : 'Secondary'}
|
||||||
|
fill={copied ? 'Solid' : 'Soft'}
|
||||||
|
radii="300"
|
||||||
|
onClick={handleCopy}
|
||||||
|
before={<Icon size="100" src={copied ? Icons.Check : Icons.Link} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{copied ? 'Copied!' : 'Copy Link'}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="Center">
|
||||||
|
<img
|
||||||
|
src={qrSrc}
|
||||||
|
alt="QR code for room invite link"
|
||||||
|
width={160}
|
||||||
|
height={160}
|
||||||
|
style={{ display: 'block', borderRadius: config.radii.R300 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</CutoutCard>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@ export * from './RoomHistoryVisibility';
|
|||||||
export * from './RoomJoinRules';
|
export * from './RoomJoinRules';
|
||||||
export * from './RoomProfile';
|
export * from './RoomProfile';
|
||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
|
|||||||
@@ -212,6 +212,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
const isServerNotice = room.getType() === 'm.server_notice';
|
const isServerNotice = room.getType() === 'm.server_notice';
|
||||||
|
|
||||||
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
|
|
||||||
|
const handleToggleFavorite = () => {
|
||||||
|
if (isFavorite) {
|
||||||
|
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||||
|
} else {
|
||||||
|
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
||||||
|
}
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
requestClose();
|
requestClose();
|
||||||
@@ -273,6 +284,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Star} filled={isFavorite} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={isFavorite}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
{isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -381,6 +403,7 @@ function RoomNavItem_({
|
|||||||
notificationMode,
|
notificationMode,
|
||||||
linkPath,
|
linkPath,
|
||||||
}: RoomNavItemProps) {
|
}: RoomNavItemProps) {
|
||||||
|
const isFavorite = !!room.tags?.['m.favourite'];
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
@@ -499,6 +522,15 @@ function RoomNavItem_({
|
|||||||
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isFavorite && (
|
||||||
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={Icons.Star}
|
||||||
|
filled
|
||||||
|
aria-label="Favorited"
|
||||||
|
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RoomLocalAddresses,
|
RoomLocalAddresses,
|
||||||
RoomPublishedAddresses,
|
RoomPublishedAddresses,
|
||||||
RoomPublish,
|
RoomPublish,
|
||||||
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
} from '../../common-settings/general';
|
} from '../../common-settings/general';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
@@ -58,6 +59,10 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<RoomPublishedAddresses permissions={permissions} />
|
<RoomPublishedAddresses permissions={permissions} />
|
||||||
<RoomLocalAddresses permissions={permissions} />
|
<RoomLocalAddresses permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Share</Text>
|
||||||
|
<RoomShareInvite />
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Advanced Options</Text>
|
<Text size="L400">Advanced Options</Text>
|
||||||
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { Box, Icon, IconButton, Icons, Text, config } from 'folds';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
|
||||||
|
interface PollCreatorProps {
|
||||||
|
roomId: string;
|
||||||
|
room: Room;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [question, setQuestion] = useState('');
|
||||||
|
const [options, setOptions] = useState<string[]>(['', '']);
|
||||||
|
const [maxSelections, setMaxSelections] = useState<number>(1);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleOptionChange = (index: number, value: string) => {
|
||||||
|
setOptions((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddOption = () => {
|
||||||
|
if (options.length >= 10) return;
|
||||||
|
setOptions((prev) => [...prev, '']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveOption = (index: number) => {
|
||||||
|
if (options.length <= 2) return;
|
||||||
|
setOptions((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const trimmedQuestion = question.trim();
|
||||||
|
if (!trimmedQuestion) {
|
||||||
|
setError('Please enter a question.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filledOptions = options.map((o) => o.trim()).filter((o) => o.length > 0);
|
||||||
|
if (filledOptions.length < 2) {
|
||||||
|
setError('Please provide at least 2 answer options.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||||
|
'm.poll': {
|
||||||
|
question: { 'm.text': trimmedQuestion },
|
||||||
|
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||||
|
max_selections: maxSelections,
|
||||||
|
kind: 'm.poll.undisclosed',
|
||||||
|
},
|
||||||
|
body: trimmedQuestion,
|
||||||
|
msgtype: 'm.text',
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to send poll.');
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
padding: config.space.S500,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '420px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S300,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.24)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box direction="Row" alignItems="Center" justifyContent="SpaceBetween">
|
||||||
|
<Text size="H4">Create Poll</Text>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close poll creator"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
||||||
|
<Text size="L400">Question</Text>
|
||||||
|
<input
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
border: '1px solid var(--bg-surface-border)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
placeholder="Ask a question..."
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
||||||
|
<Text size="L400">Options</Text>
|
||||||
|
{options.map((opt, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
border: '1px solid var(--bg-surface-border)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
value={opt}
|
||||||
|
onChange={(e) => handleOptionChange(index, e.target.value)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={() => handleRemoveOption(index)}
|
||||||
|
disabled={options.length <= 2}
|
||||||
|
aria-label={`Remove option ${index + 1}`}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{options.length < 10 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddOption}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--tc-surface-low)',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: `${config.space.S100} 0`,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Plus} size="100" />
|
||||||
|
<span>Add Option</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: config.space.S100 }}>
|
||||||
|
<Text size="L400">Selection Type</Text>
|
||||||
|
<div style={{ display: 'flex', gap: config.space.S200 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pollType"
|
||||||
|
checked={maxSelections === 1}
|
||||||
|
onChange={() => setMaxSelections(1)}
|
||||||
|
/>
|
||||||
|
Single choice
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="pollType"
|
||||||
|
checked={maxSelections !== 1}
|
||||||
|
onChange={() => setMaxSelections(options.length)}
|
||||||
|
/>
|
||||||
|
Multiple choice
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box direction="Row" justifyContent="End" gap="200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
border: '1px solid var(--bg-surface-border)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--tc-surface-high)',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-primary-main)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||||
|
color: 'var(--tc-primary-on-primary)',
|
||||||
|
fontSize: '14px',
|
||||||
|
opacity: submitting ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating...' : 'Create Poll'}
|
||||||
|
</button>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -861,6 +861,10 @@ function Editor() {
|
|||||||
function Privacy() {
|
function Privacy() {
|
||||||
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
|
const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||||
|
const [privateReadReceipts, setPrivateReadReceipts] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'privateReadReceipts',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -872,6 +876,19 @@ function Privacy() {
|
|||||||
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
after={<Switch variant="Primary" value={hideActivity} onChange={setHideActivity} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Private Read Receipts"
|
||||||
|
description="Send read receipts only to you and the server — other users won't see when you've read messages."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={privateReadReceipts}
|
||||||
|
onChange={setPrivateReadReceipts}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Hide Online Status"
|
title="Hide Online Status"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
Input,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
PopOut,
|
PopOut,
|
||||||
@@ -181,6 +182,7 @@ export function Direct() {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const directs = useDirectRooms();
|
const directs = useDirectRooms();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
const roomsWithUnreadSet = useAtomValue(
|
const roomsWithUnreadSet = useAtomValue(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -216,8 +218,14 @@ export function Direct() {
|
|||||||
return items;
|
return items;
|
||||||
}, [mx, directs, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
}, [mx, directs, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredDirects = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedDirects;
|
||||||
|
const q = filterQuery.toLowerCase();
|
||||||
|
return sortedDirects.filter((rId) => (mx.getRoom(rId)?.name ?? '').toLowerCase().includes(q));
|
||||||
|
}, [mx, sortedDirects, filterQuery]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedDirects.length,
|
count: filteredDirects.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -253,6 +261,34 @@ export function Direct() {
|
|||||||
</NavButton>
|
</NavButton>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
<NavCategory>
|
||||||
|
<Box style={{ padding: `0 ${config.space.S200}`, paddingBottom: config.space.S100 }}>
|
||||||
|
<Input
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setFilterQuery(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Filter DMs…"
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
after={
|
||||||
|
filterQuery ? (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setFilterQuery('')}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</NavCategory>
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -270,7 +306,7 @@ export function Direct() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
const roomId = sortedDirects[vItem.index];
|
const roomId = filteredDirects[vItem.index];
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
|
import React, {
|
||||||
|
ChangeEvent,
|
||||||
|
MouseEventHandler,
|
||||||
|
forwardRef,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -7,6 +14,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
Input,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
PopOut,
|
PopOut,
|
||||||
@@ -66,6 +74,7 @@ import {
|
|||||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||||
import { _RoomSearchParams } from '../../paths';
|
import { _RoomSearchParams } from '../../paths';
|
||||||
|
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type HomeMenuProps = {
|
type HomeMenuProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -201,12 +210,14 @@ function HomeEmpty() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
useNavToActivePathMapper('home');
|
useNavToActivePathMapper('home');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const rooms = useHomeRooms();
|
const rooms = useHomeRooms();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
|
const [filterQuery, setFilterQuery] = useState<string>('');
|
||||||
// Perf-3: only re-render when the set of rooms WITH unread changes, not on count updates
|
// Perf-3: only re-render when the set of rooms WITH unread changes, not on count updates
|
||||||
const roomsWithUnreadSet = useAtomValue(
|
const roomsWithUnreadSet = useAtomValue(
|
||||||
useMemo(
|
useMemo(
|
||||||
@@ -235,8 +246,32 @@ export function Home() {
|
|||||||
const noRoomToDisplay = rooms.length === 0;
|
const noRoomToDisplay = rooms.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||||
|
|
||||||
|
const { favoriteRooms, otherRooms } = useMemo(() => {
|
||||||
|
const favs: string[] = [];
|
||||||
|
const others: string[] = [];
|
||||||
|
rooms.forEach((rId) => {
|
||||||
|
const room = mx.getRoom(rId);
|
||||||
|
if (room?.tags?.['m.favourite']) {
|
||||||
|
favs.push(rId);
|
||||||
|
} else {
|
||||||
|
others.push(rId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { favoriteRooms: favs, otherRooms: others };
|
||||||
|
}, [mx, rooms]);
|
||||||
|
|
||||||
|
const sortedFavoriteRooms = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(favoriteRooms).sort(
|
||||||
|
closedCategories.has(FAVORITES_CATEGORY_ID)
|
||||||
|
? factoryRoomIdByActivity(mx)
|
||||||
|
: factoryRoomIdByAtoZ(mx),
|
||||||
|
),
|
||||||
|
[mx, favoriteRooms, closedCategories],
|
||||||
|
);
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
const sortedRooms = useMemo(() => {
|
||||||
const items = Array.from(rooms).sort(
|
const items = Array.from(otherRooms).sort(
|
||||||
closedCategories.has(DEFAULT_CATEGORY_ID)
|
closedCategories.has(DEFAULT_CATEGORY_ID)
|
||||||
? factoryRoomIdByActivity(mx)
|
? factoryRoomIdByActivity(mx)
|
||||||
: factoryRoomIdByAtoZ(mx),
|
: factoryRoomIdByAtoZ(mx),
|
||||||
@@ -245,10 +280,28 @@ export function Home() {
|
|||||||
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, rooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
}, [mx, otherRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
||||||
|
|
||||||
|
const filteredRooms = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return sortedRooms;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return sortedRooms.filter((rId) => {
|
||||||
|
const localName = localNames.rooms[rId];
|
||||||
|
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||||
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [mx, sortedRooms, filterQuery]);
|
||||||
|
|
||||||
|
const favVirtualizer = useVirtualizer({
|
||||||
|
count: sortedFavoriteRooms.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 38,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: sortedRooms.length,
|
count: filteredRooms.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => 38,
|
estimateSize: () => 38,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -338,6 +391,73 @@ export function Home() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
|
<NavCategory>
|
||||||
|
<Box
|
||||||
|
style={{ padding: `0 ${config.space.S200}`, paddingBottom: config.space.S100 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterQuery(e.target.value)}
|
||||||
|
placeholder="Filter rooms…"
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
after={
|
||||||
|
filterQuery ? (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setFilterQuery('')}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</NavCategory>
|
||||||
|
{sortedFavoriteRooms.length > 0 && (
|
||||||
|
<NavCategory>
|
||||||
|
<NavCategoryHeader>
|
||||||
|
<RoomNavCategoryButton
|
||||||
|
closed={closedCategories.has(FAVORITES_CATEGORY_ID)}
|
||||||
|
data-category-id={FAVORITES_CATEGORY_ID}
|
||||||
|
onClick={handleCategoryClick}
|
||||||
|
>
|
||||||
|
Favorites
|
||||||
|
</RoomNavCategoryButton>
|
||||||
|
</NavCategoryHeader>
|
||||||
|
<div style={{ position: 'relative', height: favVirtualizer.getTotalSize() }}>
|
||||||
|
{favVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const roomId = sortedFavoriteRooms[vItem.index];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={vItem.index}
|
||||||
|
ref={favVirtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<RoomNavItem
|
||||||
|
room={room}
|
||||||
|
selected={selectedRoomId === roomId}
|
||||||
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
|
notificationMode={getRoomNotificationMode(
|
||||||
|
notificationPreferences,
|
||||||
|
room.roomId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</NavCategory>
|
||||||
|
)}
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -355,7 +475,7 @@ export function Home() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
const roomId = sortedRooms[vItem.index];
|
const roomId = filteredRooms[vItem.index];
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
const selected = selectedRoomId === roomId;
|
const selected = selectedRoomId === roomId;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface Settings {
|
|||||||
pageZoom: number;
|
pageZoom: number;
|
||||||
hideActivity: boolean;
|
hideActivity: boolean;
|
||||||
hidePresence: boolean;
|
hidePresence: boolean;
|
||||||
|
privateReadReceipts: boolean;
|
||||||
presenceStatus: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
presenceStatus: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
||||||
|
|
||||||
isPeopleDrawer: boolean;
|
isPeopleDrawer: boolean;
|
||||||
@@ -93,6 +94,7 @@ const defaultSettings: Settings = {
|
|||||||
pageZoom: 100,
|
pageZoom: 100,
|
||||||
hideActivity: false,
|
hideActivity: false,
|
||||||
hidePresence: false,
|
hidePresence: false,
|
||||||
|
privateReadReceipts: false,
|
||||||
presenceStatus: 'auto',
|
presenceStatus: 'auto',
|
||||||
|
|
||||||
isPeopleDrawer: true,
|
isPeopleDrawer: true,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
|
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
|
||||||
|
import { getSettings } from '../state/settings';
|
||||||
|
|
||||||
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
|
||||||
|
const { privateReadReceipts } = getSettings();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
@@ -21,6 +23,6 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
|||||||
|
|
||||||
await mx.sendReadReceipt(
|
await mx.sendReadReceipt(
|
||||||
latestEvent,
|
latestEvent,
|
||||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user