chore(lint): close all typecheck and eslint tech debt to enable husky pre-commit hook with --max-warnings 0
This commit is contained in:
parent
45f5392a92
commit
88d3db2b24
91 changed files with 593 additions and 726 deletions
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
5
.husky/pre-commit
Normal file → Executable file
5
.husky/pre-commit
Normal file → Executable file
|
|
@ -1,3 +1,2 @@
|
|||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
npx tsc -p tsconfig.json --noEmit
|
||||
npx lint-staged
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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; }'.
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Повторить",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||
>
|
||||
{stageToComplete.type === AuthType.Password && (
|
||||
<PasswordStage
|
||||
userId={mx.getUserId()!}
|
||||
userId={mx.getSafeUserId()}
|
||||
stageData={stageToComplete}
|
||||
onCancel={onCancel}
|
||||
submitAuthDict={action}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function makeUIAAction<T>(
|
|||
authData: IAuthData,
|
||||
performAction: PerformAction<T>,
|
||||
resolve: (data: T) => void,
|
||||
reject: (error?: any) => void
|
||||
reject: (error?: unknown) => void
|
||||
): UIAAction<T> {
|
||||
const action: UIAAction<T> = {
|
||||
authData,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {};
|
||||
const content: Record<string, unknown> = {};
|
||||
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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>) => {
|
||||
openProfile(
|
||||
room.roomId,
|
||||
space?.roomId,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
|
|
@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
|
|
@ -214,10 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box
|
||||
className={classNames(
|
||||
css.AbsoluteContainer,
|
||||
blurred ? css.Blur : css.ImageClickable
|
||||
)}
|
||||
className={classNames(css.AbsoluteContainer, blurred ? css.Blur : css.ImageClickable)}
|
||||
>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}
|
||||
>
|
||||
<Box grow="Yes" style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}>
|
||||
<Box
|
||||
grow="Yes"
|
||||
className={ContainerColor({ variant: 'Background' })}
|
||||
|
|
@ -147,6 +140,9 @@ export function PageNav({
|
|||
const horseshoe = useHorseshoeEnabled();
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<Box
|
||||
|
|
@ -199,9 +193,7 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
|||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const horseshoe = useHorseshoeEnabled();
|
||||
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
|
||||
const [vw, setVw] = useState<number>(
|
||||
typeof window !== 'undefined' ? window.innerWidth : 1280
|
||||
);
|
||||
const [vw, setVw] = useState<number>(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}
|
||||
</Box>
|
||||
{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
|
||||
ref={handleRef}
|
||||
role="separator"
|
||||
|
|
@ -330,6 +327,7 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
|||
aria-valuemin={SIDEBAR_WIDTH_MIN}
|
||||
aria-valuemax={maxW}
|
||||
aria-label="Resize sidebar"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={css.PageNavResizeHandle}
|
||||
// On web the page-nav is followed by the horseshoe void gap
|
||||
|
|
|
|||
|
|
@ -256,7 +256,10 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
<Box gap="100">
|
||||
<Icon size="50" src={Icons.User} />
|
||||
<Text size="T200">
|
||||
{t('Explore.members_count', { count: millify(joinedMemberCount) })}
|
||||
{t('Explore.members_count', {
|
||||
count: joinedMemberCount,
|
||||
formattedCount: millify(joinedMemberCount),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
|
|||
className={css.CallMemberCard}
|
||||
variant="SurfaceVariant"
|
||||
radii="500"
|
||||
onClick={(evt: any) =>
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) =>
|
||||
openUserProfile(
|
||||
room.roomId,
|
||||
undefined,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<object, MatrixError, [object]>(
|
||||
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]
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesPr
|
|||
<SettingTile
|
||||
title={t('RoomSettings.published_addresses')}
|
||||
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') }} />
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
|||
};
|
||||
|
||||
const handleCreateSpace = () => {
|
||||
openCreateSpaceModal(item.roomId as any);
|
||||
openCreateSpaceModal(item.roomId);
|
||||
setCords(undefined);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
<div className={css.root}>
|
||||
|
|
@ -863,9 +857,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
>
|
||||
<Icon size="100" src={Icons.Download} />
|
||||
</IconButton>
|
||||
{entry.kind === 'image' && (
|
||||
<div className={css.headerSeparator} aria-hidden="true" />
|
||||
)}
|
||||
{entry.kind === 'image' && <div className={css.headerSeparator} aria-hidden="true" />}
|
||||
<IconButton
|
||||
variant="Background"
|
||||
fill="None"
|
||||
|
|
@ -881,6 +873,9 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
|
||||
<div className={css.stage} ref={stageRef}>
|
||||
{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
|
||||
ref={imgRef}
|
||||
src={effectiveSrc}
|
||||
|
|
@ -965,7 +960,6 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => (
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
),
|
||||
Smile: () => (
|
||||
<>
|
||||
|
|
@ -238,9 +234,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
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<HTMLDivElement, RoomInputProps>(
|
|||
});
|
||||
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<HTMLDivElement, RoomInputProps>(
|
|||
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<HTMLDivElement, RoomInputProps>(
|
|||
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 (
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
const events = tl.getEvents();
|
||||
for (const ev of events) {
|
||||
if (ev.getType() !== StateEvent.GroupCallMemberPrefix) continue;
|
||||
const content = ev.getContent<SessionMembershipData>();
|
||||
const prevContent = ev.getPrevContent() as Partial<SessionMembershipData>;
|
||||
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<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()) {
|
||||
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 ? (
|
||||
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||
) : undefined
|
||||
showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
|
||||
}
|
||||
layout={messageLayout}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
|
|
@ -1690,7 +1692,7 @@ export function RoomTimeline({
|
|||
) : undefined
|
||||
}
|
||||
layout={messageLayout}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
>
|
||||
{(() => {
|
||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||
|
|
@ -1807,12 +1809,10 @@ export function RoomTimeline({
|
|||
hideThreadReplyAffordance={hideThreadReplyAffordance}
|
||||
hideMainReplyAffordance={hideMainReplyAffordance}
|
||||
threadSummary={
|
||||
showThreadSummary ? (
|
||||
<ThreadSummaryCard room={room} rootEvent={mEvent} />
|
||||
) : undefined
|
||||
showThreadSummary ? <ThreadSummaryCard room={room} rootEvent={mEvent} /> : undefined
|
||||
}
|
||||
layout={messageLayout}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
|
|
@ -2025,12 +2025,7 @@ export function RoomTimeline({
|
|||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
|
||||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact
|
||||
/>
|
||||
);
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact />;
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -2049,7 +2044,6 @@ export function RoomTimeline({
|
|||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
iconSrc={Icons.Code}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -2075,12 +2069,7 @@ export function RoomTimeline({
|
|||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||
|
||||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact
|
||||
/>
|
||||
);
|
||||
const timeJSX = <Time ts={mEvent.getTs()} compact />;
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -2099,7 +2088,6 @@ export function RoomTimeline({
|
|||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
layout={messageLayout}
|
||||
channelHeaderInBubble={channelHeaderInBubble}
|
||||
iconSrc={Icons.Code}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -2280,19 +2268,20 @@ export function RoomTimeline({
|
|||
// the rail-endpoint scan and the renderer never disagree on visibility.
|
||||
const channelsModeHidden = channelsMode && isChannelsModeHidden(room, mEvent, isBridged);
|
||||
|
||||
const eventJSX = channelsModeHidden || reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
streamRailStart,
|
||||
streamRailEnd
|
||||
);
|
||||
const eventJSX =
|
||||
channelsModeHidden || reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
streamRailStart,
|
||||
streamRailEnd
|
||||
);
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = !!eventJSX;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<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>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ import { config } from 'folds';
|
|||
import FocusTrap from 'focus-trap-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { userRoomProfileAtom } from '../../state/userRoomProfile';
|
||||
import {
|
||||
useCloseUserRoomProfile,
|
||||
useOpenUserRoomProfile,
|
||||
} from '../../state/hooks/userRoomProfile';
|
||||
import { useCloseUserRoomProfile, useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
|
||||
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
|
||||
// keep the asymmetric Vaul curve in CSS where direct manipulation
|
||||
// is over and the auto-animation can take centre stage.
|
||||
const easeInOutCubic = (t: number): number =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
||||
|
||||
type RoomViewProfilePanelProps = {
|
||||
header: ReactNode;
|
||||
|
|
@ -138,8 +134,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
|||
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
|
||||
const headerDragPeer =
|
||||
peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
|
||||
const headerDragPeer = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
|
||||
const headerDragEnabled = !!headerDragPeer;
|
||||
|
||||
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
|
||||
// case the rail is exactly content-sized and there is nothing to
|
||||
// scroll — `overflow: hidden` then prevents any drag-inside-drag.
|
||||
const contentOverflows =
|
||||
contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx;
|
||||
const contentOverflows = contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx;
|
||||
|
||||
// Measure the chat header's natural height (incl. its safe-top padding).
|
||||
// `scrollHeight` returns the content size even while the outer is
|
||||
|
|
@ -252,9 +246,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
|||
if (liveUserId) lastUserIdRef.current = liveUserId;
|
||||
const renderUserId = liveUserId ?? lastUserIdRef.current;
|
||||
|
||||
const renderUserAvatarMxc = renderUserId
|
||||
? getMemberAvatarMxc(room, renderUserId)
|
||||
: undefined;
|
||||
const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined;
|
||||
const renderUserAvatarUrl =
|
||||
(renderUserAvatarMxc &&
|
||||
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
|
||||
// appear and disappear together — no per-element timing skew that
|
||||
// could create a perceived «blip».
|
||||
const horseshoeRamp = isDragging
|
||||
? easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX))
|
||||
: horseshoeActive
|
||||
? 1
|
||||
: 0;
|
||||
let horseshoeRamp: number;
|
||||
if (isDragging) {
|
||||
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
|
||||
} else if (horseshoeActive) {
|
||||
horseshoeRamp = 1;
|
||||
} else {
|
||||
horseshoeRamp = 0;
|
||||
}
|
||||
|
||||
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
|
||||
|
||||
const panelViewportTransition = isDragging
|
||||
? 'none'
|
||||
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const headerViewportTransition = isDragging
|
||||
? 'none'
|
||||
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const panelViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const headerViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const silhouetteTransition = isDragging
|
||||
? 'none'
|
||||
: `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
|
|
|
|||
|
|
@ -2,19 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
|
|||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, config } from 'folds';
|
||||
import {
|
||||
Direction,
|
||||
EventTimeline,
|
||||
|
|
@ -59,12 +47,7 @@ import { ImageViewer } from '../../components/image-viewer';
|
|||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import {
|
||||
EncryptedContent,
|
||||
Message,
|
||||
Reactions,
|
||||
useMessageInteractionHandlers,
|
||||
} from './message';
|
||||
import { EncryptedContent, Message, Reactions, useMessageInteractionHandlers } from './message';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
|
|
@ -76,10 +59,7 @@ import { useTheme } from '../../hooks/useTheme';
|
|||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import {
|
||||
useAccessiblePowerTagColors,
|
||||
useGetMemberPowerTag,
|
||||
} from '../../hooks/useMemberPowerTag';
|
||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
|
|
@ -149,12 +129,7 @@ type ThreadDrawerProps = {
|
|||
// empty). The component is intentionally light on chrome compared to
|
||||
// the full Message renderer in the timeline: M2 is MVP and rich
|
||||
// reactions / hover-menus / rail-dot inside the drawer is M9 territory.
|
||||
export function ThreadDrawer({
|
||||
room,
|
||||
rootId,
|
||||
parentRoomPath,
|
||||
variant,
|
||||
}: ThreadDrawerProps) {
|
||||
export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -172,9 +147,7 @@ export function ThreadDrawer({
|
|||
const resizableRef = useRef<HTMLDivElement>(null);
|
||||
const resizeHandleRef = useRef<HTMLDivElement>(null);
|
||||
const [savedWidth, setSavedWidth] = useAtom(threadDrawerWidthAtom);
|
||||
const [vw, setVw] = useState<number>(
|
||||
typeof window !== 'undefined' ? window.innerWidth : 1280
|
||||
);
|
||||
const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// Live width during a drag — kept in component state so we don't
|
||||
// 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
|
||||
// handle entirely — leaving a non-draggable handle reads as broken UI.
|
||||
const canResize = isDesktopVariant && drawerMaxW > THREAD_DRAWER_WIDTH_MIN;
|
||||
const atMin =
|
||||
dragging && liveWidth !== null && liveWidth <= THREAD_DRAWER_WIDTH_MIN;
|
||||
const atMin = dragging && liveWidth !== null && liveWidth <= THREAD_DRAWER_WIDTH_MIN;
|
||||
const atMax = dragging && liveWidth !== null && liveWidth >= drawerMaxW;
|
||||
|
||||
// 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
|
||||
// `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
|
||||
// the chip surfaces inside the drawer composer.
|
||||
const setReplyDraft = useSetAtom(
|
||||
roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId))
|
||||
);
|
||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)));
|
||||
|
||||
// Reply-chip click scrolls within the drawer to the matching
|
||||
// `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 host = scrollHostRef.current;
|
||||
if (!host) return;
|
||||
const target = host.querySelector<HTMLElement>(
|
||||
`[data-message-id="${evtId}"]`
|
||||
);
|
||||
const target = host.querySelector<HTMLElement>(`[data-message-id="${evtId}"]`);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, []);
|
||||
|
||||
|
|
@ -598,10 +566,7 @@ export function ThreadDrawer({
|
|||
// resume back-pagination from where this fetch left off.
|
||||
// null means «no more older events» (server reached the
|
||||
// thread start), which correctly disables back-pagination.
|
||||
written.liveTimeline.setPaginationToken(
|
||||
result.nextBatch ?? null,
|
||||
Direction.Backward
|
||||
);
|
||||
written.liveTimeline.setPaginationToken(result.nextBatch ?? null, Direction.Backward);
|
||||
setThread(written);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -798,9 +763,7 @@ export function ThreadDrawer({
|
|||
subscribed.push(evt);
|
||||
});
|
||||
return () => {
|
||||
subscribed.forEach((evt) =>
|
||||
evt.off(MatrixEventEvent.RelationsCreated, handler)
|
||||
);
|
||||
subscribed.forEach((evt) => evt.off(MatrixEventEvent.RelationsCreated, handler));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rootEvent, repliesIds]);
|
||||
|
|
@ -921,8 +884,11 @@ export function ThreadDrawer({
|
|||
host.scrollTop = host.scrollHeight;
|
||||
isAtBottomRef.current = true;
|
||||
tryMarkThreadRead();
|
||||
// Intentional: replies array read inside is stable identity-wise
|
||||
// because computed inline; lint disable for the false-positive.
|
||||
// `replies[last]` is read but the array isn't a dep: the effect is
|
||||
// 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
|
||||
}, [repliesCount, myUserId, tryMarkThreadRead]);
|
||||
|
||||
|
|
@ -998,12 +964,9 @@ export function ThreadDrawer({
|
|||
// and `getEditedEvent` return undefined for thread replies even
|
||||
// when the SDK has fully ingested the relation.
|
||||
const eventThread = mEvent.getThread();
|
||||
const isThreadReply =
|
||||
mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
|
||||
const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
|
||||
const timelineSet =
|
||||
isThreadReply && eventThread
|
||||
? eventThread.timelineSet
|
||||
: room.getUnfilteredTimelineSet();
|
||||
isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet();
|
||||
const reactionRelations = getEventReactions(timelineSet, eventId);
|
||||
const reactionList = reactionRelations?.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactionList && reactionList.length > 0;
|
||||
|
|
@ -1029,8 +992,7 @@ export function ThreadDrawer({
|
|||
// intentional quotes from auto-fallback chains.
|
||||
const wireRelatesTo = mEvent.getWireContent()['m.relates_to'];
|
||||
const isFallbackInReplyTo =
|
||||
wireRelatesTo?.rel_type === RelationType.Thread &&
|
||||
wireRelatesTo?.is_falling_back !== false;
|
||||
wireRelatesTo?.rel_type === RelationType.Thread && wireRelatesTo?.is_falling_back !== false;
|
||||
const showReplyChip = !!replyEventId && !isFallbackInReplyTo;
|
||||
|
||||
return (
|
||||
|
|
@ -1097,9 +1059,7 @@ export function ThreadDrawer({
|
|||
{(() => {
|
||||
if (mEvent.isRedacted()) {
|
||||
return (
|
||||
<RedactedContent
|
||||
reason={mEvent.getUnsigned().redacted_because?.content?.reason}
|
||||
/>
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content?.reason} />
|
||||
);
|
||||
}
|
||||
if (eventType === MessageEvent.Sticker) {
|
||||
|
|
@ -1242,21 +1202,24 @@ export function ThreadDrawer({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{thread && !coldLoadError && !coldLoadFetching && !paginating && !paginateError && replies.length === 0 && (
|
||||
<div className={css.ThreadEmptyState}>
|
||||
<Text size="T300" priority="400">
|
||||
{t('Room.thread_no_replies')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{thread &&
|
||||
!coldLoadError &&
|
||||
!coldLoadFetching &&
|
||||
!paginating &&
|
||||
!paginateError &&
|
||||
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
|
||||
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 && (
|
||||
<div ref={setTopSentinel} aria-hidden="true" />
|
||||
)}
|
||||
{replies.length > 0 && <div ref={setTopSentinel} aria-hidden="true" />}
|
||||
{paginating && replies.length > 0 && (
|
||||
<Box alignItems="Center" justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
<Spinner size="200" />
|
||||
|
|
@ -1336,13 +1299,12 @@ export function ThreadDrawer({
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={resizableRef}
|
||||
className={css.ThreadDrawerResizable}
|
||||
style={{ width: drawerWidth }}
|
||||
>
|
||||
<div ref={resizableRef} className={css.ThreadDrawerResizable} style={{ width: drawerWidth }}>
|
||||
{asideContent}
|
||||
{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
|
||||
ref={resizeHandleRef}
|
||||
role="separator"
|
||||
|
|
@ -1351,6 +1313,7 @@ export function ThreadDrawer({
|
|||
aria-valuemin={THREAD_DRAWER_WIDTH_MIN}
|
||||
aria-valuemax={drawerMaxW}
|
||||
aria-label="Resize thread drawer"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={css.ThreadDrawerResizeHandle}
|
||||
data-dragging={dragging || undefined}
|
||||
|
|
|
|||
|
|
@ -110,8 +110,7 @@ export function CallMessage({
|
|||
const senderId = aggregate.anchorSenderId;
|
||||
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
|
||||
const peerBg = !isOwnMessage;
|
||||
const senderName =
|
||||
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
||||
|
||||
const tagColor = memberPowerTag?.color
|
||||
? accessibleTagColors?.get(memberPowerTag.color)
|
||||
|
|
@ -148,11 +147,14 @@ export function CallMessage({
|
|||
})();
|
||||
|
||||
const iconSrc = wasAnswered || ongoing ? Icons.Phone : Icons.PhoneDown;
|
||||
const iconColor = ongoing
|
||||
? color.Success.Main
|
||||
: wasAnswered
|
||||
? color.Surface.OnContainer
|
||||
: color.Critical.Main;
|
||||
let iconColor: string;
|
||||
if (ongoing) {
|
||||
iconColor = color.Success.Main;
|
||||
} else if (wasAnswered) {
|
||||
iconColor = color.Surface.OnContainer;
|
||||
} else {
|
||||
iconColor = color.Critical.Main;
|
||||
}
|
||||
|
||||
const bubbleBody = (
|
||||
<Box gap="300" alignItems="Center" style={{ minWidth: 0 }}>
|
||||
|
|
@ -194,11 +196,7 @@ export function CallMessage({
|
|||
isOwn={isOwnMessage}
|
||||
headerInBubble={channelHeaderInBubble}
|
||||
avatar={
|
||||
<ChannelMessageAvatar
|
||||
room={room}
|
||||
senderId={senderId}
|
||||
senderDisplayName={senderName}
|
||||
/>
|
||||
<ChannelMessageAvatar room={room} senderId={senderId} senderDisplayName={senderName} />
|
||||
}
|
||||
header={
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import FocusTrap from 'focus-trap-react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useHover, useFocusWithin } from 'react-aria';
|
||||
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 classNames from 'classnames';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
|
|
@ -132,7 +133,7 @@ export const MessageAllReactionItem = as<
|
|||
return (
|
||||
<>
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={open}
|
||||
|
|
@ -246,7 +247,8 @@ export const MessageSourceCodeItem = as<
|
|||
: evt.event;
|
||||
|
||||
const getText = (): string => {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtId = mEvent.getId();
|
||||
if (!evtId) return '';
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const edits =
|
||||
evtTimeline &&
|
||||
|
|
@ -364,7 +366,7 @@ export const MessagePinItem = as<
|
|||
if (!isPinned && 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?.();
|
||||
};
|
||||
|
||||
|
|
@ -845,7 +847,7 @@ export const Message = as<'div', MessageProps>(
|
|||
|
||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||
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;
|
||||
evt.preventDefault();
|
||||
setMenuAnchor({
|
||||
|
|
@ -918,11 +920,13 @@ export const Message = as<'div', MessageProps>(
|
|||
returnFocusOnDeactivate={false}
|
||||
allowTextCustomEmoji
|
||||
onEmojiSelect={(key) => {
|
||||
onReactionToggle(mEvent.getId()!, key);
|
||||
const evtId = mEvent.getId();
|
||||
if (evtId) onReactionToggle(evtId, key);
|
||||
setEmojiBoardAnchor(undefined);
|
||||
}}
|
||||
onCustomEmojiSelect={(mxc, shortcode) => {
|
||||
onReactionToggle(mEvent.getId()!, mxc, shortcode);
|
||||
const evtId = mEvent.getId();
|
||||
if (evtId) onReactionToggle(evtId, mxc, shortcode);
|
||||
setEmojiBoardAnchor(undefined);
|
||||
}}
|
||||
requestClose={() => {
|
||||
|
|
@ -994,7 +998,8 @@ export const Message = as<'div', MessageProps>(
|
|||
{canSendReaction && (
|
||||
<MessageQuickReactions
|
||||
onReaction={(key, shortcode) => {
|
||||
onReactionToggle(mEvent.getId()!, key, shortcode);
|
||||
const evtId = mEvent.getId();
|
||||
if (evtId) onReactionToggle(evtId, key, shortcode);
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1030,7 +1035,7 @@ export const Message = as<'div', MessageProps>(
|
|||
after={<Icon size="100" src={Icons.ReplyArrow} />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: any) => {
|
||||
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
|
||||
onReplyClick(evt);
|
||||
closeMenu();
|
||||
}}
|
||||
|
|
@ -1051,7 +1056,7 @@ export const Message = as<'div', MessageProps>(
|
|||
after={<Icon src={Icons.ThreadPlus} size="100" />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: any) => {
|
||||
onClick={(evt: Parameters<typeof onReplyClick>[0]) => {
|
||||
onReplyClick(evt, true);
|
||||
closeMenu();
|
||||
}}
|
||||
|
|
@ -1173,11 +1178,7 @@ export const Message = as<'div', MessageProps>(
|
|||
so the bubble header matches the DM-chat rhythm.
|
||||
Channels main timeline keeps T400 for the prominent
|
||||
avatar-and-name row above an unbordered body. */}
|
||||
<Text
|
||||
as="span"
|
||||
size={channelHeaderInBubble ? 'T200' : 'T400'}
|
||||
truncate
|
||||
>
|
||||
<Text as="span" size={channelHeaderInBubble ? 'T200' : 'T400'} truncate>
|
||||
<UsernameBold>
|
||||
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
||||
</UsernameBold>
|
||||
|
|
@ -1269,7 +1270,7 @@ export const Event = as<'div', EventProps>(
|
|||
|
||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||
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;
|
||||
evt.preventDefault();
|
||||
setMenuAnchor({
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { Editor, Transforms } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
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 {
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
|
|
@ -80,7 +81,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
string | undefined,
|
||||
IMentions | undefined
|
||||
] => {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtId = mEvent.getId();
|
||||
if (!evtId) return [undefined, undefined, undefined];
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const editedEvent =
|
||||
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
|
||||
// local-replace via `mEvent.makeReplaced(localEvent)` is
|
||||
// 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])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||
})}
|
||||
{reactions.length > 0 && (
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={!!viewer}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { Editor } from 'slate';
|
||||
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 { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||
|
|
@ -14,13 +15,8 @@ import {
|
|||
getMemberDisplayName,
|
||||
getReactionContent,
|
||||
} from '../../../utils/room';
|
||||
import {
|
||||
eventWithShortcode,
|
||||
factoryEventSentBy,
|
||||
getMxIdLocalPart,
|
||||
} from '../../../utils/matrix';
|
||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { IReplyDraft } from '../../../state/room/roomInputDrafts';
|
||||
import { MessageEvent } from '../../../../types/matrix/room';
|
||||
import { getChannelsThreadPath } from '../../../pages/pathUtils';
|
||||
|
||||
export type UseMessageInteractionHandlersOptions = {
|
||||
|
|
@ -60,15 +56,8 @@ export type MessageInteractionHandlers = {
|
|||
handleOpenReply: MouseEventHandler;
|
||||
handleUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||
handleUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||
handleReplyClick: (
|
||||
ev: MouseEvent<HTMLButtonElement>,
|
||||
startThread?: boolean
|
||||
) => void;
|
||||
handleReactionToggle: (
|
||||
targetEventId: string,
|
||||
key: string,
|
||||
shortcode?: string
|
||||
) => void;
|
||||
handleReplyClick: (ev: MouseEvent<HTMLButtonElement>, startThread?: boolean) => void;
|
||||
handleReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||
};
|
||||
|
||||
// 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
|
||||
// semantic on the bridge side — fall back to the legacy
|
||||
// m.in_reply_to draft path so messages still go through.
|
||||
if (
|
||||
startThread &&
|
||||
channelsMode &&
|
||||
!isBridged &&
|
||||
spaceIdOrAlias &&
|
||||
roomIdOrAlias
|
||||
) {
|
||||
if (startThread && channelsMode && !isBridged && spaceIdOrAlias && roomIdOrAlias) {
|
||||
// useParams returns the encoded URL value; getChannelsThreadPath
|
||||
// re-encodes via generatePath so we decode once first to avoid
|
||||
// double-encoding (matches `routeParent.ts` decode pattern).
|
||||
|
|
@ -206,16 +189,7 @@ export function useMessageInteractionHandlers({
|
|||
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||
}
|
||||
},
|
||||
[
|
||||
room,
|
||||
setReplyDraft,
|
||||
editor,
|
||||
channelsMode,
|
||||
isBridged,
|
||||
navigate,
|
||||
spaceIdOrAlias,
|
||||
roomIdOrAlias,
|
||||
]
|
||||
[room, setReplyDraft, editor, channelsMode, isBridged, navigate, spaceIdOrAlias, roomIdOrAlias]
|
||||
);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
|
|
@ -224,10 +198,12 @@ export function useMessageInteractionHandlers({
|
|||
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||
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()) {
|
||||
mx.redactEvent(room.roomId, myReaction.getId()!);
|
||||
const reactionId = myReaction.getId();
|
||||
if (reactionId) mx.redactEvent(room.roomId, reactionId);
|
||||
return;
|
||||
}
|
||||
const rShortcode =
|
||||
|
|
@ -235,8 +211,8 @@ export function useMessageInteractionHandlers({
|
|||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
MessageEvent.Reaction as any,
|
||||
getReactionContent(targetEventId, key, rShortcode)
|
||||
EventType.Reaction,
|
||||
getReactionContent(targetEventId, key, rShortcode) as ReactionEventContent
|
||||
);
|
||||
},
|
||||
[mx, room]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export const getImageMsgContent = async (
|
|||
): Promise<IContent> => {
|
||||
const { file, originalFile, encInfo, metadata } = item;
|
||||
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);
|
||||
|
||||
const content: IContent = {
|
||||
|
|
@ -85,6 +89,8 @@ export const getVideoMsgContent = async (
|
|||
const { file, originalFile, encInfo, metadata } = item;
|
||||
|
||||
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);
|
||||
|
||||
const content: IContent = {
|
||||
|
|
@ -109,6 +115,7 @@ export const getVideoMsgContent = async (
|
|||
scaleYDimension(videoEl.videoWidth, 512, videoEl.videoHeight)
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
if (thumbError) console.warn(thumbError);
|
||||
content.info = {
|
||||
...getVideoInfo(videoEl, file),
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
|
|||
key={senderId}
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
radii="400"
|
||||
onClick={(event) => {
|
||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
openProfile(
|
||||
room.roomId,
|
||||
space?.roomId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable react/destructuring-assignment */
|
||||
import React, { forwardRef, MouseEventHandler, useCallback, useMemo, useRef } from 'react';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import type { StateEvents } from 'matrix-js-sdk';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
|
@ -121,7 +122,11 @@ function PinnedMessage({
|
|||
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])
|
||||
);
|
||||
|
||||
|
|
@ -172,7 +177,7 @@ function PinnedMessage({
|
|||
</Box>
|
||||
);
|
||||
|
||||
const sender = pinnedEvent.getSender()!;
|
||||
const sender = pinnedEvent.getSender() ?? '';
|
||||
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
|
||||
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
|
||||
|
|
@ -245,7 +250,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||
({ room, requestClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = mx.getSafeUserId();
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
|
|
@ -329,12 +334,12 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||
);
|
||||
},
|
||||
[MessageEvent.RoomMessageEncrypted]: (event, displayName) => {
|
||||
const eventId = event.getId()!;
|
||||
const evtTimeline = room.getTimelineForEvent(eventId);
|
||||
const eventId = event.getId();
|
||||
const evtTimeline = eventId ? room.getTimelineForEvent(eventId) : undefined;
|
||||
|
||||
const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId);
|
||||
|
||||
if (!mEvent || !evtTimeline) {
|
||||
if (!mEvent || !evtTimeline || !eventId) {
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" priority="300">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, Chip } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
|
|
@ -9,18 +9,12 @@ import { copyToClipboard } from '../../../utils/dom';
|
|||
|
||||
export function MatrixId() {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = useAuthedUserId();
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">{t('Settings.matrix_id')}</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title={userId}
|
||||
after={
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
|
|
@ -308,19 +309,13 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||
|
||||
export function Profile() {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = useAuthedUserId();
|
||||
const profile = useUserProfile(userId);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">{t('Settings.profile')}</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<ProfileAvatar userId={userId} profile={profile} />
|
||||
<ProfileDisplayName userId={userId} profile={profile} />
|
||||
</SequenceCard>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import type { AccountDataEvents } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
|
|
@ -27,7 +28,8 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||
|
||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||
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]
|
||||
);
|
||||
|
|
@ -36,7 +38,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||
return (
|
||||
<AccountDataEditor
|
||||
type={accountDataType ?? undefined}
|
||||
content={accountDataType ? mx.getAccountData(accountDataType)?.getContent() : undefined}
|
||||
content={
|
||||
accountDataType
|
||||
? mx.getAccountData(accountDataType as keyof AccountDataEvents)?.getContent()
|
||||
: undefined
|
||||
}
|
||||
submitChange={submitAccountData}
|
||||
requestClose={() => setAccountDataType(undefined)}
|
||||
/>
|
||||
|
|
@ -53,7 +59,11 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<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} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ function GlobalPackSelector({
|
|||
if (!room) return null;
|
||||
const roomPackAddresses = roomPacks
|
||||
.map((pack) => pack.address)
|
||||
.filter((addr) => addr !== undefined);
|
||||
.filter((addr): addr is PackAddress => addr !== undefined);
|
||||
const allSelected = roomPackAddresses.every((addr) =>
|
||||
selected.find((address) => packAddressEqual(addr, address))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
|
|||
if (userPack) {
|
||||
onViewPack(userPack);
|
||||
} else {
|
||||
const defaultPack = new ImagePack(mx.getUserId() ?? '', {}, undefined);
|
||||
const defaultPack = new ImagePack(mx.getSafeUserId(), {}, undefined);
|
||||
onViewPack(defaultPack);
|
||||
}
|
||||
};
|
||||
|
|
@ -34,12 +34,7 @@ export function UserPack({ onViewPack }: UserPackProps) {
|
|||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">{t('Settings.default_pack')}</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title={userPack?.meta.name ?? t('Settings.unknown')}
|
||||
description={userPack?.meta.attribution}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
|
||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { makePushRuleData, PushRuleData, usePushRule } from '../../../hooks/usePushRule';
|
||||
|
|
@ -114,8 +115,7 @@ function MentionModeSwitcher({ ruleId, pushRules, defaultPushRuleData }: PushRul
|
|||
|
||||
export function SpecialMessagesNotifications() {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = useAuthedUserId();
|
||||
const { displayName } = useUserProfile(userId);
|
||||
const pushRulesEvt = useAccountData(AccountDataEvent.PushRules);
|
||||
const pushRules = useMemo(
|
||||
|
|
@ -134,12 +134,7 @@ export function SpecialMessagesNotifications() {
|
|||
</Badge>
|
||||
</Box>
|
||||
</Box>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title={t('Settings.mention_user_id', { userId })}
|
||||
after={
|
||||
|
|
@ -151,12 +146,7 @@ export function SpecialMessagesNotifications() {
|
|||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title={
|
||||
displayName
|
||||
|
|
@ -172,12 +162,7 @@ export function SpecialMessagesNotifications() {
|
|||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title={t('Settings.contains_username', { username: getMxIdLocalPart(userId) })}
|
||||
after={
|
||||
|
|
@ -189,12 +174,7 @@ export function SpecialMessagesNotifications() {
|
|||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title={t('Settings.mention_room')}
|
||||
after={
|
||||
|
|
@ -206,12 +186,7 @@ export function SpecialMessagesNotifications() {
|
|||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SequenceCard className={SequenceCardStyle} variant="Background" direction="Column" gap="400">
|
||||
<SettingTile
|
||||
title={t('Settings.contains_room')}
|
||||
after={
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { AccountDataEvents } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||
|
||||
export function useAccountData(eventType: string) {
|
||||
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(
|
||||
mx,
|
||||
|
|
|
|||
16
src/app/hooks/useAuthedUserId.ts
Normal file
16
src/app/hooks/useAuthedUserId.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
RoomMember,
|
||||
Visibility,
|
||||
} from 'matrix-js-sdk';
|
||||
import type { StateEvents } from 'matrix-js-sdk';
|
||||
import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
|
|
@ -366,7 +367,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
if (!content) return;
|
||||
await mx.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.RoomMember as any,
|
||||
StateEvent.RoomMember as keyof StateEvents,
|
||||
{
|
||||
...content,
|
||||
displayname: nick,
|
||||
|
|
@ -388,7 +389,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
if (!content) return;
|
||||
await mx.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.RoomMember as any,
|
||||
StateEvent.RoomMember as keyof StateEvents,
|
||||
{
|
||||
...content,
|
||||
avatar_url: payload,
|
||||
|
|
@ -528,7 +529,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
|||
aclContent.allow?.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
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -37,20 +37,20 @@ export function useDotColor(
|
|||
hideReadReceipts = false
|
||||
): DotColor {
|
||||
const mx = useMatrixClient();
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const isOwn = !!myUserId && senderId === myUserId;
|
||||
const isOwn = senderId === myUserId;
|
||||
const eventId = mEvent.getId();
|
||||
|
||||
const status = useMessageStatus(room, mEvent);
|
||||
|
||||
const [haveSeen, setHaveSeen] = useState<boolean>(() =>
|
||||
enabled && !isOwn && !!eventId && !!myUserId ? room.hasUserReadEvent(myUserId, eventId) : false
|
||||
enabled && !isOwn && !!eventId ? room.hasUserReadEvent(myUserId, eventId) : false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
if (isOwn || !eventId || !myUserId) return undefined;
|
||||
if (isOwn || !eventId) return undefined;
|
||||
setHaveSeen(room.hasUserReadEvent(myUserId, eventId));
|
||||
const handler: RoomEventHandlerMap[RoomEvent.Receipt] = (_event, r) => {
|
||||
if (r.roomId !== room.roomId) return;
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function getDeliveryStatus(
|
|||
|
||||
export function useMessageStatus(room: Room, mEvent: MatrixEvent): MessageDeliveryStatus | null {
|
||||
const mx = useMatrixClient();
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const isOwnMessage = mEvent.getSender() === myUserId;
|
||||
|
||||
const [status, setStatus] = useState<MessageDeliveryStatus | null>(() =>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,8 @@ const sortPowers = (powers: number[]): number[] => powers.sort(powerSortFn);
|
|||
|
||||
export const getPowers = (tags: PowerLevelTags): number[] => {
|
||||
const powers: number[] = Object.keys(tags)
|
||||
.map((p) => {
|
||||
const power = parseInt(p, 10);
|
||||
if (Number.isNaN(power)) {
|
||||
return undefined;
|
||||
}
|
||||
return power;
|
||||
})
|
||||
.filter((power) => typeof power === 'number');
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((power): power is number => !Number.isNaN(power));
|
||||
|
||||
return sortPowers(powers);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
|
|||
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
|
||||
keys.forEach((key) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import type { StateEvents } from 'matrix-js-sdk';
|
||||
import { RoomCanonicalAliasEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
|
@ -56,7 +57,11 @@ export const useSetMainAlias = (room: Room): ((alias: string | undefined) => Pro
|
|||
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]
|
||||
);
|
||||
|
|
@ -90,7 +95,11 @@ export const usePublishUnpublishAliases = (
|
|||
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]
|
||||
);
|
||||
|
|
@ -114,7 +123,11 @@ export const usePublishUnpublishAliases = (
|
|||
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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
RoomStateEvent,
|
||||
} from 'matrix-js-sdk';
|
||||
import { Room, RoomEvent, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
|
||||
|
|
@ -39,9 +34,7 @@ const resolveRoomName = (room: Room): string => {
|
|||
if (peer.rawDisplayName && peer.rawDisplayName.trim()) {
|
||||
return peer.rawDisplayName.trim();
|
||||
}
|
||||
const local = peer.userId.startsWith('@')
|
||||
? peer.userId.slice(1).split(':')[0]
|
||||
: peer.userId;
|
||||
const local = peer.userId.startsWith('@') ? peer.userId.slice(1).split(':')[0] : peer.userId;
|
||||
return local || peer.userId;
|
||||
}
|
||||
}
|
||||
|
|
@ -67,7 +60,10 @@ export const useRoomName = (room: Room): string => {
|
|||
useEffect(() => {
|
||||
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));
|
||||
};
|
||||
// RoomEvent.Name fires when m.room.name changes;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import React, { Ref, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { launchSplash } from '../../plugins/launchSplash';
|
||||
import {
|
||||
attachMascotVideo,
|
||||
detachMascotVideo,
|
||||
mascotVideoReady,
|
||||
} from './mascotSingleton';
|
||||
import { attachMascotVideo, detachMascotVideo, mascotVideoReady } from './mascotSingleton';
|
||||
import * as css from './styles.css';
|
||||
|
||||
type AuthMascotProps = {
|
||||
|
|
@ -64,8 +60,13 @@ export function AuthMascot({ mascotRef, centered }: AuthMascotProps) {
|
|||
const setRefs = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
containerRef.current = node;
|
||||
if (typeof mascotRef === 'function') mascotRef(node);
|
||||
else if (mascotRef && typeof mascotRef === 'object') {
|
||||
if (typeof mascotRef === 'function') {
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode, useMemo } from 'react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useAuthedUserId } from '../../hooks/useAuthedUserId';
|
||||
import { makeClosedNavCategoriesAtom } from '../../state/closedNavCategories';
|
||||
import { ClosedNavCategoriesProvider } from '../../state/hooks/closedNavCategories';
|
||||
import { makeClosedLobbyCategoriesAtom } from '../../state/closedLobbyCategories';
|
||||
|
|
@ -15,8 +15,7 @@ type ClientInitStorageAtomProps = {
|
|||
children: ReactNode;
|
||||
};
|
||||
export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = useAuthedUserId();
|
||||
|
||||
const closedNavCategoriesAtom = useMemo(() => makeClosedNavCategoriesAtom(userId), [userId]);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { Box, Button, config, Dialog, Text } from 'folds';
|
|||
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
clearLocalSessionAndReload,
|
||||
initClient,
|
||||
startClient,
|
||||
} from '../../../client/initMatrix';
|
||||
import { clearLocalSessionAndReload, initClient, startClient } from '../../../client/initMatrix';
|
||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
|
||||
|
|
@ -138,9 +134,15 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||
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 (
|
||||
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||||
<SpecVersions baseUrl={baseUrl!}>
|
||||
<AutoDiscovery userId={userId ?? ''} baseUrl={baseUrl ?? ''}>
|
||||
<SpecVersions baseUrl={baseUrl ?? ''}>
|
||||
{mx && <SyncIndicator mx={mx} />}
|
||||
{(loadState.status === AsyncStatus.Error ||
|
||||
startState.status === AsyncStatus.Error ||
|
||||
|
|
@ -168,9 +170,7 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||
{loading &&
|
||||
syncErrored &&
|
||||
loadState.status !== AsyncStatus.Error &&
|
||||
startState.status !== AsyncStatus.Error && (
|
||||
<Text>{t('Boot.sync_failed')}</Text>
|
||||
)}
|
||||
startState.status !== AsyncStatus.Error && <Text>{t('Boot.sync_failed')}</Text>}
|
||||
<Button variant="Critical" onClick={onRetry}>
|
||||
<Text as="span" size="B400">
|
||||
{t('Boot.retry')}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
// After P3c the Direct tab is universal — any joined non-space room renders
|
||||
// here. Spaces (= future Channels) keep their own /{spaceId}/ route.
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -43,21 +43,15 @@ export function HomeRouteRoomProvider({ children: _children }: { children: React
|
|||
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const orphanParents = getOrphanParents(roomToParents, room.roomId);
|
||||
if (orphanParents.length > 0) {
|
||||
const parentSpace =
|
||||
guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0];
|
||||
const parentSpace = guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0];
|
||||
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
|
||||
return (
|
||||
<Navigate to={getChannelsRoomPath(pSpaceIdOrAlias, alias, eventId)} replace />
|
||||
);
|
||||
return <Navigate to={getChannelsRoomPath(pSpaceIdOrAlias, alias, eventId)} replace />;
|
||||
}
|
||||
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
|
||||
}
|
||||
|
||||
if (!roomIdOrAlias) return <Navigate to="/direct" replace />;
|
||||
return (
|
||||
<JoinBeforeNavigate
|
||||
roomIdOrAlias={roomIdOrAlias!}
|
||||
eventId={eventId}
|
||||
viaServers={viaServers}
|
||||
/>
|
||||
<JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias} eventId={eventId} viaServers={viaServers} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMatch, useNavigate } from 'react-router-dom';
|
|||
import { SidebarItem, SidebarItemTooltip, SidebarAvatar } from '../../../components/sidebar';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useAuthedUserId } from '../../../hooks/useAuthedUserId';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
|
|
@ -17,7 +18,7 @@ export function SettingsTab() {
|
|||
const useAuthentication = useMediaAuthentication();
|
||||
const navigate = useNavigate();
|
||||
const settingsMatch = useMatch({ path: SETTINGS_PATH, caseSensitive: true, end: false });
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = useAuthedUserId();
|
||||
const profile = useUserProfile(userId);
|
||||
|
||||
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
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 { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
|
|
@ -41,12 +41,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
if (!room || !allRooms.includes(room.roomId)) {
|
||||
// room is not joined
|
||||
if (!roomIdOrAlias) return <Navigate to="/direct" replace />;
|
||||
return (
|
||||
<JoinBeforeNavigate
|
||||
roomIdOrAlias={roomIdOrAlias!}
|
||||
eventId={eventId}
|
||||
viaServers={viaServers}
|
||||
/>
|
||||
<JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias} eventId={eventId} viaServers={viaServers} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -67,12 +64,9 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
}
|
||||
|
||||
if (!roomIdOrAlias) return <Navigate to="/direct" replace />;
|
||||
return (
|
||||
<JoinBeforeNavigate
|
||||
roomIdOrAlias={roomIdOrAlias!}
|
||||
eventId={eventId}
|
||||
viaServers={viaServers}
|
||||
/>
|
||||
<JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias} eventId={eventId} viaServers={viaServers} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -343,7 +343,8 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
|
|||
<Text size="T200">This space has been replaced and is no longer active.</Text>
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<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>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -126,8 +126,15 @@ export class CallEmbed {
|
|||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.title = 'Call Embed';
|
||||
iframe.sandbox =
|
||||
'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads';
|
||||
// `HTMLIFrameElement.sandbox` is typed as a `DOMTokenList` in modern
|
||||
// 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.src = url;
|
||||
|
||||
|
|
@ -201,7 +208,7 @@ export class CallEmbed {
|
|||
return this.listenEvent('preparing', callback);
|
||||
}
|
||||
|
||||
public onPreparingError(callback: (error: any) => void) {
|
||||
public onPreparingError(callback: (error: unknown) => void) {
|
||||
return this.listenEvent('error:preparing', callback);
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +235,9 @@ export class CallEmbed {
|
|||
const events = room.getLiveTimeline()?.getEvents() || [];
|
||||
const roomEvent = events[events.length - 1];
|
||||
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
|
||||
|
|
@ -296,6 +305,9 @@ export class CallEmbed {
|
|||
if (this.call === null) return;
|
||||
const raw = ev.getEffectiveEvent();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -329,7 +341,7 @@ export class CallEmbed {
|
|||
const room = this.mx.getRoom(roomId);
|
||||
if (room === null) return false;
|
||||
|
||||
const upToEventId = this.readUpToMap[ev.getRoomId()!];
|
||||
const upToEventId = this.readUpToMap[roomId];
|
||||
if (!upToEventId) {
|
||||
// There's no marker yet; start it at this event
|
||||
this.readUpToMap[roomId] = evId;
|
||||
|
|
@ -402,6 +414,7 @@ export class CallEmbed {
|
|||
} else {
|
||||
const raw = ev.getEffectiveEvent();
|
||||
this.call.feedEvent(raw as IRoomEvent).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error sending event to widget: ', e);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,10 @@ export const renderMatrixMention = (
|
|||
|
||||
export const factoryRenderLinkifyWithMention = (
|
||||
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> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const render: OptFn<(ir: IntermediateRepresentation) => any> = ({
|
||||
tagName,
|
||||
attributes,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ export class ASCIILexicalTable {
|
|||
this.populateWidthToSize();
|
||||
|
||||
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(
|
||||
`[!] Warning: ASCIILexicalTable size is larger than the Number.MAX_SAFE_INTEGER: ${this.size()} > ${
|
||||
Number.MAX_SAFE_INTEGER
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const promiseFulfilledResult = <T>(
|
|||
if (settledResult.status === 'fulfilled') return settledResult.value;
|
||||
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;
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -72,7 +72,10 @@ export const getLoginTermUrl = (params: UIAParams): string | undefined => {
|
|||
if (terms.policies === null) return undefined;
|
||||
if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') {
|
||||
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;
|
||||
if (typeof url === 'string') return url;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
UploadProgress,
|
||||
UploadResponse,
|
||||
} from 'matrix-js-sdk';
|
||||
import type { AccountDataEvents } from 'matrix-js-sdk';
|
||||
import to from 'await-to-js';
|
||||
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
|
|
@ -164,9 +165,10 @@ export const uploadContent = async (
|
|||
const mxc = data.content_uri;
|
||||
if (mxc) onSuccess(mxc);
|
||||
else onError(new MatrixError(data));
|
||||
} catch (e: any) {
|
||||
const error = typeof e?.message === 'string' ? e.message : undefined;
|
||||
const errcode = typeof e?.name === 'string' ? e.message : undefined;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: unknown; name?: unknown };
|
||||
const error = typeof err?.message === 'string' ? err.message : undefined;
|
||||
const errcode = typeof err?.name === 'string' ? err.name : undefined;
|
||||
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)
|
||||
// including the cross-user fallback scan for stale peer state (PR #10127).
|
||||
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 myUserId = mx.getUserId();
|
||||
|
|
@ -264,7 +266,7 @@ export const addRoomIdToMDirect = (
|
|||
userId: string
|
||||
): Promise<void> =>
|
||||
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[]> = {};
|
||||
|
||||
if (typeof mDirectsEvent !== 'undefined')
|
||||
|
|
@ -287,12 +289,12 @@ export const addRoomIdToMDirect = (
|
|||
}
|
||||
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> =>
|
||||
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[]> = {};
|
||||
|
||||
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 = (
|
||||
|
|
|
|||
|
|
@ -141,7 +141,6 @@ export async function ensureRtcRingPushRule(mx: MatrixClient): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// Symmetric cleanup for `useDisablePushNotifications`. The rules live 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
|
||||
|
|
@ -163,7 +162,6 @@ export async function removeRtcRingPushRule(mx: MatrixClient): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
export async function registerWebPusher(
|
||||
mx: MatrixClient,
|
||||
subscription: PushSubscription,
|
||||
|
|
@ -193,13 +191,17 @@ export async function registerWebPusher(
|
|||
app_display_name: 'Vojo Web',
|
||||
device_display_name: navigator.userAgent.slice(0, 120),
|
||||
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: {
|
||||
url: gatewayUrl,
|
||||
endpoint: subscription.endpoint,
|
||||
auth: arrayBufferToBase64Url(auth),
|
||||
format: 'event_id_only',
|
||||
events_only: true,
|
||||
},
|
||||
} as unknown as { format?: string; url?: string; brand?: string },
|
||||
append: true,
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Room,
|
||||
RoomMember,
|
||||
} from 'matrix-js-sdk';
|
||||
import type { AccountDataEvents } from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import {
|
||||
|
|
@ -44,7 +45,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
|
|||
export const getAccountData = (
|
||||
mx: MatrixClient,
|
||||
eventType: AccountDataEvent
|
||||
): MatrixEvent | undefined => mx.getAccountData(eventType as any);
|
||||
): MatrixEvent | undefined => mx.getAccountData(eventType as keyof AccountDataEvents);
|
||||
|
||||
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
|
||||
const roomIds = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||
cryptoStore: legacyCryptoStore,
|
||||
deviceId: session.deviceId,
|
||||
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,
|
||||
verificationMethods: ['m.sas.v1'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ const mountApp = () => {
|
|||
const rootContainer = document.getElementById('root');
|
||||
|
||||
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!');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
sessions.set(clientId, { accessToken, baseUrl });
|
||||
} else {
|
||||
|
|
|
|||
33
src/types/matrix/sdkAugmentation.d.ts
vendored
Normal file
33
src/types/matrix/sdkAugmentation.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue