chore(lint): close all typecheck and eslint tech debt to enable husky pre-commit hook with --max-warnings 0

This commit is contained in:
v.lagerev 2026-05-16 17:22:53 +03:00
parent 45f5392a92
commit 88d3db2b24
91 changed files with 593 additions and 726 deletions

View file

@ -60,10 +60,12 @@ module.exports = {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-shadow': 'error',
// Policy: kept as warnings, not errors. The codebase has ~70 long-standing // Policy: kept as `warn` at the rule level so editors / `eslint --fix` /
// `any` casts and ~15 non-null assertions in matrix-js-sdk interop code. // ad-hoc runs surface them as warnings, but `npm run check:eslint` and
// Promoting to error would block builds on existing usage; turning off // `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block
// would lose signal on new code. Warnings are visible without blocking. // commit. When unavoidable (matrix-js-sdk boundary, generic helpers,
// third-party callback shapes), suppress on the line with
// `// eslint-disable-next-line` and a one-line justification.
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn', '@typescript-eslint/no-non-null-assertion': 'warn',
}, },
@ -86,6 +88,11 @@ module.exports = {
'no-plusplus': 'off', 'no-plusplus': 'off',
'prefer-template': 'off', 'prefer-template': 'off',
'no-param-reassign': 'off', 'no-param-reassign': 'off',
// `for (;;)` form upstream uses for the iter-loops trips eslint
// even though it's intentional — keep upstream control flow.
'no-constant-condition': 'off',
// Diagnostic `console.log` left as-is in vendor copy.
'no-console': 'off',
}, },
}, },
], ],

5
.husky/pre-commit Normal file → Executable file
View file

@ -1,3 +1,2 @@
# These are commented until we enable lint and typecheck npx tsc -p tsconfig.json --noEmit
# npx tsc -p tsconfig.json --noEmit npx lint-staged
# npx lint-staged

View file

@ -11,7 +11,7 @@ npm run typecheck # tsc --noEmit
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins. Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
> **Note:** `.husky/pre-commit` is currently commented out. `npm run check:eslint` is **green** (0 errors, 116 warnings — kept as warn for `no-explicit-any`/`no-non-null-assertion` policy). `npm run typecheck` still has ~32 known errors (residual project bugs after the TS 5.4 + Bundler migration that cleared ~800 module-resolution errors — see `docs/known-tech-debt-lint/`). Use `bash docs/known-tech-debt-lint/diff.sh` to verify your changes added no new typecheck errors, then `npm run build` for the green build check. > **Note:** `.husky/pre-commit` is enabled and runs `tsc --noEmit` + `lint-staged` (which calls `eslint --max-warnings 0` on staged JS/TS files). Both gates are zero: `npm run typecheck` and `npm run check:eslint` are green (0 errors, 0 warnings). Custom Matrix event-types (`AccountDataEvent.Vojo*`, `PoniesRoomEmotes`, `m.bridge`, `m.call.member` etc.) live in [`src/types/matrix/sdkAugmentation.d.ts`](../../src/types/matrix/sdkAugmentation.d.ts) — add new custom types there to keep `mx.getAccountData` / `mx.getStateEvent` calls type-safe.
## Source Layout ## Source Layout
@ -287,7 +287,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
- Current vojo work branch: `vojo/dev` - Current vojo work branch: `vojo/dev`
- Semantic-release on `dev` branch - Semantic-release on `dev` branch
- CI: GitHub Actions (build, deploy, docker, netlify) - CI: GitHub Actions (build, deploy, docker, netlify)
- **Husky pre-commit is currently disabled** — `npm run typecheck` and `npm run check:eslint` do not run automatically. `check:eslint` is green; `typecheck` still has ~32 known errors. Use `bash docs/known-tech-debt-lint/diff.sh` to check your changes don't add new typecheck errors. Re-enable husky once typecheck residual is cleared. - **Husky pre-commit runs `tsc --noEmit` + `lint-staged` (`eslint --max-warnings 0`)** — both must be green to commit. `no-explicit-any` and `no-non-null-assertion` policy: kept as `'warn'` in `.eslintrc.cjs` but blocked by `--max-warnings 0`. When introducing one is unavoidable (matrix-js-sdk boundary, generic helper, third-party callback shape), add an inline `// eslint-disable-next-line` with a one-line justification rather than relaxing the rule.
- **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades - **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades
- **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer - **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer

View file

@ -1,57 +0,0 @@
# Известный техдолг по линтеру
Эта папка фиксирует **известное состояние** `npm run typecheck` в репозитории. Build при этом зелёный, prod задеплоен — это исторический технический долг, не блокер. Папка нужна чтобы при любых изменениях кода сравнивать **delta** (только то что мы добавили), не путаясь в предсуществующих ошибках.
После апгрейда TypeScript 4.9 → 5.4 + `moduleResolution: "Bundler"` (см. историю коммита) основная масса (~803 из 835 предыдущих ошибок) исчезла. Осталось ~32 ошибки уже про реальные баги/несоответствия типов в нашем коде, не про модульное резолвинг. `npm run check:eslint` теперь — обычный зелёный чек (0 ошибок, 116 warnings), отдельный snapshot не нужен.
## Состав
| Файл | Что |
|---|---|
| `typecheck.snapshot.txt` | Полный stdout `npm run typecheck`. **~61 строка, ~32 ошибки.** |
| `diff.sh` | Скрипт сравнения: запускает текущий typecheck, сравнивает с snapshot-ом, выдаёт **только delta**. |
## Как пользоваться
```bash
bash docs/known-tech-debt-lint/diff.sh
```
На чистой ветке выводит:
```
=== typecheck diff vs known-tech-debt snapshot ===
no new typecheck errors
```
Если что-то сломал — выводит конкретные новые ошибки в формате `file.tsx(_,_): error TS...` (line/col маска чтобы pure-line-shift не давал phantom NEW + fixed). Реальные позиции — `npm run typecheck` напрямую. Если случайно починил предсуществующий долг — отчитается «(incidentally fixed: N)» к сведению.
Скрипт смотрит **working tree**, не staged-состояние. Для строгого pre-commit gate сначала apply'нуть свой stage в чистый worktree (`git stash --keep-index` + `bash diff.sh` + `git stash pop`).
`npm run check:eslint` запускайте напрямую — он зелёный.
## Что в долге (TL;DR)
**Typecheck (~32 ошибок):** реальные несоответствия типов. Категории:
- TS2345 keyof literal-union mismatch (~14): `mx.getAccountData(string)` / `mx.getStateEvent(...)` ждёт `keyof AccountDataEvents` (узкие литеральные типы), у нас передаются `AccountDataEvent.PoniesEmoteRooms`, `'m.call.member'`, `'in.cinny.spaces'` и т.п. — валидные Matrix event-types, но не в SDK-юнионе.
- TS2345 i18next signature (~3): `t('Room.members_count', { count: millify(...) })``count` хочет `number`, а `millify()` возвращает `string`. На рантайме отображается корректно (в локалях нет plural-вариантов).
- TS2345 / TS18048 `Room | undefined` / `Room | null` после `.filter((r) => !!r)` (~6): TS не пропускает truthy-фильтр без type predicate. UserChips.tsx, AddExisting.tsx, Invites.tsx, GlobalPacks.tsx. Runtime безопасно.
- TS2345 `IContent``RoomMessageEventContent` (1): MessageEditor.tsx — typing gap между общим content и room-message variant.
- TS7006 implicit `any` (6): event-handler params (`evt`, `event`, `ev`) в Message.tsx, EventReaders.tsx, UrlPreviewCard.tsx, LiveChip.tsx, MemberGlance.tsx, ReactionViewer.tsx.
- TS2540 read-only `sandbox` (1): CallEmbed.ts — `iframe.sandbox = "..."`. Современные DOM types сделали его `DOMTokenList` read-only, но браузеры всё ещё принимают строку.
- TS2353 unknown property `endpoint` (1): push.ts — лишнее поле в `setPusher.data`. SDK типы неполные, sygnal/UnifiedPush его читает.
- TS2322 `(number | undefined)[]``number[]` (1): usePowerLevelTags.ts — то же truthy-filter narrowing.
Build зелёный, ESLint зелёный. Все 32 оставшихся ошибки — type-strictness без runtime-импакта (truthy-filter narrowing, узкие SDK literal-union'ы, под-типированные event-handler params, слишком строгий DOM types). Это **известный долг**, не блокер. Будущая чистка — отдельный план (создать `docs/plans/typecheck_residual_cleanup.md` когда возьмёмся).
## Когда обновлять snapshot
Когда долг будет частично разруливаться отдельной задачей — после её мерджа пересоздать snapshot:
```bash
npm run typecheck > docs/known-tech-debt-lint/typecheck.snapshot.txt 2>&1
```
И обновить TL;DR в этом README.
Когда typecheck станет зелёным — удалить эту папку целиком и включить husky pre-commit hook.

View file

@ -1,91 +0,0 @@
#!/usr/bin/env bash
# Compare current `npm run typecheck` output to the known tech-debt snapshot.
# Emits ONLY new errors introduced relative to the snapshot — does not dump
# the full output, so agents can read the result without burning context.
#
# Comparison is line/col-insensitive: each error's `(L,C):` location is masked
# to `(_,_):` before sorting + comm, so a pure line shift (e.g. one added/
# removed line above the error) doesn't trigger a phantom NEW + fixed pair.
# Re-run `npm run typecheck` to see real positions for any errors flagged here.
#
# Caveat: this checks the working tree, not the staged worktree. If you stage
# a fix but leave it unstaged, or vice versa, the diff reports the working-tree
# state. For a strict pre-commit gate, run from a clean stash-apply state.
#
# `npm run check:eslint` is now a normal green check (0 errors); no snapshot
# needed there. Run it directly if you want to see warnings.
#
# Usage:
# bash docs/known-tech-debt-lint/diff.sh
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BASELINE_DIR="$ROOT/docs/known-tech-debt-lint"
TC_BASE="$BASELINE_DIR/typecheck.snapshot.txt"
# Temp files registered for cleanup on any exit path (success, error, ^C).
TMP_FILES=()
cleanup() { [ "${#TMP_FILES[@]}" -gt 0 ] && rm -f "${TMP_FILES[@]}"; }
trap cleanup EXIT INT TERM
# Tracks whether any new errors were introduced. Set to 1 by run_typecheck_diff
# when delta > 0; script exits with this code at end so CI / runbooks can use
# the script as a gate (e.g. `bash diff.sh && echo OK`).
NEW_ERRORS=0
run_typecheck_diff() {
echo "=== typecheck diff vs known-tech-debt snapshot ==="
local now tc_rc
now="$(mktemp)"
TMP_FILES+=("$now")
( cd "$ROOT" && npm run typecheck ) >"$now" 2>&1
tc_rc=$?
# Sanity-check: distinguish "tsc ran and reported errors" (rc=2, expected on
# this baseline) from "tsc/npm/node failed to run at all" (rc=other, broken
# toolchain). Without this guard a rc=127 "tsc: not found" or rc=1 npm error
# could produce stdout with no "error TS" lines and the diff would falsely
# report "no new errors" / "incidentally fixed: 33".
if [ "$tc_rc" -ne 0 ] && [ "$tc_rc" -ne 2 ] && ! grep -q "error TS" "$now"; then
echo " ERROR: 'npm run typecheck' did not run cleanly (exit=$tc_rc):"
sed 's/^/ /' "$now"
NEW_ERRORS=2
return
fi
# Mask `(line,col):` so a pure line shift doesn't change an error's identity.
# We compare on the masked form; for NEW lines we display the masked form (the
# real position is reproducible by running `npm run typecheck` directly).
# `sort` (NOT `sort -u`): we want to preserve cardinality so that two identical
# masked errors in the same file aren't collapsed to one — a regression that
# adds a duplicate error would otherwise be hidden by the first occurrence.
local mask_re='s/\([0-9]+,[0-9]+\):/(_,_):/'
local now_masked base_masked
now_masked="$(mktemp)"
base_masked="$(mktemp)"
TMP_FILES+=("$now_masked" "$base_masked")
sed -E "$mask_re" "$now" | grep -E "error TS" | sort > "$now_masked"
sed -E "$mask_re" "$TC_BASE" | grep -E "error TS" | sort > "$base_masked"
local new
new="$(comm -23 "$now_masked" "$base_masked")"
if [ -z "$new" ]; then
echo " no new typecheck errors"
else
local count
count="$(printf '%s\n' "$new" | wc -l)"
echo " NEW errors: $count (line/col masked — run \`npm run typecheck\` for real positions)"
printf '%s\n' "$new" | sed 's/^/ /'
NEW_ERRORS=1
fi
local fixed
fixed="$(comm -13 "$now_masked" "$base_masked")"
if [ -n "$fixed" ]; then
local fcount
fcount="$(printf '%s\n' "$fixed" | wc -l)"
echo " (incidentally fixed: $fcount)"
fi
}
run_typecheck_diff
exit "$NEW_ERRORS"

View file

@ -1,54 +0,0 @@
> vojo@4.11.1 typecheck
> tsc --noEmit
src/app/components/event-readers/EventReaders.tsx(82,31): error TS7006: Parameter 'event' implicitly has an 'any' type.
src/app/components/image-pack-view/RoomImagePack.tsx(47,9): error TS2345: Argument of type 'StateEvent.PoniesRoomEmotes' is not assignable to parameter of type 'keyof StateEvents'.
src/app/components/image-pack-view/UserImagePack.tsx(16,31): error TS2345: Argument of type 'AccountDataEvent.PoniesUserEmotes' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/components/room-card/RoomCard.tsx(259,18): error TS2345: Argument of type '["Explore.members_count", { count: string; }]' is not assignable to parameter of type '[key: string | string[], options: TOptionsBase & $Dictionary & { defaultValue: string; }] | [key: string | string[], defaultValue: string, options?: (TOptionsBase & $Dictionary) | undefined] | [key: ...]'.
Type '["Explore.members_count", { count: string; }]' is not assignable to type '[key: "Explore.members_count" | "Explore.members_count"[], options?: (TOptionsBase & $Dictionary) | undefined]'.
Type at position 1 in source is not compatible with type at position 1 in target.
Type '{ count: string; }' is not assignable to type 'TOptionsBase & $Dictionary'.
Type '{ count: string; }' is not assignable to type 'TOptionsBase'.
Types of property 'count' are incompatible.
Type 'string' is not assignable to type 'number'.
src/app/components/url-preview/UrlPreviewCard.tsx(57,27): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/components/user-profile/UserChips.tsx(271,13): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(272,28): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/components/user-profile/UserChips.tsx(275,26): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(276,29): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/components/user-profile/UserChips.tsx(279,25): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/features/add-existing/AddExisting.tsx(168,18): error TS2345: Argument of type '(Room | undefined)[]' is not assignable to parameter of type 'Room[]'.
Type 'Room | undefined' is not assignable to type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/features/call-status/LiveChip.tsx(90,35): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/features/call-status/MemberGlance.tsx(49,23): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/features/common-settings/general/RoomJoinRules.tsx(92,52): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/app/features/room/message/Message.tsx(860,31): error TS7006: Parameter 'ev' implicitly has an 'any' type.
src/app/features/room/message/MessageEditor.tsx(156,39): error TS2345: Argument of type 'IContent' is not assignable to parameter of type 'RoomMessageEventContent'.
Type 'IContent' is not assignable to type 'BaseTimelineEvent & Without<(Without<ReplyEvent, NoRelationEvent> & NoRelationEvent) | (Without<NoRelationEvent, ReplyEvent> & ReplyEvent), (Without<...> & RelationEvent) | (Without<...> & ReplacementEvent<...>)> & Without<...> & ReplacementEvent<...> & FileContent'.
Property '"body"' is missing in type 'IContent' but required in type 'BaseTimelineEvent'.
src/app/features/room/reaction-viewer/ReactionViewer.tsx(135,33): error TS7006: Parameter 'event' implicitly has an 'any' type.
src/app/features/settings/developer-tools/DevelopTools.tsx(30,31): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/features/settings/developer-tools/DevelopTools.tsx(39,54): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(161,44): error TS2345: Argument of type '(PackAddress | undefined)[]' is not assignable to parameter of type 'PackAddress[]'.
Type 'PackAddress | undefined' is not assignable to type 'PackAddress'.
Type 'undefined' is not assignable to type 'PackAddress'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(164,39): error TS2345: Argument of type '(PackAddress | undefined)[]' is not assignable to parameter of type 'PackAddress[]'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(311,27): error TS2345: Argument of type 'AccountDataEvent.PoniesEmoteRooms' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(328,31): error TS2345: Argument of type 'AccountDataEvent.PoniesEmoteRooms' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/hooks/useAccountData.ts(7,62): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/hooks/usePowerLevelTags.ts(14,9): error TS2322: Type '(number | undefined)[]' is not assignable to type 'number[]'.
Type 'number | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.
src/app/pages/client/inbox/Invites.tsx(722,45): error TS2345: Argument of type 'Room | null' is not assignable to parameter of type 'Room'.
Type 'null' is not assignable to type 'Room'.
src/app/pages/client/sidebar/SpaceTabs.tsx(750,27): error TS2345: Argument of type 'AccountDataEvent.VojoSpaces' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/pages/client/sidebar/SpaceTabs.tsx(797,25): error TS2345: Argument of type 'AccountDataEvent.VojoSpaces' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/plugins/call/CallEmbed.ts(129,12): error TS2540: Cannot assign to 'sandbox' because it is a read-only property.
src/app/plugins/recent-emoji.ts(45,21): error TS2345: Argument of type 'AccountDataEvent.ElementRecentEmoji' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/utils/push.ts(160,9): error TS2353: Object literal may only specify known properties, and 'endpoint' does not exist in type '{ format?: string | undefined; url?: string | undefined; brand?: string | undefined; }'.

View file

@ -12,7 +12,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier", "lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src", "check:eslint": "eslint --max-warnings 0 src",
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .", "fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@ -30,7 +30,7 @@
"commit": "git-cz" "commit": "git-cz"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint", "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --max-warnings 0",
"*": "prettier --ignore-unknown --write" "*": "prettier --ignore-unknown --write"
}, },
"config": { "config": {

View file

@ -647,7 +647,8 @@
"no_communities": "No communities found!", "no_communities": "No communities found!",
"space_badge": "Space", "space_badge": "Space",
"members_count": "{{count}} Members", "members_count_one": "{{formattedCount}} Member",
"members_count_other": "{{formattedCount}} Members",
"join": "Join", "join": "Join",
"joining": "Joining", "joining": "Joining",
"retry": "Retry", "retry": "Retry",

View file

@ -661,7 +661,10 @@
"no_communities": "Сообщества не найдены!", "no_communities": "Сообщества не найдены!",
"space_badge": "Пространство", "space_badge": "Пространство",
"members_count": "{{count}} участников", "members_count_one": "{{formattedCount}} участник",
"members_count_few": "{{formattedCount}} участника",
"members_count_many": "{{formattedCount}} участников",
"members_count_other": "{{formattedCount}} участника",
"join": "Присоединиться", "join": "Присоединиться",
"joining": "Вступление…", "joining": "Вступление…",
"retry": "Повторить", "retry": "Повторить",

View file

@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
> >
{stageToComplete.type === AuthType.Password && ( {stageToComplete.type === AuthType.Password && (
<PasswordStage <PasswordStage
userId={mx.getUserId()!} userId={mx.getSafeUserId()}
stageData={stageToComplete} stageData={stageToComplete}
onCancel={onCancel} onCancel={onCancel}
submitAuthDict={action} submitAuthDict={action}

View file

@ -42,7 +42,7 @@ function makeUIAAction<T>(
authData: IAuthData, authData: IAuthData,
performAction: PerformAction<T>, performAction: PerformAction<T>,
resolve: (data: T) => void, resolve: (data: T) => void,
reject: (error?: any) => void reject: (error?: unknown) => void
): UIAAction<T> { ): UIAAction<T> {
const action: UIAAction<T> = { const action: UIAAction<T> = {
authData, authData,

View file

@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
<Modal <Modal
className={ModalWide} className={ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
> >
{renderViewer({ {renderViewer({
src, src,

View file

@ -39,6 +39,8 @@ export function SecretStorageRecoveryPassphrase({
bits bits
); );
// matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) { if (!match) {
@ -131,6 +133,8 @@ export function SecretStorageRecoveryKey({
async (recoveryKey) => { async (recoveryKey) => {
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey); const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
// matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) { if (!match) {

View file

@ -34,6 +34,9 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
try { try {
validatedAuthMetadata = validateAuthMetadata(authMetadata); validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) { } catch (e) {
// Auth-metadata parsing failure is non-fatal; the client falls
// back to legacy `.well-known` discovery. Surface to dev console.
// eslint-disable-next-line no-console
console.error(e); console.error(e);
} }

View file

@ -6,6 +6,7 @@ import {
RestrictedAllowType, RestrictedAllowType,
Room, Room,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { RoomType, StateEvent } from '../../../types/matrix/room'; import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
@ -17,7 +18,7 @@ export const createRoomCreationContent = (
allowFederation: boolean, allowFederation: boolean,
additionalCreators: string[] | undefined additionalCreators: string[] | undefined
): object => { ): object => {
const content: Record<string, any> = {}; const content: Record<string, unknown> = {};
if (typeof type === 'string') { if (typeof type === 'string') {
content.type = type; content.type = type;
} }
@ -152,11 +153,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
if (data.parent) { if (data.parent) {
await mx.sendStateEvent( await mx.sendStateEvent(
data.parent.roomId, data.parent.roomId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild as keyof StateEvents,
{ {
auto_join: false, auto_join: false,
suggested: false, suggested: false,
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''], via: [getMxIdServer(mx.getSafeUserId()) ?? ''],
}, },
result.room_id result.room_id
); );

View file

@ -24,7 +24,7 @@ type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
const roomAliasFromQueryText = (mx: MatrixClient, text: string) => const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
isRoomAlias(`#${text}`) isRoomAlias(`#${text}`)
? `#${text}` ? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
function UnknownRoomMentionItem({ function UnknownRoomMentionItem({
query, query,

View file

@ -26,7 +26,7 @@ type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (mx: MatrixClient, text: string) => const userIdFromQueryText = (mx: MatrixClient, text: string) =>
isUserId(`@${text}`) isUserId(`@${text}`)
? `@${text}` ? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
function UnknownMentionItem({ function UnknownMentionItem({
userId, userId,
@ -92,7 +92,7 @@ export function UserMentionAutocomplete({
}: UserMentionAutocompleteProps) { }: UserMentionAutocompleteProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const roomId: string = room.roomId!; const { roomId } = room;
const roomAliasOrId = room.getCanonicalAlias() || roomId; const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId); const members = useRoomMembers(mx, roomId);

View file

@ -79,7 +79,7 @@ export const EventReaders = as<'div', EventReadersProps>(
key={readerId} key={readerId}
style={{ padding: `0 ${config.space.S200}` }} style={{ padding: `0 ${config.space.S200}` }}
radii="400" radii="400"
onClick={(event) => { onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
openProfile( openProfile(
room.roomId, room.roomId,
space?.roomId, space?.roomId,

View file

@ -17,7 +17,7 @@ type RoomImagePackProps = {
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getSafeUserId();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room); const creators = useRoomCreators(room);

View file

@ -8,7 +8,7 @@ import { useUserImagePack } from '../../hooks/useImagePacks';
export function UserImagePack() { export function UserImagePack() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]); const defaultPack = useMemo(() => new ImagePack(mx.getSafeUserId(), {}, undefined), [mx]);
const imagePack = useUserImagePack(); const imagePack = useUserImagePack();
const handleUpdate = useCallback( const handleUpdate = useCallback(

View file

@ -32,6 +32,8 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
// `object-fit: cover` (image) / `contain` (video) on the inner element. // `object-fit: cover` (image) / `contain` (video) on the inner element.
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN; const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
if ( if (
!naturalW ||
!naturalH ||
!Number.isFinite(naturalAspect) || !Number.isFinite(naturalAspect) ||
naturalAspect < STREAM_MEDIA_MIN_ASPECT || naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
naturalAspect > STREAM_MEDIA_MAX_ASPECT naturalAspect > STREAM_MEDIA_MAX_ASPECT
@ -39,10 +41,10 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) }; return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) };
} }
if (naturalAspect >= 1) { if (naturalAspect >= 1) {
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW!); const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW);
return { width: toRem(w), height: toRem(w / naturalAspect) }; return { width: toRem(w), height: toRem(w / naturalAspect) };
} }
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH!); const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH);
return { width: toRem(h * naturalAspect), height: toRem(h) }; return { width: toRem(h * naturalAspect), height: toRem(h) };
} }

View file

@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
<Modal <Modal
className={ModalWide} className={ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
> >
{renderViewer({ {renderViewer({
name: body, name: body,
@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
<Modal <Modal
className={ModalWide} className={ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
> >
{renderViewer({ {renderViewer({
name: body, name: body,

View file

@ -177,7 +177,7 @@ export const ImageContent = as<'div', ImageContentProps>(
<Modal <Modal
className={ModalWide} className={ModalWide}
size="500" size="500"
onContextMenu={(evt: any) => evt.stopPropagation()} onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
> >
{renderViewer({ {renderViewer({
src: srcState.data, src: srcState.data,
@ -214,10 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
)} )}
{srcState.status === AsyncStatus.Success && ( {srcState.status === AsyncStatus.Success && (
<Box <Box
className={classNames( className={classNames(css.AbsoluteContainer, blurred ? css.Blur : css.ImageClickable)}
css.AbsoluteContainer,
blurred ? css.Blur : css.ImageClickable
)}
> >
{renderImage({ {renderImage({
alt: body, alt: body,

View file

@ -15,11 +15,7 @@ import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css'; import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { import { SIDEBAR_WIDTH_MIN, clampSidebarWidth, sidebarWidthAtom } from '../../state/sidebarWidth';
SIDEBAR_WIDTH_MIN,
clampSidebarWidth,
sidebarWidthAtom,
} from '../../state/sidebarWidth';
import { import {
VOJO_HORSESHOE_VOID_COLOR, VOJO_HORSESHOE_VOID_COLOR,
VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_GAP_PX,
@ -84,10 +80,7 @@ export function PageRoot({ nav, children }: PageRootProps) {
apparent colour unchanged for routes whose content has no apparent colour unchanged for routes whose content has no
opaque bg of its own (e.g. ChannelsLanding) without it opaque bg of its own (e.g. ChannelsLanding) without it
the outer void would bleed through. */} the outer void would bleed through. */}
<Box <Box grow="Yes" style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}>
grow="Yes"
style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}
>
<Box <Box
grow="Yes" grow="Yes"
className={ContainerColor({ variant: 'Background' })} className={ContainerColor({ variant: 'Background' })}
@ -147,6 +140,9 @@ export function PageNav({
const horseshoe = useHorseshoeEnabled(); const horseshoe = useHorseshoeEnabled();
if (resizable && !isMobile) { if (resizable && !isMobile) {
// `ResizablePageNav` is a function declaration (hoisted) below — the
// forward reference is safe at runtime.
// eslint-disable-next-line no-use-before-define
return <ResizablePageNav>{children}</ResizablePageNav>; return <ResizablePageNav>{children}</ResizablePageNav>;
} }
@ -160,9 +156,7 @@ export function PageNav({
// we can override the default `Background.Container` without // we can override the default `Background.Container` without
// touching the recipe. // touching the recipe.
const surfaceStyle = const surfaceStyle =
surface === 'surfaceVariant' surface === 'surfaceVariant' ? { backgroundColor: color.SurfaceVariant.Container } : undefined;
? { backgroundColor: color.SurfaceVariant.Container }
: undefined;
return ( return (
<Box <Box
@ -199,9 +193,7 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
const handleRef = useRef<HTMLDivElement>(null); const handleRef = useRef<HTMLDivElement>(null);
const horseshoe = useHorseshoeEnabled(); const horseshoe = useHorseshoeEnabled();
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom); const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
const [vw, setVw] = useState<number>( const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
typeof window !== 'undefined' ? window.innerWidth : 1280
);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
// Live width during a drag — kept in component state so we don't write to // Live width during a drag — kept in component state so we don't write to
// the localStorage-backed atom on every pointermove (hundreds of sync disk // the localStorage-backed atom on every pointermove (hundreds of sync disk
@ -322,6 +314,11 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
{children} {children}
</Box> </Box>
{canResize && ( {canResize && (
// Canonical WAI-ARIA window-splitter pattern: focusable separator
// with aria-orientation and current/min/max values. The strict
// role-supports-aria-props lookup table doesn't model the splitter
// sub-pattern, but assistive tech does.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-tabindex
<div <div
ref={handleRef} ref={handleRef}
role="separator" role="separator"
@ -330,6 +327,7 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
aria-valuemin={SIDEBAR_WIDTH_MIN} aria-valuemin={SIDEBAR_WIDTH_MIN}
aria-valuemax={maxW} aria-valuemax={maxW}
aria-label="Resize sidebar" aria-label="Resize sidebar"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0} tabIndex={0}
className={css.PageNavResizeHandle} className={css.PageNavResizeHandle}
// On web the page-nav is followed by the horseshoe void gap // On web the page-nav is followed by the horseshoe void gap

View file

@ -256,7 +256,10 @@ export const RoomCard = as<'div', RoomCardProps>(
<Box gap="100"> <Box gap="100">
<Icon size="50" src={Icons.User} /> <Icon size="50" src={Icons.User} />
<Text size="T200"> <Text size="T200">
{t('Explore.members_count', { count: millify(joinedMemberCount) })} {t('Explore.members_count', {
count: joinedMemberCount,
formattedCount: millify(joinedMemberCount),
})}
</Text> </Text>
</Box> </Box>
)} )}

View file

@ -54,7 +54,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
alt={prev['og:title']} alt={prev['og:title']}
title={prev['og:title']} title={prev['og:title']}
tabIndex={0} tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)} onKeyDown={(evt: React.KeyboardEvent) => onEnterOrSpace(() => setViewer(true))(evt)}
onClick={() => setViewer(true)} onClick={() => setViewer(true)}
/> />
)} )}

View file

@ -30,6 +30,7 @@ import React, {
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -136,7 +137,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
await mx.sendStateEvent( await mx.sendStateEvent(
parentId, parentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild as keyof StateEvents,
{ {
auto_join: false, auto_join: false,
suggested: false, suggested: false,
@ -164,7 +165,9 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
}; };
const handleApplyChanges = () => { const handleApplyChanges = () => {
const selectedRooms = selected.map((rId) => getRoom(rId)).filter((room) => room !== undefined); const selectedRooms = selected
.map((rId) => getRoom(rId))
.filter((room): room is Room => room !== undefined);
applyChanges(selectedRooms).then(() => { applyChanges(selectedRooms).then(() => {
if (alive()) { if (alive()) {
setSelected([]); setSelected([]);

View file

@ -264,7 +264,9 @@ export class BotWidgetEmbed {
} catch { } catch {
return; return;
} }
void openExternalUrl(url); openExternalUrl(url).catch(() => {
/* fire-and-forget: log handled inside openExternalUrl */
});
}; };
public constructor(private readonly options: BotWidgetEmbedOptions) { public constructor(private readonly options: BotWidgetEmbedOptions) {

View file

@ -47,7 +47,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
className={css.CallMemberCard} className={css.CallMemberCard}
variant="SurfaceVariant" variant="SurfaceVariant"
radii="500" radii="500"
onClick={(evt: any) => onClick={(evt: React.MouseEvent<HTMLButtonElement>) =>
openUserProfile( openUserProfile(
room.roomId, room.roomId,
undefined, undefined,

View file

@ -1,6 +1,7 @@
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react'; import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import type { StateEvents, TimelineEvents } from 'matrix-js-sdk';
import { import {
Box, Box,
Chip, Chip,
@ -53,9 +54,18 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
useCallback( useCallback(
(evtType, evtStateKey, evtContent) => { (evtType, evtStateKey, evtContent) => {
if (typeof evtStateKey === 'string') { if (typeof evtStateKey === 'string') {
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey); return mx.sendStateEvent(
room.roomId,
evtType as keyof StateEvents,
evtContent as StateEvents[keyof StateEvents],
evtStateKey
);
} }
return mx.sendEvent(room.roomId, evtType as any, evtContent); return mx.sendEvent(
room.roomId,
evtType as keyof TimelineEvents,
evtContent as TimelineEvents[keyof TimelineEvents]
);
}, },
[mx, room] [mx, room]
) )

View file

@ -15,6 +15,7 @@ import {
} from 'folds'; } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { Page, PageHeader } from '../../../components/page'; import { Page, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { TextViewerContent } from '../../../components/text-viewer'; import { TextViewerContent } from '../../../components/text-viewer';
@ -61,7 +62,13 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>( const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
useCallback( useCallback(
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey), (c) =>
mx.sendStateEvent(
room.roomId,
type as keyof StateEvents,
c as StateEvents[keyof StateEvents],
stateKey
),
[mx, room, type, stateKey] [mx, room, type, stateKey]
) )
); );

View file

@ -18,6 +18,7 @@ import {
} from 'folds'; } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { import {
ImagePack, ImagePack,
@ -59,7 +60,12 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
display_name: name, display_name: name,
}, },
}; };
await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey); await mx.sendStateEvent(
roomId,
StateEvent.PoniesRoomEmotes as keyof StateEvents,
content as StateEvents[keyof StateEvents],
stateKey
);
}, },
[mx, roomId] [mx, roomId]
) )
@ -164,7 +170,12 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
for (let i = 0; i < removedPacks.length; i += 1) { for (let i = 0; i < removedPacks.length; i += 1) {
const addr = removedPacks[i]; const addr = removedPacks[i];
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey); await mx.sendStateEvent(
room.roomId,
StateEvent.PoniesRoomEmotes as keyof StateEvents,
{} as StateEvents[keyof StateEvents],
addr.stateKey
);
} }
}, [mx, room, removedPacks]) }, [mx, room, removedPacks])
); );

View file

@ -65,6 +65,9 @@ export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesPr
<SettingTile <SettingTile
title={t('RoomSettings.published_addresses')} title={t('RoomSettings.published_addresses')}
description={ description={
// Static i18n string from the in-repo locale bundle — only `<b>`
// markup, no user-controlled input. Safe.
// eslint-disable-next-line react/no-danger
<span dangerouslySetInnerHTML={{ __html: t('RoomSettings.published_addresses_desc') }} /> <span dangerouslySetInnerHTML={{ __html: t('RoomSettings.published_addresses_desc') }} />
} }
/> />

View file

@ -17,6 +17,7 @@ import {
} from 'folds'; } from 'folds';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@ -48,7 +49,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
const [enableState, enable] = useAsyncCallback( const [enableState, enable] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, { await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as keyof StateEvents, {
algorithm: ROOM_ENC_ALGO, algorithm: ROOM_ENC_ALGO,
}); });
}, [mx, room.roomId]) }, [mx, room.roomId])

View file

@ -13,6 +13,7 @@ import {
Text, Text,
} from 'folds'; } from 'folds';
import { HistoryVisibility, MatrixError } from 'matrix-js-sdk'; import { HistoryVisibility, MatrixError } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types'; import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -80,7 +81,11 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp
const content: RoomHistoryVisibilityEventContent = { const content: RoomHistoryVisibilityEventContent = {
history_visibility: visibility, history_visibility: visibility,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content); await mx.sendStateEvent(
room.roomId,
StateEvent.RoomHistoryVisibility as keyof StateEvents,
content
);
}, },
[mx, room.roomId] [mx, room.roomId]
) )

View file

@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react';
import { color, Text } from 'folds'; import { color, Text } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { import {
@ -88,7 +89,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
const parents = getStateEvents(room, StateEvent.SpaceParent) const parents = getStateEvents(room, StateEvent.SpaceParent)
.map((event) => event.getStateKey()) .map((event) => event.getStateKey())
.filter((parentId) => typeof parentId === 'string') .filter((parentId): parentId is string => typeof parentId === 'string')
.filter((parentId) => roomParents?.has(parentId)); .filter((parentId) => roomParents?.has(parentId));
if (parents.length === 0 && space && roomParents) { if (parents.length === 0 && space && roomParents) {
@ -113,7 +114,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
join_rule: joinRule as JoinRule, join_rule: joinRule as JoinRule,
}; };
if (allow.length > 0) c.allow = allow; if (allow.length > 0) c.allow = allow;
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as keyof StateEvents, c);
}, },
[mx, room, space, subspaces, roomIdToParents] [mx, room, space, subspaces, roomIdToParents]
) )

View file

@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import classNames from 'classnames'; import classNames from 'classnames';
import { JoinRule, MatrixError } from 'matrix-js-sdk'; import { JoinRule, MatrixError } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css'; import { SequenceCardStyle } from '../../room-settings/styles.css';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
@ -94,15 +95,19 @@ export function RoomProfileEdit({
useCallback( useCallback(
async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => { async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
if (roomAvatarMxc !== undefined) { if (roomAvatarMxc !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, { await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as keyof StateEvents, {
url: roomAvatarMxc, url: roomAvatarMxc,
}); });
} }
if (roomName !== undefined) { if (roomName !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName }); await mx.sendStateEvent(room.roomId, StateEvent.RoomName as keyof StateEvents, {
name: roomName,
});
} }
if (roomTopic !== undefined) { if (roomTopic !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic }); await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as keyof StateEvents, {
topic: roomTopic,
});
} }
}, },
[mx, room.roomId] [mx, room.roomId]

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds'; import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import produce from 'immer'; import produce from 'immer';
import type { StateEvents } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
@ -87,7 +88,11 @@ export function PermissionGroups({
return draftPowerLevels; return draftPowerLevels;
}); });
await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels); await mx.sendStateEvent(
room.roomId,
StateEvent.RoomPowerLevels as keyof StateEvents,
editedPowerLevels
);
}, [mx, room, powerLevels, permissionUpdate, permissionGroups]) }, [mx, room, powerLevels, permissionUpdate, permissionGroups])
); );

View file

@ -21,6 +21,7 @@ import {
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import type { StateEvents } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
import { IPowerLevels } from '../../../hooks/usePowerLevels'; import { IPowerLevels } from '../../../hooks/usePowerLevels';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@ -336,7 +337,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
deleted.forEach((power) => { deleted.forEach((power) => {
delete content[power]; delete content[power];
}); });
await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as any, content); await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as keyof StateEvents, content);
}, [mx, room, powerLevelTags, editedPowerTags, deleted]) }, [mx, room, powerLevelTags, editedPowerTags, deleted])
); );

View file

@ -15,6 +15,7 @@ import {
Spinner, Spinner,
toRem, toRem,
} from 'folds'; } from 'folds';
import type { StateEvents } from 'matrix-js-sdk';
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room'; import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
@ -48,7 +49,12 @@ function SuggestMenuItem({
const [toggleState, handleToggleSuggested] = useAsyncCallback( const [toggleState, handleToggleSuggested] = useAsyncCallback(
useCallback(() => { useCallback(() => {
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested }; const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
return mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, newContent, roomId); return mx.sendStateEvent(
parentId,
StateEvent.SpaceChild as keyof StateEvents,
newContent,
roomId
);
}, [mx, parentId, roomId, content]) }, [mx, parentId, roomId, content])
); );
@ -85,7 +91,7 @@ function RemoveMenuItem({
const [removeState, handleRemove] = useAsyncCallback( const [removeState, handleRemove] = useAsyncCallback(
useCallback( useCallback(
() => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId), () => mx.sendStateEvent(parentId, StateEvent.SpaceChild as keyof StateEvents, {}, roomId),
[mx, parentId, roomId] [mx, parentId, roomId]
) )
); );

View file

@ -4,6 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk'; import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import type { AccountDataEvents, StateEvents } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import produce from 'immer'; import produce from 'immer';
@ -273,7 +274,7 @@ export function Lobby() {
if (!reorder.item.parentId) return; if (!reorder.item.parentId) return;
await mx.sendStateEvent( await mx.sendStateEvent(
reorder.item.parentId, reorder.item.parentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild as keyof StateEvents,
{ ...reorder.item.content, order: reorder.orderKey }, { ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId reorder.item.roomId
); );
@ -298,7 +299,12 @@ export function Lobby() {
// remove from current space // remove from current space
if (item.parentId !== containerParentId) { if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); mx.sendStateEvent(
item.parentId,
StateEvent.SpaceChild as keyof StateEvents,
{},
item.roomId
);
} }
if ( if (
@ -318,7 +324,7 @@ export function Lobby() {
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
[]; [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as keyof StateEvents, {
...joinRuleContent, ...joinRuleContent,
allow, allow,
}); });
@ -360,7 +366,7 @@ export function Lobby() {
await rateLimitedActions(reorders, async (reorder) => { await rateLimitedActions(reorders, async (reorder) => {
await mx.sendStateEvent( await mx.sendStateEvent(
containerParentId, containerParentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild as keyof StateEvents,
{ ...reorder.item.content, order: reorder.orderKey }, { ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId reorder.item.roomId
); );
@ -422,7 +428,10 @@ export function Lobby() {
newItems.push(rId); newItems.push(rId);
} }
const newSpacesContent = makeVojoSpacesContent(mx, newItems); const newSpacesContent = makeVojoSpacesContent(mx, newItems);
mx.setAccountData(AccountDataEvent.VojoSpaces as any, newSpacesContent as any); mx.setAccountData(
AccountDataEvent.VojoSpaces as keyof AccountDataEvents,
newSpacesContent as AccountDataEvents[keyof AccountDataEvents]
);
}, },
[mx, sidebarItems, sidebarSpaces] [mx, sidebarItems, sidebarSpaces]
); );

View file

@ -333,7 +333,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
}; };
const handleCreateSpace = () => { const handleCreateSpace = () => {
openCreateSpaceModal(item.roomId as any); openCreateSpaceModal(item.roomId);
setCords(undefined); setCords(undefined);
}; };

View file

@ -14,13 +14,13 @@
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Spinner, Text } from 'folds'; import { Box, Icon, IconButton, Icons, Spinner, Text } from 'folds';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import classNames from 'classnames'; import classNames from 'classnames';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { MatrixEvent, MatrixEventEvent, MsgType, RoomEvent } from 'matrix-js-sdk'; import { MatrixEvent, MatrixEventEvent, MsgType, RoomEvent } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { MediaViewerEntry } from '../../state/mediaViewer'; import { MediaViewerEntry } from '../../state/mediaViewer';
import { useOpenMediaViewer } from '../../state/hooks/mediaViewer'; import { useOpenMediaViewer } from '../../state/hooks/mediaViewer';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@ -28,11 +28,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
decryptFile,
downloadEncryptedMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes'; import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
import { import {
IImageContent, IImageContent,
@ -74,7 +70,7 @@ function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null
if (!content) return null; if (!content) return null;
const eventId = ev.getId(); const eventId = ev.getId();
if (!eventId) return null; if (!eventId) return null;
const file = content.file; const { file } = content;
const url = file?.url ?? content.url; const url = file?.url ?? content.url;
if (!url) return null; if (!url) return null;
const encInfo: EncryptedAttachmentInfo | undefined = file const encInfo: EncryptedAttachmentInfo | undefined = file
@ -182,21 +178,18 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed)); setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
}, []); }, []);
const clampPan = useCallback( const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => {
(raw: { x: number; y: number }, currentZoom: number) => { const stage = stageRef.current;
const stage = stageRef.current; const img = imgRef.current;
const img = imgRef.current; if (!stage || !img) return raw;
if (!stage || !img) return raw; const stageRect = stage.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect(); const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2);
const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2); const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2);
const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2); return {
return { x: Math.max(-maxX, Math.min(maxX, raw.x)),
x: Math.max(-maxX, Math.min(maxX, raw.x)), y: Math.max(-maxY, Math.min(maxY, raw.y)),
y: Math.max(-maxY, Math.min(maxY, raw.y)), };
}; }, []);
},
[]
);
// Anchor-aware zoom math (image-local point under anchor stays // Anchor-aware zoom math (image-local point under anchor stays
// under the anchor after zoom change) is inlined in the pinch // under the anchor after zoom change) is inlined in the pinch
@ -279,13 +272,14 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
// `lastBlobUrlRef.current`", the unmount cleanup is // `lastBlobUrlRef.current`", the unmount cleanup is
// unambiguous. // unambiguous.
const lastBlobUrlRef = useRef<string | null>(null); const lastBlobUrlRef = useRef<string | null>(null);
const { url: entryUrl, encInfo: entryEncInfo, mimeType: entryMimeType } = entry;
const [srcState, loadSrc] = useAsyncCallback( const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, entry.url, useAuthentication); const mediaUrl = mxcUrlToHttp(mx, entryUrl, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL'); if (!mediaUrl) throw new Error('Invalid media URL');
if (entry.encInfo) { if (entryEncInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, entry.mimeType ?? FALLBACK_MIMETYPE, entry.encInfo!) decryptFile(encBuf, entryMimeType ?? FALLBACK_MIMETYPE, entryEncInfo)
); );
const blob = URL.createObjectURL(fileContent); const blob = URL.createObjectURL(fileContent);
if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) { if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) {
@ -295,7 +289,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
return blob; return blob;
} }
return mediaUrl; return mediaUrl;
}, [mx, entry.url, entry.encInfo, entry.mimeType, useAuthentication]) }, [mx, entryUrl, entryEncInfo, entryMimeType, useAuthentication])
); );
useEffect(() => { useEffect(() => {
@ -375,6 +369,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
return entries; return entries;
// `timelineVersion` is the actual refresh trigger; `entry.eventId` // `timelineVersion` is the actual refresh trigger; `entry.eventId`
// is intentionally NOT a dep — stepping doesn't change the set. // is intentionally NOT a dep — stepping doesn't change the set.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, timelineVersion]); }, [room, timelineVersion]);
const currentIndex = useMemo( const currentIndex = useMemo(
@ -406,9 +401,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
target && target &&
(target.tagName === 'INPUT' || (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) { ) {
return; return;
} }
@ -622,10 +615,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
const pts = Array.from(pointerCacheRef.current.values()); const pts = Array.from(pointerCacheRef.current.values());
const dist = distanceBetween(pts[0], pts[1]); const dist = distanceBetween(pts[0], pts[1]);
const ratio = dist / ps.initialDistance; const ratio = dist / ps.initialDistance;
const nextZoom = Math.max( const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, ps.initialZoom * ratio));
MIN_ZOOM,
Math.min(maxZoomRef.current, ps.initialZoom * ratio)
);
// Anchor-aware: hold `anchorImage*` (image-local point // Anchor-aware: hold `anchorImage*` (image-local point
// captured at pinch start) under the moving midpoint. // captured at pinch start) under the moving midpoint.
const midClientX = (pts[0].x + pts[1].x) / 2; const midClientX = (pts[0].x + pts[1].x) / 2;
@ -661,8 +651,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
} }
if (s.claimed !== 'swipe') return; if (s.claimed !== 'swipe') return;
if (e.cancelable) e.preventDefault(); if (e.cancelable) e.preventDefault();
const blocked = const blocked = (dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current);
(dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current);
setSwipeOffset(blocked ? dx / 3 : dx); setSwipeOffset(blocked ? dx / 3 : dx);
return; return;
} }
@ -731,10 +720,14 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (entryKindRef.current !== 'image') return; if (entryKindRef.current !== 'image') return;
e.preventDefault(); e.preventDefault();
const dyPx = let dyPx: number;
e.deltaMode === 1 ? e.deltaY * 16 // line mode → ~16px / line if (e.deltaMode === 1) {
: e.deltaMode === 2 ? e.deltaY * stageEl.clientHeight // page mode dyPx = e.deltaY * 16; // line mode → ~16px / line
: e.deltaY; } else if (e.deltaMode === 2) {
dyPx = e.deltaY * stageEl.clientHeight; // page mode
} else {
dyPx = e.deltaY;
}
const factor = Math.exp(-dyPx * 0.0025); const factor = Math.exp(-dyPx * 0.0025);
const oldZoom = zoomRef.current; const oldZoom = zoomRef.current;
const nextZoomRaw = oldZoom * factor; const nextZoomRaw = oldZoom * factor;
@ -802,7 +795,8 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
const transform = isReady const transform = isReady
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})` ? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
: undefined; : undefined;
const imageTransition = swipeOffset === 0 ? 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)' : 'none'; const imageTransition =
swipeOffset === 0 ? 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
return ( return (
<div className={css.root}> <div className={css.root}>
@ -863,9 +857,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
> >
<Icon size="100" src={Icons.Download} /> <Icon size="100" src={Icons.Download} />
</IconButton> </IconButton>
{entry.kind === 'image' && ( {entry.kind === 'image' && <div className={css.headerSeparator} aria-hidden="true" />}
<div className={css.headerSeparator} aria-hidden="true" />
)}
<IconButton <IconButton
variant="Background" variant="Background"
fill="None" fill="None"
@ -881,6 +873,9 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
<div className={css.stage} ref={stageRef}> <div className={css.stage} ref={stageRef}>
{isReady && effectiveSrc && entry.kind === 'image' && ( {isReady && effectiveSrc && entry.kind === 'image' && (
// `onMouseDown` drives drag-pan when the image is zoomed in.
// Keyboard pan is wired separately at the stage level.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<img <img
ref={imgRef} ref={imgRef}
src={effectiveSrc} src={effectiveSrc}
@ -965,7 +960,6 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
</> </>
)} )}
</div> </div>
</div> </div>
); );
} }

View file

@ -13,6 +13,7 @@ import { useAtom, useAtomValue } from 'jotai';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate'; import { Transforms, Editor } from 'slate';
import { import {
@ -149,12 +150,7 @@ const COMPOSER_PLACEHOLDER_KEYS = [
// no fills. // no fills.
const StreamComposerIcons = { const StreamComposerIcons = {
Plus: () => ( Plus: () => (
<path <path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
), ),
Smile: () => ( Smile: () => (
<> <>
@ -238,9 +234,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor; const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor;
const [uploadBoard, setUploadBoard] = useState(true); const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom( const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(inputDraftKey));
roomIdToUploadItemsAtomFamily(inputDraftKey)
);
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily, roomUploadAtomFamily,
selectedFiles.map((f) => f.file) selectedFiles.map((f) => f.file)
@ -374,7 +368,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}); });
handleCancelUpload(uploads); handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, threadId ?? null, content as any)); contents.forEach((content) =>
mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent)
);
}; };
const submit = useCallback(() => { const submit = useCallback(() => {
@ -453,7 +449,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false; content['m.relates_to'].is_falling_back = false;
} }
} }
mx.sendMessage(roomId, threadId ?? null, content as any); mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setReplyDraft(undefined); setReplyDraft(undefined);
@ -634,8 +630,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const placeholderKey = useMemo(() => { const placeholderKey = useMemo(() => {
const idx = new Date().getHours() % COMPOSER_PLACEHOLDER_KEYS.length; const idx = new Date().getHours() % COMPOSER_PLACEHOLDER_KEYS.length;
return COMPOSER_PLACEHOLDER_KEYS[idx]; return COMPOSER_PLACEHOLDER_KEYS[idx];
// roomId and threadId are intentional re-roll triggers; the // roomId and threadId are intentional re-roll triggers (re-memoize on
// exhaustive-deps lint is happy with them too. // chat/thread navigation), not values read inside the body.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId, threadId]); }, [roomId, threadId]);
return ( return (

View file

@ -27,17 +27,7 @@ import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js'; import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { import { Box, Chip, Icon, Icons, Scroll, Text, as, config, toRem } from 'folds';
Box,
Chip,
Icon,
Icons,
Scroll,
Text,
as,
config,
toRem,
} from 'folds';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -447,7 +437,7 @@ const getEmptyTimeline = () => ({
}); });
const getRoomUnreadInfo = (room: Room, scrollTo = false) => { const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); const readUptoEventId = room.getEventReadUpTo(room.client.getSafeUserId());
if (!readUptoEventId) return undefined; if (!readUptoEventId) return undefined;
const evtTimeline = getEventTimeline(room, readUptoEventId); const evtTimeline = getEventTimeline(room, readUptoEventId);
const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward); const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
@ -794,7 +784,10 @@ export function RoomTimeline({
// Check if the document is in focus (user is actively viewing the app), // Check if the document is in focus (user is actively viewing the app),
// and either there are no unread messages or the latest message is from the current user. // and either there are no unread messages or the latest message is from the current user.
// If either condition is met, trigger the markAsRead function to send a read receipt. // If either condition is met, trigger the markAsRead function to send a read receipt.
requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity)); const evtRoomId = mEvt.getRoomId();
if (evtRoomId) {
requestAnimationFrame(() => markAsRead(mx, evtRoomId, hideActivity));
}
} }
if (!document.hasFocus() && !unreadInfo) { if (!document.hasFocus() && !unreadInfo) {
@ -1308,25 +1301,32 @@ export function RoomTimeline({
// Per-slot ordered list of sessions. Newest session is `last(sessions)`; // Per-slot ordered list of sessions. Newest session is `last(sessions)`;
// earlier entries are closed (count returned to 0). // earlier entries are closed (count returned to 0).
const slotSessions = new Map<string, CallScan[]>(); const slotSessions = new Map<string, CallScan[]>();
// Hot data-pipeline path: nested for-of with early `continue` is the
// clearest expression of the state-machine. Array iteration helpers would
// require restructuring the dual-key membership ledger.
/* eslint-disable no-restricted-syntax, no-continue */
for (const tl of timeline.linkedTimelines) { for (const tl of timeline.linkedTimelines) {
const events = tl.getEvents(); const events = tl.getEvents();
for (const ev of events) { for (const ev of events) {
if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue; if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue;
const content = ev.getContent<SessionMembershipData>(); const content = ev.getContent<SessionMembershipData>();
const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>; const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>;
const slotId = let slotId: string | null = null;
typeof content.call_id === 'string' if (typeof content.call_id === 'string') {
? content.call_id slotId = content.call_id;
: typeof prevContent.call_id === 'string' } else if (typeof prevContent.call_id === 'string') {
? prevContent.call_id slotId = prevContent.call_id;
: null; }
if (slotId == null) continue; if (slotId == null) continue;
// `anchorEventId` is the React key for the merged call bubble; an
// empty fallback would collide across multiple eventless rows.
const evId = ev.getId();
if (!evId) continue;
const ts = ev.getTs(); const ts = ev.getTs();
const isJoin = !!content.application; const isJoin = !!content.application;
const wasPreviouslyJoined = !!prevContent.application; const wasPreviouslyJoined = !!prevContent.application;
const sender = ev.getSender() ?? ''; const sender = ev.getSender() ?? '';
const stateKey = ev.getStateKey() ?? ''; const stateKey = ev.getStateKey() ?? '';
const evId = ev.getId() ?? '';
let sessions = slotSessions.get(slotId); let sessions = slotSessions.get(slotId);
if (!sessions) { if (!sessions) {
@ -1407,6 +1407,7 @@ export function RoomTimeline({
} }
} }
} }
/* eslint-enable no-restricted-syntax, no-continue */
const now = Date.now(); const now = Date.now();
// Consecutive unsuccessful sessions from the same caller within this // Consecutive unsuccessful sessions from the same caller within this
// window collapse into one bubble (WhatsApp/iOS Recents-style anti-spam). // window collapse into one bubble (WhatsApp/iOS Recents-style anti-spam).
@ -1432,13 +1433,15 @@ export function RoomTimeline({
lastEndTs: number; lastEndTs: number;
}; };
const anchors = new Map<string, CallAggregate>(); const anchors = new Map<string, CallAggregate>();
// Second pass: collapse same-sender unanswered scans into merge units.
// Same justification as the upstream loop — pipeline expression with
// early `continue` is clearer than reduce+filter chains.
/* eslint-disable no-restricted-syntax, no-continue */
for (const sessions of slotSessions.values()) { for (const sessions of slotSessions.values()) {
const units: DisplayUnit[] = []; const units: DisplayUnit[] = [];
for (const scan of sessions) { for (const scan of sessions) {
if (!scan.everJoined && scan.participants.size === 0) continue; if (!scan.everJoined && scan.participants.size === 0) continue;
const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some( const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some((exp) => exp > now);
(exp) => exp > now
);
const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2; const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2;
const display: SessionDisplay = { scan, ongoing, wasAnswered }; const display: SessionDisplay = { scan, ongoing, wasAnswered };
const mergeable = !ongoing && !wasAnswered; const mergeable = !ongoing && !wasAnswered;
@ -1464,8 +1467,8 @@ export function RoomTimeline({
} }
for (const unit of units) { for (const unit of units) {
const { scan, ongoing, wasAnswered } = unit.anchor; const { scan, ongoing, wasAnswered } = unit.anchor;
const conversationStart = wasAnswered ? (scan.connectedAt ?? scan.startTs) : null; const conversationStart = wasAnswered ? scan.connectedAt ?? scan.startTs : null;
const conversationEnd = wasAnswered && !ongoing ? (scan.endedAt ?? scan.endTs) : null; const conversationEnd = wasAnswered && !ongoing ? scan.endedAt ?? scan.endTs : null;
anchors.set(scan.anchorEventId, { anchors.set(scan.anchorEventId, {
callId: scan.slotId, callId: scan.slotId,
startTs: scan.startTs, startTs: scan.startTs,
@ -1480,6 +1483,7 @@ export function RoomTimeline({
}); });
} }
} }
/* eslint-enable no-restricted-syntax, no-continue */
return anchors; return anchors;
})(); })();
@ -1574,12 +1578,10 @@ export function RoomTimeline({
hideThreadReplyAffordance={hideThreadReplyAffordance} hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance} hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={ threadSummary={
showThreadSummary ? ( showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
<ThreadSummaryCard room={room} rootEvent={mEvent} />
) : undefined
} }
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble} channelHeaderInBubble={channelHeaderInBubble}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1690,7 +1692,7 @@ export function RoomTimeline({
) : undefined ) : undefined
} }
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble} channelHeaderInBubble={channelHeaderInBubble}
> >
{(() => { {(() => {
if (mEvent.isRedacted()) return <RedactedContent />; if (mEvent.isRedacted()) return <RedactedContent />;
@ -1807,12 +1809,10 @@ export function RoomTimeline({
hideThreadReplyAffordance={hideThreadReplyAffordance} hideThreadReplyAffordance={hideThreadReplyAffordance}
hideMainReplyAffordance={hideMainReplyAffordance} hideMainReplyAffordance={hideMainReplyAffordance}
threadSummary={ threadSummary={
showThreadSummary ? ( showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
<ThreadSummaryCard room={room} rootEvent={mEvent} />
) : undefined
} }
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble} channelHeaderInBubble={channelHeaderInBubble}
> >
{mEvent.isRedacted() ? ( {mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} /> <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -2025,12 +2025,7 @@ export function RoomTimeline({
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact />;
<Time
ts={mEvent.getTs()}
compact
/>
);
return ( return (
<Event <Event
@ -2049,7 +2044,6 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Code} iconSrc={Icons.Code}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -2075,12 +2069,7 @@ export function RoomTimeline({
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
const timeJSX = ( const timeJSX = <Time ts={mEvent.getTs()} compact />;
<Time
ts={mEvent.getTs()}
compact
/>
);
return ( return (
<Event <Event
@ -2099,7 +2088,6 @@ export function RoomTimeline({
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
layout={messageLayout} layout={messageLayout}
channelHeaderInBubble={channelHeaderInBubble}
iconSrc={Icons.Code} iconSrc={Icons.Code}
content={ content={
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
@ -2280,19 +2268,20 @@ export function RoomTimeline({
// the rail-endpoint scan and the renderer never disagree on visibility. // the rail-endpoint scan and the renderer never disagree on visibility.
const channelsModeHidden = channelsMode && isChannelsModeHidden(room, mEvent, isBridged); const channelsModeHidden = channelsMode && isChannelsModeHidden(room, mEvent, isBridged);
const eventJSX = channelsModeHidden || reactionOrEditEvent(mEvent) const eventJSX =
? null channelsModeHidden || reactionOrEditEvent(mEvent)
: renderMatrixEvent( ? null
mEvent.getType(), : renderMatrixEvent(
typeof mEvent.getStateKey() === 'string', mEvent.getType(),
mEventId, typeof mEvent.getStateKey() === 'string',
mEvent, mEventId,
item, mEvent,
timelineSet, item,
collapsed, timelineSet,
streamRailStart, collapsed,
streamRailEnd streamRailStart,
); streamRailEnd
);
prevEvent = mEvent; prevEvent = mEvent;
isPrevRendered = !!eventJSX; isPrevRendered = !!eventJSX;

View file

@ -36,7 +36,8 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
<Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text> <Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
{joinState.status === AsyncStatus.Error && ( {joinState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200"> <Text style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement room!'} {(joinState.error as { message?: string } | undefined)?.message ??
'Failed to join replacement room!'}
</Text> </Text>
)} )}
</Box> </Box>

View file

@ -24,10 +24,7 @@ import { config } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { userRoomProfileAtom } from '../../state/userRoomProfile'; import { userRoomProfileAtom } from '../../state/userRoomProfile';
import { import { useCloseUserRoomProfile, useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
useCloseUserRoomProfile,
useOpenUserRoomProfile,
} from '../../state/hooks/userRoomProfile';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
@ -95,8 +92,7 @@ const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
// which is the felt-feel the user signed off on. Release animations // which is the felt-feel the user signed off on. Release animations
// keep the asymmetric Vaul curve in CSS where direct manipulation // keep the asymmetric Vaul curve in CSS where direct manipulation
// is over and the auto-animation can take centre stage. // is over and the auto-animation can take centre stage.
const easeInOutCubic = (t: number): number => const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
type RoomViewProfilePanelProps = { type RoomViewProfilePanelProps = {
header: ReactNode; header: ReactNode;
@ -138,8 +134,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
const myUserId = mx.getSafeUserId(); const myUserId = mx.getSafeUserId();
const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined; const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
const headerDragPeer = const headerDragPeer = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
const headerDragEnabled = !!headerDragPeer; const headerDragEnabled = !!headerDragPeer;
const [drag, setDrag] = useState<DragState | null>(null); const [drag, setDrag] = useState<DragState | null>(null);
@ -215,8 +210,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
// (very rare — multiple stacked moderation alerts). In the common // (very rare — multiple stacked moderation alerts). In the common
// case the rail is exactly content-sized and there is nothing to // case the rail is exactly content-sized and there is nothing to
// scroll — `overflow: hidden` then prevents any drag-inside-drag. // scroll — `overflow: hidden` then prevents any drag-inside-drag.
const contentOverflows = const contentOverflows = contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx;
contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx;
// Measure the chat header's natural height (incl. its safe-top padding). // Measure the chat header's natural height (incl. its safe-top padding).
// `scrollHeight` returns the content size even while the outer is // `scrollHeight` returns the content size even while the outer is
@ -252,9 +246,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
if (liveUserId) lastUserIdRef.current = liveUserId; if (liveUserId) lastUserIdRef.current = liveUserId;
const renderUserId = liveUserId ?? lastUserIdRef.current; const renderUserId = liveUserId ?? lastUserIdRef.current;
const renderUserAvatarMxc = renderUserId const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined;
? getMemberAvatarMxc(room, renderUserId)
: undefined;
const renderUserAvatarUrl = const renderUserAvatarUrl =
(renderUserAvatarMxc && (renderUserAvatarMxc &&
mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ?? mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ??
@ -444,22 +436,21 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
// The same ramp factor feeds all three properties so they always // The same ramp factor feeds all three properties so they always
// appear and disappear together — no per-element timing skew that // appear and disappear together — no per-element timing skew that
// could create a perceived «blip». // could create a perceived «blip».
const horseshoeRamp = isDragging let horseshoeRamp: number;
? easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)) if (isDragging) {
: horseshoeActive horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
? 1 } else if (horseshoeActive) {
: 0; horseshoeRamp = 1;
} else {
horseshoeRamp = 0;
}
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
const panelViewportTransition = isDragging const panelViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
? 'none' const headerViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const headerViewportTransition = isDragging
? 'none'
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const silhouetteTransition = isDragging const silhouetteTransition = isDragging
? 'none' ? 'none'
: `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; : `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;

View file

@ -2,19 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, config } from 'folds';
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Scroll,
Spinner,
Text,
config,
toRem,
} from 'folds';
import { import {
Direction, Direction,
EventTimeline, EventTimeline,
@ -59,12 +47,7 @@ import { ImageViewer } from '../../components/image-viewer';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
import { RoomInput } from './RoomInput'; import { RoomInput } from './RoomInput';
import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { import { EncryptedContent, Message, Reactions, useMessageInteractionHandlers } from './message';
EncryptedContent,
Message,
Reactions,
useMessageInteractionHandlers,
} from './message';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@ -76,10 +59,7 @@ import { useTheme } from '../../hooks/useTheme';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../hooks/useMemberPowerTag';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
@ -149,12 +129,7 @@ type ThreadDrawerProps = {
// empty). The component is intentionally light on chrome compared to // empty). The component is intentionally light on chrome compared to
// the full Message renderer in the timeline: M2 is MVP and rich // the full Message renderer in the timeline: M2 is MVP and rich
// reactions / hover-menus / rail-dot inside the drawer is M9 territory. // reactions / hover-menus / rail-dot inside the drawer is M9 territory.
export function ThreadDrawer({ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDrawerProps) {
room,
rootId,
parentRoomPath,
variant,
}: ThreadDrawerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate(); const navigate = useNavigate();
@ -172,9 +147,7 @@ export function ThreadDrawer({
const resizableRef = useRef<HTMLDivElement>(null); const resizableRef = useRef<HTMLDivElement>(null);
const resizeHandleRef = useRef<HTMLDivElement>(null); const resizeHandleRef = useRef<HTMLDivElement>(null);
const [savedWidth, setSavedWidth] = useAtom(threadDrawerWidthAtom); const [savedWidth, setSavedWidth] = useAtom(threadDrawerWidthAtom);
const [vw, setVw] = useState<number>( const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
typeof window !== 'undefined' ? window.innerWidth : 1280
);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
// Live width during a drag — kept in component state so we don't // Live width during a drag — kept in component state so we don't
// flush localStorage on every pointermove (hundreds of sync disk // flush localStorage on every pointermove (hundreds of sync disk
@ -194,8 +167,7 @@ export function ThreadDrawer({
// Viewports too narrow for any meaningful range (max == min) hide the // Viewports too narrow for any meaningful range (max == min) hide the
// handle entirely — leaving a non-draggable handle reads as broken UI. // handle entirely — leaving a non-draggable handle reads as broken UI.
const canResize = isDesktopVariant && drawerMaxW > THREAD_DRAWER_WIDTH_MIN; const canResize = isDesktopVariant && drawerMaxW > THREAD_DRAWER_WIDTH_MIN;
const atMin = const atMin = dragging && liveWidth !== null && liveWidth <= THREAD_DRAWER_WIDTH_MIN;
dragging && liveWidth !== null && liveWidth <= THREAD_DRAWER_WIDTH_MIN;
const atMax = dragging && liveWidth !== null && liveWidth >= drawerMaxW; const atMax = dragging && liveWidth !== null && liveWidth >= drawerMaxW;
// Body-style cleanup if the drag terminates without `pointerup` // Body-style cleanup if the drag terminates without `pointerup`
@ -375,9 +347,7 @@ export function ThreadDrawer({
// The `RoomInput` instance below subscribes to the same atom via // The `RoomInput` instance below subscribes to the same atom via
// `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so // `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
// the chip surfaces inside the drawer composer. // the chip surfaces inside the drawer composer.
const setReplyDraft = useSetAtom( const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)));
roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId))
);
// Reply-chip click scrolls within the drawer to the matching // Reply-chip click scrolls within the drawer to the matching
// `data-message-id` row. If the target lives in the main timeline // `data-message-id` row. If the target lives in the main timeline
@ -387,9 +357,7 @@ export function ThreadDrawer({
const handleScrollToDrawerEvent = useCallback((evtId: string) => { const handleScrollToDrawerEvent = useCallback((evtId: string) => {
const host = scrollHostRef.current; const host = scrollHostRef.current;
if (!host) return; if (!host) return;
const target = host.querySelector<HTMLElement>( const target = host.querySelector<HTMLElement>(`[data-message-id="${evtId}"]`);
`[data-message-id="${evtId}"]`
);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, []); }, []);
@ -598,10 +566,7 @@ export function ThreadDrawer({
// resume back-pagination from where this fetch left off. // resume back-pagination from where this fetch left off.
// null means «no more older events» (server reached the // null means «no more older events» (server reached the
// thread start), which correctly disables back-pagination. // thread start), which correctly disables back-pagination.
written.liveTimeline.setPaginationToken( written.liveTimeline.setPaginationToken(result.nextBatch ?? null, Direction.Backward);
result.nextBatch ?? null,
Direction.Backward
);
setThread(written); setThread(written);
} }
} catch (err) { } catch (err) {
@ -798,9 +763,7 @@ export function ThreadDrawer({
subscribed.push(evt); subscribed.push(evt);
}); });
return () => { return () => {
subscribed.forEach((evt) => subscribed.forEach((evt) => evt.off(MatrixEventEvent.RelationsCreated, handler));
evt.off(MatrixEventEvent.RelationsCreated, handler)
);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootEvent, repliesIds]); }, [rootEvent, repliesIds]);
@ -921,8 +884,11 @@ export function ThreadDrawer({
host.scrollTop = host.scrollHeight; host.scrollTop = host.scrollHeight;
isAtBottomRef.current = true; isAtBottomRef.current = true;
tryMarkThreadRead(); tryMarkThreadRead();
// Intentional: replies array read inside is stable identity-wise // `replies[last]` is read but the array isn't a dep: the effect is
// because computed inline; lint disable for the false-positive. // keyed on `repliesCount` so it only fires when the array actually
// grew, and we always want to inspect the freshest replies snapshot
// at that moment. Listing `replies` would just refire on every render
// for the same growth event.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [repliesCount, myUserId, tryMarkThreadRead]); }, [repliesCount, myUserId, tryMarkThreadRead]);
@ -998,12 +964,9 @@ export function ThreadDrawer({
// and `getEditedEvent` return undefined for thread replies even // and `getEditedEvent` return undefined for thread replies even
// when the SDK has fully ingested the relation. // when the SDK has fully ingested the relation.
const eventThread = mEvent.getThread(); const eventThread = mEvent.getThread();
const isThreadReply = const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
const timelineSet = const timelineSet =
isThreadReply && eventThread isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet();
? eventThread.timelineSet
: room.getUnfilteredTimelineSet();
const reactionRelations = getEventReactions(timelineSet, eventId); const reactionRelations = getEventReactions(timelineSet, eventId);
const reactionList = reactionRelations?.getSortedAnnotationsByKey(); const reactionList = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = reactionList && reactionList.length > 0; const hasReactions = reactionList && reactionList.length > 0;
@ -1029,8 +992,7 @@ export function ThreadDrawer({
// intentional quotes from auto-fallback chains. // intentional quotes from auto-fallback chains.
const wireRelatesTo = mEvent.getWireContent()['m.relates_to']; const wireRelatesTo = mEvent.getWireContent()['m.relates_to'];
const isFallbackInReplyTo = const isFallbackInReplyTo =
wireRelatesTo?.rel_type === RelationType.Thread && wireRelatesTo?.rel_type === RelationType.Thread && wireRelatesTo?.is_falling_back !== false;
wireRelatesTo?.is_falling_back !== false;
const showReplyChip = !!replyEventId && !isFallbackInReplyTo; const showReplyChip = !!replyEventId && !isFallbackInReplyTo;
return ( return (
@ -1097,9 +1059,7 @@ export function ThreadDrawer({
{(() => { {(() => {
if (mEvent.isRedacted()) { if (mEvent.isRedacted()) {
return ( return (
<RedactedContent <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content?.reason} />
reason={mEvent.getUnsigned().redacted_because?.content?.reason}
/>
); );
} }
if (eventType === MessageEvent.Sticker) { if (eventType === MessageEvent.Sticker) {
@ -1242,21 +1202,24 @@ export function ThreadDrawer({
</Button> </Button>
</div> </div>
)} )}
{thread && !coldLoadError && !coldLoadFetching && !paginating && !paginateError && replies.length === 0 && ( {thread &&
<div className={css.ThreadEmptyState}> !coldLoadError &&
<Text size="T300" priority="400"> !coldLoadFetching &&
{t('Room.thread_no_replies')} !paginating &&
</Text> !paginateError &&
</div> replies.length === 0 && (
)} <div className={css.ThreadEmptyState}>
<Text size="T300" priority="400">
{t('Room.thread_no_replies')}
</Text>
</div>
)}
{/* Top sentinel IntersectionObserver triggers paginate when {/* Top sentinel IntersectionObserver triggers paginate when
scrolled into view. Element-web onFillRequest pattern. The scrolled into view. Element-web onFillRequest pattern. The
sentinel only renders while back-pagination is possible: sentinel only renders while back-pagination is possible:
once SDK reports no more events, we drop it so the observer once SDK reports no more events, we drop it so the observer
disconnects on next mount. */} disconnects on next mount. */}
{replies.length > 0 && ( {replies.length > 0 && <div ref={setTopSentinel} aria-hidden="true" />}
<div ref={setTopSentinel} aria-hidden="true" />
)}
{paginating && replies.length > 0 && ( {paginating && replies.length > 0 && (
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S200 }}> <Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S200 }}>
<Spinner size="200" /> <Spinner size="200" />
@ -1336,13 +1299,12 @@ export function ThreadDrawer({
} }
return ( return (
<div <div ref={resizableRef} className={css.ThreadDrawerResizable} style={{ width: drawerWidth }}>
ref={resizableRef}
className={css.ThreadDrawerResizable}
style={{ width: drawerWidth }}
>
{asideContent} {asideContent}
{canResize && ( {canResize && (
// Canonical WAI-ARIA window-splitter pattern (focusable separator
// with current/min/max). See Page.tsx for the same justification.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-tabindex
<div <div
ref={resizeHandleRef} ref={resizeHandleRef}
role="separator" role="separator"
@ -1351,6 +1313,7 @@ export function ThreadDrawer({
aria-valuemin={THREAD_DRAWER_WIDTH_MIN} aria-valuemin={THREAD_DRAWER_WIDTH_MIN}
aria-valuemax={drawerMaxW} aria-valuemax={drawerMaxW}
aria-label="Resize thread drawer" aria-label="Resize thread drawer"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0} tabIndex={0}
className={css.ThreadDrawerResizeHandle} className={css.ThreadDrawerResizeHandle}
data-dragging={dragging || undefined} data-dragging={dragging || undefined}

View file

@ -110,8 +110,7 @@ export function CallMessage({
const senderId = aggregate.anchorSenderId; const senderId = aggregate.anchorSenderId;
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId(); const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
const peerBg = !isOwnMessage; const peerBg = !isOwnMessage;
const senderName = const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color) ? accessibleTagColors?.get(memberPowerTag.color)
@ -148,11 +147,14 @@ export function CallMessage({
})(); })();
const iconSrc = wasAnswered || ongoing ? Icons.Phone : Icons.PhoneDown; const iconSrc = wasAnswered || ongoing ? Icons.Phone : Icons.PhoneDown;
const iconColor = ongoing let iconColor: string;
? color.Success.Main if (ongoing) {
: wasAnswered iconColor = color.Success.Main;
? color.Surface.OnContainer } else if (wasAnswered) {
: color.Critical.Main; iconColor = color.Surface.OnContainer;
} else {
iconColor = color.Critical.Main;
}
const bubbleBody = ( const bubbleBody = (
<Box gap="300" alignItems="Center" style={{ minWidth: 0 }}> <Box gap="300" alignItems="Center" style={{ minWidth: 0 }}>
@ -194,11 +196,7 @@ export function CallMessage({
isOwn={isOwnMessage} isOwn={isOwnMessage}
headerInBubble={channelHeaderInBubble} headerInBubble={channelHeaderInBubble}
avatar={ avatar={
<ChannelMessageAvatar <ChannelMessageAvatar room={room} senderId={senderId} senderDisplayName={senderName} />
room={room}
senderId={senderId}
senderDisplayName={senderName}
/>
} }
header={ header={
<> <>

View file

@ -34,6 +34,7 @@ import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHover, useFocusWithin } from 'react-aria'; import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations'; import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames'; import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
@ -132,7 +133,7 @@ export const MessageAllReactionItem = as<
return ( return (
<> <>
<Overlay <Overlay
onContextMenu={(evt: any) => { onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => {
evt.stopPropagation(); evt.stopPropagation();
}} }}
open={open} open={open}
@ -246,7 +247,8 @@ export const MessageSourceCodeItem = as<
: evt.event; : evt.event;
const getText = (): string => { const getText = (): string => {
const evtId = mEvent.getId()!; const evtId = mEvent.getId();
if (!evtId) return '';
const evtTimeline = room.getTimelineForEvent(evtId); const evtTimeline = room.getTimelineForEvent(evtId);
const edits = const edits =
evtTimeline && evtTimeline &&
@ -364,7 +366,7 @@ export const MessagePinItem = as<
if (!isPinned && eventId) { if (!isPinned && eventId) {
pinContent.pinned.push(eventId); pinContent.pinned.push(eventId);
} }
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent); mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as keyof StateEvents, pinContent);
onClose?.(); onClose?.();
}; };
@ -845,7 +847,7 @@ export const Message = as<'div', MessageProps>(
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return; if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
const tag = (evt.target as any).tagName; const tag = (evt.target as Element).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault(); evt.preventDefault();
setMenuAnchor({ setMenuAnchor({
@ -918,11 +920,13 @@ export const Message = as<'div', MessageProps>(
returnFocusOnDeactivate={false} returnFocusOnDeactivate={false}
allowTextCustomEmoji allowTextCustomEmoji
onEmojiSelect={(key) => { onEmojiSelect={(key) => {
onReactionToggle(mEvent.getId()!, key); const evtId = mEvent.getId();
if (evtId) onReactionToggle(evtId, key);
setEmojiBoardAnchor(undefined); setEmojiBoardAnchor(undefined);
}} }}
onCustomEmojiSelect={(mxc, shortcode) => { onCustomEmojiSelect={(mxc, shortcode) => {
onReactionToggle(mEvent.getId()!, mxc, shortcode); const evtId = mEvent.getId();
if (evtId) onReactionToggle(evtId, mxc, shortcode);
setEmojiBoardAnchor(undefined); setEmojiBoardAnchor(undefined);
}} }}
requestClose={() => { requestClose={() => {
@ -994,7 +998,8 @@ export const Message = as<'div', MessageProps>(
{canSendReaction && ( {canSendReaction && (
<MessageQuickReactions <MessageQuickReactions
onReaction={(key, shortcode) => { onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId()!, key, shortcode); const evtId = mEvent.getId();
if (evtId) onReactionToggle(evtId, key, shortcode);
closeMenu(); closeMenu();
}} }}
/> />
@ -1030,7 +1035,7 @@ export const Message = as<'div', MessageProps>(
after={<Icon size="100" src={Icons.ReplyArrow} />} after={<Icon size="100" src={Icons.ReplyArrow} />}
radii="300" radii="300"
data-event-id={mEvent.getId()} data-event-id={mEvent.getId()}
onClick={(evt: any) => { onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
onReplyClick(evt); onReplyClick(evt);
closeMenu(); closeMenu();
}} }}
@ -1051,7 +1056,7 @@ export const Message = as<'div', MessageProps>(
after={<Icon src={Icons.ThreadPlus} size="100" />} after={<Icon src={Icons.ThreadPlus} size="100" />}
radii="300" radii="300"
data-event-id={mEvent.getId()} data-event-id={mEvent.getId()}
onClick={(evt: any) => { onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
onReplyClick(evt, true); onReplyClick(evt, true);
closeMenu(); closeMenu();
}} }}
@ -1173,11 +1178,7 @@ export const Message = as<'div', MessageProps>(
so the bubble header matches the DM-chat rhythm. so the bubble header matches the DM-chat rhythm.
Channels main timeline keeps T400 for the prominent Channels main timeline keeps T400 for the prominent
avatar-and-name row above an unbordered body. */} avatar-and-name row above an unbordered body. */}
<Text <Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
as="span"
size={channelHeaderInBubble ? 'T200' : 'T400'}
truncate
>
<UsernameBold> <UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName} {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold> </UsernameBold>
@ -1269,7 +1270,7 @@ export const Event = as<'div', EventProps>(
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => { const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed) return; if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as any).tagName; const tag = (evt.target as Element).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault(); evt.preventDefault();
setMenuAnchor({ setMenuAnchor({

View file

@ -22,6 +22,7 @@ import {
import { Editor, Transforms } from 'slate'; import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { import {
AUTOCOMPLETE_PREFIXES, AUTOCOMPLETE_PREFIXES,
@ -80,7 +81,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
string | undefined, string | undefined,
IMentions | undefined IMentions | undefined
] => { ] => {
const evtId = mEvent.getId()!; const evtId = mEvent.getId();
if (!evtId) return [undefined, undefined, undefined];
const evtTimeline = room.getTimelineForEvent(evtId); const evtTimeline = room.getTimelineForEvent(evtId);
const editedEvent = const editedEvent =
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
@ -170,7 +172,11 @@ export const MessageEditor = as<'div', MessageEditorProps>(
// /sync round-trip — same lag as M4 receipts. Optimistic // /sync round-trip — same lag as M4 receipts. Optimistic
// local-replace via `mEvent.makeReplaced(localEvent)` is // local-replace via `mEvent.makeReplaced(localEvent)` is
// tracked for follow-up. // tracked for follow-up.
return mx.sendMessage(roomId, mEvent.threadRootId ?? null, content as any); return mx.sendMessage(
roomId,
mEvent.threadRootId ?? null,
content as RoomMessageEventContent
);
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody]) }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
); );

View file

@ -96,7 +96,7 @@ export const Reactions = as<'div', ReactionsProps>(
})} })}
{reactions.length > 0 && ( {reactions.length > 0 && (
<Overlay <Overlay
onContextMenu={(evt: any) => { onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => {
evt.stopPropagation(); evt.stopPropagation();
}} }}
open={!!viewer} open={!!viewer}

View file

@ -2,7 +2,8 @@ import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { IContent, Room } from 'matrix-js-sdk'; import { EventType, IContent, Room } from 'matrix-js-sdk';
import type { ReactionEventContent } from 'matrix-js-sdk/lib/types';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
@ -14,13 +15,8 @@ import {
getMemberDisplayName, getMemberDisplayName,
getReactionContent, getReactionContent,
} from '../../../utils/room'; } from '../../../utils/room';
import { import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
eventWithShortcode,
factoryEventSentBy,
getMxIdLocalPart,
} from '../../../utils/matrix';
import { IReplyDraft } from '../../../state/room/roomInputDrafts'; import { IReplyDraft } from '../../../state/room/roomInputDrafts';
import { MessageEvent } from '../../../../types/matrix/room';
import { getChannelsThreadPath } from '../../../pages/pathUtils'; import { getChannelsThreadPath } from '../../../pages/pathUtils';
export type UseMessageInteractionHandlersOptions = { export type UseMessageInteractionHandlersOptions = {
@ -60,15 +56,8 @@ export type MessageInteractionHandlers = {
handleOpenReply: MouseEventHandler; handleOpenReply: MouseEventHandler;
handleUserClick: MouseEventHandler<HTMLButtonElement>; handleUserClick: MouseEventHandler<HTMLButtonElement>;
handleUsernameClick: MouseEventHandler<HTMLButtonElement>; handleUsernameClick: MouseEventHandler<HTMLButtonElement>;
handleReplyClick: ( handleReplyClick: (ev: MouseEvent<HTMLButtonElement>, startThread?: boolean) => void;
ev: MouseEvent<HTMLButtonElement>, handleReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
startThread?: boolean
) => void;
handleReactionToggle: (
targetEventId: string,
key: string,
shortcode?: string
) => void;
}; };
// Wiring layer for `<Message>` event-handler props, shared between // Wiring layer for `<Message>` event-handler props, shared between
@ -166,13 +155,7 @@ export function useMessageInteractionHandlers({
// draft. Bridged rooms (Telegram puppets etc.) have no thread // draft. Bridged rooms (Telegram puppets etc.) have no thread
// semantic on the bridge side — fall back to the legacy // semantic on the bridge side — fall back to the legacy
// m.in_reply_to draft path so messages still go through. // m.in_reply_to draft path so messages still go through.
if ( if (startThread && channelsMode && !isBridged && spaceIdOrAlias && roomIdOrAlias) {
startThread &&
channelsMode &&
!isBridged &&
spaceIdOrAlias &&
roomIdOrAlias
) {
// useParams returns the encoded URL value; getChannelsThreadPath // useParams returns the encoded URL value; getChannelsThreadPath
// re-encodes via generatePath so we decode once first to avoid // re-encodes via generatePath so we decode once first to avoid
// double-encoding (matches `routeParent.ts` decode pattern). // double-encoding (matches `routeParent.ts` decode pattern).
@ -206,16 +189,7 @@ export function useMessageInteractionHandlers({
setTimeout(() => ReactEditor.focus(editor), 100); setTimeout(() => ReactEditor.focus(editor), 100);
} }
}, },
[ [room, setReplyDraft, editor, channelsMode, isBridged, navigate, spaceIdOrAlias, roomIdOrAlias]
room,
setReplyDraft,
editor,
channelsMode,
isBridged,
navigate,
spaceIdOrAlias,
roomIdOrAlias,
]
); );
const handleReactionToggle = useCallback( const handleReactionToggle = useCallback(
@ -224,10 +198,12 @@ export function useMessageInteractionHandlers({
const allReactions = relations?.getSortedAnnotationsByKey() ?? []; const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? []; const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
const reactions = reactionsSet ? Array.from(reactionsSet) : []; const reactions = reactionsSet ? Array.from(reactionsSet) : [];
const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!)); const myUserId = mx.getUserId();
const myReaction = myUserId ? reactions.find(factoryEventSentBy(myUserId)) : undefined;
if (myReaction && !!myReaction?.isRelation()) { if (myReaction && !!myReaction?.isRelation()) {
mx.redactEvent(room.roomId, myReaction.getId()!); const reactionId = myReaction.getId();
if (reactionId) mx.redactEvent(room.roomId, reactionId);
return; return;
} }
const rShortcode = const rShortcode =
@ -235,8 +211,8 @@ export function useMessageInteractionHandlers({
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent( mx.sendEvent(
room.roomId, room.roomId,
MessageEvent.Reaction as any, EventType.Reaction,
getReactionContent(targetEventId, key, rShortcode) getReactionContent(targetEventId, key, rShortcode) as ReactionEventContent
); );
}, },
[mx, room] [mx, room]

View file

@ -50,6 +50,10 @@ export const getImageMsgContent = async (
): Promise<IContent> => { ): Promise<IContent> => {
const { file, originalFile, encInfo, metadata } = item; const { file, originalFile, encInfo, metadata } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
// Non-fatal: upload continues without dimensions/blurhash when the
// browser can't decode the image. Surfacing in the dev console helps
// diagnose broken codecs without blocking the user's send.
// eslint-disable-next-line no-console
if (imgError) console.warn(imgError); if (imgError) console.warn(imgError);
const content: IContent = { const content: IContent = {
@ -85,6 +89,8 @@ export const getVideoMsgContent = async (
const { file, originalFile, encInfo, metadata } = item; const { file, originalFile, encInfo, metadata } = item;
const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile))); const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile)));
// Same justification as image branch above.
// eslint-disable-next-line no-console
if (videoError) console.warn(videoError); if (videoError) console.warn(videoError);
const content: IContent = { const content: IContent = {
@ -109,6 +115,7 @@ export const getVideoMsgContent = async (
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight) scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight)
); );
} }
// eslint-disable-next-line no-console
if (thumbError) console.warn(thumbError); if (thumbError) console.warn(thumbError);
content.info = { content.info = {
...getVideoInfo(videoEl, file), ...getVideoInfo(videoEl, file),

View file

@ -132,7 +132,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
key={senderId} key={senderId}
style={{ padding: `0 ${config.space.S200}` }} style={{ padding: `0 ${config.space.S200}` }}
radii="400" radii="400"
onClick={(event) => { onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
openProfile( openProfile(
room.roomId, room.roomId,
space?.roomId, space?.roomId,

View file

@ -1,6 +1,7 @@
/* eslint-disable react/destructuring-assignment */ /* eslint-disable react/destructuring-assignment */
import React, { forwardRef, MouseEventHandler, useCallback, useMemo, useRef } from 'react'; import React, { forwardRef, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -121,7 +122,11 @@ function PinnedMessage({
pinned: content.pinned.filter((id) => id !== eventId), pinned: content.pinned.filter((id) => id !== eventId),
}; };
return mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, newContent); return mx.sendStateEvent(
room.roomId,
StateEvent.RoomPinnedEvents as keyof StateEvents,
newContent
);
}, [room, eventId, mx]) }, [room, eventId, mx])
); );
@ -172,7 +177,7 @@ function PinnedMessage({
</Box> </Box>
); );
const sender = pinnedEvent.getSender()!; const sender = pinnedEvent.getSender() ?? '';
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender); const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
@ -245,7 +250,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
({ room, requestClose }, ref) => { ({ room, requestClose }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getSafeUserId();
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
@ -329,12 +334,12 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
); );
}, },
[MessageEvent.RoomMessageEncrypted]: (event, displayName) => { [MessageEvent.RoomMessageEncrypted]: (event, displayName) => {
const eventId = event.getId()!; const eventId = event.getId();
const evtTimeline = room.getTimelineForEvent(eventId); const evtTimeline = eventId ? room.getTimelineForEvent(eventId) : undefined;
const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId); const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId);
if (!mEvent || !evtTimeline) { if (!mEvent || !evtTimeline || !eventId) {
return ( return (
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Text size="T400" priority="300"> <Text size="T400" priority="300">

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Box, Text, Chip } from 'folds'; import { Box, Text, Chip } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
@ -9,18 +9,12 @@ import { copyToClipboard } from '../../../utils/dom';
export function MatrixId() { export function MatrixId() {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const userId = useAuthedUserId();
const userId = mx.getUserId()!;
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.matrix_id')}</Text> <Text size="L400">{t('Settings.matrix_id')}</Text>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile <SettingTile
title={userId} title={userId}
after={ after={

View file

@ -30,6 +30,7 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile'; import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
@ -308,19 +309,13 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
export function Profile() { export function Profile() {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const userId = useAuthedUserId();
const userId = mx.getUserId()!;
const profile = useUserProfile(userId); const profile = useUserProfile(userId);
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.profile')}</Text> <Text size="L400">{t('Settings.profile')}</Text>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<ProfileAvatar userId={userId} profile={profile} /> <ProfileAvatar userId={userId} profile={profile} />
<ProfileDisplayName userId={userId} profile={profile} /> <ProfileDisplayName userId={userId} profile={profile} />
</SequenceCard> </SequenceCard>

View file

@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { AccountDataEvents } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page'; import { Page, PageContent, PageHeader } from '../../../components/page';
@ -27,7 +28,8 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const submitAccountData: AccountDataSubmitCallback = useCallback( const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => { async (type, content) => {
await mx.setAccountData(type, content); // Dev tool accepts arbitrary keys; SDK signature wants `keyof AccountDataEvents`.
await mx.setAccountData(type as keyof AccountDataEvents, content);
}, },
[mx] [mx]
); );
@ -36,7 +38,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
return ( return (
<AccountDataEditor <AccountDataEditor
type={accountDataType ?? undefined} type={accountDataType ?? undefined}
content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined} content={
accountDataType
? mx.getAccountData(accountDataType as keyof AccountDataEvents)?.getContent()
: undefined
}
submitChange={submitAccountData} submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)} requestClose={() => setAccountDataType(undefined)}
/> />
@ -53,7 +59,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="SurfaceVariant" aria-label={t('Settings.close')}> <IconButton
onClick={requestClose}
variant="SurfaceVariant"
aria-label={t('Settings.close')}
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -141,7 +141,7 @@ function GlobalPackSelector({
if (!room) return null; if (!room) return null;
const roomPackAddresses = roomPacks const roomPackAddresses = roomPacks
.map((pack) => pack.address) .map((pack) => pack.address)
.filter((addr) => addr !== undefined); .filter((addr): addr is PackAddress => addr !== undefined);
const allSelected = roomPackAddresses.every((addr) => const allSelected = roomPackAddresses.every((addr) =>
selected.find((address) => packAddressEqual(addr, address)) selected.find((address) => packAddressEqual(addr, address))
); );

View file

@ -26,7 +26,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
if (userPack) { if (userPack) {
onViewPack(userPack); onViewPack(userPack);
} else { } else {
const defaultPack = new ImagePack(mx.getUserId() ?? '', {}, undefined); const defaultPack = new ImagePack(mx.getSafeUserId(), {}, undefined);
onViewPack(defaultPack); onViewPack(defaultPack);
} }
}; };
@ -34,12 +34,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">{t('Settings.default_pack')}</Text> <Text size="L400">{t('Settings.default_pack')}</Text>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile <SettingTile
title={userPack?.meta.name ?? t('Settings.unknown')} title={userPack?.meta.name ?? t('Settings.unknown')}
description={userPack?.meta.attribution} description={userPack?.meta.attribution}

View file

@ -8,6 +8,7 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { useUserProfile } from '../../../hooks/useUserProfile'; import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart } from '../../../utils/matrix'; import { getMxIdLocalPart } from '../../../utils/matrix';
import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule'; import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
@ -114,8 +115,7 @@ function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRul
export function SpecialMessagesNotifications() { export function SpecialMessagesNotifications() {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const userId = useAuthedUserId();
const userId = mx.getUserId()!;
const { displayName } = useUserProfile(userId); const { displayName } = useUserProfile(userId);
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules); const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
const pushRules = useMemo( const pushRules = useMemo(
@ -134,12 +134,7 @@ export function SpecialMessagesNotifications() {
</Badge> </Badge>
</Box> </Box>
</Box> </Box>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile <SettingTile
title={t('Settings.mention_user_id', { userId })} title={t('Settings.mention_user_id', { userId })}
after={ after={
@ -151,12 +146,7 @@ export function SpecialMessagesNotifications() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile <SettingTile
title={ title={
displayName displayName
@ -172,12 +162,7 @@ export function SpecialMessagesNotifications() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile <SettingTile
title={t('Settings.contains_username', { username: getMxIdLocalPart(userId) })} title={t('Settings.contains_username', { username: getMxIdLocalPart(userId) })}
after={ after={
@ -189,12 +174,7 @@ export function SpecialMessagesNotifications() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile <SettingTile
title={t('Settings.mention_room')} title={t('Settings.mention_room')}
after={ after={
@ -206,12 +186,7 @@ export function SpecialMessagesNotifications() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
className={SequenceCardStyle}
variant="Background"
direction="Column"
gap="400"
>
<SettingTile <SettingTile
title={t('Settings.contains_room')} title={t('Settings.contains_room')}
after={ after={

View file

@ -1,10 +1,14 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { AccountDataEvents } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback'; import { useAccountDataCallback } from './useAccountDataCallback';
export function useAccountData(eventType: string) { export function useAccountData(eventType: string) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [event, setEvent] = useState(() => mx.getAccountData(eventType)); // Generic hook accepts arbitrary string keys (incl. custom Vojo / Ponies
// event-types augmented in sdkAugmentation.d.ts and one-off dev-tool keys).
// Cast through `keyof AccountDataEvents` to satisfy the SDK literal-union.
const [event, setEvent] = useState(() => mx.getAccountData(eventType as keyof AccountDataEvents));
useAccountDataCallback( useAccountDataCallback(
mx, mx,

View file

@ -0,0 +1,16 @@
import { useMatrixClient } from './useMatrixClient';
// Returns the current user's MXID in any subtree that mounts only after
// authentication has resolved (`<ClientLayout>` and everything inside it).
// Throws if the SDK has no user ID — that's an invariant violation, not a
// soft-failure path: by the time post-login components render, the SDK
// already has a session. Prefer this over `mx.getUserId() ?? ''` so a
// broken invariant surfaces loudly instead of silently defaulting to '' and
// writing empty-string user IDs into account-data / storage keys.
//
// For utility functions that aren't React hooks, call `mx.getSafeUserId()`
// directly — it's the same contract.
export function useAuthedUserId(): string {
const mx = useMatrixClient();
return mx.getSafeUserId();
}

View file

@ -9,6 +9,7 @@ import {
RoomMember, RoomMember,
Visibility, Visibility,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types'; import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
@ -366,7 +367,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
if (!content) return; if (!content) return;
await mx.sendStateEvent( await mx.sendStateEvent(
room.roomId, room.roomId,
StateEvent.RoomMember as any, StateEvent.RoomMember as keyof StateEvents,
{ {
...content, ...content,
displayname: nick, displayname: nick,
@ -388,7 +389,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
if (!content) return; if (!content) return;
await mx.sendStateEvent( await mx.sendStateEvent(
room.roomId, room.roomId,
StateEvent.RoomMember as any, StateEvent.RoomMember as keyof StateEvents,
{ {
...content, ...content,
avatar_url: payload, avatar_url: payload,
@ -528,7 +529,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
aclContent.allow?.sort(); aclContent.allow?.sort();
aclContent.deny?.sort(); aclContent.deny?.sort();
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, aclContent); await mx.sendStateEvent(
room.roomId,
StateEvent.RoomServerAcl as keyof StateEvents,
aclContent
);
}, },
}, },
}), }),

View file

@ -37,20 +37,20 @@ export function useDotColor(
hideReadReceipts = false hideReadReceipts = false
): DotColor { ): DotColor {
const mx = useMatrixClient(); const mx = useMatrixClient();
const myUserId = mx.getUserId() ?? ''; const myUserId = mx.getSafeUserId();
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const isOwn = !!myUserId && senderId === myUserId; const isOwn = senderId === myUserId;
const eventId = mEvent.getId(); const eventId = mEvent.getId();
const status = useMessageStatus(room, mEvent); const status = useMessageStatus(room, mEvent);
const [haveSeen, setHaveSeen] = useState<boolean>(() => const [haveSeen, setHaveSeen] = useState<boolean>(() =>
enabled && !isOwn && !!eventId && !!myUserId ? room.hasUserReadEvent(myUserId, eventId) : false enabled && !isOwn && !!eventId ? room.hasUserReadEvent(myUserId, eventId) : false
); );
useEffect(() => { useEffect(() => {
if (!enabled) return undefined; if (!enabled) return undefined;
if (isOwn || !eventId || !myUserId) return undefined; if (isOwn || !eventId) return undefined;
setHaveSeen(room.hasUserReadEvent(myUserId, eventId)); setHaveSeen(room.hasUserReadEvent(myUserId, eventId));
const handler: RoomEventHandlerMap[RoomEvent.Receipt] = (_event, r) => { const handler: RoomEventHandlerMap[RoomEvent.Receipt] = (_event, r) => {
if (r.roomId !== room.roomId) return; if (r.roomId !== room.roomId) return;

View file

@ -40,7 +40,7 @@ function getDeliveryStatus(
export function useMessageStatus(room: Room, mEvent: MatrixEvent): MessageDeliveryStatus | null { export function useMessageStatus(room: Room, mEvent: MatrixEvent): MessageDeliveryStatus | null {
const mx = useMatrixClient(); const mx = useMatrixClient();
const myUserId = mx.getUserId() ?? ''; const myUserId = mx.getSafeUserId();
const isOwnMessage = mEvent.getSender() === myUserId; const isOwnMessage = mEvent.getSender() === myUserId;
const [status, setStatus] = useState<MessageDeliveryStatus | null>(() => const [status, setStatus] = useState<MessageDeliveryStatus | null>(() =>

View file

@ -12,14 +12,8 @@ const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
export const getPowers = (tags: PowerLevelTags): number[] => { export const getPowers = (tags: PowerLevelTags): number[] => {
const powers: number[] = Object.keys(tags) const powers: number[] = Object.keys(tags)
.map((p) => { .map((p) => parseInt(p, 10))
const power = parseInt(p, 10); .filter((power): power is number => !Number.isNaN(power));
if (Number.isNaN(power)) {
return undefined;
}
return power;
})
.filter((power) => typeof power === 'number');
return sortPowers(powers); return sortPowers(powers);
}; };

View file

@ -46,7 +46,8 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[]; const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
keys.forEach((key) => { keys.forEach((key) => {
if (draftPl[key] === undefined) { if (draftPl[key] === undefined) {
// eslint-disable-next-line no-param-reassign // Distributive union over IPowerLevels keys breaks TS narrowing across the indexed write.
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
draftPl[key] = DEFAULT_POWER_LEVELS[key] as any; draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
} }
}); });

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { MatrixError, Room } from 'matrix-js-sdk'; import { MatrixError, Room } from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { RoomCanonicalAliasEventContent } from 'matrix-js-sdk/lib/types'; import { RoomCanonicalAliasEventContent } from 'matrix-js-sdk/lib/types';
import { AsyncState, useAsyncCallback } from './useAsyncCallback'; import { AsyncState, useAsyncCallback } from './useAsyncCallback';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
@ -56,7 +57,11 @@ export const useSetMainAlias = (room: Room): ((alias: string | undefined) => Pro
alt_aliases: altAliases, alt_aliases: altAliases,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent); await mx.sendStateEvent(
room.roomId,
StateEvent.RoomCanonicalAlias as keyof StateEvents,
newContent
);
}, },
[mx, room] [mx, room]
); );
@ -90,7 +95,11 @@ export const usePublishUnpublishAliases = (
alt_aliases: altAliases, alt_aliases: altAliases,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent); await mx.sendStateEvent(
room.roomId,
StateEvent.RoomCanonicalAlias as keyof StateEvents,
newContent
);
}, },
[mx, room] [mx, room]
); );
@ -114,7 +123,11 @@ export const usePublishUnpublishAliases = (
alt_aliases: altAliases, alt_aliases: altAliases,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent); await mx.sendStateEvent(
room.roomId,
StateEvent.RoomCanonicalAlias as keyof StateEvents,
newContent
);
}, },
[mx, room] [mx, room]
); );

View file

@ -1,11 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { import { Room, RoomEvent, RoomStateEvent } from 'matrix-js-sdk';
Room,
RoomEvent,
RoomEventHandlerMap,
RoomStateEvent,
} from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent'; import { useStateEvent } from './useStateEvent';
@ -39,9 +34,7 @@ const resolveRoomName = (room: Room): string => {
if (peer.rawDisplayName && peer.rawDisplayName.trim()) { if (peer.rawDisplayName && peer.rawDisplayName.trim()) {
return peer.rawDisplayName.trim(); return peer.rawDisplayName.trim();
} }
const local = peer.userId.startsWith('@') const local = peer.userId.startsWith('@') ? peer.userId.slice(1).split(':')[0] : peer.userId;
? peer.userId.slice(1).split(':')[0]
: peer.userId;
return local || peer.userId; return local || peer.userId;
} }
} }
@ -67,7 +60,10 @@ export const useRoomName = (room: Room): string => {
useEffect(() => { useEffect(() => {
setName(resolveRoomName(room)); setName(resolveRoomName(room));
const recompute: RoomEventHandlerMap[RoomEvent.Name] = () => { // No-arg form keeps the same callback assignable to both RoomEvent.Name
// (event handler is `(room: Room) => void`) and RoomStateEvent.Members
// (`(event, state, member) => void`); we ignore the args either way.
const recompute = () => {
setName(resolveRoomName(room)); setName(resolveRoomName(room));
}; };
// RoomEvent.Name fires when m.room.name changes; // RoomEvent.Name fires when m.room.name changes;

View file

@ -1,11 +1,7 @@
import React, { Ref, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import React, { Ref, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { launchSplash } from '../../plugins/launchSplash'; import { launchSplash } from '../../plugins/launchSplash';
import { import { attachMascotVideo, detachMascotVideo, mascotVideoReady } from './mascotSingleton';
attachMascotVideo,
detachMascotVideo,
mascotVideoReady,
} from './mascotSingleton';
import * as css from './styles.css'; import * as css from './styles.css';
type AuthMascotProps = { type AuthMascotProps = {
@ -64,8 +60,13 @@ export function AuthMascot({ mascotRef, centered }: AuthMascotProps) {
const setRefs = useCallback( const setRefs = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
containerRef.current = node; containerRef.current = node;
if (typeof mascotRef === 'function') mascotRef(node); if (typeof mascotRef === 'function') {
else if (mascotRef && typeof mascotRef === 'object') { mascotRef(node);
} else if (mascotRef && typeof mascotRef === 'object') {
// Forward-ref mutation is the canonical React pattern for combining
// refs; the eslint rule fires on any param mutation, but a MutableRefObject
// is specifically designed for this.
// eslint-disable-next-line no-param-reassign
(mascotRef as React.MutableRefObject<HTMLDivElement | null>).current = node; (mascotRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
} }
}, },

View file

@ -1,5 +1,5 @@
import React, { ReactNode, useMemo } from 'react'; import React, { ReactNode, useMemo } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useAuthedUserId } from '../../hooks/useAuthedUserId';
import { makeClosedNavCategoriesAtom } from '../../state/closedNavCategories'; import { makeClosedNavCategoriesAtom } from '../../state/closedNavCategories';
import { ClosedNavCategoriesProvider } from '../../state/hooks/closedNavCategories'; import { ClosedNavCategoriesProvider } from '../../state/hooks/closedNavCategories';
import { makeClosedLobbyCategoriesAtom } from '../../state/closedLobbyCategories'; import { makeClosedLobbyCategoriesAtom } from '../../state/closedLobbyCategories';
@ -15,8 +15,7 @@ type ClientInitStorageAtomProps = {
children: ReactNode; children: ReactNode;
}; };
export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) { export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) {
const mx = useMatrixClient(); const userId = useAuthedUserId();
const userId = mx.getUserId()!;
const closedNavCategoriesAtom = useMemo(() => makeClosedNavCategoriesAtom(userId), [userId]); const closedNavCategoriesAtom = useMemo(() => makeClosedNavCategoriesAtom(userId), [userId]);

View file

@ -2,11 +2,7 @@ import { Box, Button, config, Dialog, Text } from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk'; import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { clearLocalSessionAndReload, initClient, startClient } from '../../../client/initMatrix';
clearLocalSessionAndReload,
initClient,
startClient,
} from '../../../client/initMatrix';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { CapabilitiesProvider } from '../../hooks/useCapabilities';
import { MediaConfigProvider } from '../../hooks/useMediaConfig'; import { MediaConfigProvider } from '../../hooks/useMediaConfig';
@ -138,9 +134,15 @@ export function ClientRoot({ children }: ClientRootProps) {
onRetry = loadMatrix; onRetry = loadMatrix;
} }
// When `getFallbackSession()` returns `undefined` (no stored session),
// `loadMatrix()` throws and the error branch below renders inside
// `AuthSplashScreen`. The empty-string defaults reach AutoDiscovery /
// SpecVersions but neither component does anything load-bearing with them
// in the error path — AutoDiscovery falls back to an empty `m.homeserver`
// record, SpecVersions probes the current origin (harmless).
return ( return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}> <AutoDiscovery userId={userId ?? ''} baseUrl={baseUrl ?? ''}>
<SpecVersions baseUrl={baseUrl!}> <SpecVersions baseUrl={baseUrl ?? ''}>
{mx && <SyncIndicator mx={mx} />} {mx && <SyncIndicator mx={mx} />}
{(loadState.status === AsyncStatus.Error || {(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error ||
@ -168,9 +170,7 @@ export function ClientRoot({ children }: ClientRootProps) {
{loading && {loading &&
syncErrored && syncErrored &&
loadState.status !== AsyncStatus.Error && loadState.status !== AsyncStatus.Error &&
startState.status !== AsyncStatus.Error && ( startState.status !== AsyncStatus.Error && <Text>{t('Boot.sync_failed')}</Text>}
<Text>{t('Boot.sync_failed')}</Text>
)}
<Button variant="Critical" onClick={onRetry}> <Button variant="Critical" onClick={onRetry}>
<Text as="span" size="B400"> <Text as="span" size="B400">
{t('Boot.retry')} {t('Boot.retry')}

View file

@ -41,7 +41,8 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
// After P3c the Direct tab is universal — any joined non-space room renders // After P3c the Direct tab is universal — any joined non-space room renders
// here. Spaces (= future Channels) keep their own /{spaceId}/ route. // here. Spaces (= future Channels) keep their own /{spaceId}/ route.
if (!room || isSpace(room)) { if (!room || isSpace(room)) {
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />; if (!roomIdOrAlias) return <Navigate to={getDirectPath()} replace />;
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias} eventId={eventId} />;
} }
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>; return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;

View file

@ -43,21 +43,15 @@ export function HomeRouteRoomProvider({ children: _children }: { children: React
const alias = getCanonicalAliasOrRoomId(mx, room.roomId); const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
const orphanParents = getOrphanParents(roomToParents, room.roomId); const orphanParents = getOrphanParents(roomToParents, room.roomId);
if (orphanParents.length > 0) { if (orphanParents.length > 0) {
const parentSpace = const parentSpace = guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0];
guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0];
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
return ( return <Navigate to={getChannelsRoomPath(pSpaceIdOrAlias, alias, eventId)} replace />;
<Navigate to={getChannelsRoomPath(pSpaceIdOrAlias, alias, eventId)} replace />
);
} }
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />; return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
} }
if (!roomIdOrAlias) return <Navigate to="/direct" replace />;
return ( return (
<JoinBeforeNavigate <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias} eventId={eventId} viaServers={viaServers} />
roomIdOrAlias={roomIdOrAlias!}
eventId={eventId}
viaServers={viaServers}
/>
); );
} }

View file

@ -4,6 +4,7 @@ import { useMatch, useNavigate } from 'react-router-dom';
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar'; import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common'; import { nameInitials } from '../../../utils/common';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@ -17,7 +18,7 @@ export function SettingsTab() {
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const navigate = useNavigate(); const navigate = useNavigate();
const settingsMatch = useMatch({ path: SETTINGS_PATH, caseSensitive: true, end: false }); const settingsMatch = useMatch({ path: SETTINGS_PATH, caseSensitive: true, end: false });
const userId = mx.getUserId()!; const userId = useAuthedUserId();
const profile = useUserProfile(userId); const profile = useUserProfile(userId);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId; const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;

View file

@ -1,6 +1,6 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom'; import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
@ -41,12 +41,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
if (!room || !allRooms.includes(room.roomId)) { if (!room || !allRooms.includes(room.roomId)) {
// room is not joined // room is not joined
if (!roomIdOrAlias) return <Navigate to="/direct" replace />;
return ( return (
<JoinBeforeNavigate <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias} eventId={eventId} viaServers={viaServers} />
roomIdOrAlias={roomIdOrAlias!}
eventId={eventId}
viaServers={viaServers}
/>
); );
} }
@ -67,12 +64,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
}); });
} }
if (!roomIdOrAlias) return <Navigate to="/direct" replace />;
return ( return (
<JoinBeforeNavigate <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias} eventId={eventId} viaServers={viaServers} />
roomIdOrAlias={roomIdOrAlias!}
eventId={eventId}
viaServers={viaServers}
/>
); );
} }

View file

@ -343,7 +343,8 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
<Text size="T200">This space has been replaced and is no longer active.</Text> <Text size="T200">This space has been replaced and is no longer active.</Text>
{joinState.status === AsyncStatus.Error && ( {joinState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200"> <Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'} {(joinState.error as { message?: string } | undefined)?.message ??
'Failed to join replacement space!'}
</Text> </Text>
)} )}
</Box> </Box>

View file

@ -126,8 +126,15 @@ export class CallEmbed {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.title = 'Call Embed'; iframe.title = 'Call Embed';
iframe.sandbox = // `HTMLIFrameElement.sandbox` is typed as a `DOMTokenList` in modern
'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads'; // lib.dom; string assignment is rejected by the `--strict` compiler even
// though browsers still honour it via `[PutForwards=value]`. Use
// `setAttribute` for an unambiguous, strict-typed write. Must be set
// BEFORE `iframe.src` so the sandbox policy applies at first navigation.
iframe.setAttribute(
'sandbox',
'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads'
);
iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;'; iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;';
iframe.src = url; iframe.src = url;
@ -201,7 +208,7 @@ export class CallEmbed {
return this.listenEvent('preparing', callback); return this.listenEvent('preparing', callback);
} }
public onPreparingError(callback: (error: any) => void) { public onPreparingError(callback: (error: unknown) => void) {
return this.listenEvent('error:preparing', callback); return this.listenEvent('error:preparing', callback);
} }
@ -228,7 +235,9 @@ export class CallEmbed {
const events = room.getLiveTimeline()?.getEvents() || []; const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1]; const roomEvent = events[events.length - 1];
if (!roomEvent) return; // force later code to think the room is fresh if (!roomEvent) return; // force later code to think the room is fresh
this.readUpToMap[room.roomId] = roomEvent.getId()!; const evtId = roomEvent.getId();
if (!evtId) return;
this.readUpToMap[room.roomId] = evtId;
}); });
// Attach listeners for feeding events - the underlying widget classes handle permissions for us // Attach listeners for feeding events - the underlying widget classes handle permissions for us
@ -296,6 +305,9 @@ export class CallEmbed {
if (this.call === null) return; if (this.call === null) return;
const raw = ev.getEffectiveEvent(); const raw = ev.getEffectiveEvent();
this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => { this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
// Element Call widget channel error — surface so a stuck widget is
// diagnosable without breaking the call lifecycle.
// eslint-disable-next-line no-console
console.error('Error sending state update to widget: ', e); console.error('Error sending state update to widget: ', e);
}); });
} }
@ -329,7 +341,7 @@ export class CallEmbed {
const room = this.mx.getRoom(roomId); const room = this.mx.getRoom(roomId);
if (room === null) return false; if (room === null) return false;
const upToEventId = this.readUpToMap[ev.getRoomId()!]; const upToEventId = this.readUpToMap[roomId];
if (!upToEventId) { if (!upToEventId) {
// There's no marker yet; start it at this event // There's no marker yet; start it at this event
this.readUpToMap[roomId] = evId; this.readUpToMap[roomId] = evId;
@ -402,6 +414,7 @@ export class CallEmbed {
} else { } else {
const raw = ev.getEffectiveEvent(); const raw = ev.getEffectiveEvent();
this.call.feedEvent(raw as IRoomEvent).catch((e) => { this.call.feedEvent(raw as IRoomEvent).catch((e) => {
// eslint-disable-next-line no-console
console.error('Error sending event to widget: ', e); console.error('Error sending event to widget: ', e);
}); });
} }

View file

@ -149,7 +149,10 @@ export const renderMatrixMention = (
export const factoryRenderLinkifyWithMention = ( export const factoryRenderLinkifyWithMention = (
mentionRender: (href: string) => JSX.Element | undefined mentionRender: (href: string) => JSX.Element | undefined
// linkifyjs render callback is typed `(ir: IntermediateRepresentation) => any` upstream.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): OptFn<(ir: IntermediateRepresentation) => any> => { ): OptFn<(ir: IntermediateRepresentation) => any> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const render: OptFn<(ir: IntermediateRepresentation) => any> = ({ const render: OptFn<(ir: IntermediateRepresentation) => any> = ({
tagName, tagName,
attributes, attributes,

View file

@ -36,6 +36,9 @@ export class ASCIILexicalTable {
this.populateWidthToSize(); this.populateWidthToSize();
if (this.size() > Number.MAX_SAFE_INTEGER) { if (this.size() > Number.MAX_SAFE_INTEGER) {
// Diagnostic for upstream Slate decorator math overflow — extremely
// unlikely to fire on real alphabets but worth surfacing to devs.
// eslint-disable-next-line no-console
console.warn( console.warn(
`[!] Warning: ASCIILexicalTable size is larger than the Number.MAX_SAFE_INTEGER: ${this.size()} > ${ `[!] Warning: ASCIILexicalTable size is larger than the Number.MAX_SAFE_INTEGER: ${this.size()} > ${
Number.MAX_SAFE_INTEGER Number.MAX_SAFE_INTEGER

View file

@ -57,7 +57,7 @@ export const promiseFulfilledResult = <T>(
if (settledResult.status === 'fulfilled') return settledResult.value; if (settledResult.status === 'fulfilled') return settledResult.value;
return undefined; return undefined;
}; };
export const promiseRejectedResult = <T>(settledResult: PromiseSettledResult<T>): any => { export const promiseRejectedResult = <T>(settledResult: PromiseSettledResult<T>): unknown => {
if (settledResult.status === 'rejected') return settledResult.reason; if (settledResult.status === 'rejected') return settledResult.reason;
return undefined; return undefined;
}; };

View file

@ -72,7 +72,10 @@ export const getLoginTermUrl = (params: UIAParams): string | undefined => {
if (terms.policies === null) return undefined; if (terms.policies === null) return undefined;
if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') { if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') {
if (terms.policies.privacy_policy === null) return undefined; if (terms.policies.privacy_policy === null) return undefined;
const langToPolicy = terms.policies.privacy_policy as Record<string, any>; const langToPolicy = terms.policies.privacy_policy as Record<
string,
{ url?: string } | undefined
>;
const url = langToPolicy.en?.url; const url = langToPolicy.en?.url;
if (typeof url === 'string') return url; if (typeof url === 'string') return url;

View file

@ -13,6 +13,7 @@ import {
UploadProgress, UploadProgress,
UploadResponse, UploadResponse,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import type { AccountDataEvents } from 'matrix-js-sdk';
import to from 'await-to-js'; import to from 'await-to-js';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
@ -164,9 +165,10 @@ export const uploadContent = async (
const mxc = data.content_uri; const mxc = data.content_uri;
if (mxc) onSuccess(mxc); if (mxc) onSuccess(mxc);
else onError(new MatrixError(data)); else onError(new MatrixError(data));
} catch (e: any) { } catch (e: unknown) {
const error = typeof e?.message === 'string' ? e.message : undefined; const err = e as { message?: unknown; name?: unknown };
const errcode = typeof e?.name === 'string' ? e.message : undefined; const error = typeof err?.message === 'string' ? err.message : undefined;
const errcode = typeof err?.name === 'string' ? err.name : undefined;
onError(new MatrixError({ error, errcode })); onError(new MatrixError({ error, errcode }));
} }
}; };
@ -184,7 +186,7 @@ export const eventWithShortcode = (ev: MatrixEvent) =>
// Element's findDMForUser (matrix-react-sdk/src/utils/dm/findDMForUser.ts) // Element's findDMForUser (matrix-react-sdk/src/utils/dm/findDMForUser.ts)
// including the cross-user fallback scan for stale peer state (PR #10127). // including the cross-user fallback scan for stale peer state (PR #10127).
export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => { export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as keyof AccountDataEvents);
const mDirect = (mDirectsEvent?.getContent() ?? {}) as Record<string, string[]>; const mDirect = (mDirectsEvent?.getContent() ?? {}) as Record<string, string[]>;
const myUserId = mx.getUserId(); const myUserId = mx.getUserId();
@ -264,7 +266,7 @@ export const addRoomIdToMDirect = (
userId: string userId: string
): Promise<void> => ): Promise<void> =>
enqueueMDirectWrite(mx, async () => { enqueueMDirectWrite(mx, async () => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as keyof AccountDataEvents);
let userIdToRoomIds: Record<string, string[]> = {}; let userIdToRoomIds: Record<string, string[]> = {};
if (typeof mDirectsEvent !== 'undefined') if (typeof mDirectsEvent !== 'undefined')
@ -287,12 +289,12 @@ export const addRoomIdToMDirect = (
} }
userIdToRoomIds[userId] = roomIds; userIdToRoomIds[userId] = roomIds;
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); await mx.setAccountData(AccountDataEvent.Direct as keyof AccountDataEvents, userIdToRoomIds);
}); });
export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise<void> => export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise<void> =>
enqueueMDirectWrite(mx, async () => { enqueueMDirectWrite(mx, async () => {
const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as keyof AccountDataEvents);
let userIdToRoomIds: Record<string, string[]> = {}; let userIdToRoomIds: Record<string, string[]> = {};
if (typeof mDirectsEvent !== 'undefined') if (typeof mDirectsEvent !== 'undefined')
@ -306,7 +308,7 @@ export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promi
} }
}); });
await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); await mx.setAccountData(AccountDataEvent.Direct as keyof AccountDataEvents, userIdToRoomIds);
}); });
export const mxcUrlToHttp = ( export const mxcUrlToHttp = (

View file

@ -141,7 +141,6 @@ export async function ensureRtcRingPushRule(mx: MatrixClient): Promise<void> {
} }
} }
// Symmetric cleanup for `useDisablePushNotifications`. The rules live in // Symmetric cleanup for `useDisablePushNotifications`. The rules live in
// account data so they would otherwise outlive the pusher — and other logged-in // account data so they would otherwise outlive the pusher — and other logged-in
// clients (Element, a second Vojo session) would keep applying the ring // clients (Element, a second Vojo session) would keep applying the ring
@ -163,7 +162,6 @@ export async function removeRtcRingPushRule(mx: MatrixClient): Promise<void> {
} }
} }
export async function registerWebPusher( export async function registerWebPusher(
mx: MatrixClient, mx: MatrixClient,
subscription: PushSubscription, subscription: PushSubscription,
@ -193,13 +191,17 @@ export async function registerWebPusher(
app_display_name: 'Vojo Web', app_display_name: 'Vojo Web',
device_display_name: navigator.userAgent.slice(0, 120), device_display_name: navigator.userAgent.slice(0, 120),
lang: navigator.language || 'en', lang: navigator.language || 'en',
// SDK's pusher `data` type only declares `{ format?, url?, brand? }`.
// Sygnal / UnifiedPush also read `endpoint`, `auth`, `events_only`; the
// extra fields are forwarded to the gateway and required for web-push
// delivery. Cast through `unknown` to drop the over-narrow SDK shape.
data: { data: {
url: gatewayUrl, url: gatewayUrl,
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
auth: arrayBufferToBase64Url(auth), auth: arrayBufferToBase64Url(auth),
format: 'event_id_only', format: 'event_id_only',
events_only: true, events_only: true,
}, } as unknown as { format?: string; url?: string; brand?: string },
append: true, append: true,
}) })
); );

View file

@ -17,6 +17,7 @@ import {
Room, Room,
RoomMember, RoomMember,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import type { AccountDataEvents } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { import {
@ -44,7 +45,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
export const getAccountData = ( export const getAccountData = (
mx: MatrixClient, mx: MatrixClient,
eventType: AccountDataEvent eventType: AccountDataEvent
): MatrixEvent | undefined => mx.getAccountData(eventType as any); ): MatrixEvent | undefined => mx.getAccountData(eventType as keyof AccountDataEvents);
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => { export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
const roomIds = new Set<string>(); const roomIds = new Set<string>();

View file

@ -31,6 +31,8 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
cryptoStore: legacyCryptoStore, cryptoStore: legacyCryptoStore,
deviceId: session.deviceId, deviceId: session.deviceId,
timelineSupport: true, timelineSupport: true,
// cryptoCallbacks ships from a .js module with a duck-typed shape; matrix-js-sdk's exported type is too narrow.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cryptoCallbacks: cryptoCallbacks as any, cryptoCallbacks: cryptoCallbacks as any,
verificationMethods: ['m.sas.v1'], verificationMethods: ['m.sas.v1'],
}); });

View file

@ -87,6 +87,9 @@ const mountApp = () => {
const rootContainer = document.getElementById('root'); const rootContainer = document.getElementById('root');
if (rootContainer === null) { if (rootContainer === null) {
// Bootstrap failure — only surfaces if `index.html` is broken; logging
// is the only path here since React hasn't mounted yet.
// eslint-disable-next-line no-console
console.error('Root container element not found!'); console.error('Root container element not found!');
return; return;
} }

View file

@ -29,7 +29,7 @@ async function cleanupDeadClients() {
}); });
} }
function setSession(clientId: string, accessToken: any, baseUrl: any) { function setSession(clientId: string, accessToken: unknown, baseUrl: unknown) {
if (typeof accessToken === 'string' && typeof baseUrl === 'string') { if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(clientId, { accessToken, baseUrl }); sessions.set(clientId, { accessToken, baseUrl });
} else { } else {

33
src/types/matrix/sdkAugmentation.d.ts vendored Normal file
View file

@ -0,0 +1,33 @@
// Module augmentation: extend matrix-js-sdk's AccountDataEvents and StateEvents
// interfaces with the custom Matrix event types Vojo / upstream Cinny uses
// (Vojo spaces sidebar, Ponies emote/sticker packs, Element recent emoji,
// MSC2346 bridge metadata, MSC3401 group-call membership, MSC4143 RTC member,
// Vojo power-level tags). Without this, `mx.getAccountData(<custom-key>)` /
// `mx.getStateEvent(<custom-key>)` / `state.getStateEvents(<custom-key>)` fail
// `keyof XxxEvents` narrowing in TypeScript 5.4+.
//
// Content types stay `unknown` here on purpose — call sites already invoke
// `.getContent<T>()` with the right local content type. Keep this file dep-free
// (only ambient declarations) so it loads before any value imports.
import 'matrix-js-sdk';
declare module 'matrix-js-sdk' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/consistent-type-definitions
interface AccountDataEvents {
'in.vojo.spaces': unknown;
'io.element.recent_emoji': unknown;
'im.ponies.user_emotes': unknown;
'im.ponies.emote_rooms': unknown;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/consistent-type-definitions
interface StateEvents {
'im.ponies.room_emotes': unknown;
'in.vojo.room.power_level_tags': unknown;
// MSC2346 bridge metadata — stable + unstable variants. Mautrix-* and
// similar bridges write at least one; SDK ships no built-in typing.
'm.bridge': unknown;
'uk.half-shot.bridge': unknown;
}
}