Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 7m14s
/ build-mobile (push) Failing after 12m6s
/ build-desktop (linux) (push) Successful in 12m16s
/ release (push) Has been skipped
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 7m14s
/ build-mobile (push) Failing after 12m6s
/ build-desktop (linux) (push) Successful in 12m16s
/ release (push) Has been skipped
# Conflicts: # apps/pwa/src/runtime.tsx # flake.nix # packages/mtp/src/context.test.tsx # packages/mtp/src/context.tsx
This commit is contained in:
commit
094cb910aa
52 changed files with 3979 additions and 927 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
"entry": [
|
"entry": [
|
||||||
"src/index.{ts,tsx,js,jsx}",
|
"src/index.{ts,tsx,js,jsx}",
|
||||||
"src/main.{ts,tsx,js,jsx}",
|
"src/main.{ts,tsx,js,jsx}",
|
||||||
|
"apps/pwa/src/serviceWorker.ts",
|
||||||
"apps/tauri/render-version.ts",
|
"apps/tauri/render-version.ts",
|
||||||
"packages/**/*.test.ts"
|
"packages/**/*.test.ts"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ jobs:
|
||||||
name: Build web
|
name: Build web
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- run: nix profile add nixpkgs#nodejs_24
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
@ -27,7 +26,6 @@ jobs:
|
||||||
name: Build desktop
|
name: Build desktop
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- run: nix profile add nixpkgs#nodejs_24
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
@ -36,12 +34,23 @@ jobs:
|
||||||
- run: nix develop .#electron --command pnpm run build:packages
|
- run: nix develop .#electron --command pnpm run build:packages
|
||||||
- run: nix develop .#electron --command pnpm run build:desktop
|
- run: nix develop .#electron --command pnpm run build:desktop
|
||||||
|
|
||||||
mobile:
|
native-mtp:
|
||||||
if: ${{ github.actor == 'rasensprenger' }}
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
name: Build mobile
|
name: Test native MTP
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- run: nix profile add nixpkgs#nodejs_24
|
- run: nix profile add nixpkgs#nodejs_24
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- run: git submodule update --init --recursive
|
||||||
|
- run: nix develop .#electron --command bash -lc 'cd apps/tauri/src-tauri && cargo test'
|
||||||
|
|
||||||
|
mobile:
|
||||||
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
|
name: Build mobile
|
||||||
|
runs-on: nixos
|
||||||
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ jobs:
|
||||||
build-web:
|
build-web:
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -36,9 +33,6 @@ jobs:
|
||||||
build-mobile:
|
build-mobile:
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -59,8 +53,6 @@ jobs:
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||||
run: |
|
run: |
|
||||||
nix profile add nixpkgs#gnused
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
|
@ -125,9 +117,6 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
target: [linux]
|
target: [linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -178,9 +167,6 @@ jobs:
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
needs: [build-web, build-mobile, build-desktop]
|
needs: [build-web, build-mobile, build-desktop]
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ jobs:
|
||||||
build-web:
|
build-web:
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -37,9 +34,6 @@ jobs:
|
||||||
build-mobile:
|
build-mobile:
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -60,8 +54,6 @@ jobs:
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||||
run: |
|
run: |
|
||||||
nix profile add nixpkgs#gnused
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
|
@ -126,9 +118,6 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
target: [linux]
|
target: [linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -177,9 +166,6 @@ jobs:
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
needs: [build-web, build-mobile, build-desktop]
|
needs: [build-web, build-mobile, build-desktop]
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
|
|
@ -34,19 +34,22 @@ function isPushPayload(value: unknown): value is PushPayload {
|
||||||
const message = payload.message as
|
const message = payload.message as
|
||||||
Partial<PushPayload["message"]> | undefined;
|
Partial<PushPayload["message"]> | undefined;
|
||||||
const secret = payload.secret as Partial<PushPayload["secret"]> | undefined;
|
const secret = payload.secret as Partial<PushPayload["secret"]> | undefined;
|
||||||
|
const stringValues = [
|
||||||
|
payload.sender,
|
||||||
|
message?.content,
|
||||||
|
secret?.chatId,
|
||||||
|
secret?.secretId,
|
||||||
|
secret?.encryptedSecret,
|
||||||
|
secret?.kemCiphertext,
|
||||||
|
secret?.wrappingScheme,
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
payload.version === 1 &&
|
payload.version === 1 &&
|
||||||
typeof payload.senderId === "number" &&
|
typeof payload.senderId === "number" &&
|
||||||
Number.isSafeInteger(payload.senderId) &&
|
Number.isSafeInteger(payload.senderId) &&
|
||||||
payload.senderId > 0 &&
|
payload.senderId > 0 &&
|
||||||
typeof payload.sender === "string" &&
|
typeof secret?.version === "number" &&
|
||||||
typeof message?.content === "string" &&
|
stringValues.every((item) => typeof item === "string")
|
||||||
typeof secret?.chatId === "string" &&
|
|
||||||
typeof secret.secretId === "string" &&
|
|
||||||
typeof secret.version === "number" &&
|
|
||||||
typeof secret.encryptedSecret === "string" &&
|
|
||||||
typeof secret.kemCiphertext === "string" &&
|
|
||||||
typeof secret.wrappingScheme === "string"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
296
apps/tauri/src-tauri/Cargo.lock
generated
296
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -674,12 +674,6 @@ dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "const-oid"
|
|
||||||
version = "0.9.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
|
|
@ -889,7 +883,6 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest 0.10.7",
|
|
||||||
"fiat-crypto 0.2.9",
|
"fiat-crypto 0.2.9",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
|
@ -974,25 +967,14 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "der"
|
|
||||||
version = "0.7.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
|
||||||
dependencies = [
|
|
||||||
"const-oid 0.9.6",
|
|
||||||
"pem-rfc7468 0.7.0",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
|
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid 0.10.2",
|
"const-oid",
|
||||||
"pem-rfc7468 1.0.0",
|
"pem-rfc7468",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1057,7 +1039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.1",
|
"block-buffer 0.12.1",
|
||||||
"const-oid 0.10.2",
|
"const-oid",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
]
|
]
|
||||||
|
|
@ -1204,38 +1186,14 @@ version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ed25519"
|
|
||||||
version = "2.2.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
|
||||||
dependencies = [
|
|
||||||
"pkcs8 0.10.2",
|
|
||||||
"signature 2.2.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkcs8 0.11.0",
|
"pkcs8",
|
||||||
"signature 3.0.0",
|
"signature",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ed25519-dalek"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
|
||||||
dependencies = [
|
|
||||||
"curve25519-dalek 4.1.3",
|
|
||||||
"ed25519 2.2.3",
|
|
||||||
"serde",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"subtle",
|
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1245,10 +1203,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"curve25519-dalek 5.0.0",
|
"curve25519-dalek 5.0.0",
|
||||||
"ed25519 3.0.0",
|
"ed25519",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"signature 3.0.0",
|
"signature",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
@ -2737,14 +2695,14 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
|
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid 0.10.2",
|
"const-oid",
|
||||||
"crypto-common 0.2.2",
|
"crypto-common 0.2.2",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
"module-lattice",
|
"module-lattice",
|
||||||
"pkcs8 0.11.0",
|
"pkcs8",
|
||||||
"shake",
|
"shake",
|
||||||
"signature 3.0.0",
|
"signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2786,72 +2744,50 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp"
|
name = "mtp"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-client",
|
"mtp-client",
|
||||||
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-codec",
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-common",
|
||||||
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-crypto",
|
||||||
"mtp-host",
|
"mtp-host",
|
||||||
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-transport",
|
||||||
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-type-map",
|
||||||
"mtp-webserver",
|
"mtp-webserver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-client"
|
name = "mtp-client"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-codec",
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-common",
|
||||||
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-crypto",
|
||||||
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-transport",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-codec"
|
name = "mtp-codec"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-common",
|
||||||
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-crypto",
|
||||||
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-type-map",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mtp-codec"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.23.1",
|
|
||||||
"byteorder",
|
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
|
||||||
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
|
||||||
"rand 0.10.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mtp-common"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
|
||||||
dependencies = [
|
|
||||||
"quinn",
|
|
||||||
"rustls",
|
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"wtransport",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-common"
|
name = "mtp-common"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quinn",
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
|
@ -2861,18 +2797,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-crypto"
|
name = "mtp-crypto"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.22.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek 2.2.0",
|
"ed25519-dalek",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"ml-dsa",
|
"ml-dsa",
|
||||||
"mlkem-tls",
|
"mlkem-tls",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.6.4",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
|
@ -2882,89 +2818,45 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-crypto"
|
name = "mtp-host"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"mtp-codec",
|
||||||
"chacha20poly1305",
|
"mtp-common",
|
||||||
"ed25519-dalek 3.0.0",
|
"mtp-crypto",
|
||||||
"getrandom 0.4.3",
|
"mtp-transport",
|
||||||
"hkdf",
|
|
||||||
"ml-dsa",
|
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rand_core 0.10.1",
|
|
||||||
"rustls",
|
|
||||||
"sha2 0.11.0",
|
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"wtransport",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mtp-transport"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"mtp-codec",
|
||||||
|
"mtp-common",
|
||||||
|
"mtp-crypto",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"rcgen",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"wtransport",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mtp-host"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
|
||||||
dependencies = [
|
|
||||||
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
|
||||||
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
|
||||||
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
|
||||||
"rand 0.10.2",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"wtransport",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mtp-transport"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
|
||||||
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
|
||||||
"rcgen",
|
|
||||||
"rustls",
|
|
||||||
"rustls-native-certs",
|
|
||||||
"sha2 0.11.0",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"wtransport",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mtp-transport"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
|
||||||
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
|
||||||
"rcgen",
|
|
||||||
"rustls",
|
|
||||||
"rustls-native-certs",
|
|
||||||
"sha2 0.11.0",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"wtransport",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-type-map"
|
name = "mtp-type-map"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_yaml",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mtp-type-map"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
|
@ -2972,8 +2864,8 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-webserver"
|
name = "mtp-webserver"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
|
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2984,13 +2876,12 @@ dependencies = [
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-codec",
|
||||||
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-common",
|
||||||
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-crypto",
|
||||||
"mtp-host",
|
"mtp-host",
|
||||||
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
|
"mtp-transport",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.10.2",
|
|
||||||
"rustls",
|
"rustls",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -3492,15 +3383,6 @@ dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pem-rfc7468"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -3586,24 +3468,14 @@ dependencies = [
|
||||||
"futures-io",
|
"futures-io",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pkcs8"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
|
||||||
dependencies = [
|
|
||||||
"der 0.7.10",
|
|
||||||
"spki 0.7.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs8"
|
name = "pkcs8"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.8.1",
|
"der",
|
||||||
"spki 0.8.0",
|
"spki",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4592,15 +4464,6 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signature"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signature"
|
name = "signature"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
|
|
@ -4709,16 +4572,6 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spki"
|
|
||||||
version = "0.7.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"der 0.7.10",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spki"
|
name = "spki"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|
@ -4726,7 +4579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
|
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"der 0.8.1",
|
"der",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5289,7 +5142,6 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"mtp",
|
"mtp",
|
||||||
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ serde_json = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
||||||
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "7182272e3edb0079d7b74b45b2f62a298d2fee22", features = ["client", "crypto"] }
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] }
|
||||||
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b" }
|
|
||||||
webpki-root-certs = "1"
|
webpki-root-certs = "1"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
@ -36,6 +35,11 @@ version = "2"
|
||||||
features = []
|
features = []
|
||||||
default-features = true
|
default-features = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies.tauri]
|
||||||
|
version = "2"
|
||||||
|
features = []
|
||||||
|
default-features = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
jni = "0.22"
|
jni = "0.22"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use lib::log;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
mobile_lib::run();
|
mobile_lib::run();
|
||||||
log("Test test 123")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::sync::{
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
Arc, Mutex, OnceLock, RwLock,
|
Arc, Mutex, OnceLock, RwLock,
|
||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use base64::{
|
use base64::{
|
||||||
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
|
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
|
||||||
|
|
@ -16,6 +16,7 @@ use mtp::crypto::{
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
const EVENT_NAME: &str = "mtp://event";
|
const EVENT_NAME: &str = "mtp://event";
|
||||||
const DISCONNECTED: u8 = 0;
|
const DISCONNECTED: u8 = 0;
|
||||||
|
|
@ -24,6 +25,9 @@ const CONNECTED: u8 = 2;
|
||||||
const CHAT_SECRET_SALT: &[u8] = b"tensamin-chat-secret-v1";
|
const CHAT_SECRET_SALT: &[u8] = b"tensamin-chat-secret-v1";
|
||||||
const CHAT_MESSAGE_SALT: &[u8] = b"tensamin-chat-message-v1";
|
const CHAT_MESSAGE_SALT: &[u8] = b"tensamin-chat-message-v1";
|
||||||
const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1";
|
const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1";
|
||||||
|
const INITIAL_SYNC_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const MAX_BUFFERED_INITIAL_FRAMES: usize = 1_000;
|
||||||
|
const NOTIFICATION_QUEUE_CAPACITY: usize = 32;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\
|
const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\
|
||||||
MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\
|
MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\
|
||||||
|
|
@ -95,7 +99,7 @@ enum MtpEvent {
|
||||||
pub struct MtpManager {
|
pub struct MtpManager {
|
||||||
runtime: tokio::runtime::Runtime,
|
runtime: tokio::runtime::Runtime,
|
||||||
config: RwLock<Option<MtpConfig>>,
|
config: RwLock<Option<MtpConfig>>,
|
||||||
connection: RwLock<Option<Arc<MTPConnection>>>,
|
connection: RwLock<Option<Arc<ManagedConnection>>>,
|
||||||
snapshot: RwLock<MtpSnapshot>,
|
snapshot: RwLock<MtpSnapshot>,
|
||||||
generation: AtomicU64,
|
generation: AtomicU64,
|
||||||
enabled: AtomicBool,
|
enabled: AtomicBool,
|
||||||
|
|
@ -104,6 +108,46 @@ pub struct MtpManager {
|
||||||
start_lock: Mutex<()>,
|
start_lock: Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RequestIdAllocator {
|
||||||
|
next: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestIdAllocator {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
next: AtomicU64::new(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(&self) -> Result<u32, String> {
|
||||||
|
let value = self.next.fetch_add(1, Ordering::Relaxed);
|
||||||
|
u32::try_from(value)
|
||||||
|
.map_err(|_| "MTP request ID space exhausted for this connection".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ManagedConnection {
|
||||||
|
mtp: Arc<MTPConnection>,
|
||||||
|
request_ids: RequestIdAllocator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedConnection {
|
||||||
|
async fn next_request_id(&self) -> Result<u32, String> {
|
||||||
|
let id = self.request_ids.next();
|
||||||
|
if id.is_err() {
|
||||||
|
self.mtp.sender.close().await;
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreparedConnection {
|
||||||
|
connection: MTPConnection,
|
||||||
|
request_ids: RequestIdAllocator,
|
||||||
|
initial_state: Value,
|
||||||
|
buffered_frames: Vec<CommunicationValue>,
|
||||||
|
}
|
||||||
|
|
||||||
static MANAGER: OnceLock<MtpManager> = OnceLock::new();
|
static MANAGER: OnceLock<MtpManager> = OnceLock::new();
|
||||||
|
|
||||||
pub fn manager() -> &'static MtpManager {
|
pub fn manager() -> &'static MtpManager {
|
||||||
|
|
@ -149,7 +193,7 @@ impl MtpManager {
|
||||||
.take()
|
.take()
|
||||||
{
|
{
|
||||||
self.runtime
|
self.runtime
|
||||||
.spawn(async move { connection.sender.close().await });
|
.spawn(async move { connection.mtp.sender.close().await });
|
||||||
}
|
}
|
||||||
self.set_snapshot(MtpSnapshot {
|
self.set_snapshot(MtpSnapshot {
|
||||||
generation,
|
generation,
|
||||||
|
|
@ -171,7 +215,7 @@ impl MtpManager {
|
||||||
.take()
|
.take()
|
||||||
{
|
{
|
||||||
self.runtime
|
self.runtime
|
||||||
.spawn(async move { connection.sender.close().await });
|
.spawn(async move { connection.mtp.sender.close().await });
|
||||||
}
|
}
|
||||||
self.set_snapshot(MtpSnapshot {
|
self.set_snapshot(MtpSnapshot {
|
||||||
generation: self.generation.load(Ordering::SeqCst),
|
generation: self.generation.load(Ordering::SeqCst),
|
||||||
|
|
@ -231,20 +275,16 @@ impl MtpManager {
|
||||||
self.enabled.load(Ordering::SeqCst) && self.generation.load(Ordering::SeqCst) == generation
|
self.enabled.load(Ordering::SeqCst) && self.generation.load(Ordering::SeqCst) == generation
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn request(
|
async fn request(&self, type_name: &str, data: Value) -> Result<Value, String> {
|
||||||
&self,
|
|
||||||
type_name: &str,
|
|
||||||
data: Value,
|
|
||||||
id: Option<u32>,
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
let connection = self
|
let connection = self
|
||||||
.connection
|
.connection
|
||||||
.read()
|
.read()
|
||||||
.map_err(|_| "MTP connection lock is unavailable")?
|
.map_err(|_| "MTP connection lock is unavailable")?
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| "MTP is not connected".to_string())?;
|
.ok_or_else(|| "MTP is not connected".to_string())?;
|
||||||
let request = json_to_frame(type_name, data, id)?;
|
let request = json_to_frame(type_name, data, connection.next_request_id().await?)?;
|
||||||
let response = connection
|
let response = connection
|
||||||
|
.mtp
|
||||||
.request(&request, None)
|
.request(&request, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
@ -261,9 +301,12 @@ async fn supervise(generation: u64) {
|
||||||
manager.log(2, "Starting native MTP connection", None);
|
manager.log(2, "Starting native MTP connection", None);
|
||||||
android_status("Connecting");
|
android_status("Connecting");
|
||||||
match connect(&config).await {
|
match connect(&config).await {
|
||||||
Ok((connection, state)) => {
|
Ok(prepared) => {
|
||||||
delay = Duration::from_secs(1);
|
delay = Duration::from_secs(1);
|
||||||
let connection = Arc::new(connection);
|
let connection = Arc::new(ManagedConnection {
|
||||||
|
mtp: Arc::new(prepared.connection),
|
||||||
|
request_ids: prepared.request_ids,
|
||||||
|
});
|
||||||
let stale = {
|
let stale = {
|
||||||
let _guard = manager.start_lock.lock().expect("start lock poisoned");
|
let _guard = manager.start_lock.lock().expect("start lock poisoned");
|
||||||
let mut current = manager
|
let mut current = manager
|
||||||
|
|
@ -279,22 +322,47 @@ async fn supervise(generation: u64) {
|
||||||
generation,
|
generation,
|
||||||
ready_state: CONNECTED,
|
ready_state: CONNECTED,
|
||||||
identified: true,
|
identified: true,
|
||||||
state: Some(state),
|
state: Some(prepared.initial_state),
|
||||||
error: None,
|
error: None,
|
||||||
});
|
});
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if stale {
|
if stale {
|
||||||
connection.sender.close().await;
|
connection.mtp.sender.close().await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
android_status("Connected");
|
android_status("Connected");
|
||||||
manager.log(2, "Native MTP connection established", None);
|
manager.log(2, "Native MTP connection established", None);
|
||||||
|
|
||||||
|
let (notification_tx, mut notification_rx) =
|
||||||
|
mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
|
||||||
|
let notification_connection = connection.clone();
|
||||||
|
let notification_config = config.clone();
|
||||||
|
let notification_worker = tokio::spawn(async move {
|
||||||
|
while let Some(frame) = notification_rx.recv().await {
|
||||||
|
if !manager().is_current(generation) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Err(error) = notify_message(
|
||||||
|
¬ification_config,
|
||||||
|
notification_connection.clone(),
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("failed to create background message notification: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for frame in prepared.buffered_frames {
|
||||||
|
handle_push(generation, ¬ification_tx, frame).await;
|
||||||
|
}
|
||||||
|
|
||||||
while manager.is_current(generation) {
|
while manager.is_current(generation) {
|
||||||
match connection.receive().await {
|
match connection.mtp.receive().await {
|
||||||
Ok(frame) => handle_push(generation, connection.clone(), frame).await,
|
Ok(frame) => handle_push(generation, ¬ification_tx, frame).await,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _guard = manager.start_lock.lock().expect("start lock poisoned");
|
let _guard = manager.start_lock.lock().expect("start lock poisoned");
|
||||||
if manager.is_current(generation) {
|
if manager.is_current(generation) {
|
||||||
|
|
@ -310,6 +378,13 @@ async fn supervise(generation: u64) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
drop(notification_tx);
|
||||||
|
notification_worker.abort();
|
||||||
|
if let Err(error) = notification_worker.await {
|
||||||
|
if !error.is_cancelled() {
|
||||||
|
eprintln!("background notification worker failed: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut current = manager
|
let mut current = manager
|
||||||
.connection
|
.connection
|
||||||
.write()
|
.write()
|
||||||
|
|
@ -339,12 +414,21 @@ async fn supervise(generation: u64) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
android_status("Reconnecting");
|
android_status("Reconnecting");
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(jittered_retry_delay(delay)).await;
|
||||||
delay = (delay * 2).min(Duration::from_secs(60));
|
delay = (delay * 2).min(Duration::from_secs(60));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
|
fn jittered_retry_delay(delay: Duration) -> Duration {
|
||||||
|
let entropy = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.subsec_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let percent = 80 + entropy % 41;
|
||||||
|
delay.mul_f64(percent as f64 / 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(config: &MtpConfig) -> Result<PreparedConnection, String> {
|
||||||
let (url, public_key) = resolve_endpoint(config)
|
let (url, public_key) = resolve_endpoint(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("endpoint discovery failed: {error}"))?;
|
.map_err(|error| format!("endpoint discovery failed: {error}"))?;
|
||||||
|
|
@ -381,39 +465,10 @@ async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
|
||||||
.map_err(|error| format!("transport authentication failed: {error}"))?;
|
.map_err(|error| format!("transport authentication failed: {error}"))?;
|
||||||
manager().log(2, "Native MTP authentication completed", None);
|
manager().log(2, "Native MTP authentication completed", None);
|
||||||
|
|
||||||
let connected = CommunicationValue::new(CommunicationType::ClientConnected)
|
let (state, buffered_frames) = await_initial_state(&connection).await?;
|
||||||
.add_typed_default(
|
let request_ids = RequestIdAllocator::new();
|
||||||
DataType::SessionId,
|
let (initial_state, ack) = prepare_initial_state_ack(&state, &request_ids)?;
|
||||||
DataValue::UnsignedNumber(current_millis() as u128),
|
let response = tokio::time::timeout(INITIAL_SYNC_TIMEOUT, connection.request(&ack, None))
|
||||||
)
|
|
||||||
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
|
|
||||||
.add_typed_default(DataType::CacheValid, DataValue::BoolFalse)
|
|
||||||
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0));
|
|
||||||
let state = tokio::time::timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
connection.request(&connected, None),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| "initial state synchronization timed out".to_string())?
|
|
||||||
.map_err(|error| format!("initial state synchronization failed: {error}"))?;
|
|
||||||
if !state.is_type(CommunicationType::ClientStateSync) {
|
|
||||||
return Err(format!(
|
|
||||||
"expected ClientStateSync, received {}",
|
|
||||||
state.get_type_name().unwrap_or("unknown")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let session_id = state
|
|
||||||
.get_data(DataType::SessionId)
|
|
||||||
.as_number()
|
|
||||||
.ok_or("ClientStateSync omitted SessionId")?;
|
|
||||||
let version = state
|
|
||||||
.get_data(DataType::VersionNumber)
|
|
||||||
.as_number()
|
|
||||||
.ok_or("ClientStateSync omitted VersionNumber")?;
|
|
||||||
let ack = CommunicationValue::new(CommunicationType::ClientStateAck)
|
|
||||||
.add_typed_default(DataType::SessionId, number_to_data(session_id))
|
|
||||||
.add_typed_default(DataType::VersionNumber, number_to_data(version));
|
|
||||||
let response = tokio::time::timeout(Duration::from_secs(30), connection.request(&ack, None))
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| "state acknowledgement timed out".to_string())?
|
.map_err(|_| "state acknowledgement timed out".to_string())?
|
||||||
.map_err(|error| format!("state acknowledgement failed: {error}"))?;
|
.map_err(|error| format!("state acknowledgement failed: {error}"))?;
|
||||||
|
|
@ -423,7 +478,182 @@ async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
|
||||||
{
|
{
|
||||||
return Err(format!("ClientStateAck failed: {response}"));
|
return Err(format!("ClientStateAck failed: {response}"));
|
||||||
}
|
}
|
||||||
Ok((connection, frame_data_to_json(&state)?))
|
Ok(PreparedConnection {
|
||||||
|
connection,
|
||||||
|
request_ids,
|
||||||
|
initial_state,
|
||||||
|
buffered_frames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_initial_state_ack(
|
||||||
|
state: &CommunicationValue,
|
||||||
|
request_ids: &RequestIdAllocator,
|
||||||
|
) -> Result<(Value, CommunicationValue), String> {
|
||||||
|
let initial_state = frame_data_to_json(state)?;
|
||||||
|
validate_client_state_sync(&initial_state)?;
|
||||||
|
let data = initial_state
|
||||||
|
.as_object()
|
||||||
|
.ok_or("ClientStateSync payload is not an object")?;
|
||||||
|
let session_id = required_integer(data, "SessionId")?;
|
||||||
|
let version = required_integer(data, "VersionNumber")?;
|
||||||
|
let ack = CommunicationValue::new(CommunicationType::ClientStateAck)
|
||||||
|
.with_id(request_ids.next()?)
|
||||||
|
.add_typed_default(DataType::SessionId, number_to_data(session_id))
|
||||||
|
.add_typed_default(DataType::VersionNumber, number_to_data(version));
|
||||||
|
Ok((initial_state, ack))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_client_state_sync(state: &Value) -> Result<(), String> {
|
||||||
|
let data = state
|
||||||
|
.as_object()
|
||||||
|
.ok_or("ClientStateSync payload is not an object")?;
|
||||||
|
if required_integer(data, "SessionId")? <= 0 {
|
||||||
|
return Err("ClientStateSync SessionId must be a positive integer".into());
|
||||||
|
}
|
||||||
|
for field in ["VersionNumber", "CacheSchemaVersion"] {
|
||||||
|
if required_integer(data, field)? < 0 {
|
||||||
|
return Err(format!("ClientStateSync {field} must be nonnegative"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match data.get("SyncMode").and_then(Value::as_str) {
|
||||||
|
Some("full" | "delta") => {}
|
||||||
|
_ => return Err("ClientStateSync SyncMode must be 'full' or 'delta'".into()),
|
||||||
|
}
|
||||||
|
for field in ["Contacts", "Communities", "Calls", "Messages"] {
|
||||||
|
if !data.get(field).is_some_and(Value::is_array) {
|
||||||
|
return Err(format!("ClientStateSync {field} must be an array"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field in ["DeletedMessageIds", "DeletedContactIds"] {
|
||||||
|
if let Some(value) = data.get(field) {
|
||||||
|
let values = value
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| format!("ClientStateSync {field} must be an array"))?;
|
||||||
|
if values.iter().any(|value| !value.is_number()) {
|
||||||
|
return Err(format!("ClientStateSync {field} must contain numbers"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_object_array(data, "Communities", |_| Ok(()))?;
|
||||||
|
validate_object_array(data, "Contacts", validate_contact)?;
|
||||||
|
validate_object_array(data, "Calls", validate_call)?;
|
||||||
|
validate_object_array(data, "Messages", validate_message)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_object_array(
|
||||||
|
data: &Map<String, Value>,
|
||||||
|
field: &str,
|
||||||
|
validate: impl Fn(&Map<String, Value>) -> Result<(), String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let values = data
|
||||||
|
.get(field)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| format!("ClientStateSync {field} must be an array"))?;
|
||||||
|
for value in values {
|
||||||
|
let object = value
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| format!("ClientStateSync {field} entries must be objects"))?;
|
||||||
|
validate(object)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_contact(contact: &Map<String, Value>) -> Result<(), String> {
|
||||||
|
if !contact.get("UserId").is_some_and(Value::is_number) {
|
||||||
|
return Err("ClientStateSync contact omitted numeric UserId".into());
|
||||||
|
}
|
||||||
|
if let Some(messages) = contact.get("Messages") {
|
||||||
|
let messages = messages
|
||||||
|
.as_array()
|
||||||
|
.ok_or("ClientStateSync contact Messages must be an array")?;
|
||||||
|
for message in messages {
|
||||||
|
validate_message(
|
||||||
|
message
|
||||||
|
.as_object()
|
||||||
|
.ok_or("ClientStateSync contact message must be an object")?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_call(call: &Map<String, Value>) -> Result<(), String> {
|
||||||
|
if !call.get("CallId").is_some_and(Value::is_string) {
|
||||||
|
return Err("ClientStateSync call omitted string CallId".into());
|
||||||
|
}
|
||||||
|
let members = call
|
||||||
|
.get("CallMembers")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or("ClientStateSync call omitted CallMembers array")?;
|
||||||
|
if members.iter().any(|member| !member.is_number()) {
|
||||||
|
return Err("ClientStateSync CallMembers must contain numbers".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_message(message: &Map<String, Value>) -> Result<(), String> {
|
||||||
|
for field in ["SenderId", "SendTime"] {
|
||||||
|
if !message.get(field).is_some_and(Value::is_number) {
|
||||||
|
return Err(format!("ClientStateSync message omitted numeric {field}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let content = message
|
||||||
|
.get("Content")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("ClientStateSync message omitted string Content")?;
|
||||||
|
STANDARD
|
||||||
|
.decode(content)
|
||||||
|
.or_else(|_| STANDARD_NO_PAD.decode(content))
|
||||||
|
.map_err(|_| "ClientStateSync message Content must be base64".to_string())?;
|
||||||
|
if let Some(state) = message.get("MessageState") {
|
||||||
|
match state.as_str() {
|
||||||
|
Some("read" | "received" | "sent" | "sending" | "awaiting") => {}
|
||||||
|
_ => return Err("ClientStateSync message has invalid MessageState".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_integer(data: &Map<String, Value>, field: &str) -> Result<i128, String> {
|
||||||
|
let value = data
|
||||||
|
.get(field)
|
||||||
|
.ok_or_else(|| format!("ClientStateSync omitted {field}"))?;
|
||||||
|
if let Some(value) = value.as_i64() {
|
||||||
|
return Ok(value as i128);
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.map(|value| value as i128)
|
||||||
|
.ok_or_else(|| format!("ClientStateSync {field} must be an integer"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_initial_state(
|
||||||
|
connection: &MTPConnection,
|
||||||
|
) -> Result<(CommunicationValue, Vec<CommunicationValue>), String> {
|
||||||
|
let mut buffered = Vec::new();
|
||||||
|
let deadline = tokio::time::Instant::now() + INITIAL_SYNC_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let frame = tokio::time::timeout_at(deadline, connection.receive())
|
||||||
|
.await
|
||||||
|
.map_err(|_| "initial state synchronization timed out".to_string())?
|
||||||
|
.map_err(|error| format!("initial state synchronization failed: {error}"))?;
|
||||||
|
|
||||||
|
if frame.is_type(CommunicationType::ErrorNoIota) {
|
||||||
|
return Err("No Iota is currently connected".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if frame.is_type(CommunicationType::ClientStateSync) {
|
||||||
|
return Ok((frame, buffered));
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffered.len() == MAX_BUFFERED_INITIAL_FRAMES {
|
||||||
|
return Err("initial state synchronization buffered too many frames".into());
|
||||||
|
}
|
||||||
|
buffered.push(frame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String> {
|
async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String> {
|
||||||
|
|
@ -465,7 +695,11 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: CommunicationValue) {
|
async fn handle_push(
|
||||||
|
generation: u64,
|
||||||
|
notification_tx: &mpsc::Sender<CommunicationValue>,
|
||||||
|
frame: CommunicationValue,
|
||||||
|
) {
|
||||||
let manager = manager();
|
let manager = manager();
|
||||||
if let Ok(message) = frame_to_json(&frame) {
|
if let Ok(message) = frame_to_json(&frame) {
|
||||||
manager.emit(MtpEvent::Message {
|
manager.emit(MtpEvent::Message {
|
||||||
|
|
@ -478,7 +712,7 @@ async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: Com
|
||||||
{
|
{
|
||||||
if let Some(partner_id) = frame
|
if let Some(partner_id) = frame
|
||||||
.get_data(DataType::ChatPartnerId)
|
.get_data(DataType::ChatPartnerId)
|
||||||
.as_number()
|
.and_then(DataValue::as_number)
|
||||||
.and_then(|value| u64::try_from(value).ok())
|
.and_then(|value| u64::try_from(value).ok())
|
||||||
{
|
{
|
||||||
if let Err(error) = android_cancel_notification(partner_id) {
|
if let Err(error) = android_cancel_notification(partner_id) {
|
||||||
|
|
@ -487,40 +721,39 @@ async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: Com
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) {
|
if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) {
|
||||||
if let Err(error) = notify_message(connection, &frame).await {
|
if notification_tx.try_send(frame).is_err() {
|
||||||
eprintln!("failed to create background message notification: {error}");
|
eprintln!("background message notification queue is full");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn notify_message(
|
async fn notify_message(
|
||||||
connection: Arc<MTPConnection>,
|
config: &MtpConfig,
|
||||||
|
connection: Arc<ManagedConnection>,
|
||||||
frame: &CommunicationValue,
|
frame: &CommunicationValue,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let sender_id = frame
|
let sender_id = frame
|
||||||
.get_data(DataType::SenderId)
|
.get_data(DataType::SenderId)
|
||||||
.as_number()
|
.and_then(DataValue::as_number)
|
||||||
.and_then(|value| u64::try_from(value).ok())
|
.and_then(|value| u64::try_from(value).ok())
|
||||||
.ok_or("MessageLive omitted SenderId")?;
|
.ok_or("MessageLive omitted SenderId")?;
|
||||||
let message = frame.get_data(DataType::Message);
|
let message = frame
|
||||||
let content = container_value(message, DataType::Content)
|
.get_data(DataType::Message)
|
||||||
|
.ok_or("MessageLive omitted Message")?;
|
||||||
|
let content = container_value(message, DataType::AppContent)
|
||||||
.and_then(DataValue::as_str)
|
.and_then(DataValue::as_str)
|
||||||
.ok_or("MessageLive omitted Content")?;
|
.ok_or("MessageLive omitted Content")?;
|
||||||
let config = manager()
|
|
||||||
.config
|
|
||||||
.read()
|
|
||||||
.expect("config lock poisoned")
|
|
||||||
.clone()
|
|
||||||
.ok_or("missing config")?;
|
|
||||||
let keyring_bytes = decode_browser_base64(&config.keyring)?;
|
let keyring_bytes = decode_browser_base64(&config.keyring)?;
|
||||||
let keyring = Keyring::from_bytes(&keyring_bytes).map_err(|error| error.to_string())?;
|
let keyring = Keyring::from_bytes(&keyring_bytes).map_err(|error| error.to_string())?;
|
||||||
let chat_id = derive_chat_id(config.user_id, sender_id);
|
let chat_id = derive_chat_id(config.user_id, sender_id);
|
||||||
let secret_id = format!("chat:{chat_id}:main");
|
let secret_id = format!("chat:{chat_id}:main");
|
||||||
let secret_request = CommunicationValue::new(CommunicationType::GetChatSecret)
|
let secret_request = CommunicationValue::new(CommunicationType::GetChatSecret)
|
||||||
|
.with_id(connection.next_request_id().await?)
|
||||||
.add_typed_default(DataType::UserId, DataValue::Str(config.user_id.to_string()))
|
.add_typed_default(DataType::UserId, DataValue::Str(config.user_id.to_string()))
|
||||||
.add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone()))
|
.add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone()))
|
||||||
.add_typed_default(DataType::SecretId, DataValue::Str(secret_id.clone()));
|
.add_typed_default(DataType::SecretId, DataValue::Str(secret_id.clone()));
|
||||||
let secret = connection
|
let secret = connection
|
||||||
|
.mtp
|
||||||
.request(&secret_request, None)
|
.request(&secret_request, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
@ -532,7 +765,7 @@ async fn notify_message(
|
||||||
}
|
}
|
||||||
let version = secret
|
let version = secret
|
||||||
.get_data(DataType::VersionNumber)
|
.get_data(DataType::VersionNumber)
|
||||||
.as_number()
|
.and_then(DataValue::as_number)
|
||||||
.ok_or("missing secret version")?;
|
.ok_or("missing secret version")?;
|
||||||
let encrypted_secret = secret
|
let encrypted_secret = secret
|
||||||
.get_bytes(DataType::EncryptedSecret)
|
.get_bytes(DataType::EncryptedSecret)
|
||||||
|
|
@ -560,11 +793,14 @@ async fn notify_message(
|
||||||
.decrypt(&ciphertext, b"")
|
.decrypt(&ciphertext, b"")
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
let user_request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
|
let user_request = CommunicationValue::new(CommunicationType::GetUserData)
|
||||||
DataType::UserId,
|
.with_id(connection.next_request_id().await?)
|
||||||
DataValue::UnsignedNumber(sender_id as u128),
|
.add_typed_default(
|
||||||
);
|
DataType::UserId,
|
||||||
|
DataValue::UnsignedNumber(sender_id as u128),
|
||||||
|
);
|
||||||
let user = connection
|
let user = connection
|
||||||
|
.mtp
|
||||||
.request(&user_request, None)
|
.request(&user_request, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
@ -652,24 +888,42 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> {
|
||||||
value.get_field(id)
|
value.get_field(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_to_frame(
|
fn wire_field_name(name: &str) -> &str {
|
||||||
type_name: &str,
|
match name {
|
||||||
data: Value,
|
"Content" => "AppContent",
|
||||||
id: Option<u32>,
|
"CreatedAt" => "AppCreatedAt",
|
||||||
) -> Result<CommunicationValue, String> {
|
"MessageId" => "AppMessageId",
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn application_field_name(name: &str) -> &str {
|
||||||
|
match name {
|
||||||
|
"AppContent" => "Content",
|
||||||
|
"AppCreatedAt" => "CreatedAt",
|
||||||
|
"AppMessageId" => "MessageId",
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
|
||||||
let comm_type = CommunicationType::from_name(type_name)
|
let comm_type = CommunicationType::from_name(type_name)
|
||||||
.ok_or_else(|| format!("unknown communication type: {type_name}"))?;
|
.ok_or_else(|| format!("unknown communication type: {type_name}"))?;
|
||||||
let mut frame = CommunicationValue::new(comm_type);
|
let mut frame = CommunicationValue::new(comm_type).with_id(id);
|
||||||
if let Some(id) = id {
|
|
||||||
frame = frame.with_id(id);
|
|
||||||
}
|
|
||||||
let Value::Object(fields) = data else {
|
let Value::Object(fields) = data else {
|
||||||
return Err("MTP request data must be an object".into());
|
return Err("MTP request data must be an object".into());
|
||||||
};
|
};
|
||||||
|
let mut translated_fields = std::collections::HashSet::<String>::with_capacity(fields.len());
|
||||||
for (name, value) in fields {
|
for (name, value) in fields {
|
||||||
|
let wire_name = wire_field_name(&name).to_owned();
|
||||||
|
if !translated_fields.insert(wire_name.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate MTP field after translation: {wire_name}"
|
||||||
|
));
|
||||||
|
}
|
||||||
let data_type =
|
let data_type =
|
||||||
DataType::from_name(&name).ok_or_else(|| format!("unknown data type: {name}"))?;
|
DataType::from_name(&wire_name).ok_or_else(|| format!("unknown data type: {name}"))?;
|
||||||
frame = frame.add_typed_default(data_type, json_to_data(&name, value)?);
|
frame = frame.add_typed_default(data_type, json_to_data(&wire_name, value)?);
|
||||||
}
|
}
|
||||||
Ok(frame)
|
Ok(frame)
|
||||||
}
|
}
|
||||||
|
|
@ -707,13 +961,21 @@ fn json_to_data(field: &str, value: Value) -> Result<DataValue, String> {
|
||||||
),
|
),
|
||||||
Value::Object(fields) => {
|
Value::Object(fields) => {
|
||||||
let mut entries = Vec::with_capacity(fields.len());
|
let mut entries = Vec::with_capacity(fields.len());
|
||||||
|
let mut translated_fields =
|
||||||
|
std::collections::HashSet::<String>::with_capacity(fields.len());
|
||||||
for (name, value) in fields {
|
for (name, value) in fields {
|
||||||
let data_type = DataType::from_name(&name)
|
let wire_name = wire_field_name(&name).to_owned();
|
||||||
|
if !translated_fields.insert(wire_name.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate MTP field after translation: {wire_name}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let data_type = DataType::from_name(&wire_name)
|
||||||
.ok_or_else(|| format!("unknown nested data type: {name}"))?;
|
.ok_or_else(|| format!("unknown nested data type: {name}"))?;
|
||||||
let id = data_type
|
let id = data_type
|
||||||
.try_to_id(&TypeMap::latest())
|
.try_to_id(&TypeMap::latest())
|
||||||
.ok_or_else(|| format!("unmapped data type: {name}"))?;
|
.ok_or_else(|| format!("unmapped data type: {name}"))?;
|
||||||
entries.push((id, json_to_data(&name, value)?));
|
entries.push((id, json_to_data(&wire_name, value)?));
|
||||||
}
|
}
|
||||||
DataValue::Container(entries)
|
DataValue::Container(entries)
|
||||||
}
|
}
|
||||||
|
|
@ -737,8 +999,8 @@ fn is_bytes_field(field: &str) -> bool {
|
||||||
|
|
||||||
fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> {
|
fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> {
|
||||||
let mut result = Map::new();
|
let mut result = Map::new();
|
||||||
if frame.get_id() != 0 {
|
if let Some(id) = frame.id() {
|
||||||
result.insert("id".into(), Value::from(frame.get_id()));
|
result.insert("id".into(), Value::from(id));
|
||||||
}
|
}
|
||||||
result.insert(
|
result.insert(
|
||||||
"type".into(),
|
"type".into(),
|
||||||
|
|
@ -751,11 +1013,20 @@ fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> {
|
||||||
fn frame_data_to_json(frame: &CommunicationValue) -> Result<Value, String> {
|
fn frame_data_to_json(frame: &CommunicationValue) -> Result<Value, String> {
|
||||||
let map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest);
|
let map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest);
|
||||||
let mut result = Map::new();
|
let mut result = Map::new();
|
||||||
for (id, value) in frame.data() {
|
let entries = frame
|
||||||
|
.data()
|
||||||
|
.ok_or("MTP frame payload is not a data container")?;
|
||||||
|
for (id, value) in entries {
|
||||||
let name = map
|
let name = map
|
||||||
.data_type_name(id.0)
|
.data_type_name(id.0)
|
||||||
.ok_or_else(|| format!("unknown data type id: {}", id.0))?;
|
.ok_or_else(|| format!("unknown data type id: {}", id.0))?;
|
||||||
result.insert(name.to_owned(), data_to_json(value, &map)?);
|
let application_name = application_field_name(name);
|
||||||
|
if result.contains_key(application_name) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate MTP field after translation: {application_name}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
result.insert(application_name.to_owned(), data_to_json(value, &map)?);
|
||||||
}
|
}
|
||||||
Ok(Value::Object(result))
|
Ok(Value::Object(result))
|
||||||
}
|
}
|
||||||
|
|
@ -786,7 +1057,13 @@ fn data_to_json(value: &DataValue, map: &TypeMap) -> Result<Value, String> {
|
||||||
let name = map
|
let name = map
|
||||||
.data_type_name(id.0)
|
.data_type_name(id.0)
|
||||||
.ok_or_else(|| format!("unknown nested data type id: {}", id.0))?;
|
.ok_or_else(|| format!("unknown nested data type id: {}", id.0))?;
|
||||||
object.insert(name.to_owned(), data_to_json(value, map)?);
|
let application_name = application_field_name(name);
|
||||||
|
if object.contains_key(application_name) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate MTP field after translation: {application_name}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
object.insert(application_name.to_owned(), data_to_json(value, map)?);
|
||||||
}
|
}
|
||||||
Value::Object(object)
|
Value::Object(object)
|
||||||
}
|
}
|
||||||
|
|
@ -801,16 +1078,15 @@ fn number_to_json(value: i128) -> Result<Value, String> {
|
||||||
.map_err(|_| "number exceeds JSON range".into())
|
.map_err(|_| "number exceeds JSON range".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_millis() -> u64 {
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis() as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{decode_browser_base64, decode_sdk_bytes};
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
container_value, decode_browser_base64, decode_sdk_bytes, frame_to_json,
|
||||||
|
jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_base64_accepts_file_whitespace_and_missing_padding() {
|
fn browser_base64_accepts_file_whitespace_and_missing_padding() {
|
||||||
|
|
@ -823,11 +1099,148 @@ mod tests {
|
||||||
assert_eq!(decode_sdk_bytes("0x01:02-ff").unwrap(), [1, 2, 255]);
|
assert_eq!(decode_sdk_bytes("0x01:02-ff").unwrap(), [1, 2, 255]);
|
||||||
assert_eq!(decode_sdk_bytes("AQI=").unwrap(), [1, 2]);
|
assert_eq!(decode_sdk_bytes("AQI=").unwrap(), [1, 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_ids_are_nonzero_and_monotonic() {
|
||||||
|
let ids = RequestIdAllocator::new();
|
||||||
|
|
||||||
|
assert_eq!(ids.next().unwrap(), 1);
|
||||||
|
assert_eq!(ids.next().unwrap(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_jitter_stays_within_policy_bounds() {
|
||||||
|
let delay = jittered_retry_delay(std::time::Duration::from_secs(10));
|
||||||
|
|
||||||
|
assert!(delay >= std::time::Duration::from_secs(8));
|
||||||
|
assert!(delay <= std::time::Duration::from_secs(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_content_uses_app_content_wire_type() {
|
||||||
|
let frame = json_to_frame(
|
||||||
|
"MessageEdit",
|
||||||
|
json!({
|
||||||
|
"Content": "ciphertext",
|
||||||
|
"ChatPartnerId": 42,
|
||||||
|
"SendTime": 10,
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
frame
|
||||||
|
.get_data(DataType::AppContent)
|
||||||
|
.and_then(DataValue::as_str),
|
||||||
|
Some("ciphertext")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_json_content_uses_app_content_wire_type() {
|
||||||
|
let frame = json_to_frame(
|
||||||
|
"MessageEdit",
|
||||||
|
json!({
|
||||||
|
"Message": { "Content": "ciphertext" },
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let message = frame.get_data(DataType::Message).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
container_value(message, DataType::AppContent).and_then(DataValue::as_str),
|
||||||
|
Some("ciphertext")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_content_is_exposed_as_content_to_frontend() {
|
||||||
|
let frame = CommunicationValue::new(CommunicationType::MessageEditLive)
|
||||||
|
.with_id(1)
|
||||||
|
.add_typed_default(DataType::AppContent, DataValue::Str("ciphertext".into()));
|
||||||
|
|
||||||
|
let json = frame_to_json(&frame).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["data"]["Content"], "ciphertext");
|
||||||
|
assert!(json["data"].get("AppContent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translated_field_collisions_are_rejected() {
|
||||||
|
assert!(json_to_frame(
|
||||||
|
"MessageEdit",
|
||||||
|
json!({ "Content": "a", "AppContent": "b" }),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_initial_state() -> CommunicationValue {
|
||||||
|
CommunicationValue::new(CommunicationType::ClientStateSync)
|
||||||
|
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
|
||||||
|
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
|
||||||
|
.add_typed_default(DataType::SyncMode, DataValue::Str("full".into()))
|
||||||
|
.add_typed_default(DataType::Contacts, DataValue::Array(vec![]))
|
||||||
|
.add_typed_default(DataType::Communities, DataValue::Array(vec![]))
|
||||||
|
.add_typed_default(DataType::Calls, DataValue::Array(vec![]))
|
||||||
|
.add_typed_default(DataType::Messages, DataValue::Array(vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_initial_state_is_prepared_before_ack() {
|
||||||
|
let ids = RequestIdAllocator::new();
|
||||||
|
let (state, ack) = prepare_initial_state_ack(&valid_initial_state(), &ids).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state["SyncMode"], "full");
|
||||||
|
assert!(ack.is_type(CommunicationType::ClientStateAck));
|
||||||
|
assert_eq!(ack.id(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_initial_state_does_not_prepare_ack() {
|
||||||
|
let ids = RequestIdAllocator::new();
|
||||||
|
let malformed = valid_initial_state()
|
||||||
|
.add_typed_default(DataType::SyncMode, DataValue::Str("invalid".into()));
|
||||||
|
|
||||||
|
assert!(prepare_initial_state_ack(&malformed, &ids).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
ids.next().unwrap(),
|
||||||
|
1,
|
||||||
|
"no acknowledgement ID was allocated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_nested_initial_state_does_not_prepare_ack() {
|
||||||
|
let ids = RequestIdAllocator::new();
|
||||||
|
let malformed = CommunicationValue::new(CommunicationType::ClientStateSync)
|
||||||
|
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
|
||||||
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
|
||||||
|
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
|
||||||
|
.add_typed_default(DataType::SyncMode, DataValue::Str("full".into()))
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::Contacts,
|
||||||
|
DataValue::Array(vec![DataValue::Container(vec![])]),
|
||||||
|
)
|
||||||
|
.add_typed_default(DataType::Communities, DataValue::Array(vec![]))
|
||||||
|
.add_typed_default(DataType::Calls, DataValue::Array(vec![]))
|
||||||
|
.add_typed_default(DataType::Messages, DataValue::Array(vec![]));
|
||||||
|
|
||||||
|
assert!(prepare_initial_state_ack(&malformed, &ids).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
ids.next().unwrap(),
|
||||||
|
1,
|
||||||
|
"no acknowledgement ID was allocated"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn mtp_request(type_name: String, data: Value, id: Option<u32>) -> Result<Value, String> {
|
pub async fn mtp_request(type_name: String, data: Value) -> Result<Value, String> {
|
||||||
manager().request(&type_name, data, id).await
|
manager().request(&type_name, data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.28.1",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"globals": "^17.9.0",
|
"globals": "^17.9.0",
|
||||||
|
"mtp": "*",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.66.0",
|
"typescript-eslint": "^8.66.0",
|
||||||
"vite": "^8.2.1"
|
"vite": "^8.2.1"
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit a297dcce60bc6e84696c6a16f5fd510beb2ca643
|
Subproject commit a70085b0c41844745449c9913a361f5bb9bee529
|
||||||
23
package.json
23
package.json
|
|
@ -25,26 +25,27 @@
|
||||||
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
||||||
"dev:desktop": "cd apps/electron && pnpm run dev",
|
"dev:desktop": "cd apps/electron && pnpm run dev",
|
||||||
"build:desktop": "cd apps/electron && pnpm run package",
|
"build:desktop": "cd apps/electron && pnpm run package",
|
||||||
"delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts"
|
"delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts",
|
||||||
|
"update-submodules": "git submodule update --remote --force --recursive"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.2.0",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@typescript-eslint/parser": "^8.66.0",
|
"@typescript-eslint/parser": "^8.67.0",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"fallow": "^3.14.0",
|
"fallow": "^3.17.0",
|
||||||
"globals": "^17.9.0",
|
"globals": "^17.11.0",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.66.0",
|
"typescript-eslint": "^8.67.0",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@methanium/ui": "*",
|
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz",
|
||||||
"mtp": "*",
|
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz",
|
||||||
"sonner": "^2.0.7"
|
"sonner": "^2.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"deepfilternet3-noise-filter": "1.3.0",
|
"deepfilternet3-noise-filter": "1.3.0",
|
||||||
"livekit-client": "^2.21.0",
|
"livekit-client": "^2.21.0",
|
||||||
"lucide-react": "^1.29.0",
|
"lucide-react": "^1.29.0",
|
||||||
|
"mtp": "*",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ export function getMediaShareAdapter(): MediaShareAdapter {
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
MediaShareAdapter,
|
MediaShareAdapter,
|
||||||
MediaShareCapabilities,
|
|
||||||
MediaShareKind,
|
MediaShareKind,
|
||||||
MediaShareRequest,
|
MediaShareRequest,
|
||||||
MediaShareSession,
|
MediaShareSession,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"mtp": "*",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,13 @@ export function deriveCallSecretId(callId: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
||||||
return crypto.keyringToKeys(keyring).kemPublicKey;
|
return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
|
||||||
|
.kemPublicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
||||||
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey;
|
return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
|
||||||
|
.kemPublicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wrapCallSecret(args: {
|
export async function wrapCallSecret(args: {
|
||||||
|
|
@ -72,7 +74,10 @@ export async function unwrapCallSecret(args: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownKeys = crypto.keyringToKeys(args.keyring);
|
const ownKeys = crypto.keyringToKeys({
|
||||||
|
value: args.keyring,
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
const sharedSecret = crypto.decapsulate(
|
const sharedSecret = crypto.decapsulate(
|
||||||
ownKeys.kemSecretKey,
|
ownKeys.kemSecretKey,
|
||||||
args.kemCiphertext,
|
args.kemCiphertext,
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,13 @@ export function randomChatSecret(): Uint8Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
||||||
return crypto.keyringToKeys(keyring).kemPublicKey;
|
return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
|
||||||
|
.kemPublicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
||||||
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey;
|
return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
|
||||||
|
.kemPublicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wrapChatSecret(args: {
|
export async function wrapChatSecret(args: {
|
||||||
|
|
@ -79,7 +81,10 @@ export async function unwrapChatSecret(args: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownKeys = crypto.keyringToKeys(args.keyring);
|
const ownKeys = crypto.keyringToKeys({
|
||||||
|
value: args.keyring,
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
const sharedSecret = crypto.decapsulate(
|
const sharedSecret = crypto.decapsulate(
|
||||||
ownKeys.kemSecretKey,
|
ownKeys.kemSecretKey,
|
||||||
args.kemCiphertext,
|
args.kemCiphertext,
|
||||||
|
|
|
||||||
30
packages/markdown/package.json
Normal file
30
packages/markdown/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "@tensamin/markdown",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./text": "./src/text.tsx",
|
||||||
|
"./input": "./src/input.tsx",
|
||||||
|
"./emoji": "./src/emoji.tsx"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"format": "pnpm exec prettier --write .",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
|
"@codemirror/commands": "^6.10.4",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.2",
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/state": "^6.7.1",
|
||||||
|
"@codemirror/view": "^6.43.8",
|
||||||
|
"@methanium/ui": "*",
|
||||||
|
"@twemoji/api": "^17.0.3",
|
||||||
|
"emojibase-data": "^17.0.0",
|
||||||
|
"lucide-react": "^1.30.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
packages/markdown/src/emoji.tsx
Normal file
50
packages/markdown/src/emoji.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import twemoji from "@twemoji/api";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
|
||||||
|
import { resolveEmoji } from "./emojiData";
|
||||||
|
|
||||||
|
export {
|
||||||
|
emojis,
|
||||||
|
findEmojiShortcodes,
|
||||||
|
normalizeShortcode,
|
||||||
|
resolveEmoji,
|
||||||
|
searchEmojis,
|
||||||
|
} from "./emojiData";
|
||||||
|
export type { EmojiDefinition } from "./emojiData";
|
||||||
|
|
||||||
|
export function getEmojiUrl(shortcode: string): string | undefined {
|
||||||
|
const emoji = resolveEmoji(shortcode);
|
||||||
|
return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Emoji({
|
||||||
|
className = "h-6 w-6",
|
||||||
|
shortcode,
|
||||||
|
tooltip = true,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
shortcode: string;
|
||||||
|
tooltip?: boolean;
|
||||||
|
}) {
|
||||||
|
const emoji = resolveEmoji(shortcode);
|
||||||
|
if (!emoji) return <span>{shortcode}</span>;
|
||||||
|
|
||||||
|
const image = (
|
||||||
|
<img
|
||||||
|
alt={emoji.shortcode}
|
||||||
|
className={className}
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
src={`${twemoji.base}svg/${emoji.hexcode}.svg`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tooltip) return image;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={image} />
|
||||||
|
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
packages/markdown/src/emojiData.ts
Normal file
94
packages/markdown/src/emojiData.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
|
||||||
|
|
||||||
|
export type EmojiDefinition = {
|
||||||
|
aliases: readonly string[];
|
||||||
|
hexcode: string;
|
||||||
|
name: string;
|
||||||
|
shortcode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeName(value: string) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/^:+|:+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emojis: readonly EmojiDefinition[] = Object.entries(
|
||||||
|
shortcodeData as Record<string, string | string[]>,
|
||||||
|
).map(([hexcode, value]) => {
|
||||||
|
const aliases = Array.isArray(value) ? value : [value];
|
||||||
|
const name = aliases[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
aliases,
|
||||||
|
hexcode: hexcode.toLowerCase().replaceAll("_", "-"),
|
||||||
|
name,
|
||||||
|
shortcode: `:${name}:`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const emojiByName = new Map<string, EmojiDefinition>();
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
for (const alias of emoji.aliases) {
|
||||||
|
emojiByName.set(normalizeName(alias), emoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEmoji(value: string): EmojiDefinition | undefined {
|
||||||
|
return emojiByName.get(normalizeName(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeShortcode(value: string): string | undefined {
|
||||||
|
return resolveEmoji(value)?.shortcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmojiShortcodes(value: string) {
|
||||||
|
const matches: Array<{
|
||||||
|
emoji: EmojiDefinition;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}> = [];
|
||||||
|
let searchFrom = 0;
|
||||||
|
|
||||||
|
while (searchFrom < value.length) {
|
||||||
|
const from = value.indexOf(":", searchFrom);
|
||||||
|
if (from === -1) break;
|
||||||
|
|
||||||
|
const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i);
|
||||||
|
if (!candidate) {
|
||||||
|
searchFrom = from + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = resolveEmoji(candidate[1]);
|
||||||
|
if (!emoji) {
|
||||||
|
// The closing colon may also open the next valid shortcode.
|
||||||
|
searchFrom = from + candidate[0].length - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = from + candidate[0].length;
|
||||||
|
matches.push({ emoji, from, to });
|
||||||
|
searchFrom = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchEmojis(query: string): EmojiDefinition[] {
|
||||||
|
const normalizedQuery = normalizeName(query);
|
||||||
|
if (!normalizedQuery) return [...emojis];
|
||||||
|
|
||||||
|
return emojis
|
||||||
|
.map((emoji) => {
|
||||||
|
const names = emoji.aliases.map(normalizeName);
|
||||||
|
const exact = names.includes(normalizedQuery);
|
||||||
|
const prefix = names.some((name) => name.startsWith(normalizedQuery));
|
||||||
|
const contains = names.some((name) => name.includes(normalizedQuery));
|
||||||
|
return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 };
|
||||||
|
})
|
||||||
|
.filter(({ rank }) => rank < 3)
|
||||||
|
.sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name))
|
||||||
|
.map(({ emoji }) => emoji);
|
||||||
|
}
|
||||||
850
packages/markdown/src/input.tsx
Normal file
850
packages/markdown/src/input.tsx
Normal file
|
|
@ -0,0 +1,850 @@
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import {
|
||||||
|
acceptCompletion,
|
||||||
|
autocompletion,
|
||||||
|
completionStatus,
|
||||||
|
pickedCompletion,
|
||||||
|
startCompletion,
|
||||||
|
type Completion,
|
||||||
|
type CompletionContext,
|
||||||
|
type CompletionResult,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import {
|
||||||
|
EditorState,
|
||||||
|
EditorSelection,
|
||||||
|
Annotation,
|
||||||
|
Compartment,
|
||||||
|
Prec,
|
||||||
|
Transaction,
|
||||||
|
type Extension,
|
||||||
|
type Range,
|
||||||
|
type SelectionRange,
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
keymap,
|
||||||
|
placeholder,
|
||||||
|
ViewPlugin,
|
||||||
|
WidgetType,
|
||||||
|
type DecorationSet,
|
||||||
|
type KeyBinding,
|
||||||
|
type ViewUpdate,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import {
|
||||||
|
defaultKeymap,
|
||||||
|
history,
|
||||||
|
historyKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
} from "@codemirror/commands";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
|
||||||
|
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
||||||
|
import Emoji, {
|
||||||
|
findEmojiShortcodes,
|
||||||
|
getEmojiUrl,
|
||||||
|
resolveEmoji,
|
||||||
|
searchEmojis,
|
||||||
|
} from "./emoji";
|
||||||
|
|
||||||
|
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
||||||
|
|
||||||
|
export type InputController = {
|
||||||
|
focus: () => void;
|
||||||
|
hasFocus: () => boolean;
|
||||||
|
insertText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InputProps = {
|
||||||
|
ref?: HTMLDivElement;
|
||||||
|
placeholder?: string;
|
||||||
|
value: string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
invertEnterBehavior?: boolean;
|
||||||
|
styled?: boolean;
|
||||||
|
fontSize?: CSSProperties["fontSize"];
|
||||||
|
paddingX?: CSSProperties["padding"];
|
||||||
|
paddingY?: CSSProperties["padding"];
|
||||||
|
className?: string;
|
||||||
|
emojiFrequencies?: Readonly<Record<string, number>>;
|
||||||
|
onEmojiSelect?: (shortcode: string) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
onControllerChange?: (controller: InputController | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toCssLength(value: CSSProperties["padding"]): string | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "number" ? `${value}px` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCssPadding(
|
||||||
|
vertical: CSSProperties["padding"],
|
||||||
|
horizontal: CSSProperties["padding"],
|
||||||
|
styled: boolean,
|
||||||
|
): string {
|
||||||
|
const defaultVertical = styled ? "0.25rem" : "0";
|
||||||
|
const defaultHorizontal = styled ? "0.625rem" : "0";
|
||||||
|
|
||||||
|
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" });
|
||||||
|
const strongDecoration = Decoration.mark({ class: "tm-md-strong" });
|
||||||
|
const emDecoration = Decoration.mark({ class: "tm-md-em" });
|
||||||
|
const delDecoration = Decoration.mark({ class: "tm-md-del" });
|
||||||
|
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
|
||||||
|
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
|
||||||
|
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
|
||||||
|
const externalValueSync = Annotation.define<boolean>();
|
||||||
|
const widgetRoots = new WeakMap<HTMLElement, Root>();
|
||||||
|
|
||||||
|
type EmojiRange = {
|
||||||
|
from: number;
|
||||||
|
shortcode: string;
|
||||||
|
to: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EmojiWidget extends WidgetType {
|
||||||
|
readonly shortcode: string;
|
||||||
|
readonly url: string;
|
||||||
|
|
||||||
|
constructor(shortcode: string, url: string) {
|
||||||
|
super();
|
||||||
|
this.shortcode = shortcode;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: EmojiWidget) {
|
||||||
|
return other.shortcode === this.shortcode && other.url === this.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
const container = document.createElement("span");
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<Emoji className="tm-md-editor-emoji" shortcode={this.shortcode} />,
|
||||||
|
);
|
||||||
|
widgetRoots.set(container, root);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(dom: HTMLElement) {
|
||||||
|
widgetRoots.get(dom)?.unmount();
|
||||||
|
widgetRoots.delete(dom);
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeRanges(state: EditorState) {
|
||||||
|
const ranges: Array<{ from: number; to: number }> = [];
|
||||||
|
|
||||||
|
syntaxTree(state).iterate({
|
||||||
|
enter(node) {
|
||||||
|
if (
|
||||||
|
node.name === "InlineCode" ||
|
||||||
|
node.name === "FencedCode" ||
|
||||||
|
node.name === "CodeBlock"
|
||||||
|
) {
|
||||||
|
ranges.push({ from: node.from, to: node.to });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmojiRanges(state: EditorState): EmojiRange[] {
|
||||||
|
const document = state.doc.toString();
|
||||||
|
const excluded = codeRanges(state);
|
||||||
|
const ranges: EmojiRange[] = [];
|
||||||
|
|
||||||
|
for (const match of findEmojiShortcodes(document)) {
|
||||||
|
const { from, to } = match;
|
||||||
|
const inCode = excluded.some((range) => from < range.to && to > range.from);
|
||||||
|
const emoji = inCode ? undefined : match.emoji;
|
||||||
|
const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined;
|
||||||
|
|
||||||
|
if (emoji && url) {
|
||||||
|
ranges.push({ from, shortcode: emoji.shortcode, to, url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmojiPluginValue {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
ranges: EmojiRange[];
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.ranges = findEmojiRanges(view.state);
|
||||||
|
this.decorations = this.buildDecorations();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||||
|
) {
|
||||||
|
this.ranges = findEmojiRanges(update.state);
|
||||||
|
this.decorations = this.buildDecorations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDecorations() {
|
||||||
|
return Decoration.set(
|
||||||
|
this.ranges.map((range) =>
|
||||||
|
Decoration.replace({
|
||||||
|
inclusive: false,
|
||||||
|
widget: new EmojiWidget(range.shortcode, range.url),
|
||||||
|
}).range(range.from, range.to),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, {
|
||||||
|
decorations: (instance) => instance.decorations,
|
||||||
|
provide: (plugin) =>
|
||||||
|
EditorView.atomicRanges.of(
|
||||||
|
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteEmoji(view: EditorView, direction: "backward" | "forward") {
|
||||||
|
const ranges = view.plugin(emojiDecorations)?.ranges ?? [];
|
||||||
|
const deletions: Array<{ from: number; to: number }> = [];
|
||||||
|
|
||||||
|
for (const selection of view.state.selection.ranges) {
|
||||||
|
if (selection.empty) {
|
||||||
|
const emoji = ranges.find((range) =>
|
||||||
|
direction === "backward"
|
||||||
|
? selection.from > range.from && selection.from <= range.to
|
||||||
|
: selection.from >= range.from && selection.from < range.to,
|
||||||
|
);
|
||||||
|
if (emoji) deletions.push({ from: emoji.from, to: emoji.to });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = selection.from;
|
||||||
|
let to = selection.to;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const emoji of ranges) {
|
||||||
|
if (from < emoji.to && to > emoji.from) {
|
||||||
|
from = Math.min(from, emoji.from);
|
||||||
|
to = Math.max(to, emoji.to);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) deletions.push({ from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletions.length === 0) return false;
|
||||||
|
|
||||||
|
const merged = deletions
|
||||||
|
.sort((a, b) => a.from - b.from)
|
||||||
|
.reduce<Array<{ from: number; to: number }>>((result, deletion) => {
|
||||||
|
const previous = result.at(-1);
|
||||||
|
if (previous && deletion.from <= previous.to) {
|
||||||
|
previous.to = Math.max(previous.to, deletion.to);
|
||||||
|
} else {
|
||||||
|
result.push({ ...deletion });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: merged.map((range) => ({ from: range.from, to: range.to })),
|
||||||
|
selection: EditorSelection.cursor(merged[0].from),
|
||||||
|
scrollIntoView: true,
|
||||||
|
userEvent: direction === "backward" ? "delete.backward" : "delete.forward",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds markdown styling decorations every time the document or cursor selection changes.
|
||||||
|
* Token delimiters are hidden unless the cursor is currently intersecting that token range.
|
||||||
|
*/
|
||||||
|
const markdownDecorations = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||||
|
this.decorations = buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (instance: { decorations: DecorationSet }) =>
|
||||||
|
instance.decorations,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes Input.
|
||||||
|
* @param props Parameter props.
|
||||||
|
* @returns unknown.
|
||||||
|
*/
|
||||||
|
export default function Input(props: InputProps) {
|
||||||
|
ensureMarkdownStyles();
|
||||||
|
|
||||||
|
const shellClassName = props.styled
|
||||||
|
? "min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||||
|
const setValueRef = useRef(props.setValue);
|
||||||
|
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
||||||
|
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
|
||||||
|
props.onEmojiSelect,
|
||||||
|
);
|
||||||
|
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
|
||||||
|
const completionCompartmentRef = useRef<Compartment | null>(null);
|
||||||
|
completionCompartmentRef.current ??= new Compartment();
|
||||||
|
const completionCompartment = completionCompartmentRef.current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValueRef.current = props.setValue;
|
||||||
|
onSubmitRef.current = props.onSubmit;
|
||||||
|
onEmojiSelectRef.current = props.onEmojiSelect;
|
||||||
|
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
||||||
|
}, [
|
||||||
|
props.onEmojiSelect,
|
||||||
|
props.onSubmit,
|
||||||
|
props.invertEnterBehavior,
|
||||||
|
props.setValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!elementRef.current) return;
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: props.value,
|
||||||
|
extensions: createEditorExtensions(
|
||||||
|
(value) => {
|
||||||
|
setValueRef.current(value);
|
||||||
|
},
|
||||||
|
() => props.placeholder,
|
||||||
|
() => invertEnterBehaviorRef.current,
|
||||||
|
() => onSubmitRef.current?.(),
|
||||||
|
completionCompartment,
|
||||||
|
props.emojiFrequencies,
|
||||||
|
(shortcode) => onEmojiSelectRef.current?.(shortcode),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
viewRef.current = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: elementRef.current,
|
||||||
|
});
|
||||||
|
props.onControllerChange?.({
|
||||||
|
focus: () => viewRef.current?.contentDOM.focus({ preventScroll: true }),
|
||||||
|
hasFocus: () => viewRef.current?.hasFocus ?? false,
|
||||||
|
insertText: (text) => {
|
||||||
|
const editor = viewRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
editor.dispatch({
|
||||||
|
...editor.state.replaceSelection(text),
|
||||||
|
annotations: Transaction.userEvent.of("input.type"),
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (props.autoFocus) viewRef.current.focus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
props.onControllerChange?.(null);
|
||||||
|
viewRef.current?.destroy();
|
||||||
|
viewRef.current = undefined;
|
||||||
|
};
|
||||||
|
// Run once to initialize/destroy the editor instance.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = viewRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const next = props.value;
|
||||||
|
const current = editor.state.doc.toString();
|
||||||
|
|
||||||
|
if (next === current) return;
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: current.length,
|
||||||
|
insert: next,
|
||||||
|
},
|
||||||
|
annotations: [
|
||||||
|
externalValueSync.of(true),
|
||||||
|
Transaction.addToHistory.of(false),
|
||||||
|
],
|
||||||
|
filter: false,
|
||||||
|
});
|
||||||
|
}, [props.value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = viewRef.current;
|
||||||
|
const compartment = completionCompartmentRef.current;
|
||||||
|
if (!editor || !compartment) return;
|
||||||
|
|
||||||
|
const wasActive = completionStatus(editor.state) === "active";
|
||||||
|
editor.dispatch({
|
||||||
|
effects: compartment.reconfigure(
|
||||||
|
createEmojiAutocomplete(props.emojiFrequencies, (shortcode) =>
|
||||||
|
onEmojiSelectRef.current?.(shortcode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (wasActive) startCompletion(editor);
|
||||||
|
}, [props.emojiFrequencies]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={elementRef}
|
||||||
|
className={`tm-md-root ${shellClassName} ${props.className ?? ""}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
fontSize: props.fontSize ?? "1rem",
|
||||||
|
"--tm-md-content-padding": toCssPadding(
|
||||||
|
props.paddingY,
|
||||||
|
props.paddingX,
|
||||||
|
Boolean(props.styled),
|
||||||
|
),
|
||||||
|
} as CSSProperties & {
|
||||||
|
"--tm-md-content-padding"?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes createEditorExtensions.
|
||||||
|
* @param onChange Parameter onChange.
|
||||||
|
* @param getPlaceholder Parameter getPlaceholder.
|
||||||
|
* @param getInvertEnterBehavior Parameter getInvertEnterBehavior.
|
||||||
|
* @param onSubmit Parameter onSubmit.
|
||||||
|
* @returns Extension[].
|
||||||
|
*/
|
||||||
|
function createEditorExtensions(
|
||||||
|
onChange: (value: string) => void,
|
||||||
|
getPlaceholder: () => string | undefined,
|
||||||
|
getInvertEnterBehavior: () => boolean,
|
||||||
|
onSubmit: () => void,
|
||||||
|
completionCompartment: Compartment,
|
||||||
|
emojiFrequencies: Readonly<Record<string, number>> | undefined,
|
||||||
|
onEmojiSelect: (shortcode: string) => void,
|
||||||
|
): Extension[] {
|
||||||
|
const editorKeymap = [
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
] as unknown as readonly KeyBinding[];
|
||||||
|
|
||||||
|
const customEnterKeymap = keymap.of([
|
||||||
|
{
|
||||||
|
key: "Shift-Enter",
|
||||||
|
run: (view) => {
|
||||||
|
if (completionStatus(view.state) === "active") {
|
||||||
|
return acceptCompletion(view);
|
||||||
|
}
|
||||||
|
if (!getInvertEnterBehavior()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Enter",
|
||||||
|
run: (view) => {
|
||||||
|
if (completionStatus(view.state) === "active") {
|
||||||
|
return acceptCompletion(view);
|
||||||
|
}
|
||||||
|
if (getInvertEnterBehavior()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const completionTabKeymap = keymap.of([
|
||||||
|
{
|
||||||
|
key: "Tab",
|
||||||
|
run: (view) =>
|
||||||
|
completionStatus(view.state) === "active"
|
||||||
|
? acceptCompletion(view)
|
||||||
|
: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const emojiDeletionKeymap = keymap.of([
|
||||||
|
{
|
||||||
|
key: "Backspace",
|
||||||
|
run: (view) => deleteEmoji(view, "backward"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Delete",
|
||||||
|
run: (view) => deleteEmoji(view, "forward"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
history(),
|
||||||
|
markdown(),
|
||||||
|
completionCompartment.of(
|
||||||
|
createEmojiAutocomplete(emojiFrequencies, onEmojiSelect),
|
||||||
|
),
|
||||||
|
emojiDecorations,
|
||||||
|
keymap.of(editorKeymap),
|
||||||
|
Prec.highest(completionTabKeymap),
|
||||||
|
Prec.highest(emojiDeletionKeymap),
|
||||||
|
Prec.highest(customEnterKeymap),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
placeholder(getPlaceholder() ?? ""),
|
||||||
|
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||||
|
if (!update.docChanged) return;
|
||||||
|
if (
|
||||||
|
update.transactions.some(
|
||||||
|
(transaction) => transaction.annotation(externalValueSync) === true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(update.state.doc.toString());
|
||||||
|
}),
|
||||||
|
EditorView.theme({
|
||||||
|
"&": {
|
||||||
|
fontSize: "inherit",
|
||||||
|
},
|
||||||
|
"&.cm-editor": {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
EditorView.editorAttributes.of({
|
||||||
|
class: "tm-md-editor",
|
||||||
|
spellcheck: "true",
|
||||||
|
"aria-label": "Markdown input",
|
||||||
|
}),
|
||||||
|
markdownDecorations,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmojiAutocomplete(
|
||||||
|
frequencies: Readonly<Record<string, number>> | undefined,
|
||||||
|
onEmojiSelect: (shortcode: string) => void,
|
||||||
|
) {
|
||||||
|
return autocompletion({
|
||||||
|
activateOnTyping: true,
|
||||||
|
addToOptions: [
|
||||||
|
{
|
||||||
|
position: 20,
|
||||||
|
render(completion) {
|
||||||
|
const container = document.createElement("span");
|
||||||
|
createRoot(container).render(
|
||||||
|
<Emoji
|
||||||
|
className="tm-md-completion-emoji"
|
||||||
|
shortcode={completion.label}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS,
|
||||||
|
override: [createEmojiCompletionSource(frequencies, onEmojiSelect)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFrequencies(
|
||||||
|
frequencies: Readonly<Record<string, number>> | undefined,
|
||||||
|
) {
|
||||||
|
const normalized = new Map<string, number>();
|
||||||
|
for (const [value, frequency] of Object.entries(frequencies ?? {})) {
|
||||||
|
const shortcode = resolveEmoji(value)?.shortcode;
|
||||||
|
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue;
|
||||||
|
normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmojiCompletionSource(
|
||||||
|
frequencies?: Readonly<Record<string, number>>,
|
||||||
|
onEmojiSelect: (shortcode: string) => void = () => undefined,
|
||||||
|
) {
|
||||||
|
const normalized = normalizedFrequencies(frequencies);
|
||||||
|
const maxFrequency = Math.max(0, ...normalized.values());
|
||||||
|
|
||||||
|
return (context: CompletionContext): CompletionResult | null => {
|
||||||
|
const token = context.matchBefore(/:[a-z0-9_+-]*$/i);
|
||||||
|
if (!token) return null;
|
||||||
|
if (
|
||||||
|
codeRanges(context.state).some(
|
||||||
|
(range) => token.from < range.to && token.to > range.from,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterBefore = context.state.sliceDoc(
|
||||||
|
Math.max(0, token.from - 1),
|
||||||
|
token.from,
|
||||||
|
);
|
||||||
|
if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null;
|
||||||
|
|
||||||
|
const query = token.text.slice(1).toLowerCase();
|
||||||
|
const options: Completion[] = searchEmojis(query)
|
||||||
|
.map((emoji) => {
|
||||||
|
const aliases = emoji.aliases.map((alias) => alias.toLowerCase());
|
||||||
|
const matchedAlias =
|
||||||
|
aliases.find((alias) => alias === query) ??
|
||||||
|
aliases.find((alias) => alias.startsWith(query)) ??
|
||||||
|
aliases.find((alias) => alias.includes(query)) ??
|
||||||
|
emoji.name;
|
||||||
|
const relevance = !query
|
||||||
|
? 0
|
||||||
|
: matchedAlias === query
|
||||||
|
? 80
|
||||||
|
: matchedAlias.startsWith(query)
|
||||||
|
? 40
|
||||||
|
: 0;
|
||||||
|
const frequency = normalized.get(emoji.shortcode) ?? 0;
|
||||||
|
const usage =
|
||||||
|
maxFrequency > 0
|
||||||
|
? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
apply(view, completion, from, to) {
|
||||||
|
view.dispatch({
|
||||||
|
annotations: pickedCompletion.of(completion),
|
||||||
|
changes: { from, insert: `${emoji.shortcode} `, to },
|
||||||
|
selection: EditorSelection.cursor(
|
||||||
|
from + emoji.shortcode.length + 1,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
onEmojiSelect(emoji.shortcode);
|
||||||
|
},
|
||||||
|
boost: relevance + usage,
|
||||||
|
displayLabel: emoji.shortcode,
|
||||||
|
label: `:${matchedAlias}:`,
|
||||||
|
type: "text",
|
||||||
|
frequency,
|
||||||
|
relevance,
|
||||||
|
} satisfies Completion & { frequency: number; relevance: number };
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.relevance - a.relevance ||
|
||||||
|
b.frequency - a.frequency ||
|
||||||
|
(a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: token.from,
|
||||||
|
options,
|
||||||
|
validFor: /^:[a-z0-9_+-]*$/i,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emojiCompletionSource = createEmojiCompletionSource();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes buildDecorations.
|
||||||
|
* @param view Parameter view.
|
||||||
|
* @returns DecorationSet.
|
||||||
|
*/
|
||||||
|
function buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
const builder: Range<Decoration>[] = [];
|
||||||
|
const selections = view.state.selection.ranges.map(
|
||||||
|
(range: SelectionRange) => ({
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let codeFenceOpen = false;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let lineNumber = 1;
|
||||||
|
lineNumber <= view.state.doc.lines;
|
||||||
|
lineNumber += 1
|
||||||
|
) {
|
||||||
|
const line = view.state.doc.line(lineNumber);
|
||||||
|
const text = line.text;
|
||||||
|
const lineFrom = line.from;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
const fence = text.match(/^```\s*([^`]*)$/);
|
||||||
|
if (fence) {
|
||||||
|
const ticksStart = lineFrom + text.indexOf("```");
|
||||||
|
const ticksEnd = ticksStart + 3;
|
||||||
|
addHiddenToken(builder, selections, { from: ticksStart, to: ticksEnd });
|
||||||
|
if (trimmed.length > 3) {
|
||||||
|
addHiddenToken(builder, selections, {
|
||||||
|
from: ticksEnd,
|
||||||
|
to: line.to,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
codeFenceOpen = !codeFenceOpen;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codeFenceOpen) {
|
||||||
|
builder.push(codeLineDecoration.range(lineFrom));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = text.match(/^(#{1,6})\s+/);
|
||||||
|
if (heading) {
|
||||||
|
const markerLength = heading[0].length;
|
||||||
|
addHiddenToken(builder, selections, {
|
||||||
|
from: lineFrom,
|
||||||
|
to: lineFrom + markerLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
const level = heading[1].length;
|
||||||
|
const headingClass = Decoration.mark({
|
||||||
|
class: `tm-md-heading tm-md-h${String(level)}`,
|
||||||
|
});
|
||||||
|
const contentFrom = lineFrom + markerLength;
|
||||||
|
if (contentFrom < line.to) {
|
||||||
|
builder.push(headingClass.range(contentFrom, line.to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = text.match(/^>\s?/);
|
||||||
|
if (quote) {
|
||||||
|
addHiddenToken(builder, selections, {
|
||||||
|
from: lineFrom,
|
||||||
|
to: lineFrom + quote[0].length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const unordered = text.match(/^(\s*)([-+*])\s+(?:\[( |x|X)\]\s+)?/);
|
||||||
|
if (unordered) {
|
||||||
|
const markerStart = lineFrom + unordered[1].length;
|
||||||
|
const markerEnd = markerStart + unordered[2].length + 1;
|
||||||
|
addHiddenToken(builder, selections, { from: markerStart, to: markerEnd });
|
||||||
|
|
||||||
|
const checkbox = unordered[0].match(/\[( |x|X)\]\s+$/);
|
||||||
|
if (checkbox) {
|
||||||
|
const checkboxStart = lineFrom + unordered[0].lastIndexOf("[");
|
||||||
|
addHiddenToken(builder, selections, {
|
||||||
|
from: checkboxStart,
|
||||||
|
to: checkboxStart + checkbox[0].length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = text.match(/^(\s*)(\d+\.)\s+/);
|
||||||
|
if (ordered) {
|
||||||
|
const markerStart = lineFrom + ordered[1].length;
|
||||||
|
addHiddenToken(builder, selections, {
|
||||||
|
from: markerStart,
|
||||||
|
to: markerStart + ordered[2].length + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(trimmed)) {
|
||||||
|
if (lineFrom < line.to) {
|
||||||
|
builder.push(
|
||||||
|
Decoration.mark({ class: "tm-md-hr" }).range(lineFrom, line.to),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableSeparator = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
if (tableSeparator) {
|
||||||
|
if (lineFrom < line.to) {
|
||||||
|
builder.push(
|
||||||
|
Decoration.mark({ class: "tm-md-del" }).range(lineFrom, line.to),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { styleRanges, tokenRanges } = collectInlineRanges(text, lineFrom);
|
||||||
|
|
||||||
|
for (const range of styleRanges) {
|
||||||
|
if (range.from >= range.to) continue;
|
||||||
|
|
||||||
|
if (range.className === "tm-md-strong") {
|
||||||
|
builder.push(strongDecoration.range(range.from, range.to));
|
||||||
|
} else if (range.className === "tm-md-em") {
|
||||||
|
builder.push(emDecoration.range(range.from, range.to));
|
||||||
|
} else if (range.className === "tm-md-del") {
|
||||||
|
builder.push(delDecoration.range(range.from, range.to));
|
||||||
|
} else if (range.className === "tm-md-code") {
|
||||||
|
builder.push(codeDecoration.range(range.from, range.to));
|
||||||
|
} else if (range.className === "tm-md-link") {
|
||||||
|
builder.push(linkDecoration.range(range.from, range.to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of tokenRanges) {
|
||||||
|
addHiddenToken(builder, selections, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(builder, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps markdown syntax visible only when user selection intersects the token.
|
||||||
|
* This preserves cursor predictability and cross-token selection while still hiding syntax during reading.
|
||||||
|
*/
|
||||||
|
function addHiddenToken(
|
||||||
|
builder: Range<Decoration>[],
|
||||||
|
selections: ReadonlyArray<{ from: number; to: number }>,
|
||||||
|
token: {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
if (token.from >= token.to) return;
|
||||||
|
|
||||||
|
const overlapsSelection = selections.some((selection) => {
|
||||||
|
const selectionFrom = Math.min(selection.from, selection.to);
|
||||||
|
const selectionTo = Math.max(selection.from, selection.to);
|
||||||
|
|
||||||
|
if (selectionFrom === selectionTo) {
|
||||||
|
return selectionFrom >= token.from && selectionFrom <= token.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectionFrom < token.to && selectionTo > token.from;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overlapsSelection) return;
|
||||||
|
builder.push(hiddenTokenDecoration.range(token.from, token.to));
|
||||||
|
}
|
||||||
815
packages/markdown/src/markdown.tsx
Normal file
815
packages/markdown/src/markdown.tsx
Normal file
|
|
@ -0,0 +1,815 @@
|
||||||
|
import {
|
||||||
|
Fragment,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import Emoji from "./emoji";
|
||||||
|
import { findEmojiShortcodes } from "./emojiData";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
type InlineNode =
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "emoji"; shortcode: string }
|
||||||
|
| { type: "strong"; value: string }
|
||||||
|
| { type: "em"; value: string }
|
||||||
|
| { type: "del"; value: string }
|
||||||
|
| { type: "code"; value: string }
|
||||||
|
| { type: "link"; label: string; href: string }
|
||||||
|
| { type: "image"; alt: string; src: string };
|
||||||
|
|
||||||
|
type InlineDecorationRange = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
className: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InlineTokenRange = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListItem = {
|
||||||
|
text: string;
|
||||||
|
checked: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TableBlock = {
|
||||||
|
type: "table";
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarkdownBlock =
|
||||||
|
| {
|
||||||
|
type: "paragraph";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "heading";
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "hr";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "blockquote";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "code";
|
||||||
|
language: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "list";
|
||||||
|
ordered: boolean;
|
||||||
|
items: ListItem[];
|
||||||
|
}
|
||||||
|
| TableBlock;
|
||||||
|
|
||||||
|
const INLINE_TOKEN_REGEX =
|
||||||
|
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
|
||||||
|
|
||||||
|
function CopiedIndicator({
|
||||||
|
block,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
block: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-flex align-middle text-foreground transition-opacity duration-200 ease-out ${block ? "mt-3 shrink-0" : "ml-1"} ${visible ? "opacity-100" : "opacity-0"}`}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-hidden={!visible}
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyableCode({
|
||||||
|
block = false,
|
||||||
|
language,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
block?: boolean;
|
||||||
|
language?: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearTimeout(copiedTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
clearTimeout(copiedTimer.current);
|
||||||
|
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (
|
||||||
|
<code
|
||||||
|
className={block ? "" : "bg-card border p-0.5 rounded text-sm"}
|
||||||
|
data-language={language}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => void copy()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
void copy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (block) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-start gap-1">
|
||||||
|
<pre className="tm-md-pre min-w-0 flex-1">{code}</pre>
|
||||||
|
<CopiedIndicator block visible={copied} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{code}
|
||||||
|
<CopiedIndicator block={false} visible={copied} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes parseInlineNodes.
|
||||||
|
* @param input Parameter input.
|
||||||
|
* @returns InlineNode[].
|
||||||
|
*/
|
||||||
|
export function parseInlineNodes(input: string): InlineNode[] {
|
||||||
|
const nodes: InlineNode[] = [];
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
let match = INLINE_TOKEN_REGEX.exec(input);
|
||||||
|
|
||||||
|
while (match) {
|
||||||
|
const index = match.index;
|
||||||
|
const raw = match[0];
|
||||||
|
|
||||||
|
if (index > cursor) {
|
||||||
|
nodes.push(...parseEmojiText(input.slice(cursor, index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[1] !== undefined && match[2] !== undefined) {
|
||||||
|
nodes.push({ type: "image", alt: match[1], src: normalizeUrl(match[2]) });
|
||||||
|
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||||
|
nodes.push({
|
||||||
|
type: "link",
|
||||||
|
label: match[3],
|
||||||
|
href: normalizeUrl(match[4]),
|
||||||
|
});
|
||||||
|
} else if (match[5] !== undefined) {
|
||||||
|
nodes.push({ type: "code", value: match[5] });
|
||||||
|
} else if (match[6] !== undefined) {
|
||||||
|
nodes.push({ type: "del", value: match[6] });
|
||||||
|
} else if (match[7] !== undefined || match[8] !== undefined) {
|
||||||
|
nodes.push({ type: "strong", value: match[7] ?? match[8] ?? "" });
|
||||||
|
} else if (match[9] !== undefined || match[10] !== undefined) {
|
||||||
|
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
|
||||||
|
} else {
|
||||||
|
nodes.push(...parseEmojiText(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = index + raw.length;
|
||||||
|
match = INLINE_TOKEN_REGEX.exec(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < input.length) {
|
||||||
|
nodes.push(...parseEmojiText(input.slice(cursor)));
|
||||||
|
}
|
||||||
|
|
||||||
|
INLINE_TOKEN_REGEX.lastIndex = 0;
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEmojiText(input: string): InlineNode[] {
|
||||||
|
const nodes: InlineNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const match of findEmojiShortcodes(input)) {
|
||||||
|
if (match.from > cursor) {
|
||||||
|
nodes.push({ type: "text", value: input.slice(cursor, match.from) });
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push({ type: "emoji", shortcode: match.emoji.shortcode });
|
||||||
|
cursor = match.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < input.length) {
|
||||||
|
nodes.push({ type: "text", value: input.slice(cursor) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes collectInlineRanges.
|
||||||
|
* @param input Parameter input.
|
||||||
|
* @param offset Parameter offset.
|
||||||
|
* @returns {
|
||||||
|
styleRanges: InlineDecorationRange[];
|
||||||
|
tokenRanges: InlineTokenRange[];
|
||||||
|
}.
|
||||||
|
*/
|
||||||
|
export function collectInlineRanges(
|
||||||
|
input: string,
|
||||||
|
offset = 0,
|
||||||
|
): {
|
||||||
|
styleRanges: InlineDecorationRange[];
|
||||||
|
tokenRanges: InlineTokenRange[];
|
||||||
|
} {
|
||||||
|
const styleRanges: InlineDecorationRange[] = [];
|
||||||
|
const tokenRanges: InlineTokenRange[] = [];
|
||||||
|
|
||||||
|
let match = INLINE_TOKEN_REGEX.exec(input);
|
||||||
|
|
||||||
|
while (match) {
|
||||||
|
const raw = match[0];
|
||||||
|
const start = offset + match.index;
|
||||||
|
const end = start + raw.length;
|
||||||
|
|
||||||
|
if (match[1] !== undefined && match[2] !== undefined) {
|
||||||
|
const openLength = 2;
|
||||||
|
const closeLength = raw.endsWith(")") ? 1 : 0;
|
||||||
|
|
||||||
|
const imageEnd = start + openLength + match[1].length;
|
||||||
|
tokenRanges.push({ from: start, to: start + openLength });
|
||||||
|
tokenRanges.push({ from: imageEnd, to: imageEnd + 1 });
|
||||||
|
|
||||||
|
const srcStart = imageEnd + 1;
|
||||||
|
const srcEnd = end - closeLength;
|
||||||
|
tokenRanges.push({ from: srcStart, to: srcStart + 1 });
|
||||||
|
tokenRanges.push({ from: srcEnd, to: srcEnd + closeLength });
|
||||||
|
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||||
|
const label = match[3];
|
||||||
|
const labelStart = start + 1;
|
||||||
|
const labelEnd = labelStart + label.length;
|
||||||
|
|
||||||
|
tokenRanges.push({ from: start, to: start + 1 });
|
||||||
|
tokenRanges.push({ from: labelEnd, to: labelEnd + 1 });
|
||||||
|
tokenRanges.push({ from: labelEnd + 1, to: labelEnd + 2 });
|
||||||
|
tokenRanges.push({ from: end - 1, to: end });
|
||||||
|
|
||||||
|
styleRanges.push({
|
||||||
|
from: labelStart,
|
||||||
|
to: labelEnd,
|
||||||
|
className: "tm-md-link",
|
||||||
|
});
|
||||||
|
} else if (match[5] !== undefined) {
|
||||||
|
const codeStart = start + 1;
|
||||||
|
const codeEnd = end - 1;
|
||||||
|
|
||||||
|
tokenRanges.push({ from: start, to: start + 1 });
|
||||||
|
tokenRanges.push({ from: end - 1, to: end });
|
||||||
|
styleRanges.push({
|
||||||
|
from: codeStart,
|
||||||
|
to: codeEnd,
|
||||||
|
className: "tm-md-code",
|
||||||
|
});
|
||||||
|
} else if (match[6] !== undefined) {
|
||||||
|
const contentStart = start + 2;
|
||||||
|
const contentEnd = end - 2;
|
||||||
|
|
||||||
|
tokenRanges.push({ from: start, to: start + 2 });
|
||||||
|
tokenRanges.push({ from: end - 2, to: end });
|
||||||
|
styleRanges.push({
|
||||||
|
from: contentStart,
|
||||||
|
to: contentEnd,
|
||||||
|
className: "tm-md-del",
|
||||||
|
});
|
||||||
|
} else if (match[7] !== undefined || match[8] !== undefined) {
|
||||||
|
const contentStart = start + 2;
|
||||||
|
const contentEnd = end - 2;
|
||||||
|
|
||||||
|
tokenRanges.push({ from: start, to: start + 2 });
|
||||||
|
tokenRanges.push({ from: end - 2, to: end });
|
||||||
|
styleRanges.push({
|
||||||
|
from: contentStart,
|
||||||
|
to: contentEnd,
|
||||||
|
className: "tm-md-strong",
|
||||||
|
});
|
||||||
|
} else if (match[9] !== undefined || match[10] !== undefined) {
|
||||||
|
const contentStart = start + 1;
|
||||||
|
const contentEnd = end - 1;
|
||||||
|
|
||||||
|
tokenRanges.push({ from: start, to: start + 1 });
|
||||||
|
tokenRanges.push({ from: end - 1, to: end });
|
||||||
|
styleRanges.push({
|
||||||
|
from: contentStart,
|
||||||
|
to: contentEnd,
|
||||||
|
className: "tm-md-em",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
match = INLINE_TOKEN_REGEX.exec(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
INLINE_TOKEN_REGEX.lastIndex = 0;
|
||||||
|
return { styleRanges, tokenRanges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes parseMarkdownBlocks.
|
||||||
|
* @param markdown Parameter markdown.
|
||||||
|
* @returns MarkdownBlock[].
|
||||||
|
*/
|
||||||
|
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
|
||||||
|
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||||
|
const blocks: MarkdownBlock[] = [];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index];
|
||||||
|
|
||||||
|
if (!line.trim()) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeFence = line.match(/^```\s*([^`]*)$/);
|
||||||
|
if (codeFence) {
|
||||||
|
const language = (codeFence[1] ?? "").trim();
|
||||||
|
const codeLines: string[] = [];
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
|
||||||
|
codeLines.push(lines[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < lines.length) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({ type: "code", language, code: codeLines.join("\n") });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(line.trim())) {
|
||||||
|
blocks.push({ type: "hr" });
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
if (heading) {
|
||||||
|
blocks.push({
|
||||||
|
type: "heading",
|
||||||
|
level: heading[1].length,
|
||||||
|
text: heading[2],
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = line.match(/^>\s?(.*)$/);
|
||||||
|
if (quote) {
|
||||||
|
const quoteLines: string[] = [quote[1]];
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
const next = lines[index].match(/^>\s?(.*)$/);
|
||||||
|
if (!next) break;
|
||||||
|
quoteLines.push(next[1]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({ type: "blockquote", text: quoteLines.join("\n") });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableCandidate = readTable(lines, index);
|
||||||
|
if (tableCandidate) {
|
||||||
|
blocks.push(tableCandidate.block);
|
||||||
|
index = tableCandidate.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unordered = line.match(/^\s*[-*+]\s+(.*)$/);
|
||||||
|
const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
|
||||||
|
if (unordered || ordered) {
|
||||||
|
const orderedList = Boolean(ordered);
|
||||||
|
const items: ListItem[] = [];
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
const current = lines[index];
|
||||||
|
const match = orderedList
|
||||||
|
? current.match(/^\s*\d+\.\s+(.*)$/)
|
||||||
|
: current.match(/^\s*[-*+]\s+(.*)$/);
|
||||||
|
|
||||||
|
if (!match) break;
|
||||||
|
|
||||||
|
const task = match[1].match(/^\[( |x|X)\]\s+(.*)$/);
|
||||||
|
if (task) {
|
||||||
|
items.push({
|
||||||
|
text: task[2],
|
||||||
|
checked: task[1].toLowerCase() === "x",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({ text: match[1], checked: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({ type: "list", ordered: orderedList, items });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraphLines = [line];
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
while (
|
||||||
|
index < lines.length &&
|
||||||
|
lines[index].trim() &&
|
||||||
|
!/^(#{1,6})\s+/.test(lines[index]) &&
|
||||||
|
!/^```\s*/.test(lines[index]) &&
|
||||||
|
!/^>\s?/.test(lines[index]) &&
|
||||||
|
!/^\s*[-*+]\s+/.test(lines[index]) &&
|
||||||
|
!/^\s*\d+\.\s+/.test(lines[index]) &&
|
||||||
|
!/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(lines[index].trim())
|
||||||
|
) {
|
||||||
|
paragraphLines.push(lines[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({ type: "paragraph", text: paragraphLines.join("\n") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes renderInline.
|
||||||
|
* @param nodes Parameter nodes.
|
||||||
|
* @returns React.ReactNode[].
|
||||||
|
*/
|
||||||
|
function renderInline(nodes: InlineNode[]): ReactNode[] {
|
||||||
|
return nodes.map((node, index) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "emoji") {
|
||||||
|
return (
|
||||||
|
<Emoji key={index} className="tm-md-emoji" shortcode={node.shortcode} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "strong") {
|
||||||
|
return (
|
||||||
|
<strong key={index} className="tm-md-strong">
|
||||||
|
{renderInline(parseEmojiText(node.value))}
|
||||||
|
</strong>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "em") {
|
||||||
|
return (
|
||||||
|
<em key={index} className="tm-md-em">
|
||||||
|
{renderInline(parseEmojiText(node.value))}
|
||||||
|
</em>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "del") {
|
||||||
|
return (
|
||||||
|
<del key={index} className="tm-md-del">
|
||||||
|
{renderInline(parseEmojiText(node.value))}
|
||||||
|
</del>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "code") {
|
||||||
|
return <CopyableCode key={index} value={node.value} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "link") {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
className="tm-md-link"
|
||||||
|
href={node.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{renderInline(parseEmojiText(node.label))}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
className="tm-md-image"
|
||||||
|
src={node.src}
|
||||||
|
alt={node.alt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes renderBlocks.
|
||||||
|
* @param blocks Parameter blocks.
|
||||||
|
* @returns React.ReactElement.
|
||||||
|
*/
|
||||||
|
export function renderBlocks(blocks: MarkdownBlock[]): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((block, blockIndex) => {
|
||||||
|
if (block.type === "heading") {
|
||||||
|
const className = `tm-md-heading tm-md-h${String(block.level)}`;
|
||||||
|
if (block.level === 1)
|
||||||
|
return (
|
||||||
|
<h1 key={blockIndex} className={className}>
|
||||||
|
{renderInline(parseInlineNodes(block.text))}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
if (block.level === 2)
|
||||||
|
return (
|
||||||
|
<h2 key={blockIndex} className={className}>
|
||||||
|
{renderInline(parseInlineNodes(block.text))}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
if (block.level === 3)
|
||||||
|
return (
|
||||||
|
<h3 key={blockIndex} className={className}>
|
||||||
|
{renderInline(parseInlineNodes(block.text))}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
if (block.level === 4)
|
||||||
|
return (
|
||||||
|
<h4 key={blockIndex} className={className}>
|
||||||
|
{renderInline(parseInlineNodes(block.text))}
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
if (block.level === 5)
|
||||||
|
return (
|
||||||
|
<h5 key={blockIndex} className={className}>
|
||||||
|
{renderInline(parseInlineNodes(block.text))}
|
||||||
|
</h5>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<h6 key={blockIndex} className={className}>
|
||||||
|
{renderInline(parseInlineNodes(block.text))}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "blockquote") {
|
||||||
|
return (
|
||||||
|
<blockquote key={blockIndex} className="tm-md-blockquote">
|
||||||
|
{block.text.split("\n").map((line, lineIndex) => (
|
||||||
|
<p key={lineIndex}>{renderInline(parseInlineNodes(line))}</p>
|
||||||
|
))}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "code") {
|
||||||
|
return (
|
||||||
|
<CopyableCode
|
||||||
|
key={blockIndex}
|
||||||
|
block
|
||||||
|
language={block.language}
|
||||||
|
value={block.code}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "list") {
|
||||||
|
const Tag = block.ordered ? "ol" : "ul";
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={blockIndex}
|
||||||
|
className={block.ordered ? "tm-md-ol" : "tm-md-ul"}
|
||||||
|
>
|
||||||
|
{block.items.map((item, itemIndex) => (
|
||||||
|
<li key={itemIndex} className="tm-md-li">
|
||||||
|
{item.checked !== null ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.checked}
|
||||||
|
disabled
|
||||||
|
className="tm-md-checkbox"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>{renderInline(parseInlineNodes(item.text))}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "table") {
|
||||||
|
return (
|
||||||
|
<div key={blockIndex} className="tm-md-table-wrap">
|
||||||
|
<table className="tm-md-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{block.headers.map((header, headerIndex) => (
|
||||||
|
<th key={headerIndex}>
|
||||||
|
{renderInline(parseInlineNodes(header))}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex}>
|
||||||
|
{row.map((cell, cellIndex) => (
|
||||||
|
<td key={cellIndex}>
|
||||||
|
{renderInline(parseInlineNodes(cell))}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "hr") {
|
||||||
|
return <hr key={blockIndex} className="tm-md-hr" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p key={blockIndex}>
|
||||||
|
{block.text.split("\n").map((line, lineIndex) => (
|
||||||
|
<Fragment key={lineIndex}>
|
||||||
|
{lineIndex > 0 ? <br /> : null}
|
||||||
|
{renderInline(parseInlineNodes(line))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes normalizeUrl.
|
||||||
|
* @param input Parameter input.
|
||||||
|
* @returns string.
|
||||||
|
*/
|
||||||
|
function normalizeUrl(input: string): string {
|
||||||
|
const value = input.trim();
|
||||||
|
if (/^(https?:|mailto:|tel:|\/)/i.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "#";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes splitTableRow.
|
||||||
|
* @param row Parameter row.
|
||||||
|
* @returns string[].
|
||||||
|
*/
|
||||||
|
function splitTableRow(row: string): string[] {
|
||||||
|
const cleaned = row.trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||||
|
return cleaned.split("|").map((cell) => cell.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes readTable.
|
||||||
|
* @param lines Parameter lines.
|
||||||
|
* @param index Parameter index.
|
||||||
|
* @returns { block: TableBlock; nextIndex: number } | null.
|
||||||
|
*/
|
||||||
|
function readTable(
|
||||||
|
lines: string[],
|
||||||
|
index: number,
|
||||||
|
): { block: TableBlock; nextIndex: number } | null {
|
||||||
|
const header = lines[index] ?? "";
|
||||||
|
const separator = lines[index + 1] ?? "";
|
||||||
|
|
||||||
|
if (!header.includes("|") || !separator.includes("|")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorCells = splitTableRow(separator);
|
||||||
|
const isSeparator = separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
||||||
|
if (!isSeparator) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = splitTableRow(header);
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let cursor = index + 2;
|
||||||
|
|
||||||
|
while (cursor < lines.length && lines[cursor].includes("|")) {
|
||||||
|
rows.push(splitTableRow(lines[cursor]));
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
block: { type: "table", headers, rows },
|
||||||
|
nextIndex: cursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownStyles = `
|
||||||
|
.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; }
|
||||||
|
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
||||||
|
.tm-md-h1 { font-size: 1.65rem; }
|
||||||
|
.tm-md-h2 { font-size: 1.45rem; }
|
||||||
|
.tm-md-h3 { font-size: 1.25rem; }
|
||||||
|
.tm-md-h4 { font-size: 1.1rem; }
|
||||||
|
.tm-md-h5 { font-size: 1rem; }
|
||||||
|
.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; }
|
||||||
|
.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; }
|
||||||
|
.tm-md-blockquote p { margin: 0.2rem 0; }
|
||||||
|
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; }
|
||||||
|
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; }
|
||||||
|
.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); }
|
||||||
|
.tm-md-codeblock { display: block; }
|
||||||
|
.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||||
|
.tm-md-strong { font-weight: 700; }
|
||||||
|
.tm-md-em { font-style: italic; }
|
||||||
|
.tm-md-del { text-decoration: line-through; }
|
||||||
|
.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; }
|
||||||
|
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
|
||||||
|
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
|
||||||
|
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
|
||||||
|
.tm-md-li { margin: 0.2rem 0; }
|
||||||
|
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
|
||||||
|
.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; }
|
||||||
|
.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; }
|
||||||
|
.tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; }
|
||||||
|
.tm-md-table th { background: var(--muted); font-weight: 600; }
|
||||||
|
.tm-md-hr { margin: 0.55rem 0; }
|
||||||
|
|
||||||
|
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
|
||||||
|
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
|
||||||
|
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
|
||||||
|
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
||||||
|
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
||||||
|
.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); }
|
||||||
|
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
|
||||||
|
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
||||||
|
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
|
||||||
|
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; }
|
||||||
|
.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes ensureMarkdownStyles.
|
||||||
|
* @param none This function has no parameters.
|
||||||
|
* @returns void.
|
||||||
|
*/
|
||||||
|
export function ensureMarkdownStyles(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const styleId = "tensamin-markdown-styles";
|
||||||
|
let style = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||||
|
|
||||||
|
if (!style) {
|
||||||
|
style = document.createElement("style");
|
||||||
|
style.id = styleId;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.textContent !== markdownStyles) {
|
||||||
|
style.textContent = markdownStyles;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/markdown/src/text.tsx
Normal file
30
packages/markdown/src/text.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useMemo, type CSSProperties } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ensureMarkdownStyles,
|
||||||
|
parseMarkdownBlocks,
|
||||||
|
renderBlocks,
|
||||||
|
} from "./markdown";
|
||||||
|
|
||||||
|
export type TextProps = {
|
||||||
|
value: string;
|
||||||
|
fontSize?: CSSProperties["fontSize"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes Text.
|
||||||
|
* @param props Parameter props.
|
||||||
|
* @returns unknown.
|
||||||
|
*/
|
||||||
|
export default function Text(props: TextProps) {
|
||||||
|
ensureMarkdownStyles();
|
||||||
|
|
||||||
|
const blocks = useMemo(() => parseMarkdownBlocks(props.value), [props.value]);
|
||||||
|
const renderedBlocks = useMemo(() => renderBlocks(blocks), [blocks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tm-md-root" style={{ fontSize: props.fontSize }}>
|
||||||
|
{renderedBlocks}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
packages/markdown/todo.md
Normal file
1
packages/markdown/todo.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
- Improve the Input box
|
||||||
12
packages/markdown/tsconfig.json
Normal file
12
packages/markdown/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -9,15 +9,19 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"test": "vitest run",
|
||||||
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@methanium/ui": "*",
|
"@methanium/ui": "*",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tensamin/crypto": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
"mtp": "*",
|
"mtp": "*",
|
||||||
"react": "^19.2.8"
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^10.8.0"
|
"eslint": "^10.8.0"
|
||||||
|
|
|
||||||
220
packages/mtp/src/context.test.tsx
Normal file
220
packages/mtp/src/context.test.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { RequestIdAllocator } from "./requestIds";
|
||||||
|
|
||||||
|
vi.mock("@methanium/ui", () => ({
|
||||||
|
toast: {
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
loading: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tensamin/shared/log", () => ({ log: vi.fn() }));
|
||||||
|
vi.mock("@tensamin/storage/context", () => ({
|
||||||
|
useStorage: () => ({ load: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { completeInitialSynchronization, isPushType, validateResponse } =
|
||||||
|
await import("./context");
|
||||||
|
|
||||||
|
const validState = {
|
||||||
|
SessionId: 7,
|
||||||
|
VersionNumber: 2,
|
||||||
|
CacheSchemaVersion: 0,
|
||||||
|
SyncMode: "full",
|
||||||
|
Contacts: [],
|
||||||
|
Communities: [],
|
||||||
|
Calls: [],
|
||||||
|
Messages: [],
|
||||||
|
DeletedMessageIds: [],
|
||||||
|
DeletedContactIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockInitialSyncClient(
|
||||||
|
state: { type: string; data: unknown } = {
|
||||||
|
type: "ClientStateSync",
|
||||||
|
data: validState,
|
||||||
|
},
|
||||||
|
acknowledgement: unknown = { type: "ClientStateAck", data: {} },
|
||||||
|
) {
|
||||||
|
const handlers = new Map<string, (message: never) => void>();
|
||||||
|
const request = vi.fn().mockResolvedValue(acknowledgement);
|
||||||
|
const client = {
|
||||||
|
auth: vi.fn(async () => {
|
||||||
|
handlers.get(state.type)?.(state as never);
|
||||||
|
}),
|
||||||
|
subscribe: vi.fn((type: string, handler: (message: never) => void) => {
|
||||||
|
handlers.set(type, handler);
|
||||||
|
return () => handlers.delete(type);
|
||||||
|
}),
|
||||||
|
request,
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
} as unknown as Parameters<typeof completeInitialSynchronization>[0];
|
||||||
|
return { client, handlers, request };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MTP protocol dispatch", () => {
|
||||||
|
it("preserves protocol errors for the request layer", () => {
|
||||||
|
const error = validateResponse("GetStates", {
|
||||||
|
id: 12,
|
||||||
|
type: "ErrorInternal",
|
||||||
|
data: { ErrorType: "temporary" },
|
||||||
|
});
|
||||||
|
expect(error.type).toBe("ErrorInternal");
|
||||||
|
expect(error.id).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes initial and live presence pushes", () => {
|
||||||
|
expect(isPushType("GetStates")).toBe(true);
|
||||||
|
expect(isPushType("ClientChanged")).toBe(true);
|
||||||
|
expect(isPushType("UnknownMessage")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browser initial synchronization", () => {
|
||||||
|
it("validates state, sends a nonzero acknowledgement, then resolves", async () => {
|
||||||
|
const { client, request } = mockInitialSyncClient();
|
||||||
|
|
||||||
|
const state = await completeInitialSynchronization(
|
||||||
|
client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.data).toEqual(validState);
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
"ClientStateAck",
|
||||||
|
{ SessionId: 7, VersionNumber: 2 },
|
||||||
|
{ id: 1 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not acknowledge malformed state", async () => {
|
||||||
|
const { client, request } = mockInitialSyncClient({
|
||||||
|
type: "ClientStateSync",
|
||||||
|
data: { ...validState, SyncMode: "invalid" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeInitialSynchronization(
|
||||||
|
client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Response validation failed");
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects ErrorNoIota during initial synchronization", async () => {
|
||||||
|
const { client, request } = mockInitialSyncClient({
|
||||||
|
type: "ErrorNoIota",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeInitialSynchronization(
|
||||||
|
client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("No Iota is currently connected");
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an acknowledgement protocol error", async () => {
|
||||||
|
const { client } = mockInitialSyncClient(undefined, {
|
||||||
|
type: "ErrorInvalidData",
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeInitialSynchronization(
|
||||||
|
client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("State acknowledgement failed: ErrorInvalidData");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out a missing acknowledgement", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { client } = mockInitialSyncClient();
|
||||||
|
vi.mocked(client.request).mockReturnValue(new Promise(() => {}));
|
||||||
|
const result = completeInitialSynchronization(
|
||||||
|
client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
100,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const assertion = expect(result).rejects.toThrow(
|
||||||
|
"State acknowledgement timed out",
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
await assertion;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops immediately when the connection attempt is cancelled", async () => {
|
||||||
|
const { client } = mockInitialSyncClient();
|
||||||
|
vi.mocked(client.auth).mockImplementation(() => new Promise(() => {}));
|
||||||
|
const controller = new AbortController();
|
||||||
|
const result = completeInitialSynchronization(
|
||||||
|
client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.abort(new Error("MTP connection lost"));
|
||||||
|
await expect(result).rejects.toThrow("MTP connection lost");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out authentication while waiting for initial state", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { client, handlers } = mockInitialSyncClient();
|
||||||
|
vi.mocked(client.auth).mockImplementation(() => {
|
||||||
|
handlers.get("ClientStateSync")?.({
|
||||||
|
type: "ClientStateSync",
|
||||||
|
data: validState,
|
||||||
|
} as never);
|
||||||
|
return new Promise(() => {});
|
||||||
|
});
|
||||||
|
const result = completeInitialSynchronization(
|
||||||
|
client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
100,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const assertion = expect(result).rejects.toThrow(
|
||||||
|
"MTP authentication timed out",
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await assertion;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a fresh request ID namespace for each connection", async () => {
|
||||||
|
const first = mockInitialSyncClient();
|
||||||
|
const second = mockInitialSyncClient();
|
||||||
|
|
||||||
|
await completeInitialSynchronization(
|
||||||
|
first.client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
await completeInitialSynchronization(
|
||||||
|
second.client,
|
||||||
|
new RequestIdAllocator(),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
|
||||||
|
expect(second.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,18 +10,9 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import {
|
import { MTPClient } from "mtp";
|
||||||
base64ToBytes,
|
import { type z } from "zod";
|
||||||
ConnectionState,
|
import { ConnectionState } from "mtp";
|
||||||
MTPClient,
|
|
||||||
MTPProxyConnection,
|
|
||||||
type MTPFrame,
|
|
||||||
type MTPKeyMaterialInput,
|
|
||||||
type MTPProxyAdapter,
|
|
||||||
type MTPRequestOptions,
|
|
||||||
type MTPRequestFunction,
|
|
||||||
type MTPResponseFrame,
|
|
||||||
} from "mtp";
|
|
||||||
import createAsyncQueue from "@tensamin/shared/asyncQueue";
|
import createAsyncQueue from "@tensamin/shared/asyncQueue";
|
||||||
import { toast as sonnerToast } from "@methanium/ui";
|
import { toast as sonnerToast } from "@methanium/ui";
|
||||||
|
|
||||||
|
|
@ -33,15 +24,47 @@ import {
|
||||||
type MTP as Schemas,
|
type MTP as Schemas,
|
||||||
} from "@tensamin/shared/data";
|
} from "@tensamin/shared/data";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import { ProtocolError } from "@tensamin/shared/errors";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
|
import { fromWireMessage, toWireData } from "./protocolFields";
|
||||||
|
import { RequestIdAllocator } from "./requestIds";
|
||||||
|
import {
|
||||||
|
DISCOVERY_TIMEOUT,
|
||||||
|
INITIAL_SYNC_TIMEOUT,
|
||||||
|
RECONNECT_JITTER,
|
||||||
|
RECONNECT_LONG_INTERVAL,
|
||||||
|
RECONNECT_RESET,
|
||||||
|
RECONNECT_TRIES,
|
||||||
|
RETRY_INTERVAL,
|
||||||
|
STATE_ACK_TIMEOUT,
|
||||||
|
} from "./values";
|
||||||
|
|
||||||
|
type BrowserMtpClient = Awaited<ReturnType<typeof MTPClient.create>>;
|
||||||
|
|
||||||
|
function base64ToUint8Array(b64: string) {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < bin.length; i++) {
|
||||||
|
out[i] = bin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export type ProtocolMessage<
|
export type ProtocolMessage<
|
||||||
T extends keyof Schemas & string = keyof Schemas & string,
|
T extends keyof Schemas & string = keyof Schemas & string,
|
||||||
> = MTPResponseFrame<Schemas, T>;
|
> = {
|
||||||
|
id?: number;
|
||||||
|
type: T | string;
|
||||||
|
data: z.infer<Schemas[T]["response"]>;
|
||||||
|
};
|
||||||
|
|
||||||
export type BoundSendFn = MTPRequestFunction<Schemas>;
|
export type BoundSendFn = <T extends keyof Schemas & string>(
|
||||||
|
type: T,
|
||||||
|
data?: z.infer<Schemas[T]["request"]>,
|
||||||
|
) => Promise<ProtocolMessage<T>>;
|
||||||
|
|
||||||
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
|
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
|
||||||
|
|
||||||
|
|
@ -57,19 +80,33 @@ const PUSH_TYPES = [
|
||||||
"ErrorNoIota",
|
"ErrorNoIota",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function encodedPublicKey(value: string): MTPKeyMaterialInput {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
const hex = trimmed.replace(/^0x/i, "");
|
|
||||||
return {
|
|
||||||
value: /^[0-9a-f]+$/i.test(hex) ? hex : trimmed,
|
|
||||||
encoding: /^[0-9a-f]+$/i.test(hex) ? "hex" : "base64",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPushType(type: string): boolean {
|
export function isPushType(type: string): boolean {
|
||||||
return (PUSH_TYPES as readonly string[]).includes(type);
|
return (PUSH_TYPES as readonly string[]).includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMtpMessage<T extends { data: unknown }>(message: T): T {
|
||||||
|
return fromWireMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithId(
|
||||||
|
client: BrowserMtpClient,
|
||||||
|
ids: RequestIdAllocator,
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
let id: number;
|
||||||
|
try {
|
||||||
|
id = ids.allocate();
|
||||||
|
} catch (error) {
|
||||||
|
client.disconnect();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.request(type, toWireData(data) as Record<string, unknown>, {
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function removeMissingContacts(
|
function removeMissingContacts(
|
||||||
contacts: Contacts,
|
contacts: Contacts,
|
||||||
message: ProtocolMessage,
|
message: ProtocolMessage,
|
||||||
|
|
@ -112,6 +149,167 @@ type ContextType = {
|
||||||
|
|
||||||
const MTPContext = createContext<ContextType | undefined>(undefined);
|
const MTPContext = createContext<ContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
function getProtocolErrorDetails(error: unknown) {
|
||||||
|
if (typeof error !== "object" || error === null || !("type" in error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolError = error as {
|
||||||
|
id?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: protocolError.id,
|
||||||
|
type: protocolError.type,
|
||||||
|
data: protocolError.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zod schema validation
|
||||||
|
export function validateResponse<T extends keyof Schemas & string>(
|
||||||
|
type: T,
|
||||||
|
message: { id?: number; type: string; data: unknown },
|
||||||
|
): ProtocolMessage<T> {
|
||||||
|
if (message.type.startsWith("Error")) {
|
||||||
|
return message as ProtocolMessage<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema =
|
||||||
|
schemas[message.type as keyof Schemas & string]?.response ??
|
||||||
|
schemas[type]?.response;
|
||||||
|
if (!schema) {
|
||||||
|
return message as ProtocolMessage<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(message.data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Response validation failed for ${type}: ${parsed.error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
type: message.type,
|
||||||
|
data: parsed.data,
|
||||||
|
} as ProtocolMessage<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(signal: AbortSignal): Error {
|
||||||
|
return signal.reason instanceof Error
|
||||||
|
? signal.reason
|
||||||
|
: new Error("Initial state synchronization was cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDeadline<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutMessage: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
reject(abortError(signal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settled = false;
|
||||||
|
const finish = (complete: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
complete();
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => finish(() => reject(new Error(timeoutMessage))),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
const onAbort = () => finish(() => reject(abortError(signal)));
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
promise.then(
|
||||||
|
(value) => finish(() => resolve(value)),
|
||||||
|
(error: unknown) => finish(() => reject(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeInitialSynchronization(
|
||||||
|
client: BrowserMtpClient,
|
||||||
|
ids: RequestIdAllocator,
|
||||||
|
signal: AbortSignal,
|
||||||
|
syncTimeoutMs = INITIAL_SYNC_TIMEOUT,
|
||||||
|
ackTimeoutMs = STATE_ACK_TIMEOUT,
|
||||||
|
): Promise<ProtocolMessage<"ClientStateSync">> {
|
||||||
|
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
let unsubscribeStateSync = () => {};
|
||||||
|
let unsubscribeNoIota = () => {};
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribeStateSync();
|
||||||
|
unsubscribeNoIota();
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(abortError(signal));
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Initial state synchronization timed out"));
|
||||||
|
}, syncTimeoutMs);
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsubscribeStateSync = client.subscribe("ClientStateSync", (message) => {
|
||||||
|
cleanup();
|
||||||
|
try {
|
||||||
|
resolve(
|
||||||
|
validateResponse("ClientStateSync", normalizeMtpMessage(message)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsubscribeNoIota = client.subscribe("ErrorNoIota", () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("No Iota is currently connected"));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [, state] = await Promise.all([
|
||||||
|
withDeadline(
|
||||||
|
client.auth(),
|
||||||
|
syncTimeoutMs,
|
||||||
|
"MTP authentication timed out",
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
stateSync,
|
||||||
|
]);
|
||||||
|
if (state.type.startsWith("Error")) {
|
||||||
|
throw new Error(`State synchronization failed: ${state.type}`);
|
||||||
|
}
|
||||||
|
const acknowledgement = normalizeMtpMessage(
|
||||||
|
await withDeadline(
|
||||||
|
requestWithId(client, ids, "ClientStateAck", {
|
||||||
|
SessionId: state.data.SessionId,
|
||||||
|
VersionNumber: state.data.VersionNumber,
|
||||||
|
}),
|
||||||
|
ackTimeoutMs,
|
||||||
|
"State acknowledgement timed out",
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (acknowledgement.type.startsWith("Error")) {
|
||||||
|
throw new Error(`State acknowledgement failed: ${acknowledgement.type}`);
|
||||||
|
}
|
||||||
|
if (signal.aborted) throw abortError(signal);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
function useMessageHandlers() {
|
function useMessageHandlers() {
|
||||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||||
const pushHandlersRef = useRef(new Set<PushHandler>());
|
const pushHandlersRef = useRef(new Set<PushHandler>());
|
||||||
|
|
@ -153,7 +351,10 @@ function BrowserProvider(props: {
|
||||||
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||||
|
|
||||||
const clientRef = useRef<MTPClient<Schemas> | null>(null);
|
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const requestIdsRef = useRef<RequestIdAllocator | null>(null);
|
||||||
const {
|
const {
|
||||||
addInterceptor,
|
addInterceptor,
|
||||||
interceptorsRef,
|
interceptorsRef,
|
||||||
|
|
@ -170,16 +371,38 @@ function BrowserProvider(props: {
|
||||||
load("omega_url").then(setMtpUrl);
|
load("omega_url").then(setMtpUrl);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// Validation override functions
|
||||||
const send: BoundSendFn = useMemo(
|
const send: BoundSendFn = useMemo(
|
||||||
() => async (type, data, options) => {
|
() => async (type, data) => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
|
const ids = requestIdsRef.current;
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("mtp is not connected");
|
throw new Error("mtp is not connected");
|
||||||
}
|
}
|
||||||
|
if (!ids) {
|
||||||
|
throw new Error("MTP request allocator is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await client.request(type, data, options);
|
const rawMessage = await requestWithId(
|
||||||
|
client,
|
||||||
|
ids,
|
||||||
|
type,
|
||||||
|
(data ?? {}) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const response = validateResponse(type, normalizeMtpMessage(rawMessage));
|
||||||
setFreshContacts((contacts) => removeMissingContacts(contacts, response));
|
setFreshContacts((contacts) => removeMissingContacts(contacts, response));
|
||||||
|
if (response.type.startsWith("Error")) {
|
||||||
|
const errorData = response.data as Record<string, unknown>;
|
||||||
|
throw new ProtocolError({
|
||||||
|
type: response.type,
|
||||||
|
requestId: response.id,
|
||||||
|
errorType:
|
||||||
|
typeof errorData.ErrorType === "string"
|
||||||
|
? errorData.ErrorType
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
|
@ -191,7 +414,9 @@ function BrowserProvider(props: {
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return client.subscribe(type, handler);
|
return client.subscribe(type, (message) => {
|
||||||
|
handler(validateResponse(type, normalizeMtpMessage(message)));
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reconnect stuff
|
// Reconnect stuff
|
||||||
|
|
@ -221,45 +446,57 @@ function BrowserProvider(props: {
|
||||||
|
|
||||||
const scheduleReconnect = (error: unknown) => {
|
const scheduleReconnect = (error: unknown) => {
|
||||||
if (disposed || reconnectScheduled) return;
|
if (disposed || reconnectScheduled) return;
|
||||||
if (attempts >= RECONNECT_TRIES) {
|
attempts += 1;
|
||||||
|
const shortRetry = attempts <= RECONNECT_TRIES;
|
||||||
|
if (!shortRetry) {
|
||||||
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
|
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
|
||||||
sonnerToast.error("Connection failed", {
|
sonnerToast.error("Connection failed", {
|
||||||
id: "mtp-connection-toast",
|
id: "mtp-connection-toast",
|
||||||
description:
|
description:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message.split(":")[0]
|
? `${error.message.split(":")[0]}. Retrying in the background.`
|
||||||
: "Connection lost",
|
: "Connection lost. Retrying in the background.",
|
||||||
icon: null,
|
icon: null,
|
||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
promise: null,
|
promise: null,
|
||||||
} as unknown as Parameters<typeof sonnerToast.error>[1]);
|
} as unknown as Parameters<typeof sonnerToast.error>[1]);
|
||||||
return;
|
} else {
|
||||||
|
sonnerToast.loading(
|
||||||
|
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
|
||||||
|
{ id: "mtp-connection-toast" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
attempts += 1;
|
const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL;
|
||||||
sonnerToast.loading(
|
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
|
||||||
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
|
|
||||||
{ id: "mtp-connection-toast" },
|
|
||||||
);
|
|
||||||
reconnectScheduled = true;
|
reconnectScheduled = true;
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(
|
||||||
reconnectScheduled = false;
|
() => {
|
||||||
reconnectTimer = null;
|
reconnectScheduled = false;
|
||||||
void connect();
|
reconnectTimer = null;
|
||||||
}, RETRY_INTERVAL);
|
void connect();
|
||||||
|
},
|
||||||
|
Math.round(baseDelay * jitter),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (disposed || props.blockConnection) return;
|
if (disposed || props.blockConnection) return;
|
||||||
|
|
||||||
const generation = ++connectionGeneration;
|
const generation = ++connectionGeneration;
|
||||||
let client: MTPClient<Schemas> | null = null;
|
let client: Awaited<ReturnType<typeof MTPClient.create>> | null = null;
|
||||||
let failed = false;
|
let failed = false;
|
||||||
|
let connectionReady = false;
|
||||||
|
const attemptAbort = new AbortController();
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
attemptAbort.abort(
|
||||||
|
new Error("Initial state synchronization was cancelled"),
|
||||||
|
);
|
||||||
client?.disconnect();
|
client?.disconnect();
|
||||||
if (clientRef.current === client) {
|
if (clientRef.current === client) {
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
|
requestIdsRef.current = null;
|
||||||
}
|
}
|
||||||
clearReconnectResetTimer();
|
clearReconnectResetTimer();
|
||||||
if (generation === connectionGeneration) {
|
if (generation === connectionGeneration) {
|
||||||
|
|
@ -289,19 +526,18 @@ function BrowserProvider(props: {
|
||||||
omikronPublicKey = forcedOmikronPublicKey;
|
omikronPublicKey = forcedOmikronPublicKey;
|
||||||
} else {
|
} else {
|
||||||
log(2, "mtp", "purple", "Fetching Omikron data.");
|
log(2, "mtp", "purple", "Fetching Omikron data.");
|
||||||
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`);
|
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, {
|
||||||
|
signal: AbortSignal.any([
|
||||||
|
attemptAbort.signal,
|
||||||
|
AbortSignal.timeout(DISCOVERY_TIMEOUT),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
if (data.status === 404) {
|
if (data.status === 404) {
|
||||||
sonnerToast.error("We couldn't reach your Iota", {
|
throw new Error("No Omikron assignment is currently available");
|
||||||
description:
|
}
|
||||||
"Check your network connection and try restarting your Iota",
|
if (!data.ok) {
|
||||||
icon: null,
|
throw new Error(`Omikron discovery failed: HTTP ${data.status}`);
|
||||||
duration: Infinity,
|
|
||||||
closeButton: true,
|
|
||||||
});
|
|
||||||
resolveConnectionRef.current?.();
|
|
||||||
cleanup();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const omikronData = (await data.json()) as {
|
const omikronData = (await data.json()) as {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -328,20 +564,18 @@ function BrowserProvider(props: {
|
||||||
|
|
||||||
log(2, "mtp", "green", "Connecting to: " + url);
|
log(2, "mtp", "green", "Connecting to: " + url);
|
||||||
|
|
||||||
client = await MTPClient.create<Schemas>({
|
client = await MTPClient.create({
|
||||||
url,
|
url,
|
||||||
credentials: {
|
credentials: {
|
||||||
clientId: userId,
|
clientId: userId,
|
||||||
keyring: base64ToBytes(keyring),
|
keyring: base64ToUint8Array(keyring),
|
||||||
|
},
|
||||||
|
hostPublicKey: {
|
||||||
|
value: omikronPublicKey,
|
||||||
|
encoding: "base64",
|
||||||
},
|
},
|
||||||
hostPublicKey: encodedPublicKey(omikronPublicKey),
|
|
||||||
descriptor: "client",
|
descriptor: "client",
|
||||||
pings: true,
|
pings: true,
|
||||||
schemas,
|
|
||||||
throwProtocolErrors: true,
|
|
||||||
onValidationError: (error) => {
|
|
||||||
log(1, "mtp", "red", "Failed to validate MTP message", error);
|
|
||||||
},
|
|
||||||
logger: (event) => {
|
logger: (event) => {
|
||||||
if (event.type === "state") {
|
if (event.type === "state") {
|
||||||
if (generation !== connectionGeneration) return;
|
if (generation !== connectionGeneration) return;
|
||||||
|
|
@ -353,10 +587,12 @@ function BrowserProvider(props: {
|
||||||
!failed
|
!failed
|
||||||
) {
|
) {
|
||||||
failed = true;
|
failed = true;
|
||||||
clientRef.current = null;
|
const error = new Error("MTP connection lost");
|
||||||
setIdentified(false);
|
attemptAbort.abort(error);
|
||||||
setIdentifying(false);
|
if (connectionReady) {
|
||||||
scheduleReconnect(new Error("MTP connection lost"));
|
cleanup();
|
||||||
|
scheduleReconnect(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,9 +627,19 @@ function BrowserProvider(props: {
|
||||||
const activeClient = client;
|
const activeClient = client;
|
||||||
|
|
||||||
clientRef.current = activeClient;
|
clientRef.current = activeClient;
|
||||||
|
requestIdsRef.current = new RequestIdAllocator();
|
||||||
for (const type of PUSH_TYPES) {
|
for (const type of PUSH_TYPES) {
|
||||||
activeClient.subscribe(type, (message) => {
|
activeClient.subscribe(type, (message) => {
|
||||||
const validated = message as ProtocolMessage;
|
let validated: ProtocolMessage;
|
||||||
|
try {
|
||||||
|
validated = validateResponse(type, normalizeMtpMessage(message));
|
||||||
|
} catch (error) {
|
||||||
|
log(1, "mtp", "red", "Failed to validate push message", error, {
|
||||||
|
type,
|
||||||
|
data: message.data,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setFreshContacts((contacts) =>
|
setFreshContacts((contacts) =>
|
||||||
removeMissingContacts(contacts, validated),
|
removeMissingContacts(contacts, validated),
|
||||||
|
|
@ -408,71 +654,45 @@ function BrowserProvider(props: {
|
||||||
if (validated.type === "GetStates") {
|
if (validated.type === "GetStates") {
|
||||||
lastInitialStateRef.current = validated;
|
lastInitialStateRef.current = validated;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
validated.type === "ErrorNoIota" &&
|
||||||
|
clientRef.current === activeClient &&
|
||||||
|
!failed
|
||||||
|
) {
|
||||||
|
failed = true;
|
||||||
|
const error = new Error("No Iota is currently connected");
|
||||||
|
attemptAbort.abort(error);
|
||||||
|
cleanup();
|
||||||
|
scheduleReconnect(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setReadyState(activeClient.state);
|
|
||||||
|
|
||||||
clearReconnectTimer();
|
|
||||||
|
|
||||||
// Schedule reconnect reset
|
|
||||||
clearReconnectResetTimer();
|
|
||||||
reconnectResetTimer = setTimeout(() => {
|
|
||||||
attempts = 0;
|
|
||||||
reconnectResetTimer = null;
|
|
||||||
}, RECONNECT_RESET * 1_000);
|
|
||||||
|
|
||||||
setReadyState(activeClient.state);
|
setReadyState(activeClient.state);
|
||||||
setIdentifying(true);
|
setIdentifying(true);
|
||||||
|
|
||||||
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
|
const ids = requestIdsRef.current;
|
||||||
(resolve, reject) => {
|
if (!ids) throw new Error("MTP request allocator is unavailable");
|
||||||
let unsubscribeStateSync = () => {};
|
const finalResponse = await completeInitialSynchronization(
|
||||||
let unsubscribeNoIota = () => {};
|
activeClient,
|
||||||
const cleanupStateSync = () => {
|
ids,
|
||||||
clearTimeout(timeout);
|
attemptAbort.signal,
|
||||||
unsubscribeStateSync();
|
|
||||||
unsubscribeNoIota();
|
|
||||||
};
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
cleanupStateSync();
|
|
||||||
reject(new Error("Initial state synchronization timed out"));
|
|
||||||
}, 120_000);
|
|
||||||
unsubscribeStateSync = activeClient.subscribe(
|
|
||||||
"ClientStateSync",
|
|
||||||
(message) => {
|
|
||||||
cleanupStateSync();
|
|
||||||
resolve(message);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => {
|
|
||||||
cleanupStateSync();
|
|
||||||
reject(new Error("No Iota is currently connected"));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const [, finalResponse] = await Promise.all([
|
|
||||||
activeClient.auth(),
|
|
||||||
stateSync,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (finalResponse.type.startsWith("Error")) {
|
|
||||||
throw new Error(
|
|
||||||
`State synchronization failed: ${finalResponse.type}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await activeClient.request("ClientStateAck", {
|
|
||||||
SessionId: finalResponse.data.SessionId,
|
|
||||||
VersionNumber: finalResponse.data.VersionNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (disposed || clientRef.current !== activeClient) return;
|
if (disposed || clientRef.current !== activeClient) return;
|
||||||
|
|
||||||
setFreshContacts(finalResponse.data.Contacts);
|
setFreshContacts(finalResponse.data.Contacts);
|
||||||
setFreshCommunities(finalResponse.data.Communities);
|
setFreshCommunities(finalResponse.data.Communities);
|
||||||
setFreshCalls(finalResponse.data.Calls);
|
setFreshCalls(finalResponse.data.Calls);
|
||||||
|
connectionReady = true;
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
setIdentified(true);
|
setIdentified(true);
|
||||||
|
|
||||||
|
clearReconnectTimer();
|
||||||
|
clearReconnectResetTimer();
|
||||||
|
reconnectResetTimer = setTimeout(() => {
|
||||||
|
attempts = 0;
|
||||||
|
reconnectResetTimer = null;
|
||||||
|
}, RECONNECT_RESET * 1_000);
|
||||||
resolveConnectionRef.current?.();
|
resolveConnectionRef.current?.();
|
||||||
} catch (connectError) {
|
} catch (connectError) {
|
||||||
if (disposed || generation !== connectionGeneration) {
|
if (disposed || generation !== connectionGeneration) {
|
||||||
|
|
@ -490,7 +710,7 @@ function BrowserProvider(props: {
|
||||||
"mtp",
|
"mtp",
|
||||||
"red",
|
"red",
|
||||||
`Connection/authentication attempt failed: ${connectErrorMessage}`,
|
`Connection/authentication attempt failed: ${connectErrorMessage}`,
|
||||||
connectError,
|
getProtocolErrorDetails(connectError) ?? connectError,
|
||||||
);
|
);
|
||||||
|
|
||||||
scheduleReconnect(connectError);
|
scheduleReconnect(connectError);
|
||||||
|
|
@ -506,6 +726,7 @@ function BrowserProvider(props: {
|
||||||
|
|
||||||
clientRef.current?.disconnect();
|
clientRef.current?.disconnect();
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
|
requestIdsRef.current = null;
|
||||||
setReadyState(ConnectionState.Disconnected);
|
setReadyState(ConnectionState.Disconnected);
|
||||||
setIdentified(false);
|
setIdentified(false);
|
||||||
setIdentifying(false);
|
setIdentifying(false);
|
||||||
|
|
@ -567,9 +788,9 @@ function BrowserProvider(props: {
|
||||||
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
|
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
|
||||||
|
|
||||||
const sendQueued: BoundSendFn = useMemo(
|
const sendQueued: BoundSendFn = useMemo(
|
||||||
() => async (type, data, options) => {
|
() => async (type, data) => {
|
||||||
const mtp = await mtpRef.get();
|
const mtp = await mtpRef.get();
|
||||||
const response = await mtp.send(type, data, options);
|
const response = await mtp.send(type, data);
|
||||||
for (const interceptor of interceptorsRef.current) {
|
for (const interceptor of interceptorsRef.current) {
|
||||||
void Promise.resolve(
|
void Promise.resolve(
|
||||||
interceptor({ type, data, response: response as ProtocolMessage }),
|
interceptor({ type, data, response: response as ProtocolMessage }),
|
||||||
|
|
@ -611,39 +832,6 @@ type NativeSnapshot = {
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class TauriMTPAdapter implements MTPProxyAdapter {
|
|
||||||
readonly #subscriptions = new Map<string, Set<(message: MTPFrame) => void>>();
|
|
||||||
|
|
||||||
async request(
|
|
||||||
type: string,
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
options?: MTPRequestOptions,
|
|
||||||
) {
|
|
||||||
return await invoke<MTPFrame>("mtp_request", {
|
|
||||||
typeName: type,
|
|
||||||
data,
|
|
||||||
id: options?.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(type: string, handler: (message: MTPFrame) => void) {
|
|
||||||
const handlers =
|
|
||||||
this.#subscriptions.get(type) ?? new Set<(message: MTPFrame) => void>();
|
|
||||||
handlers.add(handler);
|
|
||||||
this.#subscriptions.set(type, handlers);
|
|
||||||
return () => {
|
|
||||||
handlers.delete(handler);
|
|
||||||
if (handlers.size === 0) this.#subscriptions.delete(type);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(message: MTPFrame) {
|
|
||||||
for (const handler of this.#subscriptions.get(message.type) ?? []) {
|
|
||||||
handler(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function TauriProvider(props: {
|
function TauriProvider(props: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
blockConnection?: boolean;
|
blockConnection?: boolean;
|
||||||
|
|
@ -664,16 +852,8 @@ function TauriProvider(props: {
|
||||||
pushHandlersRef,
|
pushHandlersRef,
|
||||||
subscribePush,
|
subscribePush,
|
||||||
} = useMessageHandlers();
|
} = useMessageHandlers();
|
||||||
const [adapter] = useState(() => new TauriMTPAdapter());
|
const subscriptionsRef = useRef(
|
||||||
const [connection] = useState(
|
new Map<string, Set<(message: ProtocolMessage) => void>>(),
|
||||||
() =>
|
|
||||||
new MTPProxyConnection<Schemas>(adapter, {
|
|
||||||
schemas,
|
|
||||||
throwProtocolErrors: true,
|
|
||||||
onValidationError: (error) => {
|
|
||||||
log(1, "mtp", "red", "Failed to validate native MTP message", error);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const applySnapshot = useCallback((next: NativeSnapshot) => {
|
const applySnapshot = useCallback((next: NativeSnapshot) => {
|
||||||
|
|
@ -682,49 +862,69 @@ function TauriProvider(props: {
|
||||||
if (next.error) {
|
if (next.error) {
|
||||||
log(0, "android", "orange", "MTP connection failed", next.error);
|
log(0, "android", "orange", "MTP connection failed", next.error);
|
||||||
}
|
}
|
||||||
setSnapshot(next);
|
if (!next.identified) {
|
||||||
if (!next.identified || next.state === undefined) return;
|
setSnapshot(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.state === undefined) {
|
||||||
|
setSnapshot({
|
||||||
|
...next,
|
||||||
|
identified: false,
|
||||||
|
error: "Native MTP connection omitted initial state",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
|
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
|
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
|
||||||
|
setSnapshot({
|
||||||
|
...next,
|
||||||
|
identified: false,
|
||||||
|
error: "Invalid ClientStateSync payload",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFreshContacts(parsed.data.Contacts);
|
setFreshContacts(parsed.data.Contacts);
|
||||||
setFreshCommunities(parsed.data.Communities);
|
setFreshCommunities(parsed.data.Communities);
|
||||||
setFreshCalls(parsed.data.Calls);
|
setFreshCalls(parsed.data.Calls);
|
||||||
|
setSnapshot(next);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dispatchMessage = useCallback(
|
const dispatchMessage = useCallback(
|
||||||
(raw: unknown) => {
|
(raw: unknown) => {
|
||||||
if (!raw || typeof raw !== "object" || !("type" in raw)) return;
|
if (!raw || typeof raw !== "object" || !("type" in raw)) return;
|
||||||
adapter.dispatch(raw as MTPFrame);
|
const message = raw as { id?: number; type: string; data: unknown };
|
||||||
},
|
let validated: ProtocolMessage;
|
||||||
[adapter],
|
try {
|
||||||
);
|
validated = validateResponse(
|
||||||
|
message.type as keyof Schemas & string,
|
||||||
useEffect(() => {
|
normalizeMtpMessage(message),
|
||||||
const unsubscribers = PUSH_TYPES.map((type) =>
|
|
||||||
connection.subscribe(type, (message) => {
|
|
||||||
const validated = message as ProtocolMessage;
|
|
||||||
setFreshContacts((contacts) =>
|
|
||||||
removeMissingContacts(contacts, validated),
|
|
||||||
);
|
);
|
||||||
for (const handler of [...pushHandlersRef.current]) {
|
} catch (error) {
|
||||||
void Promise.resolve(handler(validated)).catch((error) => {
|
log(1, "mtp", "red", "Failed to validate native MTP message", error);
|
||||||
log(1, "mtp", "red", "Native MTP push handler failed", error, {
|
return;
|
||||||
type,
|
}
|
||||||
});
|
for (const handler of subscriptionsRef.current.get(validated.type) ??
|
||||||
|
[]) {
|
||||||
|
handler(validated);
|
||||||
|
}
|
||||||
|
if (!isPushType(validated.type)) return;
|
||||||
|
setFreshContacts((contacts) =>
|
||||||
|
removeMissingContacts(contacts, validated),
|
||||||
|
);
|
||||||
|
for (const handler of [...pushHandlersRef.current]) {
|
||||||
|
void Promise.resolve(handler(validated)).catch((error) => {
|
||||||
|
log(1, "mtp", "red", "Native MTP push handler failed", error, {
|
||||||
|
type: validated.type,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
if (validated.type === "GetStates") {
|
}
|
||||||
lastInitialStateRef.current = validated;
|
if (validated.type === "GetStates") {
|
||||||
}
|
lastInitialStateRef.current = validated;
|
||||||
}),
|
}
|
||||||
);
|
},
|
||||||
return () => {
|
[lastInitialStateRef, pushHandlersRef],
|
||||||
for (const unsubscribe of unsubscribers) unsubscribe();
|
);
|
||||||
};
|
|
||||||
}, [connection, lastInitialStateRef, pushHandlersRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.blockConnection) return;
|
if (props.blockConnection) return;
|
||||||
|
|
@ -800,11 +1000,26 @@ function TauriProvider(props: {
|
||||||
}, [props.blockConnection]);
|
}, [props.blockConnection]);
|
||||||
|
|
||||||
const send = useCallback<BoundSendFn>(
|
const send = useCallback<BoundSendFn>(
|
||||||
async (type, data, options) => {
|
async (type, data) => {
|
||||||
const validated = await connection.request(type, data, options);
|
const response = await invoke<ProtocolMessage>("mtp_request", {
|
||||||
|
typeName: type,
|
||||||
|
data: data ?? {},
|
||||||
|
});
|
||||||
|
const validated = validateResponse(type, normalizeMtpMessage(response));
|
||||||
setFreshContacts((contacts) =>
|
setFreshContacts((contacts) =>
|
||||||
removeMissingContacts(contacts, validated),
|
removeMissingContacts(contacts, validated),
|
||||||
);
|
);
|
||||||
|
if (validated.type.startsWith("Error")) {
|
||||||
|
const errorData = validated.data as Record<string, unknown>;
|
||||||
|
throw new ProtocolError({
|
||||||
|
type: validated.type,
|
||||||
|
requestId: validated.id,
|
||||||
|
errorType:
|
||||||
|
typeof errorData.ErrorType === "string"
|
||||||
|
? errorData.ErrorType
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const interceptor of interceptorsRef.current) {
|
for (const interceptor of interceptorsRef.current) {
|
||||||
void Promise.resolve(
|
void Promise.resolve(
|
||||||
interceptor({ type, data, response: validated as ProtocolMessage }),
|
interceptor({ type, data, response: validated as ProtocolMessage }),
|
||||||
|
|
@ -814,13 +1029,20 @@ function TauriProvider(props: {
|
||||||
}
|
}
|
||||||
return validated;
|
return validated;
|
||||||
},
|
},
|
||||||
[connection, interceptorsRef],
|
[interceptorsRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscribe = useCallback<ContextType["subscribe"]>(
|
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
|
||||||
(type, handler) => connection.subscribe(type, handler),
|
const handlers =
|
||||||
[connection],
|
subscriptionsRef.current.get(type) ??
|
||||||
);
|
new Set<(message: ProtocolMessage) => void>();
|
||||||
|
handlers.add(handler as (message: ProtocolMessage) => void);
|
||||||
|
subscriptionsRef.current.set(type, handlers);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler as (message: ProtocolMessage) => void);
|
||||||
|
if (handlers.size === 0) subscriptionsRef.current.delete(type);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const connected = snapshot.readyState === ConnectionState.Connected;
|
const connected = snapshot.readyState === ConnectionState.Connected;
|
||||||
const contextReady = connected && snapshot.identified;
|
const contextReady = connected && snapshot.identified;
|
||||||
|
|
||||||
|
|
@ -847,7 +1069,7 @@ function TauriProvider(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowserProviderLoader(props: {
|
export function Provider(props: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
blockConnection?: boolean;
|
blockConnection?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -872,17 +1094,10 @@ function BrowserProviderLoader(props: {
|
||||||
if (wasmError) throw wasmError;
|
if (wasmError) throw wasmError;
|
||||||
if (!wasmReady) return null;
|
if (!wasmReady) return null;
|
||||||
|
|
||||||
return <BrowserProvider {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider(props: {
|
|
||||||
children: ReactNode;
|
|
||||||
blockConnection?: boolean;
|
|
||||||
}) {
|
|
||||||
return isTauri() ? (
|
return isTauri() ? (
|
||||||
<TauriProvider {...props} />
|
<TauriProvider {...props} />
|
||||||
) : (
|
) : (
|
||||||
<BrowserProviderLoader {...props} />
|
<BrowserProvider {...props} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { Provider, useMTP } from "./context";
|
export { Provider, useMTP } from "./context";
|
||||||
|
export { RequestIdAllocator } from "./requestIds";
|
||||||
export type {
|
export type {
|
||||||
BoundSendFn,
|
BoundSendFn,
|
||||||
MTPExchange,
|
MTPExchange,
|
||||||
|
|
|
||||||
44
packages/mtp/src/protocolFields.test.ts
Normal file
44
packages/mtp/src/protocolFields.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { fromWireData, toWireData } from "./protocolFields";
|
||||||
|
|
||||||
|
describe("MTP protocol field translation", () => {
|
||||||
|
it("maps application message fields to MTP wire fields", () => {
|
||||||
|
expect(
|
||||||
|
toWireData({
|
||||||
|
Messages: [{ Content: "abc", MessageId: 4 }],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
Messages: [{ AppContent: "abc", AppMessageId: 4 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps MTP wire fields back to application fields", () => {
|
||||||
|
expect(
|
||||||
|
fromWireData({
|
||||||
|
AppCreatedAt: 123,
|
||||||
|
Message: { AppContent: "abc", AppMessageId: 4 },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
CreatedAt: 123,
|
||||||
|
Message: { Content: "abc", MessageId: 4 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves byte arrays and unrelated fields", () => {
|
||||||
|
const bytes = new Uint8Array([1, 2, 3]);
|
||||||
|
const translated = toWireData({ Payload: bytes, Other: "value" }) as {
|
||||||
|
Payload: Uint8Array;
|
||||||
|
Other: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(translated.Payload).toBe(bytes);
|
||||||
|
expect(translated.Other).toBe("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects field mapping collisions", () => {
|
||||||
|
expect(() => toWireData({ Content: "a", AppContent: "b" })).toThrow(
|
||||||
|
"MTP field translation collision",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
packages/mtp/src/protocolFields.ts
Normal file
58
packages/mtp/src/protocolFields.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
const APPLICATION_TO_WIRE_FIELDS = {
|
||||||
|
Content: "AppContent",
|
||||||
|
CreatedAt: "AppCreatedAt",
|
||||||
|
MessageId: "AppMessageId",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const WIRE_TO_APPLICATION_FIELDS = {
|
||||||
|
AppContent: "Content",
|
||||||
|
AppCreatedAt: "CreatedAt",
|
||||||
|
AppMessageId: "MessageId",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function mapProtocolFields(
|
||||||
|
value: unknown,
|
||||||
|
fieldMap: Readonly<Record<string, string>>,
|
||||||
|
): unknown {
|
||||||
|
if (
|
||||||
|
value === null ||
|
||||||
|
typeof value !== "object" ||
|
||||||
|
value instanceof Uint8Array
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => mapProtocolFields(item, fieldMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = value as Record<string, unknown>;
|
||||||
|
const target: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, child] of Object.entries(source)) {
|
||||||
|
const mappedKey = fieldMap[key] ?? key;
|
||||||
|
|
||||||
|
if (mappedKey in target) {
|
||||||
|
throw new Error(`MTP field translation collision for ${mappedKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
target[mappedKey] = mapProtocolFields(child, fieldMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toWireData(value: unknown): unknown {
|
||||||
|
return mapProtocolFields(value, APPLICATION_TO_WIRE_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromWireData(value: unknown): unknown {
|
||||||
|
return mapProtocolFields(value, WIRE_TO_APPLICATION_FIELDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromWireMessage<T extends { data: unknown }>(message: T): T {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
data: fromWireData(message.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
25
packages/mtp/src/requestIds.test.ts
Normal file
25
packages/mtp/src/requestIds.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { RequestIdAllocator } from "./requestIds";
|
||||||
|
|
||||||
|
describe("MTP request ID allocation", () => {
|
||||||
|
it("allocates nonzero request IDs monotonically", () => {
|
||||||
|
const ids = new RequestIdAllocator();
|
||||||
|
|
||||||
|
expect(ids.allocate()).toBe(1);
|
||||||
|
expect(ids.allocate()).toBe(2);
|
||||||
|
expect(new RequestIdAllocator().allocate()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not wrap exhausted request IDs", () => {
|
||||||
|
const ids = new RequestIdAllocator(0x1_0000_0000);
|
||||||
|
|
||||||
|
expect(() => ids.allocate()).toThrow("MTP request ID space exhausted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid allocator states", () => {
|
||||||
|
expect(() => new RequestIdAllocator(0)).toThrow(
|
||||||
|
"invalid MTP request ID allocator state",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
packages/mtp/src/requestIds.ts
Normal file
24
packages/mtp/src/requestIds.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const MAX_MTP_REQUEST_ID = 0xffff_ffff;
|
||||||
|
|
||||||
|
export class RequestIdAllocator {
|
||||||
|
#next: number;
|
||||||
|
|
||||||
|
constructor(next = 1) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(next) ||
|
||||||
|
next <= 0 ||
|
||||||
|
next > MAX_MTP_REQUEST_ID + 1
|
||||||
|
) {
|
||||||
|
throw new RangeError("invalid MTP request ID allocator state");
|
||||||
|
}
|
||||||
|
this.#next = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
allocate(): number {
|
||||||
|
if (this.#next > MAX_MTP_REQUEST_ID) {
|
||||||
|
throw new Error("MTP request ID space exhausted for this connection");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#next++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
export const RETRY_INTERVAL = 3_000;
|
export const RETRY_INTERVAL = 3_000;
|
||||||
export const RECONNECT_TRIES = 3;
|
export const RECONNECT_TRIES = 3;
|
||||||
export const RECONNECT_RESET = 6;
|
export const RECONNECT_RESET = 6;
|
||||||
|
export const RECONNECT_LONG_INTERVAL = 60_000;
|
||||||
|
export const RECONNECT_JITTER = 0.2;
|
||||||
|
export const DISCOVERY_TIMEOUT = 20_000;
|
||||||
|
export const INITIAL_SYNC_TIMEOUT = 120_000;
|
||||||
|
export const STATE_ACK_TIMEOUT = 30_000;
|
||||||
|
|
|
||||||
|
|
@ -87,15 +87,9 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
moveUserIdToTop(data.SenderId);
|
moveUserIdToTop(data.SenderId);
|
||||||
|
|
||||||
if (await load("settings.receive_confirmations")) {
|
if (await load("settings.receive_confirmations")) {
|
||||||
void send(
|
void send("MessageState", {
|
||||||
"MessageState",
|
MessageState: "received",
|
||||||
{
|
});
|
||||||
MessageState: "received",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: data.Message.SendTime,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
|
import { Outlet, useLocation } from "@tanstack/react-router";
|
||||||
import { Button, ClearStorageButton, cn, useIsMobile } from "@methanium/ui";
|
import { Button, useIsMobile } from "@methanium/ui";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
import { settingsNavigation } from "./manifest";
|
import { SettingsSidebar } from "./sidebar";
|
||||||
|
|
||||||
export default function SettingsLayout() {
|
export default function SettingsLayout() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
@ -36,47 +36,3 @@ export default function SettingsLayout() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSidebar({
|
|
||||||
mobile = false,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
mobile?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const categories = [
|
|
||||||
...new Set(settingsNavigation.map((page) => page.category)),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
|
||||||
"flex flex-col gap-6",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<div key={category} className="flex flex-col gap-2">
|
|
||||||
<h2 className="font-bold text-xs uppercase">{category}</h2>
|
|
||||||
{settingsNavigation
|
|
||||||
.filter((page) => page.category === category)
|
|
||||||
.map((page) => (
|
|
||||||
<Button
|
|
||||||
key={page.path}
|
|
||||||
className="w-full"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate({ to: `/settings/${page.path}` })}
|
|
||||||
>
|
|
||||||
{page.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="mt-auto">
|
|
||||||
<ClearStorageButton className="w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,46 +8,24 @@ import Profile from "./pages/profile";
|
||||||
import Security from "./pages/security";
|
import Security from "./pages/security";
|
||||||
import Theme from "./pages/theme";
|
import Theme from "./pages/theme";
|
||||||
import Hotkeys from "./pages/hotkeys";
|
import Hotkeys from "./pages/hotkeys";
|
||||||
|
import { settingsNavigation } from "./navigation";
|
||||||
|
|
||||||
|
const pageComponents = {
|
||||||
|
profile: Profile,
|
||||||
|
security: Security,
|
||||||
|
chat: Chat,
|
||||||
|
call: Call,
|
||||||
|
cache: Cache,
|
||||||
|
theme: Theme,
|
||||||
|
accessibility: Accessibility,
|
||||||
|
hotkeys: Hotkeys,
|
||||||
|
licenses: Licenses,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const settingsPages = [
|
export const settingsPages = [
|
||||||
{ path: "/", component: Index },
|
{ path: "/", component: Index },
|
||||||
{
|
...settingsNavigation.map((page) => ({
|
||||||
category: "account",
|
...page,
|
||||||
path: "profile",
|
component: pageComponents[page.path],
|
||||||
label: "Profile",
|
})),
|
||||||
component: Profile,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "account",
|
|
||||||
path: "security",
|
|
||||||
label: "Security",
|
|
||||||
component: Security,
|
|
||||||
},
|
|
||||||
{ category: "general", path: "chat", label: "Chat", component: Chat },
|
|
||||||
{ category: "general", path: "call", label: "Call", component: Call },
|
|
||||||
{ category: "application", path: "cache", label: "Cache", component: Cache },
|
|
||||||
{ category: "application", path: "theme", label: "Theme", component: Theme },
|
|
||||||
{
|
|
||||||
category: "application",
|
|
||||||
path: "accessibility",
|
|
||||||
label: "Accessibility",
|
|
||||||
component: Accessibility,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "application",
|
|
||||||
path: "hotkeys",
|
|
||||||
label: "Hotkeys",
|
|
||||||
component: Hotkeys,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "application",
|
|
||||||
path: "licenses",
|
|
||||||
label: "Licenses",
|
|
||||||
component: Licenses,
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const settingsNavigation = settingsPages.filter(
|
|
||||||
(page): page is Exclude<(typeof settingsPages)[number], { path: "/" }> =>
|
|
||||||
page.path !== "/",
|
|
||||||
);
|
|
||||||
|
|
|
||||||
31
packages/settings/src/navigation.ts
Normal file
31
packages/settings/src/navigation.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
export const settingsNavigation = [
|
||||||
|
{
|
||||||
|
category: "account",
|
||||||
|
path: "profile",
|
||||||
|
label: "Profile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "account",
|
||||||
|
path: "security",
|
||||||
|
label: "Security",
|
||||||
|
},
|
||||||
|
{ category: "general", path: "chat", label: "Chat" },
|
||||||
|
{ category: "general", path: "call", label: "Call" },
|
||||||
|
{ category: "application", path: "cache", label: "Cache" },
|
||||||
|
{ category: "application", path: "theme", label: "Theme" },
|
||||||
|
{
|
||||||
|
category: "application",
|
||||||
|
path: "accessibility",
|
||||||
|
label: "Accessibility",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "application",
|
||||||
|
path: "hotkeys",
|
||||||
|
label: "Hotkeys",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "application",
|
||||||
|
path: "licenses",
|
||||||
|
label: "Licenses",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { SettingsSidebar } from "../layout";
|
import { SettingsSidebar } from "../sidebar";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
48
packages/settings/src/sidebar.tsx
Normal file
48
packages/settings/src/sidebar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Button, ClearStorageButton, cn } from "@methanium/ui";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { settingsNavigation } from "./navigation";
|
||||||
|
|
||||||
|
export function SettingsSidebar({
|
||||||
|
mobile = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
mobile?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const categories = [
|
||||||
|
...new Set(settingsNavigation.map((page) => page.category)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
||||||
|
"flex flex-col gap-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<div key={category} className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-bold text-xs uppercase">{category}</h2>
|
||||||
|
{settingsNavigation
|
||||||
|
.filter((page) => page.category === category)
|
||||||
|
.map((page) => (
|
||||||
|
<Button
|
||||||
|
key={page.path}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate({ to: `/settings/${page.path}` })}
|
||||||
|
>
|
||||||
|
{page.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="mt-auto">
|
||||||
|
<ClearStorageButton className="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
774
pnpm-lock.yaml
generated
774
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,4 +7,4 @@ allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
overrides:
|
overrides:
|
||||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
|
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
|
||||||
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz"
|
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,14 @@ function getPackageDirs(dir: string): string[] {
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry);
|
const fullPath = join(dir, entry);
|
||||||
if (!statSync(fullPath).isDirectory()) continue;
|
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = statSync(fullPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!stats.isDirectory()) continue;
|
||||||
|
|
||||||
if (existsSync(join(fullPath, "package.json"))) {
|
if (existsSync(join(fullPath, "package.json"))) {
|
||||||
dirs.push(fullPath);
|
dirs.push(fullPath);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,14 @@ function getPackageDirs(dir: string): string[] {
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry);
|
const fullPath = join(dir, entry);
|
||||||
if (!statSync(fullPath).isDirectory()) continue;
|
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = statSync(fullPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!stats.isDirectory()) continue;
|
||||||
|
|
||||||
if (existsSync(join(fullPath, "package.json"))) {
|
if (existsSync(join(fullPath, "package.json"))) {
|
||||||
dirs.push(fullPath);
|
dirs.push(fullPath);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,14 @@ function getPackageDirs(dir: string): string[] {
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry);
|
const fullPath = join(dir, entry);
|
||||||
if (!statSync(fullPath).isDirectory()) continue;
|
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = statSync(fullPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!stats.isDirectory()) continue;
|
||||||
|
|
||||||
if (existsSync(join(fullPath, "package.json"))) {
|
if (existsSync(join(fullPath, "package.json"))) {
|
||||||
dirs.push(fullPath);
|
dirs.push(fullPath);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue