Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/fix-account-signout-confirm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Account switcher: show a confirmation dialog before signing out of an account.

Closes #44
7 changes: 7 additions & 0 deletions .changeset/fix-autocomplete-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Autocomplete: pressing Enter now selects the highlighted item instead of sending the message. The first item is highlighted on open and ArrowUp/Down navigate the list while keeping typing focus in the editor. Focus returns to the message editor after completing a mention or emoji.

Closes #35, #69, #79
7 changes: 7 additions & 0 deletions .changeset/fix-cancel-add-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Adding account: show a "Cancel" button next to the "Adding account" label so users can abort the flow.

Closes #66
7 changes: 7 additions & 0 deletions .changeset/fix-editor-autocapitalize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Message editor: add `autoCapitalize="sentences"` to respect the OS/keyboard capitalisation setting on mobile.

Closes #52
7 changes: 7 additions & 0 deletions .changeset/fix-favicon-mentions-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Notifications: add "Favicon Dot: Mentions Only" setting — when enabled, the favicon badge only changes for mentions/keywords, not plain unreads.

Closes #168
7 changes: 7 additions & 0 deletions .changeset/fix-media-volume-persist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Video and audio messages: volume level is now persisted across page loads via `localStorage` and shared between all media players.

Closes #120
7 changes: 7 additions & 0 deletions .changeset/fix-reduced-motion-animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Reduced-motion: add `animation-iteration-count: 1` so spinners stop after one cycle instead of running indefinitely at near-zero speed.

Closes #2
7 changes: 7 additions & 0 deletions .changeset/fix-server-picker-ios-autofill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Server picker: prevent iOS from restoring the old server name while the user is actively editing the input.

Closes #45
7 changes: 7 additions & 0 deletions .changeset/fix-theme-color-meta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
default: patch
---

Browser tab/PWA: use the correct light (`#ffffff`) and dark (`#1b1a21`) theme-color values via `media` attribute on the meta tags.

Closes #103
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
property="og:description"
content="A Matrix client where you can enjoy the conversation using (not) simple, elegant and secure interface protected by e2ee with the power of open source. And colorful cosmetics. Because yes."
/>
<meta name="theme-color" content="#000000" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1b1a21" />

<link id="favicon" rel="shortcut icon" href="./public/favicon.png" />

