diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a30cb4f0..bcfccb0d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -60,10 +60,12 @@ module.exports = { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-shadow': 'error', - // Policy: kept as warnings, not errors. The codebase has ~70 long-standing - // `any` casts and ~15 non-null assertions in matrix-js-sdk interop code. - // Promoting to error would block builds on existing usage; turning off - // would lose signal on new code. Warnings are visible without blocking. + // Policy: kept as `warn` at the rule level so editors / `eslint --fix` / + // ad-hoc runs surface them as warnings, but `npm run check:eslint` and + // `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block + // 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-non-null-assertion': 'warn', }, @@ -86,6 +88,11 @@ module.exports = { 'no-plusplus': 'off', 'prefer-template': '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', }, }, ], diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index 7aea7a8d..e55b74d6 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,2 @@ -# These are commented until we enable lint and typecheck -# npx tsc -p tsconfig.json --noEmit -# npx lint-staged \ No newline at end of file +npx tsc -p tsconfig.json --noEmit +npx lint-staged diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 76c215e2..4e508589 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -11,7 +11,7 @@ npm run typecheck # tsc --noEmit 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 @@ -287,7 +287,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga - Current vojo work branch: `vojo/dev` - Semantic-release on `dev` branch - 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 - **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer diff --git a/docs/known-tech-debt-lint/README.md b/docs/known-tech-debt-lint/README.md deleted file mode 100644 index 30ab6199..00000000 --- a/docs/known-tech-debt-lint/README.md +++ /dev/null @@ -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. diff --git a/docs/known-tech-debt-lint/diff.sh b/docs/known-tech-debt-lint/diff.sh deleted file mode 100755 index fa8383bc..00000000 --- a/docs/known-tech-debt-lint/diff.sh +++ /dev/null @@ -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" diff --git a/docs/known-tech-debt-lint/typecheck.snapshot.txt b/docs/known-tech-debt-lint/typecheck.snapshot.txt deleted file mode 100644 index 2b2a66ea..00000000 --- a/docs/known-tech-debt-lint/typecheck.snapshot.txt +++ /dev/null @@ -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 & NoRelationEvent) | (Without & 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; }'. diff --git a/package.json b/package.json index 59ab8424..2cc3415b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "vite build", "preview": "vite preview", "lint": "npm run check:eslint && npm run check:prettier", - "check:eslint": "eslint src", + "check:eslint": "eslint --max-warnings 0 src", "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", @@ -30,7 +30,7 @@ "commit": "git-cz" }, "lint-staged": { - "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint", + "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --max-warnings 0", "*": "prettier --ignore-unknown --write" }, "config": { diff --git a/public/locales/en.json b/public/locales/en.json index ee0e114f..ece119a5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -647,7 +647,8 @@ "no_communities": "No communities found!", "space_badge": "Space", - "members_count": "{{count}} Members", + "members_count_one": "{{formattedCount}} Member", + "members_count_other": "{{formattedCount}} Members", "join": "Join", "joining": "Joining", "retry": "Retry", diff --git a/public/locales/ru.json b/public/locales/ru.json index 4e20a631..b4200f4e 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -661,7 +661,10 @@ "no_communities": "Сообщества не найдены!", "space_badge": "Пространство", - "members_count": "{{count}} участников", + "members_count_one": "{{formattedCount}} участник", + "members_count_few": "{{formattedCount}} участника", + "members_count_many": "{{formattedCount}} участников", + "members_count_other": "{{formattedCount}} участника", "join": "Присоединиться", "joining": "Вступление…", "retry": "Повторить", diff --git a/src/app/components/ActionUIA.tsx b/src/app/components/ActionUIA.tsx index b9cd122f..608f283e 100644 --- a/src/app/components/ActionUIA.tsx +++ b/src/app/components/ActionUIA.tsx @@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA > {stageToComplete.type === AuthType.Password && ( ( authData: IAuthData, performAction: PerformAction, resolve: (data: T) => void, - reject: (error?: any) => void + reject: (error?: unknown) => void ): UIAAction { const action: UIAAction = { authData, diff --git a/src/app/components/ImageOverlay.tsx b/src/app/components/ImageOverlay.tsx index ea690924..d87f934b 100644 --- a/src/app/components/ImageOverlay.tsx +++ b/src/app/components/ImageOverlay.tsx @@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>( evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ src, diff --git a/src/app/components/SecretStorage.tsx b/src/app/components/SecretStorage.tsx index 9d8628e5..815f5fbc 100644 --- a/src/app/components/SecretStorage.tsx +++ b/src/app/components/SecretStorage.tsx @@ -39,6 +39,8 @@ export function SecretStorageRecoveryPassphrase({ 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); if (!match) { @@ -131,6 +133,8 @@ export function SecretStorageRecoveryKey({ async (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); if (!match) { diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx index 3c8ce8eb..ddd198bb 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -34,6 +34,9 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { try { validatedAuthMetadata = validateAuthMetadata(authMetadata); } 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); } diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index f3e699aa..4f7d6be1 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -6,6 +6,7 @@ import { RestrictedAllowType, Room, } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomType, StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; @@ -17,7 +18,7 @@ export const createRoomCreationContent = ( allowFederation: boolean, additionalCreators: string[] | undefined ): object => { - const content: Record = {}; + const content: Record = {}; if (typeof type === 'string') { content.type = type; } @@ -152,11 +153,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis if (data.parent) { await mx.sendStateEvent( data.parent.roomId, - StateEvent.SpaceChild as any, + StateEvent.SpaceChild as keyof StateEvents, { auto_join: false, suggested: false, - via: [getMxIdServer(mx.getUserId() ?? '') ?? ''], + via: [getMxIdServer(mx.getSafeUserId()) ?? ''], }, result.room_id ); diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 377cecab..de5b9b10 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -24,7 +24,7 @@ type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; const roomAliasFromQueryText = (mx: MatrixClient, text: string) => isRoomAlias(`#${text}`) ? `#${text}` - : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; + : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`; function UnknownRoomMentionItem({ query, diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 7a8012eb..dc446137 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -26,7 +26,7 @@ type MentionAutoCompleteHandler = (userId: string, name: string) => void; const userIdFromQueryText = (mx: MatrixClient, text: string) => isUserId(`@${text}`) ? `@${text}` - : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; + : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`; function UnknownMentionItem({ userId, @@ -92,7 +92,7 @@ export function UserMentionAutocomplete({ }: UserMentionAutocompleteProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const roomId: string = room.roomId!; + const { roomId } = room; const roomAliasOrId = room.getCanonicalAlias() || roomId; const members = useRoomMembers(mx, roomId); diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index c7900237..fbbba81c 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -79,7 +79,7 @@ export const EventReaders = as<'div', EventReadersProps>( key={readerId} style={{ padding: `0 ${config.space.S200}` }} radii="400" - onClick={(event) => { + onClick={(event: React.MouseEvent) => { openProfile( room.roomId, space?.roomId, diff --git a/src/app/components/image-pack-view/RoomImagePack.tsx b/src/app/components/image-pack-view/RoomImagePack.tsx index 92b4ff21..50e32ad7 100644 --- a/src/app/components/image-pack-view/RoomImagePack.tsx +++ b/src/app/components/image-pack-view/RoomImagePack.tsx @@ -17,7 +17,7 @@ type RoomImagePackProps = { export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { const mx = useMatrixClient(); - const userId = mx.getUserId()!; + const userId = mx.getSafeUserId(); const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); diff --git a/src/app/components/image-pack-view/UserImagePack.tsx b/src/app/components/image-pack-view/UserImagePack.tsx index 4987793d..2c46b07c 100644 --- a/src/app/components/image-pack-view/UserImagePack.tsx +++ b/src/app/components/image-pack-view/UserImagePack.tsx @@ -8,7 +8,7 @@ import { useUserImagePack } from '../../hooks/useImagePacks'; export function UserImagePack() { 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 handleUpdate = useCallback( diff --git a/src/app/components/message/attachment/StreamMediaShell.tsx b/src/app/components/message/attachment/StreamMediaShell.tsx index f1e1388d..10cd6026 100644 --- a/src/app/components/message/attachment/StreamMediaShell.tsx +++ b/src/app/components/message/attachment/StreamMediaShell.tsx @@ -32,6 +32,8 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert // `object-fit: cover` (image) / `contain` (video) on the inner element. const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN; if ( + !naturalW || + !naturalH || !Number.isFinite(naturalAspect) || naturalAspect < STREAM_MEDIA_MIN_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) }; } 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) }; } - 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) }; } diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 7e127f2a..b6ae97e3 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ name: body, @@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ name: body, diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 90122b24..bf10c0ad 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -177,7 +177,7 @@ export const ImageContent = as<'div', ImageContentProps>( evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ src: srcState.data, @@ -214,10 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>( )} {srcState.status === AsyncStatus.Success && ( {renderImage({ alt: body, diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index d4007983..ab077687 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -15,11 +15,7 @@ import classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; import * as css from './style.css'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { - SIDEBAR_WIDTH_MIN, - clampSidebarWidth, - sidebarWidthAtom, -} from '../../state/sidebarWidth'; +import { SIDEBAR_WIDTH_MIN, clampSidebarWidth, sidebarWidthAtom } from '../../state/sidebarWidth'; import { VOJO_HORSESHOE_VOID_COLOR, VOJO_HORSESHOE_GAP_PX, @@ -84,10 +80,7 @@ export function PageRoot({ nav, children }: PageRootProps) { apparent colour unchanged for routes whose content has no opaque bg of its own (e.g. ChannelsLanding) — without it the outer void would bleed through. */} - + {children}; } @@ -160,9 +156,7 @@ export function PageNav({ // we can override the default `Background.Container` without // touching the recipe. const surfaceStyle = - surface === 'surfaceVariant' - ? { backgroundColor: color.SurfaceVariant.Container } - : undefined; + surface === 'surfaceVariant' ? { backgroundColor: color.SurfaceVariant.Container } : undefined; return ( (null); const horseshoe = useHorseshoeEnabled(); const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom); - const [vw, setVw] = useState( - typeof window !== 'undefined' ? window.innerWidth : 1280 - ); + const [vw, setVw] = useState(typeof window !== 'undefined' ? window.innerWidth : 1280); const [dragging, setDragging] = useState(false); // 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 @@ -322,6 +314,11 @@ function ResizablePageNav({ children }: { children: ReactNode }) { {children} {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
( - {t('Explore.members_count', { count: millify(joinedMemberCount) })} + {t('Explore.members_count', { + count: joinedMemberCount, + formattedCount: millify(joinedMemberCount), + })} )} diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ee8967af..bc53c71f 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -54,7 +54,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( alt={prev['og:title']} title={prev['og:title']} tabIndex={0} - onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)} + onKeyDown={(evt: React.KeyboardEvent) => onEnterOrSpace(() => setViewer(true))(evt)} onClick={() => setViewer(true)} /> )} diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index d100096b..6b4aecc2 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -30,6 +30,7 @@ import React, { import { useAtomValue } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Room } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { stopPropagation } from '../../utils/keyboard'; import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -136,7 +137,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM await mx.sendStateEvent( parentId, - StateEvent.SpaceChild as any, + StateEvent.SpaceChild as keyof StateEvents, { auto_join: false, suggested: false, @@ -164,7 +165,9 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM }; 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(() => { if (alive()) { setSelected([]); diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 7b3ed8c2..54090bde 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -264,7 +264,9 @@ export class BotWidgetEmbed { } catch { return; } - void openExternalUrl(url); + openExternalUrl(url).catch(() => { + /* fire-and-forget: log handled inside openExternalUrl */ + }); }; public constructor(private readonly options: BotWidgetEmbedOptions) { diff --git a/src/app/features/call/CallMemberCard.tsx b/src/app/features/call/CallMemberCard.tsx index 46903f80..1c3680f5 100644 --- a/src/app/features/call/CallMemberCard.tsx +++ b/src/app/features/call/CallMemberCard.tsx @@ -47,7 +47,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) { className={css.CallMemberCard} variant="SurfaceVariant" radii="500" - onClick={(evt: any) => + onClick={(evt: React.MouseEvent) => openUserProfile( room.roomId, undefined, diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index 729690ec..d86224c6 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents, TimelineEvents } from 'matrix-js-sdk'; import { Box, Chip, @@ -53,9 +54,18 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro useCallback( (evtType, evtStateKey, evtContent) => { 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] ) diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 5ce9341c..5bdeb970 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -15,6 +15,7 @@ import { } from 'folds'; import { useTranslation } from 'react-i18next'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { Page, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { TextViewerContent } from '../../../components/text-viewer'; @@ -61,7 +62,13 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi const [submitState, submit] = useAsyncCallback( 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] ) ); diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index 0979cc1a..e36d3484 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -18,6 +18,7 @@ import { } from 'folds'; import { useTranslation } from 'react-i18next'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { ImagePack, @@ -59,7 +60,12 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) { 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] ) @@ -164,7 +170,12 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) { for (let i = 0; i < removedPacks.length; i += 1) { const addr = removedPacks[i]; // 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]) ); diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx index 275a6ff2..bcae00c3 100644 --- a/src/app/features/common-settings/general/RoomAddress.tsx +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -65,6 +65,9 @@ export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesPr ` + // markup, no user-controlled input. Safe. + // eslint-disable-next-line react/no-danger } /> diff --git a/src/app/features/common-settings/general/RoomEncryption.tsx b/src/app/features/common-settings/general/RoomEncryption.tsx index 425a47d7..fde639b7 100644 --- a/src/app/features/common-settings/general/RoomEncryption.tsx +++ b/src/app/features/common-settings/general/RoomEncryption.tsx @@ -17,6 +17,7 @@ import { } from 'folds'; import React, { useCallback, useState } from 'react'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; import { SequenceCard } from '../../../components/sequence-card'; @@ -48,7 +49,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) { const [enableState, enable] = useAsyncCallback( 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, }); }, [mx, room.roomId]) diff --git a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx index 4c9ccf9b..4af93e4e 100644 --- a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx +++ b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx @@ -13,6 +13,7 @@ import { Text, } from 'folds'; import { HistoryVisibility, MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; @@ -80,7 +81,11 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp const content: RoomHistoryVisibilityEventContent = { 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] ) diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index 842e7570..6279822e 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { color, Text } from 'folds'; import { useTranslation } from 'react-i18next'; import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { useAtomValue } from 'jotai'; import { @@ -88,7 +89,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { const parents = getStateEvents(room, StateEvent.SpaceParent) .map((event) => event.getStateKey()) - .filter((parentId) => typeof parentId === 'string') + .filter((parentId): parentId is string => typeof parentId === 'string') .filter((parentId) => roomParents?.has(parentId)); if (parents.length === 0 && space && roomParents) { @@ -113,7 +114,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { join_rule: joinRule as JoinRule, }; 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] ) diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 5382218d..bade282a 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import Linkify from 'linkify-react'; import classNames from 'classnames'; import { JoinRule, MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { useRoom } from '../../../hooks/useRoom'; @@ -94,15 +95,19 @@ export function RoomProfileEdit({ useCallback( async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => { if (roomAvatarMxc !== undefined) { - await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, { + await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as keyof StateEvents, { url: roomAvatarMxc, }); } 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) { - 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] diff --git a/src/app/features/common-settings/permissions/PermissionGroups.tsx b/src/app/features/common-settings/permissions/PermissionGroups.tsx index daa3c41d..f173b595 100644 --- a/src/app/features/common-settings/permissions/PermissionGroups.tsx +++ b/src/app/features/common-settings/permissions/PermissionGroups.tsx @@ -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 { useTranslation } from 'react-i18next'; import produce from 'immer'; +import type { StateEvents } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; @@ -87,7 +88,11 @@ export function PermissionGroups({ 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]) ); diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index b45da7d1..c94a0f7b 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -21,6 +21,7 @@ import { import { HexColorPicker } from 'react-colorful'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; +import type { StateEvents } from 'matrix-js-sdk'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { IPowerLevels } from '../../../hooks/usePowerLevels'; import { SequenceCard } from '../../../components/sequence-card'; @@ -336,7 +337,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { deleted.forEach((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]) ); diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index c6aff741..a76f6aa2 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -15,6 +15,7 @@ import { Spinner, toRem, } from 'folds'; +import type { StateEvents } from 'matrix-js-sdk'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room'; @@ -48,7 +49,12 @@ function SuggestMenuItem({ const [toggleState, handleToggleSuggested] = useAsyncCallback( useCallback(() => { 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]) ); @@ -85,7 +91,7 @@ function RemoveMenuItem({ const [removeState, handleRemove] = useAsyncCallback( useCallback( - () => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId), + () => mx.sendStateEvent(parentId, StateEvent.SpaceChild as keyof StateEvents, {}, roomId), [mx, parentId, roomId] ) ); diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index cd212d72..1ea6f542 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -4,6 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; 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 { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import produce from 'immer'; @@ -273,7 +274,7 @@ export function Lobby() { if (!reorder.item.parentId) return; await mx.sendStateEvent( reorder.item.parentId, - StateEvent.SpaceChild as any, + StateEvent.SpaceChild as keyof StateEvents, { ...reorder.item.content, order: reorder.orderKey }, reorder.item.roomId ); @@ -298,7 +299,12 @@ export function Lobby() { // remove from current space 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 ( @@ -318,7 +324,7 @@ export function Lobby() { joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? []; 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, allow, }); @@ -360,7 +366,7 @@ export function Lobby() { await rateLimitedActions(reorders, async (reorder) => { await mx.sendStateEvent( containerParentId, - StateEvent.SpaceChild as any, + StateEvent.SpaceChild as keyof StateEvents, { ...reorder.item.content, order: reorder.orderKey }, reorder.item.roomId ); @@ -422,7 +428,10 @@ export function Lobby() { newItems.push(rId); } 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] ); diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index a66cb1f3..a2114b78 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -333,7 +333,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) { }; const handleCreateSpace = () => { - openCreateSpaceModal(item.roomId as any); + openCreateSpaceModal(item.roomId); setCords(undefined); }; diff --git a/src/app/features/room/MediaViewerBody.tsx b/src/app/features/room/MediaViewerBody.tsx index a03ac2e5..9b655be9 100644 --- a/src/app/features/room/MediaViewerBody.tsx +++ b/src/app/features/room/MediaViewerBody.tsx @@ -14,13 +14,13 @@ import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 FileSaver from 'file-saver'; import { MatrixEvent, MatrixEventEvent, MsgType, RoomEvent } from 'matrix-js-sdk'; import { useTranslation } from 'react-i18next'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { PageHeader } from '../../components/page'; +import { ContainerColor } from '../../styles/ContainerColor.css'; import { MediaViewerEntry } from '../../state/mediaViewer'; import { useOpenMediaViewer } from '../../state/hooks/mediaViewer'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; @@ -28,11 +28,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useRoom } from '../../hooks/useRoom'; import { useZoom } from '../../hooks/useZoom'; -import { - decryptFile, - downloadEncryptedMedia, - mxcUrlToHttp, -} from '../../utils/matrix'; +import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix'; import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes'; import { IImageContent, @@ -74,7 +70,7 @@ function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null if (!content) return null; const eventId = ev.getId(); if (!eventId) return null; - const file = content.file; + const { file } = content; const url = file?.url ?? content.url; if (!url) return null; const encInfo: EncryptedAttachmentInfo | undefined = file @@ -182,21 +178,18 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed)); }, []); - const clampPan = useCallback( - (raw: { x: number; y: number }, currentZoom: number) => { - const stage = stageRef.current; - const img = imgRef.current; - if (!stage || !img) return raw; - const stageRect = stage.getBoundingClientRect(); - const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2); - const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2); - return { - x: Math.max(-maxX, Math.min(maxX, raw.x)), - y: Math.max(-maxY, Math.min(maxY, raw.y)), - }; - }, - [] - ); + const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => { + const stage = stageRef.current; + const img = imgRef.current; + if (!stage || !img) return raw; + const stageRect = stage.getBoundingClientRect(); + const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2); + const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2); + return { + x: Math.max(-maxX, Math.min(maxX, raw.x)), + y: Math.max(-maxY, Math.min(maxY, raw.y)), + }; + }, []); // Anchor-aware zoom math (image-local point under anchor stays // 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 // unambiguous. const lastBlobUrlRef = useRef(null); + const { url: entryUrl, encInfo: entryEncInfo, mimeType: entryMimeType } = entry; const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, entry.url, useAuthentication); + const mediaUrl = mxcUrlToHttp(mx, entryUrl, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); - if (entry.encInfo) { + if (entryEncInfo) { 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); if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) { @@ -295,7 +289,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { return blob; } return mediaUrl; - }, [mx, entry.url, entry.encInfo, entry.mimeType, useAuthentication]) + }, [mx, entryUrl, entryEncInfo, entryMimeType, useAuthentication]) ); useEffect(() => { @@ -375,6 +369,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { return entries; // `timelineVersion` is the actual refresh trigger; `entry.eventId` // is intentionally NOT a dep — stepping doesn't change the set. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [room, timelineVersion]); const currentIndex = useMemo( @@ -406,9 +401,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { const target = e.target as HTMLElement | null; if ( target && - (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable) + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) ) { return; } @@ -622,10 +615,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { const pts = Array.from(pointerCacheRef.current.values()); const dist = distanceBetween(pts[0], pts[1]); const ratio = dist / ps.initialDistance; - const nextZoom = Math.max( - MIN_ZOOM, - Math.min(maxZoomRef.current, ps.initialZoom * ratio) - ); + const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, ps.initialZoom * ratio)); // Anchor-aware: hold `anchorImage*` (image-local point // captured at pinch start) under the moving midpoint. 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 (e.cancelable) e.preventDefault(); - const blocked = - (dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current); + const blocked = (dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current); setSwipeOffset(blocked ? dx / 3 : dx); return; } @@ -731,10 +720,14 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { const onWheel = (e: WheelEvent) => { if (entryKindRef.current !== 'image') return; e.preventDefault(); - const dyPx = - e.deltaMode === 1 ? e.deltaY * 16 // line mode → ~16px / line - : e.deltaMode === 2 ? e.deltaY * stageEl.clientHeight // page mode - : e.deltaY; + let dyPx: number; + if (e.deltaMode === 1) { + dyPx = e.deltaY * 16; // line mode → ~16px / line + } else if (e.deltaMode === 2) { + dyPx = e.deltaY * stageEl.clientHeight; // page mode + } else { + dyPx = e.deltaY; + } const factor = Math.exp(-dyPx * 0.0025); const oldZoom = zoomRef.current; const nextZoomRaw = oldZoom * factor; @@ -802,7 +795,8 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { const transform = isReady ? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})` : 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 (
@@ -863,9 +857,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { > - {entry.kind === 'image' && ( - ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 9c046abd..f8fbe427 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -13,6 +13,7 @@ import { useAtom, useAtomValue } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; import { useTranslation } from 'react-i18next'; 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 { Transforms, Editor } from 'slate'; import { @@ -149,12 +150,7 @@ const COMPOSER_PLACEHOLDER_KEYS = [ // no fills. const StreamComposerIcons = { Plus: () => ( - + ), Smile: () => ( <> @@ -238,9 +234,7 @@ export const RoomInput = forwardRef( const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor; const [uploadBoard, setUploadBoard] = useState(true); - const [selectedFiles, setSelectedFiles] = useAtom( - roomIdToUploadItemsAtomFamily(inputDraftKey) - ); + const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(inputDraftKey)); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file) @@ -374,7 +368,9 @@ export const RoomInput = forwardRef( }); handleCancelUpload(uploads); 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(() => { @@ -453,7 +449,7 @@ export const RoomInput = forwardRef( 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); resetEditorHistory(editor); setReplyDraft(undefined); @@ -634,8 +630,9 @@ export const RoomInput = forwardRef( const placeholderKey = useMemo(() => { const idx = new Date().getHours() % COMPOSER_PLACEHOLDER_KEYS.length; return COMPOSER_PLACEHOLDER_KEYS[idx]; - // roomId and threadId are intentional re-roll triggers; the - // exhaustive-deps lint is happy with them too. + // roomId and threadId are intentional re-roll triggers (re-memoize on + // chat/thread navigation), not values read inside the body. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId, threadId]); return ( diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index aaa79cc5..f9c2c9a1 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -27,17 +27,7 @@ import { Editor } from 'slate'; import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; -import { - Box, - Chip, - Icon, - Icons, - Scroll, - Text, - as, - config, - toRem, -} from 'folds'; +import { Box, Chip, Icon, Icons, Scroll, Text, as, config, toRem } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; @@ -447,7 +437,7 @@ const getEmptyTimeline = () => ({ }); const getRoomUnreadInfo = (room: Room, scrollTo = false) => { - const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? ''); + const readUptoEventId = room.getEventReadUpTo(room.client.getSafeUserId()); if (!readUptoEventId) return undefined; const evtTimeline = getEventTimeline(room, readUptoEventId); 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), // 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. - requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity)); + const evtRoomId = mEvt.getRoomId(); + if (evtRoomId) { + requestAnimationFrame(() => markAsRead(mx, evtRoomId, hideActivity)); + } } if (!document.hasFocus() && !unreadInfo) { @@ -1308,25 +1301,32 @@ export function RoomTimeline({ // Per-slot ordered list of sessions. Newest session is `last(sessions)`; // earlier entries are closed (count returned to 0). const slotSessions = new Map(); + // 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) { const events = tl.getEvents(); for (const ev of events) { if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue; const content = ev.getContent(); const prevContent = ev.getPrevContent() as Partial; - const slotId = - typeof content.call_id === 'string' - ? content.call_id - : typeof prevContent.call_id === 'string' - ? prevContent.call_id - : null; + let slotId: string | null = null; + if (typeof content.call_id === 'string') { + slotId = content.call_id; + } else if (typeof prevContent.call_id === 'string') { + slotId = prevContent.call_id; + } 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 isJoin = !!content.application; const wasPreviouslyJoined = !!prevContent.application; const sender = ev.getSender() ?? ''; const stateKey = ev.getStateKey() ?? ''; - const evId = ev.getId() ?? ''; let sessions = slotSessions.get(slotId); if (!sessions) { @@ -1407,6 +1407,7 @@ export function RoomTimeline({ } } } + /* eslint-enable no-restricted-syntax, no-continue */ const now = Date.now(); // Consecutive unsuccessful sessions from the same caller within this // window collapse into one bubble (WhatsApp/iOS Recents-style anti-spam). @@ -1432,13 +1433,15 @@ export function RoomTimeline({ lastEndTs: number; }; const anchors = new Map(); + // 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()) { const units: DisplayUnit[] = []; for (const scan of sessions) { if (!scan.everJoined && scan.participants.size === 0) continue; - const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some( - (exp) => exp > now - ); + const ongoing = Array.from(scan.joinedAbsoluteExpiries.values()).some((exp) => exp > now); const wasAnswered = scan.connectedAt !== null || scan.participants.size >= 2; const display: SessionDisplay = { scan, ongoing, wasAnswered }; const mergeable = !ongoing && !wasAnswered; @@ -1464,8 +1467,8 @@ export function RoomTimeline({ } for (const unit of units) { const { scan, ongoing, wasAnswered } = unit.anchor; - const conversationStart = wasAnswered ? (scan.connectedAt ?? scan.startTs) : null; - const conversationEnd = wasAnswered && !ongoing ? (scan.endedAt ?? scan.endTs) : null; + const conversationStart = wasAnswered ? scan.connectedAt ?? scan.startTs : null; + const conversationEnd = wasAnswered && !ongoing ? scan.endedAt ?? scan.endTs : null; anchors.set(scan.anchorEventId, { callId: scan.slotId, startTs: scan.startTs, @@ -1480,6 +1483,7 @@ export function RoomTimeline({ }); } } + /* eslint-enable no-restricted-syntax, no-continue */ return anchors; })(); @@ -1574,12 +1578,10 @@ export function RoomTimeline({ hideThreadReplyAffordance={hideThreadReplyAffordance} hideMainReplyAffordance={hideMainReplyAffordance} threadSummary={ - showThreadSummary ? ( - - ) : undefined + showThreadSummary ? : undefined } layout={messageLayout} - channelHeaderInBubble={channelHeaderInBubble} + channelHeaderInBubble={channelHeaderInBubble} > {mEvent.isRedacted() ? ( @@ -1690,7 +1692,7 @@ export function RoomTimeline({ ) : undefined } layout={messageLayout} - channelHeaderInBubble={channelHeaderInBubble} + channelHeaderInBubble={channelHeaderInBubble} > {(() => { if (mEvent.isRedacted()) return ; @@ -1807,12 +1809,10 @@ export function RoomTimeline({ hideThreadReplyAffordance={hideThreadReplyAffordance} hideMainReplyAffordance={hideMainReplyAffordance} threadSummary={ - showThreadSummary ? ( - - ) : undefined + showThreadSummary ? : undefined } layout={messageLayout} - channelHeaderInBubble={channelHeaderInBubble} + channelHeaderInBubble={channelHeaderInBubble} > {mEvent.isRedacted() ? ( @@ -2025,12 +2025,7 @@ export function RoomTimeline({ const senderId = mEvent.getSender() ?? ''; const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); - const timeJSX = ( -
)} - {thread && !coldLoadError && !coldLoadFetching && !paginating && !paginateError && replies.length === 0 && ( -
- - {t('Room.thread_no_replies')} - -
- )} + {thread && + !coldLoadError && + !coldLoadFetching && + !paginating && + !paginateError && + replies.length === 0 && ( +
+ + {t('Room.thread_no_replies')} + +
+ )} {/* Top sentinel — IntersectionObserver triggers paginate when scrolled into view. Element-web onFillRequest pattern. The sentinel only renders while back-pagination is possible: once SDK reports no more events, we drop it so the observer disconnects on next mount. */} - {replies.length > 0 && ( -