diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 2e87433ea..f91315174 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -96,6 +96,22 @@ export function AudioContent({ 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 = () => { if (srcState.status === AsyncStatus.Success) { setPlaying(!playing); @@ -163,6 +179,15 @@ export function AudioContent({ {`${secondsToMinutesAndSeconds( currentTime, )} / ${secondsToMinutesAndSeconds(duration)}`} + + + {`${playbackSpeed}×`} + ), rightControl: ( diff --git a/src/app/features/common-settings/general/RoomShareInvite.tsx b/src/app/features/common-settings/general/RoomShareInvite.tsx new file mode 100644 index 000000000..6e18b3a53 --- /dev/null +++ b/src/app/features/common-settings/general/RoomShareInvite.tsx @@ -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 ( + + + + + + + + {inviteUrl} + + + + + + + + QR code for room invite link + + + + + ); +} diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts index 80804b0b3..a23a85d0e 100644 --- a/src/app/features/common-settings/general/index.ts +++ b/src/app/features/common-settings/general/index.ts @@ -4,4 +4,5 @@ export * from './RoomHistoryVisibility'; export * from './RoomJoinRules'; export * from './RoomProfile'; export * from './RoomPublish'; +export * from './RoomShareInvite'; export * from './RoomUpgrade'; diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index d5e284b3a..075a930a7 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -212,6 +212,17 @@ const RoomNavItemMenu = forwardRef( const [invitePrompt, setInvitePrompt] = useState(false); 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 = () => { markAsRead(mx, room.roomId, hideActivity); requestClose(); @@ -273,6 +284,17 @@ const RoomNavItemMenu = forwardRef( + } + radii="300" + aria-pressed={isFavorite} + > + + {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} + + )} + {isFavorite && ( + + )} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index b9f89519f..c12dd8683 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -11,6 +11,7 @@ import { RoomLocalAddresses, RoomPublishedAddresses, RoomPublish, + RoomShareInvite, RoomUpgrade, } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; @@ -58,6 +59,10 @@ export function General({ requestClose }: GeneralProps) { + + Share + + Advanced Options diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx new file mode 100644 index 000000000..243dd9f2b --- /dev/null +++ b/src/app/features/room/PollCreator.tsx @@ -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(['', '']); + const [maxSelections, setMaxSelections] = useState(1); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ + Create Poll + + + + + +
+ Question + setQuestion(e.target.value)} + autoFocus + /> +
+ +
+ Options + {options.map((opt, index) => ( +
+ handleOptionChange(index, e.target.value)} + /> + handleRemoveOption(index)} + disabled={options.length <= 2} + aria-label={`Remove option ${index + 1}`} + > + + +
+ ))} + {options.length < 10 && ( + + )} +
+ +
+ Selection Type +
+ + +
+
+ + {error && ( + + {error} + + )} + + + + + +
+
+ ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 662bdc13d..3b6ea06eb 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -861,6 +861,10 @@ function Editor() { function Privacy() { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hidePresence, setHidePresence] = useSetting(settingsAtom, 'hidePresence'); + const [privateReadReceipts, setPrivateReadReceipts] = useSetting( + settingsAtom, + 'privateReadReceipts', + ); return ( @@ -872,6 +876,19 @@ function Privacy() { after={} /> + + + } + /> + (null); const directs = useDirectRooms(); const notificationPreferences = useRoomsNotificationPreferencesContext(); + const [filterQuery, setFilterQuery] = useState(''); const roomsWithUnreadSet = useAtomValue( useMemo( () => @@ -216,8 +218,14 @@ export function Direct() { return items; }, [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({ - count: sortedDirects.length, + count: filteredDirects.length, getScrollElement: () => scrollRef.current, estimateSize: () => 38, overscan: 10, @@ -253,6 +261,34 @@ export function Direct() { + + + ) => + setFilterQuery(e.target.value) + } + placeholder="Filter DMs…" + variant="Surface" + size="300" + radii="300" + after={ + filterQuery ? ( + setFilterQuery('')} + size="300" + radii="300" + variant="Background" + fill="None" + aria-label="Clear filter" + > + + + ) : undefined + } + /> + + {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedDirects[vItem.index]; + const roomId = filteredDirects[vItem.index]; const room = mx.getRoom(roomId); if (!room) return null; const selected = selectedRoomId === roomId; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 576835947..ba453a163 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -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 { Avatar, @@ -7,6 +14,7 @@ import { Icon, IconButton, Icons, + Input, Menu, MenuItem, PopOut, @@ -66,6 +74,7 @@ import { import { UseStateProvider } from '../../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../../components/join-address-prompt'; import { _RoomSearchParams } from '../../paths'; +import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta'; type HomeMenuProps = { requestClose: () => void; @@ -201,12 +210,14 @@ function HomeEmpty() { } const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); +const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite'); export function Home() { const mx = useMatrixClient(); useNavToActivePathMapper('home'); const scrollRef = useRef(null); const rooms = useHomeRooms(); const notificationPreferences = useRoomsNotificationPreferencesContext(); + const [filterQuery, setFilterQuery] = useState(''); // Perf-3: only re-render when the set of rooms WITH unread changes, not on count updates const roomsWithUnreadSet = useAtomValue( useMemo( @@ -235,8 +246,32 @@ export function Home() { const noRoomToDisplay = rooms.length === 0; 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 items = Array.from(rooms).sort( + const items = Array.from(otherRooms).sort( closedCategories.has(DEFAULT_CATEGORY_ID) ? factoryRoomIdByActivity(mx) : factoryRoomIdByAtoZ(mx), @@ -245,10 +280,28 @@ export function Home() { return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId); } 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({ - count: sortedRooms.length, + count: filteredRooms.length, getScrollElement: () => scrollRef.current, estimateSize: () => 38, overscan: 10, @@ -338,6 +391,73 @@ export function Home() { + + + ) => setFilterQuery(e.target.value)} + placeholder="Filter rooms…" + variant="Surface" + size="300" + radii="300" + after={ + filterQuery ? ( + setFilterQuery('')} + size="300" + radii="300" + variant="Background" + fill="None" + aria-label="Clear filter" + > + + + ) : undefined + } + /> + + + {sortedFavoriteRooms.length > 0 && ( + + + + Favorites + + +
+ {favVirtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedFavoriteRooms[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + return ( + + + + ); + })} +
+
+ )} {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedRooms[vItem.index]; + const roomId = filteredRooms[vItem.index]; const room = mx.getRoom(roomId); if (!room) return null; const selected = selectedRoomId === roomId; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9c8810d72..1f09a1a8c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -45,6 +45,7 @@ export interface Settings { pageZoom: number; hideActivity: boolean; hidePresence: boolean; + privateReadReceipts: boolean; presenceStatus: 'auto' | 'online' | 'idle' | 'dnd' | 'invisible'; isPeopleDrawer: boolean; @@ -93,6 +94,7 @@ const defaultSettings: Settings = { pageZoom: 100, hideActivity: false, hidePresence: false, + privateReadReceipts: false, presenceStatus: 'auto', isPeopleDrawer: true, diff --git a/src/app/utils/notifications.ts b/src/app/utils/notifications.ts index 87cf270a9..c0ff68596 100644 --- a/src/app/utils/notifications.ts +++ b/src/app/utils/notifications.ts @@ -1,6 +1,8 @@ import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; +import { getSettings } from '../state/settings'; export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { + const { privateReadReceipts } = getSettings(); const room = mx.getRoom(roomId); if (!room) return; @@ -21,6 +23,6 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip await mx.sendReadReceipt( latestEvent, - privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read, + privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read, ); }