Expand Down
4 changes: 2 additions & 2 deletions public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"background_color": "#fff",
"theme_color": "#fff",
"background_color": "#1b1a21",
"theme_color": "#1b1a21",
"icons": [
{
"src": "./public/android/android-chrome-36x36.png",
Expand Down
4 changes: 2 additions & 2 deletions src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
VideoContent,
} from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { Image, MediaControl, PersistedVolumeVideo } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
Expand Down Expand Up @@ -323,7 +323,7 @@ function RenderMessageContentInternal({
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
renderVideo={(p) => <PersistedVolumeVideo {...p} />}
/>
)}
outlined={outlineAttachment}
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
// Defer to OS capitalization setting (respects iOS sentence-case toggle).
autoCapitalize="sentences"
// keeps focus after pressing send.
onBlur={() => {
if (mobileOrTablet()) ReactEditor.focus(editor);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';

export const AutocompleteMenuBase = style([
Expand Down Expand Up @@ -38,3 +38,11 @@ export const AutocompleteNotice = style([
AutocompleteMenuHeader,
{ color: color.SurfaceVariant.OnContainer },
]);

export const AutocompleteMenuItems = style({});

globalStyle(`${AutocompleteMenuItems} button[data-selected='true']`, {
backgroundColor: color.SurfaceVariant.ContainerHover,
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: -2,
});
54 changes: 49 additions & 5 deletions src/app/components/editor/autocomplete/AutocompleteMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Header, Menu, Scroll, config } from 'folds';

import { preventScrollWithArrowKey, stopPropagation } from '$utils/keyboard';
import { useAlive } from '$hooks/useAlive';
import * as css from './AutocompleteMenu.css';
import { BaseAutocompleteMenu } from './BaseAutocompleteMenu';

export const AUTOCOMPLETE_NAVIGATE_EVENT = 'autocomplete-navigate';
export type AutocompleteNavigateDetail = { direction: 1 | -1 };

type AutocompleteMenuProps = {
requestClose: () => void;
headerContent: ReactNode;
children: ReactNode;
};
export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) {
const alive = useAlive();
const itemsRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const prevButtonCountRef = useRef(-1);

const handleDeactivate = () => {
if (alive()) {
Expand All @@ -23,6 +28,41 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto
}
};

// Sync data-selected to DOM; reset to index 0 when the item list changes
useLayoutEffect(() => {
const buttons = Array.from(
itemsRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? []
);
const count = buttons.length;

let idx = selectedIndex;
if (count !== prevButtonCountRef.current) {
prevButtonCountRef.current = count;
idx = 0;
if (selectedIndex !== 0) setSelectedIndex(0);
}

const safeIdx = Math.max(0, Math.min(idx, count - 1));
buttons.forEach((btn, i) => {
btn.setAttribute('data-selected', String(i === safeIdx));
});
}, [selectedIndex]);

// Listen for navigation events dispatched by the editor key handler
useEffect(() => {
const container = itemsRef.current?.closest('[data-autocomplete-menu]');
if (!container) return undefined;
const handler = (e: Event) => {
const { direction } = (e as CustomEvent<AutocompleteNavigateDetail>).detail;
setSelectedIndex((prev) => {
const buttons = itemsRef.current?.querySelectorAll('button') ?? [];
return Math.max(0, Math.min(prev + direction, buttons.length - 1));
});
};
container.addEventListener(AUTOCOMPLETE_NAVIGATE_EVENT, handler);
return () => container.removeEventListener(AUTOCOMPLETE_NAVIGATE_EVENT, handler);
}, []);

return (
<BaseAutocompleteMenu>
<FocusTrap
Expand All @@ -32,8 +72,6 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto
returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
escapeDeactivates: stopPropagation,
}}
>
Expand All @@ -42,7 +80,13 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto
{headerContent}
</Header>
<Scroll style={{ flexGrow: 1 }} onKeyDown={preventScrollWithArrowKey}>
<div style={{ padding: config.space.S200 }}>{children}</div>
<div
ref={itemsRef}
className={css.AutocompleteMenuItems}
style={{ padding: config.space.S200 }}
>
{children}
</div>
</Scroll>
</Menu>
</FocusTrap>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ type BaseAutocompleteMenuProps = {
export function BaseAutocompleteMenu({ children }: BaseAutocompleteMenuProps) {
return (
<div className={css.AutocompleteMenuBase}>
<div className={css.AutocompleteMenuContainer}>{children}</div>
<div className={css.AutocompleteMenuContainer} data-autocomplete-menu="true">
{children}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from '$types/matrix-sdk';

Expand Down Expand Up @@ -91,6 +92,7 @@ export function EmoticonAutocomplete({
const emoticonEl = createEmoticonElement(key, shortcode);
replaceWithElement(editor, query.range, emoticonEl);
moveCursor(editor, true);
ReactEditor.focus(editor);
requestClose();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { JoinRule, MatrixClient } from '$types/matrix-sdk';
import { useAtomValue } from 'jotai';
Expand Down Expand Up @@ -115,6 +116,7 @@ export function RoomMentionAutocomplete({
);
replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true);
ReactEditor.focus(editor);
requestClose();
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from '$types/matrix-sdk';

Expand Down Expand Up @@ -113,6 +114,7 @@ export function UserMentionAutocomplete({
);
replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true);
ReactEditor.focus(editor);
requestClose();
};

Expand Down
30 changes: 29 additions & 1 deletion src/app/components/media/Video.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { VideoHTMLAttributes, forwardRef } from 'react';
import { VideoHTMLAttributes, forwardRef, useEffect, useRef } from 'react';
import classNames from 'classnames';
import * as css from './media.css';

Expand All @@ -8,3 +8,31 @@ export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoE
<video className={classNames(css.Video, className)} {...props} ref={ref} />
)
);

export const MEDIA_VOLUME_KEY = 'mediaVolume';

export function PersistedVolumeVideo({
onVolumeChange,
...props
}: VideoHTMLAttributes<HTMLVideoElement>) {
const innerRef = useRef<HTMLVideoElement>(null);

useEffect(() => {
const stored = localStorage.getItem(MEDIA_VOLUME_KEY);
if (innerRef.current && stored !== null) {
const parsed = parseFloat(stored);
if (!Number.isNaN(parsed)) innerRef.current.volume = parsed;
}
}, []);

return (
<Video
{...props}
ref={innerRef}
onVolumeChange={(e) => {
localStorage.setItem(MEDIA_VOLUME_KEY, String((e.target as HTMLVideoElement).volume));
onVolumeChange?.(e);
}}
/>
);
}
20 changes: 18 additions & 2 deletions src/app/components/message/content/AudioContent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable jsx-a11y/media-has-caption */
import { ReactNode, useCallback, useRef, useState } from 'react';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
Expand All @@ -18,6 +18,7 @@ import { useThrottle } from '$hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '$utils/common';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { MEDIA_VOLUME_KEY } from '$components/media';

const PLAY_TIME_THROTTLE_OPS = {
wait: 500,
Expand Down Expand Up @@ -60,6 +61,14 @@ export function AudioContent({

const audioRef = useRef<HTMLAudioElement | null>(null);

useEffect(() => {
const stored = localStorage.getItem(MEDIA_VOLUME_KEY);
if (audioRef.current && stored !== null) {
const parsed = parseFloat(stored);
if (!Number.isNaN(parsed)) audioRef.current.volume = parsed;
}
}, []);

const [currentTime, setCurrentTime] = useState(0);
// duration in seconds. (NOTE: info.duration is in milliseconds)
const infoDuration = info.duration ?? 0;
Expand Down Expand Up @@ -197,7 +206,14 @@ export function AudioContent({
</>
),
children: (
<audio controls={false} autoPlay ref={audioRef}>
<audio
controls={false}
autoPlay
ref={audioRef}
onVolumeChange={(e) => {
localStorage.setItem(MEDIA_VOLUME_KEY, String((e.target as HTMLAudioElement).volume));
}}
>
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
</audio>
),
Expand Down
Loading
Loading