Compare commits

..
Author SHA1 Message Date
ab2ac4813a Merge pull request 'feat(pwa): remove install prompt' (#76) from dev into main
All checks were successful
/ build-web (push) Successful in 5m0s
/ build-desktop (linux) (push) Successful in 11m28s
/ build-mobile (push) Successful in 29m19s
/ release (push) Successful in 2m5s
Reviewed-on: #76
2026-08-30 16:59:43 +03:00
0c0a341f1e
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
All checks were successful
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 5m22s
/ build-desktop (linux) (push) Successful in 10m44s
/ build-mobile (push) Successful in 28m17s
/ release (push) Successful in 2m4s
2026-08-30 15:58:40 +02:00
f74ca52f4f
feat(nix-flake): rework nix package 2026-08-30 15:58:34 +02:00
Alex Emmet
94f11f60f6
Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
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 6m37s
/ build-desktop (linux) (push) Successful in 12m5s
/ build-mobile (push) Successful in 25m44s
/ release (push) Successful in 1m58s
2026-08-29 16:11:40 +02:00
Alex Emmet
f5461bbd42
chore(mtp): update type-maps 2026-08-29 16:11:24 +02:00
7c9d9529b4
feat(workflows): allow manual runs for the dev deploy
All checks were successful
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 7m13s
/ build-desktop (linux) (push) Successful in 14m39s
/ build-mobile (push) Successful in 38m15s
/ release (push) Successful in 3m8s
2026-08-29 12:57:44 +02:00
8a04b327fc
chore(mtp): update type-maps
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-mobile (push) Failing after 1m44s
/ build-desktop (linux) (push) Failing after 10m56s
/ build-web (push) Failing after 10m56s
/ release (push) Has been cancelled
2026-08-28 20:02:35 +02:00
0a304e44f2
feat(mtp): move useful stuff over to mtp directly
All checks were successful
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 6m14s
/ build-desktop (linux) (push) Successful in 11m36s
/ build-mobile (push) Successful in 24m26s
/ release (push) Successful in 1m39s
2026-08-27 23:30:33 +02:00
094cb910aa
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
# Conflicts:
#	apps/pwa/src/runtime.tsx
#	flake.nix
#	packages/mtp/src/context.test.tsx
#	packages/mtp/src/context.tsx
2026-08-27 19:39:52 +02:00
cf17365c8f
Merge branch 'main' into dev 2026-08-27 19:36:41 +02:00
aa34bd962b
temp(mtp): temp commit stuff that needs to get a rework 2026-08-27 19:35:17 +02:00
1d1b304bcf
feat(workflows): remove env thing 2026-08-27 19:32:48 +02:00
forgejo-actions
0fbedf6733 (qol): update release flake hash
Some checks were skipped
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
2026-08-27 12:59:08 +00:00
96b81d30b1
feat(pwa): keep service workers from getting registerd outside the pwa
Some checks failed
/ build-web (push) Successful in 5m32s
/ build-desktop (linux) (push) Successful in 9m55s
/ build-mobile (push) Successful in 18m33s
/ release (push) Failing after 1m42s
2026-08-27 14:38:53 +02:00
edcf0e15dd
chore(mtp): update type-maps
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 6m3s
/ build-desktop (linux) (push) Successful in 11m9s
/ build-mobile (push) Failing after 11m15s
/ release (push) Has been skipped
2026-08-25 21:00:32 +02:00
5056f084b4
chore(mtp): update type-maps
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-desktop (linux) (push) Failing after 4s
/ build-web (push) Failing after 4s
/ build-mobile (push) Failing after 5s
/ release (push) Has been skipped
2026-08-25 20:58:32 +02:00
ede0199858
chore(mtp): udpate type maps
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) Failing after 3m37s
/ build-desktop (linux) (push) Failing after 3m58s
/ build-mobile (push) Failing after 6m4s
/ release (push) Has been skipped
2026-08-25 20:48:56 +02:00
88a094c8bf
feat(pwa): remove update toast
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
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-25 20:46:14 +02:00
e43e03ff12
Merge remote-tracking branch 'refs/remotes/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
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
2026-08-20 20:56:38 +02:00
97270abed8
Update workflows 2026-08-20 20:56:30 +02:00
2a55c87df1
[Updt] Mtp 0.3.0
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) Failing after 3m32s
/ build-desktop (linux) (push) Failing after 2m47s
/ build-mobile (push) Failing after 5m29s
/ release (push) Has been skipped
2026-08-20 17:05:53 +02:00
forgejo-actions
57c7ceb27a (qol): update release flake hash 2026-08-18 20:46:45 +00:00
forgejo-actions
d0918ff384 (qol): update release flake hash 2026-08-18 20:46:44 +00:00
caa7d65b84
feat(pwa): remove install prompt
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 5m40s
/ build-desktop (linux) (push) Successful in 10m33s
/ build-mobile (push) Successful in 23m6s
/ release (push) Successful in 2m15s
2026-08-18 21:40:57 +02:00
0ac20f09e4 Merge pull request 'Add pwa & stuff' (#75) from dev into main
All checks were successful
/ build-web (push) Successful in 5m44s
/ build-desktop (linux) (push) Successful in 10m41s
/ build-mobile (push) Successful in 20m49s
/ release (push) Successful in 1m46s
Reviewed-on: #75
2026-08-18 22:32:16 +03:00
7b36218ffa
feat(pwa): add base
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
2026-08-18 18:44:45 +02:00
73db7e6b2b Update flake.nix 2026-08-12 15:12:05 +02:00
6e977bca7e
feat(mobile): use drawer for gif picker
Some checks failed
/ build-desktop (linux) (push) Failing after 6m36s
/ build-web (push) Failing after 7m7s
/ build-mobile (push) Failing after 9m21s
/ release (push) Has been skipped
fix(mobile): some ui bugs
fix(mobile): disable pinch zooming
2026-08-12 02:16:56 +02:00
eaf3f61ebd
feat(android): add zoom slider
All checks were successful
/ build-web (push) Successful in 8m21s
/ build-desktop (linux) (push) Successful in 18m41s
/ build-mobile (push) Successful in 44m57s
/ release (push) Successful in 9m27s
fix(android): gray screen bug
2026-08-11 15:50:52 +02:00
6f4cc62530
feat(gif-picker): add gif groups
Some checks failed
/ build-web (push) Successful in 8m40s
/ build-mobile (push) Failing after 11m17s
/ build-desktop (linux) (push) Successful in 14m8s
/ release (push) Has been skipped
feat(gif-picker): increase default size
2026-08-11 13:01:09 +02:00
eaeff1b8d5
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Successful in 8m15s
/ build-mobile (push) Failing after 11m13s
/ build-desktop (linux) (push) Successful in 13m21s
/ release (push) Has been skipped
2026-08-10 15:27:53 +02:00
4016181db9
feat(mtp): update type maps
fix(renovate): workflow not pulling submodules
2026-08-10 15:27:47 +02:00
2299555fe6 Merge pull request 'chore(deps): update dependency com.android.tools.build:gradle to v9' (#73) from renovate/com.android.tools.build-gradle-9.x into dev
Some checks failed
/ build-web (push) Successful in 12m49s
/ build-mobile (push) Failing after 15m57s
/ build-desktop (linux) (push) Successful in 17m37s
/ release (push) Has been skipped
2026-08-10 14:00:35 +02:00
f50b4eaebe chore(deps): update dependency com.android.tools.build:gradle to v9
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 9m12s
Dependency builds / Build desktop (pull_request) Failing after 10m6s
Dependency builds / Build mobile (pull_request) Failing after 15m5s
2026-08-10 14:00:33 +02:00
825be4eb33
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Successful in 8m17s
/ build-mobile (push) Failing after 10m56s
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-10 13:48:42 +02:00
1c90a0c859
feat(status): make the set-status menu work
feat(user): optimise get() function for react
qol(todos): update todos
2026-08-10 13:48:32 +02:00
6dee7e8dcc Merge pull request 'chore(deps): update rust crate reqwest to 0.13' (#72) from renovate/reqwest-0.x into dev
Some checks failed
/ build-web (push) Successful in 12m8s
/ build-mobile (push) Failing after 15m1s
/ build-desktop (linux) (push) Successful in 17m16s
/ release (push) Has been skipped
2026-08-10 13:00:41 +02:00
41258a8483 chore(deps): update rust crate reqwest to 0.13
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 8m47s
Dependency builds / Build desktop (pull_request) Failing after 8m21s
Dependency builds / Build mobile (pull_request) Failing after 13m21s
2026-08-10 13:00:39 +02:00
746f0cc9a4 Merge pull request 'chore(deps): update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v2.4.10' (#71) from renovate/org.jetbrains.kotlin-kotlin-gradle-plugin-2.x into dev
Some checks failed
/ build-web (push) Successful in 10m21s
/ build-mobile (push) Failing after 19m34s
/ build-desktop (linux) (push) Successful in 19m43s
/ release (push) Has been skipped
2026-08-10 12:00:38 +02:00
c1baf3576d chore(deps): update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v2.4.10
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 12m5s
Dependency builds / Build mobile (pull_request) Failing after 16m5s
Dependency builds / Build desktop (pull_request) Failing after 16m13s
2026-08-10 12:00:36 +02:00
7ffbab6b08 Merge pull request 'chore(deps): update tauri-build digest to 2f11853' (#70) from renovate/tauri-build-digest into dev
All checks were successful
/ build-web (push) Successful in 13m53s
/ build-desktop (linux) (push) Successful in 19m49s
/ build-mobile (push) Successful in 30m21s
/ release (push) Successful in 3m24s
2026-08-10 11:00:37 +02:00
f1af0f210d chore(deps): update tauri-build digest to e2e585a
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 10m29s
Dependency builds / Build desktop (pull_request) Failing after 11m47s
Dependency builds / Build mobile (pull_request) Failing after 15m0s
2026-08-10 11:00:32 +02:00
forgejo-actions
ac1bdf5ac2 (qol): update release flake hash 2026-08-06 14:47:23 +00:00
134 changed files with 9684 additions and 3512 deletions

2
.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[env]
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }

View file

@ -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"
], ],

View file

@ -4,7 +4,6 @@ on:
pull_request: pull_request:
env: env:
NIX_CONFIG: experimental-features = nix-command flakes
FORGEJO_TOKEN: "" FORGEJO_TOKEN: ""
GITHUB_TOKEN: "" GITHUB_TOKEN: ""
@ -14,10 +13,10 @@ 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
- run: git submodule update --init --recursive
- run: nix develop .#electron --command pnpm install --frozen-lockfile - run: nix develop .#electron --command pnpm install --frozen-lockfile
- 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:web - run: nix develop .#electron --command pnpm run build:web
@ -27,23 +26,35 @@ 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
- run: git submodule update --init --recursive
- run: nix develop .#electron --command pnpm install --frozen-lockfile - run: nix develop .#electron --command pnpm install --frozen-lockfile
- 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 - uses: https://data.forgejo.org/actions/checkout@v4
with: with:
persist-credentials: false 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
with:
persist-credentials: false
- run: git submodule update --init --recursive
- run: nix develop .#tauri --command pnpm install --frozen-lockfile - run: nix develop .#tauri --command pnpm install --frozen-lockfile
- run: nix develop .#tauri --command pnpm run build:packages - run: nix develop .#tauri --command pnpm run build:packages
- run: nix develop .#tauri --command pnpm --dir apps/tauri run build:mobile:ci - run: nix develop .#tauri --command pnpm --dir apps/tauri run build:mobile:ci

View file

@ -1,20 +1,15 @@
on: on:
workflow_dispatch:
push: push:
branches: branches:
- dev - dev
paths-ignore: paths-ignore:
- flake.nix - flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs: 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
@ -39,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
@ -62,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
@ -128,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
@ -181,9 +168,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:

View file

@ -6,16 +6,10 @@ on:
paths-ignore: paths-ignore:
- flake.nix - flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs: 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
@ -40,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
@ -63,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
@ -129,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
@ -180,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
@ -328,58 +311,3 @@ jobs:
"$API/repos/$REPO/releases/$release_id" "$API/repos/$REPO/releases/$release_id"
done < "$DELETE_RELEASES" done < "$DELETE_RELEASES"
EOF EOF
- name: Update root flake release hash
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -eu
DEB="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-amd64.deb' -print -quit)"
test -n "$DEB"
HASH="$(node -e 'const fs = require("fs"); const crypto = require("crypto"); const file = process.argv[1]; console.log("sha256-" + crypto.createHash("sha256").update(fs.readFileSync(file)).digest("base64"));' "$DEB")"
export HASH
node -e '
const fs = require("fs");
const version = process.env.TAG;
const hash = process.env.HASH;
let content = fs.readFileSync("flake.nix", "utf8");
content = content.replace(/version = "[^"]+";/, `version = "${version}";`);
content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash on main."
else
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push
fi
git fetch origin dev
git worktree add ../dev-flake-update origin/dev
cd ../dev-flake-update
node -e '
const fs = require("fs");
const version = process.env.TAG;
const hash = process.env.HASH;
let content = fs.readFileSync("flake.nix", "utf8");
content = content.replace(/version = "[^"]+";/, `version = "${version}";`);
content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash on dev."
exit 0
fi
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push origin HEAD:dev
EOF

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

36
apps/pwa/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "@tensamin/pwa",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./vite": "./src/vite.ts",
"./runtime": "./src/runtime.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.worker.json --noEmit"
},
"dependencies": {
"@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"mtp": "*",
"react": "^19.2.8",
"sonner": "^2.0.7",
"vite-plugin-pwa": "^1.1.0",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0"
},
"devDependencies": {
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"typescript": "~6.0.3",
"vite": "^8.2.1"
}
}

189
apps/pwa/src/runtime.tsx Normal file
View file

@ -0,0 +1,189 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { setDatabaseEntry } from "@tensamin/shared/indexedDb";
import { isTauri } from "@tauri-apps/api/core";
import "./style.css";
const launchedFiles: File[] = [];
const fileListeners = new Set<(file: File) => void>();
function emitLaunchedFile(file: File) {
if (fileListeners.size === 0) launchedFiles.push(file);
else for (const listener of fileListeners) listener(file);
}
export function subscribeTuFileLaunch(listener: (file: File) => void) {
fileListeners.add(listener);
for (const file of launchedFiles.splice(0)) listener(file);
return () => {
fileListeners.delete(listener);
};
}
function isInstalledPwa() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: window-controls-overlay)").matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function applicationServerKey(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}
async function enablePush() {
if (!("Notification" in window))
throw new Error("Notifications are not supported by this browser.");
const permission = await Notification.requestPermission();
if (permission !== "granted")
throw new Error("Notification permission was not granted.");
const publicKey = import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY;
if (
!publicKey ||
!("serviceWorker" in navigator) ||
!("PushManager" in window)
) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey(publicKey),
}));
await setDatabaseEntry("keys", "push-subscription", subscription.toJSON());
}
function InstalledPwaRuntime() {
useEffect(() => {
if (
!("serviceWorker" in navigator) ||
!["http:", "https:"].includes(window.location.protocol)
) {
return;
}
let reloading = false;
const handleControllerChange = () => {
if (reloading) return;
reloading = true;
// A newly activated service worker must reload the document it controls.
// eslint-disable-next-line tensamin/no-window-location-reload
window.location.reload();
};
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange,
);
void navigator.serviceWorker
.register(
import.meta.env.DEV ? "/dev-sw.js?dev-sw" : "/serviceWorker.js",
{
type: "module",
},
)
.then((registration) => {
const watchWorker = (worker: ServiceWorker) => {
worker.addEventListener("statechange", () => {
if (worker.state !== "installed") return;
if (!navigator.serviceWorker.controller) {
toast.success("Tensamin is ready for offline startup");
return;
}
toast("A Tensamin update is ready", {
duration: Infinity,
action: {
label: "Update",
onClick: () => worker.postMessage({ type: "SKIP_WAITING" }),
},
});
});
};
if (registration.installing) watchWorker(registration.installing);
registration.addEventListener("updatefound", () => {
if (registration.installing) watchWorker(registration.installing);
});
})
.catch((error: unknown) => {
console.error("Failed to register the Tensamin service worker", error);
});
return () => {
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange,
);
};
}, []);
useEffect(() => {
const launchQueue = (
window as Window & {
launchQueue?: {
setConsumer: (
consumer: (params: {
files?: Array<{ getFile: () => Promise<File> }>;
}) => void,
) => void;
};
}
).launchQueue;
launchQueue?.setConsumer((params) => {
for (const handle of params.files ?? []) {
void handle.getFile().then(emitLaunchedFile);
}
});
}, []);
useEffect(() => {
if (
!("Notification" in window) ||
Notification.permission !== "default" ||
localStorage.getItem("pwa-push-hint")
) {
return;
}
localStorage.setItem("pwa-push-hint", "shown");
toast("Enable message notifications", {
duration: Infinity,
action: {
label: "Enable",
onClick: () => {
void enablePush()
.then(() => toast.success("Notifications enabled"))
.catch((error: unknown) =>
toast.error(
error instanceof Error
? error.message
: "Could not enable notifications",
),
);
},
},
});
}, []);
useEffect(() => {
if (
"Notification" in window &&
Notification.permission === "granted" &&
import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY
) {
void enablePush().catch((error: unknown) => {
console.error("Failed to refresh the Web Push subscription", error);
});
}
}, []);
return null;
}
export default function PwaRuntime() {
if (isTauri() || !isInstalledPwa()) return null;
return <InstalledPwaRuntime />;
}

View file

@ -0,0 +1,161 @@
/// <reference lib="webworker" />
import { base64ToBytes } from "mtp";
import { clientsClaim } from "workbox-core";
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { createHandlerBoundToURL } from "workbox-precaching";
import { CacheFirst } from "workbox-strategies";
import { decryptChatText, unwrapChatSecret } from "@tensamin/crypto/chatSecret";
import { loadSecureBrowserValue } from "@tensamin/storage/browserSecure";
declare let self: ServiceWorkerGlobalScope;
type PushPayload = {
version: 1;
senderId: number;
sender: string;
avatar?: string;
message: { content: string };
secret: {
chatId: string;
secretId: string;
version: number;
encryptedSecret: string;
kemCiphertext: string;
wrappingScheme: string;
};
};
function isPushPayload(value: unknown): value is PushPayload {
if (!value || typeof value !== "object") return false;
const payload = value as Partial<PushPayload>;
const message = payload.message as
Partial<PushPayload["message"]> | 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 (
payload.version === 1 &&
typeof payload.senderId === "number" &&
Number.isSafeInteger(payload.senderId) &&
payload.senderId > 0 &&
typeof secret?.version === "number" &&
stringValues.every((item) => typeof item === "string")
);
}
async function decryptPush(payload: PushPayload) {
const keyring = await loadSecureBrowserValue<string>("mtp_keyring");
if (!keyring) throw new Error("MTP credentials are unavailable.");
const chatSecret = await unwrapChatSecret({
encryptedSecret: base64ToBytes(payload.secret.encryptedSecret),
kemCiphertext: base64ToBytes(payload.secret.kemCiphertext),
keyring,
chatId: payload.secret.chatId,
secretId: payload.secret.secretId,
version: payload.secret.version,
wrappingScheme: payload.secret.wrappingScheme,
});
try {
return await decryptChatText(chatSecret, payload.message.content);
} finally {
chatSecret.fill(0);
}
}
clientsClaim();
cleanupOutdatedCaches();
const precacheManifest = self.__WB_MANIFEST;
precacheAndRoute(precacheManifest);
if (
precacheManifest.some((entry) =>
(typeof entry === "string" ? entry : entry.url).endsWith("index.html"),
)
) {
registerRoute(
new NavigationRoute(createHandlerBoundToURL("index.html"), {
denylist: [/^\/api\//],
}),
);
}
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
["font", "image", "style"].includes(request.destination),
new CacheFirst({ cacheName: "tensamin-static-v1" }),
);
self.addEventListener("push", (event) => {
event.waitUntil(
(async () => {
let payload: PushPayload | undefined;
try {
const value = event.data?.json() as unknown;
if (isPushPayload(value)) payload = value;
} catch {
// The generic notification below is safe for malformed payloads.
}
let body = "Open Tensamin to view the encrypted message.";
if (payload) {
try {
body = await decryptPush(payload);
} catch {
// Do not leak credential or decryption failures in the notification.
}
}
await self.registration.showNotification(payload?.sender ?? "Tensamin", {
body,
icon: payload?.avatar || "./icons/icon-192.png",
badge: "./icons/notification-badge.png",
tag: payload ? `message-${payload.senderId}` : "message",
data: { url: payload ? `/chat?id=${payload.senderId}` : "/" },
});
const navigatorWithBadge = self.navigator as WorkerNavigator & {
setAppBadge?: (contents?: number) => Promise<void>;
};
await navigatorWithBadge.setAppBadge?.().catch(() => undefined);
})(),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
(async () => {
const target = new URL(
String(
(event.notification.data as { url?: string } | undefined)?.url ?? "/",
),
self.location.origin,
);
const windows = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of windows) {
if ("navigate" in client) await client.navigate(target.href);
return client.focus();
}
return self.clients.openWindow(target.href);
})(),
);
});
self.addEventListener("message", (event) => {
if ((event.data as { type?: string } | undefined)?.type === "SKIP_WAITING") {
void self.skipWaiting();
}
});

18
apps/pwa/src/style.css Normal file
View file

@ -0,0 +1,18 @@
@media (display-mode: standalone), (display-mode: fullscreen) {
[data-pwa-root] {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
}
}
@media (display-mode: window-controls-overlay) and (min-width: 768px) {
[data-pwa-navbar] {
min-height: env(titlebar-area-height, 3.375rem);
padding-left: max(1px, env(titlebar-area-x, 0px));
padding-right: max(
0px,
calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))
);
}
}

218
apps/pwa/src/vite.ts Normal file
View file

@ -0,0 +1,218 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
const pwaDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const tauriIcons = resolve(pwaDirectory, "../tauri/src-tauri/icons");
const androidResources = resolve(
pwaDirectory,
"../tauri/src-tauri/gen/android/app/src/main/res",
);
function emitIcons(): Plugin {
const icons = [
{
fileName: "icons/icon-180.png",
source: resolve(tauriIcons, "ios/AppIcon-60x60@3x.png"),
},
{
fileName: "icons/icon-192.png",
source: resolve(androidResources, "mipmap-xxxhdpi/ic_launcher.png"),
},
{
fileName: "icons/icon-96.png",
source: resolve(androidResources, "mipmap-xhdpi/ic_launcher.png"),
},
{
fileName: "icons/icon-512.png",
source: resolve(tauriIcons, "icon.png"),
},
{
fileName: "icons/icon-maskable-512.png",
source: resolve(tauriIcons, "icon.png"),
},
{
fileName: "icons/icon-monochrome-432.png",
source: resolve(
androidResources,
"mipmap-xxxhdpi/ic_launcher_monochrome.png",
),
},
{
fileName: "icons/notification-badge.png",
source: resolve(androidResources, "drawable/ic_notification_small.png"),
},
];
return {
name: "tensamin-pwa-icons",
configureServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = request.url
? new URL(request.url, "http://localhost").pathname.slice(1)
: "";
const icon = icons.find(({ fileName }) => fileName === pathname);
if (!icon) {
next();
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", "image/png");
response.setHeader("Cache-Control", "no-cache");
response.end(readFileSync(icon.source));
});
},
generateBundle() {
for (const icon of icons) {
this.emitFile({
type: "asset",
fileName: icon.fileName,
source: readFileSync(icon.source),
});
}
},
transformIndexHtml: {
order: "post",
handler() {
return [
{
tag: "link",
attrs: {
rel: "apple-touch-icon",
sizes: "180x180",
href: "./icons/icon-180.png",
},
injectTo: "head",
},
{
tag: "meta",
attrs: { name: "apple-mobile-web-app-capable", content: "yes" },
injectTo: "head",
},
{
tag: "meta",
attrs: {
name: "apple-mobile-web-app-status-bar-style",
content: "black-translucent",
},
injectTo: "head",
},
{
tag: "meta",
attrs: {
name: "apple-mobile-web-app-title",
content: "Tensamin",
},
injectTo: "head",
},
{
tag: "meta",
attrs: { name: "theme-color", content: "#006a67" },
injectTo: "head",
},
];
},
},
};
}
export function tensaminPwa(): Plugin[] {
return [
emitIcons(),
...VitePWA({
strategies: "injectManifest",
srcDir: resolve(pwaDirectory, "src"),
filename: "serviceWorker.ts",
injectRegister: null,
registerType: "prompt",
buildBase: "/",
manifestFilename: "manifest.json",
includeAssets: ["favicon.ico", "icons/*.png"],
manifest: {
id: "/",
name: "Tensamin",
short_name: "Tensamin",
description: "Private messaging and calls with Tensamin.",
start_url: "/",
scope: "/",
display: "standalone",
display_override: ["window-controls-overlay", "standalone"],
background_color: "#001f1e",
theme_color: "#006a67",
categories: ["social", "communication"],
orientation: "any",
launch_handler: { client_mode: "focus-existing" },
icons: [
{
src: "icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "icons/icon-monochrome-432.png",
sizes: "432x432",
type: "image/png",
purpose: "monochrome",
},
],
shortcuts: [
{
name: "Chats",
short_name: "Chats",
url: "/",
icons: [
{
src: "icons/icon-96.png",
sizes: "96x96",
type: "image/png",
},
],
},
{
name: "Settings",
short_name: "Settings",
url: "/settings",
icons: [
{
src: "icons/icon-96.png",
sizes: "96x96",
type: "image/png",
},
],
},
],
file_handlers: [
{
action: "/login",
accept: { "application/x-tensamin-user": [".tu"] },
},
],
},
injectManifest: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2,wasm,mp3,wav}"],
globIgnores: ["assets/v2/**"],
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
},
devOptions: {
enabled: true,
type: "module",
},
}),
];
}

33
apps/pwa/todo.md Normal file
View file

@ -0,0 +1,33 @@
# Web Push Backend TODO
The client can subscribe and decrypt version 1 push payloads, but reliable delivery requires backend support.
- Generate and securely store a VAPID key pair. Expose only the public key to the web build as `VITE_WEB_PUSH_PUBLIC_KEY`.
- Add authenticated MTP requests for registering, replacing, and deleting a browser `PushSubscription` per user and installation.
- Persist the endpoint, `p256dh`, `auth`, expiration time, stable installation ID, and last-seen time.
- Remove subscriptions when a push service returns HTTP 404 or 410 and rate-limit registrations per user.
- Send pushes when an encrypted live message cannot be delivered to an active browser client. Define duplicate suppression for clients that receive both MTP and Web Push.
- Keep the JSON payload within push-provider limits and use this version 1 shape:
```json
{
"version": 1,
"senderId": 123,
"sender": "Display name",
"avatar": "https://optional.example/avatar",
"message": { "content": "base64 encrypted message content" },
"secret": {
"chatId": "123:456",
"secretId": "chat:123:456:main",
"version": 1,
"encryptedSecret": "base64 wrapped chat secret",
"kemCiphertext": "base64 KEM ciphertext",
"wrappingScheme": "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"
}
}
```
- Ensure the wrapped secret is intended for the receiving user's MTP keyring. The server must never receive plaintext message content or plaintext chat secrets.
- Decide how edits, deletions, reactions, calls, read states, and per-chat notification cancellation map to push events.
- Add subscription rotation handling and unregister subscriptions when a user logs out or clears application data.
- Configure production HTTPS, SPA route fallback, `application/manifest+json` for `manifest.json`, and `Cache-Control: no-cache` for the service worker.

23
apps/pwa/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vite-plugin-pwa/client", "node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/runtime.tsx", "src/vite.ts"]
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "WebWorker"],
"types": ["vite-plugin-pwa/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/serviceWorker.ts"]
}

View file

@ -1,2 +0,0 @@
[env]
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }

View file

@ -7,14 +7,10 @@
"./deeplinkHandler": { "./deeplinkHandler": {
"types": "./src/deeplinkHandler.tsx", "types": "./src/deeplinkHandler.tsx",
"default": "./src/deeplinkHandler.tsx" "default": "./src/deeplinkHandler.tsx"
},
"./qrCodeScanner": {
"types": "./src/qrCodeScanner.tsx",
"default": "./src/qrCodeScanner.tsx"
} }
}, },
"scripts": { "scripts": {
"dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}", "dev:mobile:raw": "adb reverse tcp:3000 tcp:3000 && tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
"start-adb:mobile:raw": "adb devices", "start-adb:mobile:raw": "adb devices",
"build:mobile:raw": "tauri android build", "build:mobile:raw": "tauri android build",
"build:mobile:ci": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && tauri android build --debug", "build:mobile:ci": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && tauri android build --debug",
@ -28,7 +24,6 @@
"dependencies": { "dependencies": {
"@methanium/ui": "*", "@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-barcode-scanner": "~2.4.5",
"@tauri-apps/plugin-deep-link": "~2.4.9", "@tauri-apps/plugin-deep-link": "~2.4.9",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"react": "^19.2.8", "react": "^19.2.8",

File diff suppressed because it is too large Load diff

View file

@ -15,17 +15,16 @@ name = "mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "c0bd0d5a61eedba5c4783add24455c5028c6f390", features = [] } tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] }
[dependencies] [dependencies]
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 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,12 +35,14 @@ 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"
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2"
[patch.crates-io.tauri] [patch.crates-io.tauri]
git = "https://github.com/tauri-apps/tauri" git = "https://github.com/tauri-apps/tauri"
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41" rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"

View file

@ -1,17 +1,10 @@
{ {
"identifier": "mobile-capability", "identifier": "mobile-capability",
"platforms": [ "platforms": ["android", "iOS"],
"android", "windows": ["main"],
"iOS"
],
"windows": [
"main"
],
"permissions": [ "permissions": [
"core:event:default",
"deep-link:default", "deep-link:default",
"barcode-scanner:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"notification:default", "notification:default",
"log:default" "log:default"
] ]

View file

@ -1,3 +1,4 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties import java.util.Properties
import java.io.FileInputStream import java.io.FileInputStream
@ -61,14 +62,17 @@ android {
) )
} }
} }
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }
} }
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
}
rust { rust {
rootDirRel = "../../../" rootDirRel = "../../../"
} }

View file

@ -3,10 +3,9 @@ package net.tensamin.client
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Rect
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup import android.view.MotionEvent
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowManager import android.view.WindowManager
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
@ -56,7 +55,33 @@ class MainActivity : TauriActivity() {
} }
override fun onWebViewCreate(webView: WebView) { override fun onWebViewCreate(webView: WebView) {
webView.setInitialScale(290) webView.settings.apply {
setSupportZoom(false)
builtInZoomControls = false
displayZoomControls = false
}
var blockingMultiTouch = false
webView.setOnTouchListener { _, event ->
val shouldBlock = blockingMultiTouch || event.pointerCount > 1
when (event.actionMasked) {
MotionEvent.ACTION_POINTER_DOWN -> {
// Cancel the one-finger gesture before consuming the rest of the pinch.
MotionEvent.obtain(event).let { cancelEvent ->
cancelEvent.action = MotionEvent.ACTION_CANCEL
webView.onTouchEvent(cancelEvent)
cancelEvent.recycle()
}
blockingMultiTouch = true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> blockingMultiTouch = false
}
shouldBlock
}
NativeAccessibilityBridge.attach(webView)
mediaWebView = webView mediaWebView = webView
MobileMediaEvents.attach(webView) MobileMediaEvents.attach(webView)
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia") webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
@ -65,11 +90,12 @@ class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
super.onCreate(savedInstanceState)
NativeMtpBridge.nativeAttach(applicationContext)
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) { if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
NativeMtpBridge.startService(this) NativeMtpBridge.startService(this)
} }
super.onCreate(savedInstanceState) NativeAccessibilityBridge.nativeAttach(applicationContext)
NativeMtpBridge.nativeAttach(applicationContext)
installKeyboardResizeWorkaround() installKeyboardResizeWorkaround()
} }
@ -90,7 +116,10 @@ class MainActivity : TauriActivity() {
attachLayoutListener = null attachLayoutListener = null
contentRoot = null contentRoot = null
contentChild = null contentChild = null
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia") mediaWebView?.let {
NativeAccessibilityBridge.detach(it)
it.removeJavascriptInterface("tensaminMobileMedia")
}
mediaWebView = null mediaWebView = null
MobileMediaEvents.detach() MobileMediaEvents.detach()
super.onDestroy() super.onDestroy()
@ -203,16 +232,20 @@ class MainActivity : TauriActivity() {
child: android.view.View, child: android.view.View,
insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child), insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child),
) { ) {
val visibleFrame = Rect()
child.getWindowVisibleDisplayFrame(visibleFrame)
val rootHeight = child.rootView.height val rootHeight = child.rootView.height
if (rootHeight <= 0) return if (rootHeight <= 0) return
val imeHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 val imeVisible = insets?.isVisible(WindowInsetsCompat.Type.ime()) == true
val keyboardHeight = maxOf(imeHeight, rootHeight - visibleFrame.bottom) val imeHeight = if (imeVisible) {
val keyboardVisible = keyboardHeight > rootHeight * 0.15 insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
val usableHeight = if (keyboardVisible) rootHeight - keyboardHeight else ViewGroup.LayoutParams.MATCH_PARENT } else {
0
}
val usableHeight = if (imeHeight in 1 until rootHeight) {
rootHeight - imeHeight
} else {
WindowManager.LayoutParams.MATCH_PARENT
}
if (previousUsableHeight == usableHeight) return if (previousUsableHeight == usableHeight) return

View file

@ -26,6 +26,7 @@ import android.os.HandlerThread
import android.os.IBinder import android.os.IBinder
import android.util.Base64 import android.util.Base64
import android.util.DisplayMetrics import android.util.DisplayMetrics
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -107,16 +108,12 @@ class MediaProjectionService : Service() {
stopIntent, stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
android.app.Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
} else {
android.app.Notification.Builder(this)
}
.setSmallIcon(android.R.drawable.ic_menu_share) .setSmallIcon(android.R.drawable.ic_menu_share)
.setContentTitle("Tensamin is sharing your screen") .setContentTitle("Tensamin is sharing your screen")
.setContentText("Tap Stop to end screen sharing") .setContentText("Tap Stop to end screen sharing")
.setOngoing(true) .setOngoing(true)
.setCategory(android.app.Notification.CATEGORY_SERVICE) .setCategory(NotificationCompat.CATEGORY_SERVICE)
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent) .addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
.build() .build()

View file

@ -13,6 +13,8 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.Process
import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
class MtpForegroundService : Service() { class MtpForegroundService : Service() {
@ -74,10 +76,19 @@ class MtpForegroundService : Service() {
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
val preferences = getSharedPreferences(SERVICE_PREFERENCES, Context.MODE_PRIVATE)
val now = SystemClock.elapsedRealtime()
if (now - preferences.getLong(LAST_TASK_RESTART, 0) < TASK_RESTART_COOLDOWN_MS) {
super.onTaskRemoved(rootIntent)
return
}
preferences.edit().putLong(LAST_TASK_RESTART, now).commit()
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) { if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
startService(Intent(this, MtpForegroundService::class.java)) startService(Intent(this, MtpForegroundService::class.java))
} }
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
// Tauri cannot recreate its WebView after the UI task is removed while this process survives.
Process.killProcess(Process.myPid())
} }
override fun onDestroy() { override fun onDestroy() {
@ -94,6 +105,9 @@ class MtpForegroundService : Service() {
private const val CHANNEL_ID = "tensamin-connection" private const val CHANNEL_ID = "tensamin-connection"
private const val NOTIFICATION_ID = 2201 private const val NOTIFICATION_ID = 2201
private const val ACTION_STOP = "net.tensamin.client.STOP_MTP" private const val ACTION_STOP = "net.tensamin.client.STOP_MTP"
private const val SERVICE_PREFERENCES = "tensamin-service"
private const val LAST_TASK_RESTART = "last-task-restart"
private const val TASK_RESTART_COOLDOWN_MS = 15_000L
@Volatile private var connectionStatus = "Connecting" @Volatile private var connectionStatus = "Connecting"
fun updateNotification(context: Context, status: String) { fun updateNotification(context: Context, status: String) {

View file

@ -0,0 +1,51 @@
package net.tensamin.client
import android.content.Context
import android.webkit.WebView
import androidx.annotation.Keep
import java.lang.ref.WeakReference
@Keep
object NativeAccessibilityBridge {
const val DEFAULT_INITIAL_SCALE = 290
private const val MIN_INITIAL_SCALE = 210
private const val MAX_INITIAL_SCALE = 500
private const val PREFERENCES = "tensamin-accessibility"
private const val INITIAL_SCALE = "initial-scale"
private var webView = WeakReference<WebView>(null)
init {
System.loadLibrary("mobile_lib")
}
@JvmStatic external fun nativeAttach(context: Context)
fun attach(webView: WebView) {
this.webView = WeakReference(webView)
webView.setInitialScale(getInitialScale(webView.context))
}
fun detach(webView: WebView) {
if (this.webView.get() === webView) this.webView.clear()
}
fun getInitialScale(context: Context): Int {
val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val storedScale = preferences.getInt(INITIAL_SCALE, DEFAULT_INITIAL_SCALE)
val scale = storedScale.coerceIn(MIN_INITIAL_SCALE, MAX_INITIAL_SCALE)
if (scale != storedScale) preferences.edit().putInt(INITIAL_SCALE, scale).apply()
return scale
}
fun setInitialScale(context: Context, initialScale: Int) {
val nextScale = initialScale.coerceIn(MIN_INITIAL_SCALE, MAX_INITIAL_SCALE)
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putInt(INITIAL_SCALE, nextScale)
.apply()
webView.get()?.let { currentWebView ->
currentWebView.post { currentWebView.setInitialScale(nextScale) }
}
}
}

View file

@ -1,4 +1,4 @@
import com.android.build.gradle.LibraryExtension import com.android.build.api.dsl.LibraryExtension
buildscript { buildscript {
repositories { repositories {
@ -6,7 +6,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle:8.13.2") classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20")
} }
} }

View file

@ -18,6 +18,5 @@ repositories {
dependencies { dependencies {
compileOnly(gradleApi()) compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.13.2") implementation("com.android.tools.build:gradle:8.11.0")
} }

View file

@ -5,8 +5,12 @@ import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.process.ExecOperations
import javax.inject.Inject
open class BuildTask : DefaultTask() { open class BuildTask @Inject constructor(
private val execOperations: ExecOperations,
) : DefaultTask() {
@Input @Input
var rootDirRel: String? = null var rootDirRel: String? = null
@Input @Input
@ -50,7 +54,7 @@ open class BuildTask : DefaultTask() {
val release = release ?: throw GradleException("release cannot be null") val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script"); val args = listOf("tauri", "android", "android-studio-script");
project.exec { execOperations.exec {
workingDir(File(project.projectDir, rootDirRel)) workingDir(File(project.projectDir, rootDirRel))
executable(executable) executable(executable)
args(args) args(args)
@ -65,4 +69,4 @@ open class BuildTask : DefaultTask() {
args(listOf("--target", target)) args(listOf("--target", target))
}.assertNormalExitValue() }.assertNormalExitValue()
} }
} }

View file

@ -0,0 +1,135 @@
#[cfg(not(target_os = "android"))]
const DEFAULT_INITIAL_SCALE: i32 = 290;
const MIN_INITIAL_SCALE: i32 = 210;
const MAX_INITIAL_SCALE: i32 = 500;
#[tauri::command]
pub fn accessibility_get_initial_scale() -> Result<i32, String> {
android_get_initial_scale()
}
#[tauri::command]
pub fn accessibility_set_initial_scale(initial_scale: i32) -> Result<(), String> {
if !(MIN_INITIAL_SCALE..=MAX_INITIAL_SCALE).contains(&initial_scale) {
return Err(format!(
"Initial scale must be between {MIN_INITIAL_SCALE} and {MAX_INITIAL_SCALE}"
));
}
android_set_initial_scale(initial_scale)
}
#[cfg(not(target_os = "android"))]
fn android_get_initial_scale() -> Result<i32, String> {
Ok(DEFAULT_INITIAL_SCALE)
}
#[cfg(not(target_os = "android"))]
fn android_set_initial_scale(_: i32) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "android")]
mod android {
use std::sync::OnceLock;
use jni::{
jni_sig, jni_str,
objects::{Global, JClass, JObject, JValue},
Env, EnvUnowned, JavaVM,
};
struct Host {
vm: JavaVM,
context: Global<JObject<'static>>,
bridge: Global<JObject<'static>>,
}
static HOST: OnceLock<Host> = OnceLock::new();
fn attach(env: &mut Env, context: JObject) -> Result<(), String> {
if HOST.get().is_some() {
return Ok(());
}
let class = env
.find_class(jni_str!("net/tensamin/client/NativeAccessibilityBridge"))
.map_err(|error| error.to_string())?;
let bridge = env
.get_static_field(
class,
jni_str!("INSTANCE"),
jni_sig!("Lnet/tensamin/client/NativeAccessibilityBridge;"),
)
.and_then(|value| value.l())
.map_err(|error| error.to_string())?;
HOST.set(Host {
vm: env.get_java_vm().map_err(|error| error.to_string())?,
context: env
.new_global_ref(context)
.map_err(|error| error.to_string())?,
bridge: env
.new_global_ref(bridge)
.map_err(|error| error.to_string())?,
})
.map_err(|_| "Android accessibility host is already attached".to_string())
}
fn with_env<T>(call: impl FnOnce(&mut Env, &Host) -> Result<T, String>) -> Result<T, String> {
let host = HOST
.get()
.ok_or("Android accessibility host is not attached")?;
host.vm
.attach_current_thread(|env| Ok::<_, jni::errors::Error>(call(env, host)))
.map_err(|error| error.to_string())?
}
pub fn get_initial_scale() -> Result<i32, String> {
with_env(|env, host| {
env.call_method(
host.bridge.as_obj(),
jni_str!("getInitialScale"),
jni_sig!("(Landroid/content/Context;)I"),
&[JValue::Object(host.context.as_obj())],
)
.and_then(|value| value.i())
.map_err(|error| error.to_string())
})
}
pub fn set_initial_scale(initial_scale: i32) -> Result<(), String> {
with_env(|env, host| {
env.call_method(
host.bridge.as_obj(),
jni_str!("setInitialScale"),
jni_sig!("(Landroid/content/Context;I)V"),
&[
JValue::Object(host.context.as_obj()),
JValue::Int(initial_scale),
],
)
.map_err(|error| error.to_string())?;
Ok(())
})
}
#[no_mangle]
pub extern "system" fn Java_net_tensamin_client_NativeAccessibilityBridge_nativeAttach<
'caller,
>(
mut env: EnvUnowned<'caller>,
_class: JClass,
context: JObject<'caller>,
) {
let _ = env.with_env(|env| {
let _ = attach(env, context);
Ok::<_, jni::errors::Error>(())
});
}
}
#[cfg(target_os = "android")]
use android::{
get_initial_scale as android_get_initial_scale, set_initial_scale as android_set_initial_scale,
};

View file

@ -1,3 +1,4 @@
mod accessibility_backend;
mod mtp_backend; mod mtp_backend;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -14,11 +15,10 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init()); .plugin(tauri_plugin_opener::init());
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
let app = builder let app = builder
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
accessibility_backend::accessibility_get_initial_scale,
accessibility_backend::accessibility_set_initial_scale,
mtp_backend::mtp_request, mtp_backend::mtp_request,
mtp_backend::mtp_status, mtp_backend::mtp_status,
mtp_backend::mtp_store_credentials, mtp_backend::mtp_store_credentials,

View file

@ -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")
} }

View file

@ -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(
&notification_config,
notification_connection.clone(),
&frame,
)
.await
{
eprintln!("failed to create background message notification: {error}");
}
}
});
for frame in prepared.buffered_frames {
handle_push(generation, &notification_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, &notification_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}"))?;
@ -372,43 +456,21 @@ async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
.with_max_missed_pings(3); .with_max_missed_pings(3);
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
let client_config = client_config.with_pinned_pem(android_root_certificates().clone()); let client_config = client_config.with_pinned_pem(android_root_certificates().clone());
let connection = MTPClient::auth_connect(client_config, &keyring, &host_key) let connection = tokio::time::timeout(
.await Duration::from_secs(30),
.map_err(|error| format!("transport authentication failed: {error}"))?; MTPClient::auth_connect(client_config, &keyring, &host_key),
)
.await
.map_err(|_| "transport authentication timed out".to_string())?
.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 = connection
.request(&connected, None)
.await
.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 = connection
.request(&ack, None)
.await .await
.map_err(|_| "state acknowledgement timed out".to_string())?
.map_err(|error| format!("state acknowledgement failed: {error}"))?; .map_err(|error| format!("state acknowledgement failed: {error}"))?;
if response if response
.get_type_name() .get_type_name()
@ -416,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(communication_type("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.get_type_name() == Some("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> {
@ -433,7 +670,19 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String
public_key: String, public_key: String,
} }
let root = config.omega_url.trim_end_matches('/'); let root = config.omega_url.trim_end_matches('/');
let response = reqwest::get(format!("{root}/api/get/omikron/{}", config.user_id)) let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(20));
#[cfg(target_os = "android")]
let client = client.tls_certs_only(
reqwest::Certificate::from_pem_bundle(android_root_certificates())
.map_err(|error| format!("invalid bundled root certificates: {error}"))?,
);
let response = client
.build()
.map_err(|error| error.to_string())?
.get(format!("{root}/api/get/omikron/{}", config.user_id))
.send()
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
if !response.status().is_success() { if !response.status().is_success() {
@ -446,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 {
@ -459,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) {
@ -468,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_by_name(message, "Content")
.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())?;
@ -513,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)
@ -541,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())?;
@ -633,17 +888,17 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> {
value.get_field(id) value.get_field(id)
} }
fn json_to_frame( fn container_value_by_name<'a>(value: &'a DataValue, field: &str) -> Option<&'a DataValue> {
type_name: &str, container_value(value, DataType::from_name(field)?)
data: Value, }
id: Option<u32>,
) -> Result<CommunicationValue, String> { fn communication_type(name: &str) -> Result<CommunicationType, String> {
let comm_type = CommunicationType::from_name(type_name) CommunicationType::from_name(name).ok_or_else(|| format!("unknown communication type: {name}"))
.ok_or_else(|| format!("unknown communication type: {type_name}"))?; }
let mut frame = CommunicationValue::new(comm_type);
if let Some(id) = id { fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
frame = frame.with_id(id); let comm_type = communication_type(type_name)?;
} let mut frame = CommunicationValue::new(comm_type).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());
}; };
@ -718,8 +973,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(),
@ -732,7 +987,10 @@ 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))?;
@ -782,16 +1040,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_by_name, 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() {
@ -804,11 +1061,137 @@ 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_content_wire_type() {
let frame = json_to_frame(
"MessageEdit",
json!({
"Content": "ciphertext",
"ChatPartnerId": 42,
"SendTime": 10,
}),
1,
)
.unwrap();
assert_eq!(
frame
.get_data(DataType::Content)
.and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn nested_json_content_uses_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_by_name(message, "Content").and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn content_is_exposed_to_frontend() {
let frame = CommunicationValue::new(CommunicationType::MessageEditLive)
.with_id(1)
.add_typed_default(DataType::Content, DataValue::Str("ciphertext".into()));
let json = frame_to_json(&frame).unwrap();
assert_eq!(json["data"]["Content"], "ciphertext");
}
fn valid_initial_state() -> CommunicationValue {
CommunicationValue::new(communication_type("ClientStateSync").unwrap())
.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_eq!(ack.get_type_name(), Some("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(communication_type("ClientStateSync").unwrap())
.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]

View file

@ -1,38 +0,0 @@
import {
scan,
Format,
requestPermissions,
} from "@tauri-apps/plugin-barcode-scanner";
import { Button } from "@methanium/ui";
import { toast } from "@tensamin/shared/log";
export default function QrCodeScanner({
onData,
}: {
onData: (data: string) => void;
}) {
return (
<Button
onClick={() => {
requestPermissions()
.catch((err) => {
toast("error", err.message);
})
.then(() =>
scan({ windowed: false, formats: [Format.QRCode] })
.catch((err) => {
toast("error", err.message);
})
.then((data) => {
if (data) {
onData(data.content);
}
}),
);
}}
>
Open QR Code Scanner
</Button>
);
}

View file

@ -2,8 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Tensamin</title> <title>Tensamin</title>
</head> </head>
<body> <body>

View file

@ -6,9 +6,9 @@
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"dev": "vite --port 3000 --host 0.0.0.0", "dev": "vite",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"build": "pnpm run test && tsc -b && vite build", "build": "pnpm --filter @tensamin/pwa build && pnpm run test && tsc -b && vite build",
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
}, },
"dependencies": { "dependencies": {
@ -18,6 +18,7 @@
"@tanstack/react-router": "^1.170.21", "@tanstack/react-router": "^1.170.21",
"@tanstack/react-virtual": "^3.14.9", "@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tensamin/pwa": "workspace:*",
"@tensamin/cache": "workspace:*", "@tensamin/cache": "workspace:*",
"@tensamin/call": "workspace:*", "@tensamin/call": "workspace:*",
"@tensamin/chat": "workspace:*", "@tensamin/chat": "workspace:*",
@ -54,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"

View file

@ -15,7 +15,10 @@ export function Basic({
user, user,
extra, extra,
}: { }: {
user: User; user: Pick<
User,
"Display" | "Username" | "Avatar" | "OnlineStatus" | "Status"
>;
extra?: React.ReactNode; extra?: React.ReactNode;
}) { }) {
const display = user.Display || user.Username || "Unknown"; const display = user.Display || user.Username || "Unknown";
@ -57,8 +60,9 @@ export function Basic({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex w-full flex-col items-start justify-center gap-1 text-[15px]"> <div className="flex w-full flex-col items-start justify-center text-[15px]">
<p>{display}</p> <p>{display}</p>
<p className="text-xs text-muted-foreground">{user.Status}</p>
</div> </div>
<div className="pr-1">{extra}</div> <div className="pr-1">{extra}</div>
</Button> </Button>

View file

@ -4,7 +4,20 @@ import { Text } from "@methanium/ui/markdown";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export default function Profile({ user }: { user: User }) { export default function Profile({
user,
}: {
user: Pick<
User,
| "UserId"
| "Display"
| "Username"
| "Avatar"
| "About"
| "IotaId"
| "PublicKey"
>;
}) {
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false); const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
return ( return (

View file

@ -52,6 +52,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
data-pwa-navbar
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`} className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@ -91,6 +92,15 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
{pathname === "/chat" && id && ( {pathname === "/chat" && id && (
<Wrapper <Wrapper
userId={id} userId={id}
fields={[
"UserId",
"Display",
"Username",
"Avatar",
"About",
"IotaId",
"PublicKey",
]}
component={(user) => component={(user) =>
isMobile ? ( isMobile ? (
<p className="font-medium text-[1.07rem]">{user?.Display}</p> <p className="font-medium text-[1.07rem]">{user?.Display}</p>

View file

@ -13,8 +13,11 @@ import {
useState, useState,
} from "react"; } from "react";
import { z } from "zod"; import { z } from "zod";
import { invoke, isTauri } from "@tauri-apps/api/core"; import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner"; import {
parseTuFileContent,
persistMtpCredentials,
} from "@tensamin/storage/credentials";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({ const fetchedUser = z.object({
@ -35,55 +38,6 @@ const formSchema = z.object({
mtp_keyring: z.string().min(1).max(92), mtp_keyring: z.string().min(1).max(92),
}); });
/**
* Parses a .tu file payload into credentials.
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
* @returns Parsed user id and private key credentials.
*/
function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
if (rawFileContent.trim().length === 0) {
throw new Error("File is empty");
} else if (!rawFileContent.includes("::")) {
throw new Error("Invalid file");
} else if (rawFileContent.split("::").length !== 2) {
throw new Error("Invalid file");
}
const left = rawFileContent.split("::")[0];
const right = rawFileContent.split("::")[1];
if (left.length === 0) {
throw new Error("Invalid file");
} else if (right.length === 0) {
throw new Error("Invalid file");
} else if (isNaN(Number(left)) && !left.includes("@")) {
throw new Error("Invalid file");
}
const [userIdString, privateKeyValue] = rawFileContent.split("::");
const privateKey = privateKeyValue.trim();
const userId = isNaN(Number(userIdString))
? Number(userIdString.split("@")[0])
: Number(userIdString);
const domain = userIdString.includes("@") ? userIdString.split("@")[1] : null;
if (!userId || !privateKey) {
throw new Error("Invalid file");
}
console.log({
domain,
userId,
});
return { userId, privateKey, domain };
}
export default function Form() { export default function Form() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const uploadRef = useRef<HTMLInputElement | null>(null); const uploadRef = useRef<HTMLInputElement | null>(null);
@ -97,27 +51,12 @@ export default function Form() {
if (loginPendingRef.current) return false; if (loginPendingRef.current) return false;
loginPendingRef.current = true; loginPendingRef.current = true;
try { try {
if (domain) await save("omega_url", `https://${domain}/`); await persistMtpCredentials({
if (isTauri()) { storage: { load, save },
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] = userId,
await Promise.all([ keyring: privateKey,
domain ? `https://${domain}/` : load("omega_url"), domain,
load("forced_omikron_url"), });
load("forced_omikron_public_key"),
]);
await invoke("mtp_store_credentials", {
config: {
userId,
keyring: privateKey,
omegaUrl,
forcedOmikronUrl,
forcedOmikronPublicKey,
},
});
}
await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now());
await save("user_id", userId);
await navigate({ to: "/", replace: true }); await navigate({ to: "/", replace: true });
return true; return true;
} finally { } finally {
@ -148,6 +87,11 @@ export default function Form() {
[persistLogin], [persistLogin],
); );
useEffect(
() => subscribeTuFileLaunch(processDroppedFile),
[processDroppedFile],
);
// Handle .tu files // Handle .tu files
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => { async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
@ -286,32 +230,10 @@ export default function Form() {
return ( return (
<div className="relative flex md:flex-row flex-col gap-15"> <div className="relative flex md:flex-row flex-col gap-15">
{isTauri() && isMobile ? ( {isMobile ? (
<> <Button onClick={() => uploadRef.current?.click()}>
<QrCodeScanner Select .tu file
onData={async (data) => { </Button>
if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code");
return;
}
const decoded = data.replace("tensamin://tu::", "");
try {
const { userId, privateKey, domain } =
parseTuFileContent(decoded);
await persistLogin(userId, privateKey, domain);
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
}
}}
/>
<Button onClick={() => uploadRef.current?.click()}>
Select .tu file
</Button>
</>
) : ( ) : (
<div <div
onClick={() => uploadRef.current?.click()} onClick={() => uploadRef.current?.click()}

View file

@ -27,7 +27,6 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
} from "@methanium/ui"; } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@methanium/ui"; import { useIsMobile } from "@methanium/ui";
import { MobileNavbar } from "./navbar"; import { MobileNavbar } from "./navbar";
@ -35,7 +34,7 @@ import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar"; import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
import { Ellipsis, Check } from "lucide-react"; import { Ellipsis, Check } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { User } from "@tensamin/user/context"; import { useUser, type User } from "@tensamin/user/context";
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data"; import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { import {
@ -64,7 +63,7 @@ function StatusDialog({
saveSucceeded, saveSucceeded,
setSaveSucceeded, setSaveSucceeded,
}: { }: {
user: User; user: Pick<User, "UserId" | "OnlineStatus" | "Status">;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
send: ReturnType<typeof useMTP>["send"]; send: ReturnType<typeof useMTP>["send"];
@ -77,6 +76,8 @@ function StatusDialog({
saveSucceeded: boolean; saveSucceeded: boolean;
setSaveSucceeded: (value: boolean) => void; setSaveSucceeded: (value: boolean) => void;
}) { }) {
const { updateProfile, updateState } = useUser();
return ( return (
<Dialog <Dialog
open={open} open={open}
@ -137,9 +138,10 @@ function StatusDialog({
<DialogClose render={<Button variant="destructive">Cancel</Button>} /> <DialogClose render={<Button variant="destructive">Cancel</Button>} />
<Button <Button
onClick={async () => { onClick={async () => {
const payload = { UserState: draftOnlineStatus }; const validation = mtp.ChangeUserData.request.safeParse({
OnlineStatus: draftOnlineStatus,
const validation = mtp.SetUserState.request.safeParse(payload); Status: draftStatus,
});
if (!validation.success) { if (!validation.success) {
setSaveSucceeded(false); setSaveSucceeded(false);
@ -150,17 +152,9 @@ function StatusDialog({
} }
try { try {
await send("SetUserState", validation.data); await send("ChangeUserData", validation.data);
const profileValidation = mtp.ChangeUserData.request.safeParse({ updateState(user.UserId, draftOnlineStatus);
Status: draftStatus, await updateProfile(user.UserId, { Status: draftStatus });
});
if (!profileValidation.success) {
throw new Error(
profileValidation.error.issues[0]?.message ??
"Invalid status data",
);
}
await send("ChangeUserData", profileValidation.data);
setSaveSucceeded(true); setSaveSucceeded(true);
setErrorMessage(""); setErrorMessage("");
} catch (err) { } catch (err) {
@ -199,16 +193,20 @@ export default function Sidebar() {
const content = ( const content = (
<> <>
<SidebarContent <SidebarContent className="pt-2">
className={
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
}
>
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!"> <div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
<div> <div>
<Wrapper <Wrapper
loading={<Loading />} loading={<Loading />}
userId={"own"} userId={"own"}
fields={[
"UserId",
"Display",
"Username",
"Avatar",
"OnlineStatus",
"Status",
]}
component={(user) => ( component={(user) => (
<> <>
<Basic <Basic
@ -306,7 +304,7 @@ export default function Sidebar() {
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground" className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar pt-[var(--ui-safe-area-top)] pr-[var(--ui-safe-area-right)] pl-[var(--ui-safe-area-left)] text-sidebar-foreground"
style={{ style={{
transform: openMobile ? "translateX(0)" : "translateX(-100%)", transform: openMobile ? "translateX(0)" : "translateX(-100%)",
opacity: openMobile ? 1 : 0, opacity: openMobile ? 1 : 0,

View file

@ -2,7 +2,7 @@ export const onlineStatusOptions = [
{ label: "Online", value: "user_online" }, { label: "Online", value: "user_online" },
{ label: "Idle", value: "user_idle" }, { label: "Idle", value: "user_idle" },
{ label: "Do not disturb", value: "user_dnd" }, { label: "Do not disturb", value: "user_dnd" },
{ label: "Away", value: "user_wc" }, { label: "On the toilet", value: "user_wc" },
{ label: "Offline", value: "user_invisible" }, { label: "Offline", value: "user_invisible" },
] as const; ] as const;

View file

@ -28,6 +28,13 @@ export default function ConversationModal({ userId }: { userId: number }) {
<Wrapper <Wrapper
loading={<Loading />} loading={<Loading />}
userId={userId} userId={userId}
fields={[
"Display",
"Username",
"Avatar",
"OnlineStatus",
"Status",
]}
component={(user) => <Basic user={user} />} component={(user) => <Basic user={user} />}
/> />
</div> </div>

View file

@ -29,6 +29,7 @@ import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context"; import UserProvider from "@tensamin/user/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context"; import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime";
import TAuthWrapper from "@tensamin/tauth/context"; import TAuthWrapper from "@tensamin/tauth/context";
@ -48,7 +49,6 @@ import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui"; import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
import { HotkeysProvider } from "@tensamin/hotkeys"; import { HotkeysProvider } from "@tensamin/hotkeys";
const wrapper = document.getElementById("root"); const wrapper = document.getElementById("root");
@ -254,19 +254,14 @@ function RootShell() {
parentThemeStorageKey={null} parentThemeStorageKey={null}
designStorageKey={null} designStorageKey={null}
> >
<div className="w-screen h-dvh overflow-hidden"> <div
<Toaster data-pwa-root
position={isMobile ? "top-center" : "bottom-right"} className="box-border w-screen h-dvh overflow-hidden"
{...(isTauri() && isMobile >
? { <Toaster position={isMobile ? "top-center" : "bottom-right"} />
mobileOffset: {
top: "env(safe-area-inset-top)",
},
}
: {})}
/>
<TooltipProvider> <TooltipProvider>
<Storage> <Storage>
<PwaRuntime />
<HotkeysProvider> <HotkeysProvider>
<ThemeStorageBridge /> <ThemeStorageBridge />
<LoginWrapper> <LoginWrapper>

View file

@ -19,6 +19,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<Sidebar /> <Sidebar />
<CallPopout /> <CallPopout />
<div <div
data-app-layout
// Background of ui that is overlapping with the system ui // Background of ui that is overlapping with the system ui
className={cn( className={cn(
"w-full h-full min-h-0 flex flex-col overflow-hidden", "w-full h-full min-h-0 flex flex-col overflow-hidden",

View file

@ -8,6 +8,7 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite"; import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite"; import { methaniumUi } from "@methanium/ui/vite";
import { tensaminPwa } from "@tensamin/pwa/vite";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url)); const appDir = dirname(fileURLToPath(import.meta.url));
@ -70,9 +71,9 @@ export default defineConfig({
], ],
}, },
server: { server: {
port: 5173, port: 3000,
strictPort: true, strictPort: true,
host: host || "0.0.0.0", host: "0.0.0.0",
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",
@ -125,6 +126,7 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG, sourcemap: !!process.env.TAURI_ENV_DEBUG,
}, },
plugins: [ plugins: [
...tensaminPwa(),
methaniumUi({ defaultThemeId: "tensamin" }), methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")), deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }), mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),

181
flake.nix
View file

@ -17,8 +17,6 @@
systems = [ "x86_64-linux" ]; systems = [ "x86_64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems; forAllSystems = nixpkgs.lib.genAttrs systems;
version = "0.0.11"; version = "0.0.11";
x86_64DebHash = "sha256-E4FGMpCt2ByaN6d+YzglqeA6LbhMOQmPf3UzAj7uZXE=";
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
in in
{ {
packages = forAllSystems ( packages = forAllSystems (
@ -32,8 +30,6 @@
android_sdk.accept_license = true; android_sdk.accept_license = true;
}; };
}; };
debArtifact = "Tensamin-${version}-linux-amd64.deb";
electronRuntimeLibs = with pkgs; [ electronRuntimeLibs = with pkgs; [
alsa-lib alsa-lib
at-spi2-atk at-spi2-atk
@ -75,71 +71,130 @@
libXtst libXtst
libxcb libxcb
]; ];
packageDeb = electron = pkgs.electron;
src: pnpm = pkgs.pnpm;
pkgs.stdenv.mkDerivation { mtpTypeMaps = pkgs.fetchgit {
pname = "tensamin"; url = "https://git.methanium.net/tensamin/mtp-type-maps";
inherit version src; rev = "6e5122fe44f793c0e0d3229b3d34145ce17c2d31";
hash = "sha256-/4n8F0YLJaLncefKL907P5l+en1CTjpgdFmLBF3gbiQ=";
nativeBuildInputs = with pkgs; [ };
autoPatchelfHook mtpSource = pkgs.fetchzip {
dpkg url = "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz";
makeWrapper hash = "sha256-XLTa8DxP93Q4hBHRCLUzCPOqkbdb4V3aZxE5Iuq+kW0=";
]; };
buildInputs = electronRuntimeLibs; mtpCargoDeps = pkgs.rustPlatform.fetchCargoVendor {
src = mtpSource;
dontConfigure = true; hash = "sha256-8MZ65N/EtWPAggal0JkGDx3WSn+LxWQORinkqVbsrys=";
dontBuild = true; };
wasmBindgenCliSource = pkgs.fetchCrate {
unpackPhase = '' pname = "wasm-bindgen-cli";
runHook preUnpack version = "0.2.127";
dpkg-deb -x "$src" . hash = "sha256-di+qBAdd7pENLiIB9CoZoab+W5xeDoByMREcCGTSzWo=";
runHook postUnpack };
''; wasmBindgenCli = pkgs.buildWasmBindgenCli {
src = wasmBindgenCliSource;
installPhase = '' cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
runHook preInstall src = wasmBindgenCliSource;
hash = "sha256-FTv2GZIAQs0ePdIZXIXil7JbZ6kIT05VG6vqC1qNFxQ=";
mkdir -p "$out"
cp -r opt "$out/"
cp -r usr/* "$out/"
mkdir -p "$out/bin"
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin" \
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
substituteInPlace "$out/share/applications/Tensamin.desktop" \
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
runHook postInstall
'';
}; };
defaultPackage = packageDeb ( };
pkgs.fetchurl { desktopItem = pkgs.makeDesktopItem {
url = "${forgejoBaseUrl}/${debArtifact}"; name = "tensamin";
hash = x86_64DebHash; desktopName = "Tensamin";
} exec = "tensamin";
); icon = "tensamin";
localDebPath = builtins.getEnv "TENSAMIN_DEB"; startupWMClass = "Tensamin";
localPathPackage = categories = [ "Network" ];
if localDebPath == "" then };
pkgs.writeShellScriptBin "tensamin" '' defaultPackage = pkgs.stdenv.mkDerivation (finalAttrs: {
echo "Set TENSAMIN_DEB to a local .deb path and run with --impure." >&2 pname = "tensamin";
exit 1 inherit version;
'' src = self;
else
packageDeb ( pnpmDeps = pkgs.fetchPnpmDeps {
builtins.path { inherit (finalAttrs) pname version src;
path = localDebPath; inherit pnpm;
name = debArtifact; fetcherVersion = 4;
} hash = "sha256-imP3MTr1YLc28Z9n617m0Wt/6vPirFznzoriKzojlEg=";
); };
nativeBuildInputs = with pkgs; [
copyDesktopItems
makeWrapper
nodejs_22
pnpm
pnpmConfigHook
cargo
lld
rustc
wasm-pack
wasmBindgenCli
binaryen
];
env.ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
postPatch = ''
rm -rf mtp-type-maps
ln -s ${mtpTypeMaps} mtp-type-maps
node -e '
const fs = require("fs");
const path = "apps/electron/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = "${version}";
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
'';
buildPhase = ''
runHook preBuild
mkdir -p "$HOME/.cargo"
substitute ${mtpCargoDeps}/.cargo/config.toml "$HOME/.cargo/config.toml" \
--replace-fail @vendor@ ${mtpCargoDeps}
pnpm run copy-licenses
pnpm run build:packages
pnpm run build:web
pnpm --dir apps/tauri run gen-icons
pnpm --dir apps/electron run build
pnpm --dir apps/electron exec electron-builder --dir --linux --publish never \
--config.electronDist=${electron.dist} \
--config.electronVersion=${electron.version}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/tensamin" "$out/bin"
cp -r apps/electron/release/linux-unpacked/. "$out/lib/tensamin/"
makeWrapper "$out/lib/tensamin/tensamin" "$out/bin/tensamin" \
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
install -Dm644 apps/electron/build/icons/icon.png \
"$out/share/icons/hicolor/512x512/apps/tensamin.png"
runHook postInstall
'';
desktopItems = [ desktopItem ];
meta = {
description = "Tensamin desktop client";
homepage = "https://git.methanium.net/tensamin/client";
mainProgram = "tensamin";
platforms = pkgs.lib.platforms.linux;
};
});
in in
{ {
default = defaultPackage; default = defaultPackage;
tensamin = defaultPackage; tensamin = defaultPackage;
electron = defaultPackage; electron = defaultPackage;
localPathForDev = localPathPackage;
} }
); );

View file

@ -1,20 +0,0 @@
SPDXVersion: SPDX-2.1
DataLicense: CC0-1.0
PackageName: tauri
DataFormat: SPDXRef-1
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
PackageHomePage: https://tauri.app
PackageLicenseDeclared: Apache-2.0
PackageLicenseDeclared: MIT
PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
PackageSummary: <text>Tauri is a rust project that enables developers to make secure
and small desktop applications using a web frontend.
</text>
PackageComment: <text>The package includes the following libraries; see
Relationship information.
</text>
Created: 2019-05-20T09:00:00Z
PackageDownloadLocation: git://github.com/tauri-apps/tauri
PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
Creator: Person: Daniel Thompson-Yvetot

View file

@ -117,15 +117,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@tauri-apps_cli@2.11.4` - Folder: `licenses/@tauri-apps_cli@2.11.4`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/cli` - Source package dir: `apps/tauri/node_modules/@tauri-apps/cli`
## @tauri-apps/plugin-barcode-scanner@2.4.5
- License: MIT OR Apache-2.0
- Repository: https://github.com/tauri-apps/plugins-workspace
- Description: Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS
- Included files: LICENSE.spdx
- Folder: `licenses/@tauri-apps_plugin-barcode-scanner@2.4.5`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner`
## @tauri-apps/plugin-deep-link@2.4.9 ## @tauri-apps/plugin-deep-link@2.4.9
- License: MIT OR Apache-2.0 - License: MIT OR Apache-2.0

View file

@ -414,37 +414,6 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/%40tauri-apps/plugin-barcode-scanner@2.4.5",
"name": "@tauri-apps/plugin-barcode-scanner",
"version": "2.4.5",
"purl": "pkg:npm/%40tauri-apps/plugin-barcode-scanner@2.4.5",
"description": "Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS",
"licenses": [
{
"license": {
"name": "MIT OR Apache-2.0"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/tauri-apps/plugins-workspace"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@tauri-apps_plugin-barcode-scanner@2.4.5"
},
{
"name": "local:sourcePackageDir",
"value": "apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40tauri-apps/plugin-deep-link@2.4.9", "bomRef": "pkg:npm/%40tauri-apps/plugin-deep-link@2.4.9",
@ -1754,4 +1723,4 @@
] ]
} }
] ]
} }

View file

@ -160,19 +160,6 @@
"licenseFolder": "licenses/@tauri-apps_cli@2.11.4", "licenseFolder": "licenses/@tauri-apps_cli@2.11.4",
"sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/cli" "sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/cli"
}, },
{
"name": "@tauri-apps/plugin-barcode-scanner",
"version": "2.4.5",
"license": "MIT OR Apache-2.0",
"homepage": null,
"repository": "https://github.com/tauri-apps/plugins-workspace",
"description": "Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS",
"files": [
"LICENSE.spdx"
],
"licenseFolder": "licenses/@tauri-apps_plugin-barcode-scanner@2.4.5",
"sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner"
},
{ {
"name": "@tauri-apps/plugin-deep-link", "name": "@tauri-apps/plugin-deep-link",
"version": "2.4.9", "version": "2.4.9",
@ -678,4 +665,4 @@
"sourcePackageDir": "packages/call/node_modules/zustand" "sourcePackageDir": "packages/call/node_modules/zustand"
} }
] ]
} }

@ -1 +1 @@
Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c

View file

@ -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"
} }
} }

View file

@ -12,8 +12,7 @@
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run", "build": "tsc -p tsconfig.json --noEmit"
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",

View file

@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import {
replaceConversation,
selectConversationWindows,
trimMessages,
} from "./helpers";
import type { CachedMessage, ConversationWindow } from "./schemas";
const message = (SendTime: number): CachedMessage => ({
SenderId: 1,
SendTime,
Content: "Y2lwaGVydGV4dA==",
MessageState: "received",
});
const window = (UserId: number, LastMessageAt: number): ConversationWindow => ({
UserId,
LastMessageAt,
Messages: [],
});
describe("conversation cache helpers", () => {
it("selects the five most recent windows", () => {
const selected = selectConversationWindows(
[
window(1, 1),
window(2, 6),
window(3, 3),
window(4, 4),
window(5, 5),
window(6, 2),
],
5,
);
expect(selected.map(({ UserId }) => UserId)).toEqual([2, 5, 4, 3, 6]);
});
it("replaces only the matching conversation", () => {
expect(
replaceConversation([window(1, 1), window(2, 2)], window(1, 9)),
).toEqual([window(1, 9), window(2, 2)]);
});
it("retains the newest messages in chronological order", () => {
expect(
trimMessages([message(2), message(3), message(1)], 2).map(
(item) => item.SendTime,
),
).toEqual([2, 3]);
});
});

View file

@ -21,8 +21,7 @@ export function removeMissingContactSnapshots<T extends { UserId: number }>(
} }
export default function CacheSync() { export default function CacheSync() {
const { addInterceptor, contextReady, freshContacts, subscribePush } = const { addInterceptor, contextReady, freshContacts, subscribe } = useMTP();
useMTP();
const { load } = useStorage(); const { load } = useStorage();
const [accountId, setAccountId] = useState(0); const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve()); const queueRef = useRef(Promise.resolve());
@ -321,10 +320,19 @@ export default function CacheSync() {
useEffect(() => { useEffect(() => {
if (!accountId || !contextReady) return; if (!accountId || !contextReady) return;
return subscribePush((message) => { const handleMessage = (message: ProtocolMessage) => {
void enqueue(() => synchronizePush(message)); void enqueue(() => synchronizePush(message));
}); };
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]); const unsubscribers = [
subscribe("GetStates", handleMessage),
subscribe("MessageLive", handleMessage),
subscribe("MessageEditLive", handleMessage),
subscribe("MessageDeleteLive", handleMessage),
subscribe("MessageState", handleMessage),
subscribe("MessageReactionLive", handleMessage),
];
return () => unsubscribers.forEach((unsubscribe) => unsubscribe());
}, [accountId, contextReady, enqueue, subscribe, synchronizePush]);
return null; return null;
} }

View file

@ -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",

View file

@ -63,6 +63,7 @@ export default function InviteButton({
<Wrapper <Wrapper
key={contact.UserId} key={contact.UserId}
userId={contact.UserId} userId={contact.UserId}
fields={["Display"]}
loading={<div>Loading...</div>} loading={<div>Loading...</div>}
component={(user) => ( component={(user) => (
<Button <Button

View file

@ -23,6 +23,7 @@ export default function InvitePopup({
return ( return (
<Wrapper <Wrapper
userId={user} userId={user}
fields={["Avatar", "Display"]}
loading={null} loading={null}
component={(user) => ( component={(user) => (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>

View file

@ -16,13 +16,15 @@ import {
} from "../../store"; } from "../../store";
import { Track, type Participant } from "livekit-client"; import { Track, type Participant } from "livekit-client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useUser, type User } from "@tensamin/user/context"; import { type SelectedUser, useUserFields } from "@tensamin/user/context";
import { useIsSpeaking } from "../../speakingState"; import { useIsSpeaking } from "../../speakingState";
import VideoViewer from "../videoViewer"; import VideoViewer from "../videoViewer";
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react"; import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";
import ContextMenu from "./contextMenu"; import ContextMenu from "./contextMenu";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
const USER_FIELDS = ["UserId", "Avatar", "Display"] as const;
function getTrackPublicationBySource( function getTrackPublicationBySource(
participant: Participant | undefined, participant: Participant | undefined,
source: Track.Source, source: Track.Source,
@ -113,7 +115,7 @@ function Overlay({
participant, participant,
}: { }: {
type: "user" | "stream"; type: "user" | "stream";
user: User; user: SelectedUser<typeof USER_FIELDS>;
participant: Participant; participant: Participant;
}) { }) {
const isAdmin = getRoomMetadata()?.admins.includes(user.UserId) === true; const isAdmin = getRoomMetadata()?.admins.includes(user.UserId) === true;
@ -163,12 +165,16 @@ export default function Base({
fill?: boolean; fill?: boolean;
flush?: boolean; flush?: boolean;
}) { }) {
const { get } = useUser();
const { load } = useStorage(); const { load } = useStorage();
const focusedParticipantId = useCall((state) => state.focusedParticipantId); const focusedParticipantId = useCall((state) => state.focusedParticipantId);
const view = useCall((state) => state.view); const view = useCall((state) => state.view);
const [user, setUser] = useState<User | null>(null); const participantId = Number(participant?.identity);
const validParticipantId =
participant && Number.isInteger(participantId) && participantId > 0
? participantId
: null;
const { data: user } = useUserFields(validParticipantId, USER_FIELDS);
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState< const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
string | undefined string | undefined
>(undefined); >(undefined);
@ -191,31 +197,6 @@ export default function Base({
load("user_id").then(setOwnId); load("user_id").then(setOwnId);
}, [load]); }, [load]);
useEffect(() => {
const participantId = Number(participant?.identity);
if (
!participant ||
!Number.isInteger(participantId) ||
participantId <= 0
) {
setUser(null);
return;
}
let active = true;
void get(participantId).then((nextUser) => {
if (active) {
setUser(nextUser);
}
});
return () => {
active = false;
};
}, [participant, get]);
useEffect(() => { useEffect(() => {
if (type !== "user" || !user?.Avatar) { if (type !== "user" || !user?.Avatar) {
setAvatarBackgroundColor(undefined); setAvatarBackgroundColor(undefined);

View file

@ -5,7 +5,6 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
Slider, Slider,
} from "@methanium/ui"; } from "@methanium/ui";
import type { User } from "@tensamin/user/context";
import { setParticipantCameraDisabled, useCall } from "../../store"; import { setParticipantCameraDisabled, useCall } from "../../store";
import { useState } from "react"; import { useState } from "react";
@ -23,7 +22,7 @@ export default function ContextMenu({
user, user,
ownId, ownId,
}: { }: {
user: User; user: Readonly<{ UserId: number }>;
ownId: number; ownId: number;
}) { }) {
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);

View file

@ -14,10 +14,12 @@ import {
useSidebar, useSidebar,
} from "@methanium/ui"; } from "@methanium/ui";
import { ScreenShareOff } from "lucide-react"; import { ScreenShareOff } from "lucide-react";
import { useUser, type User } from "@tensamin/user/context"; import { useUserFields } from "@tensamin/user/context";
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState"; import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
import { getAverageImageColor } from "./modals/base"; import { getAverageImageColor } from "./modals/base";
const USER_FIELDS = ["Avatar", "Display"] as const;
function getTrackPublicationBySource( function getTrackPublicationBySource(
participant: Participant | undefined, participant: Participant | undefined,
source: Track.Source, source: Track.Source,
@ -62,10 +64,12 @@ function MobileCallPill({
callId: string; callId: string;
}) { }) {
const { setOpenMobile } = useSidebar(); const { setOpenMobile } = useSidebar();
const { get } = useUser();
const lastSpeakingParticipantId = useLastSpeakingParticipantId(); const lastSpeakingParticipantId = useLastSpeakingParticipantId();
const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1); const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1);
const [lastSpeakingUser, setLastSpeakingUser] = useState<User | null>(null); const { data: lastSpeakingUser } = useUserFields(
lastSpeakingParticipantId,
USER_FIELDS,
);
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState< const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
string | undefined string | undefined
>(undefined); >(undefined);
@ -83,25 +87,6 @@ function MobileCallPill({
const [coords, setCoords] = useState(initialCoords); const [coords, setCoords] = useState(initialCoords);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (lastSpeakingParticipantId == null) {
setLastSpeakingUser(null);
return;
}
let mounted = true;
void get(lastSpeakingParticipantId).then((user) => {
if (mounted) {
setLastSpeakingUser(user);
}
});
return () => {
mounted = false;
};
}, [get, lastSpeakingParticipantId]);
useEffect(() => { useEffect(() => {
if (!lastSpeakingUser?.Avatar) { if (!lastSpeakingUser?.Avatar) {
setAvatarBackgroundColor(undefined); setAvatarBackgroundColor(undefined);

View file

@ -1,4 +1,4 @@
import { useUser, type User } from "@tensamin/user/context"; import { useUserFields } from "@tensamin/user/context";
import { useCall, getRoom } from "../store"; import { useCall, getRoom } from "../store";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
@ -11,8 +11,43 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@methanium/ui"; } from "@methanium/ui";
const USER_FIELDS = ["Avatar", "Display"] as const;
function ParticipantAvatar({
userId,
portalContainer,
}: {
userId: number;
portalContainer?: HTMLElement;
}) {
const { data: user } = useUserFields(userId, USER_FIELDS);
if (!user) return null;
return (
<div className="-ml-4">
<Tooltip>
<TooltipTrigger
render={
<Avatar className="size-7">
<AvatarImage src={user.Avatar} />
<AvatarFallback className="text-xs">
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
}
/>
<TooltipContent
side="bottom"
portalProps={{ container: portalContainer }}
>
{user.Display}
</TooltipContent>
</Tooltip>
</div>
);
}
export default function TopBar() { export default function TopBar() {
const { get } = useUser();
const { load } = useStorage(); const { load } = useStorage();
const room = getRoom(); const room = getRoom();
const screenRef = useCall((state) => state.screenRef); const screenRef = useCall((state) => state.screenRef);
@ -31,65 +66,23 @@ export default function TopBar() {
: null; : null;
}, },
).filter((participantId): participantId is number => participantId != null); ).filter((participantId): participantId is number => participantId != null);
const userIdsKey = userIds.join(","); const [ownId, setOwnId] = useState<number>();
const [users, setUsers] = useState<User[]>([]);
useEffect(() => { useEffect(() => {
let active = true; void load("user_id").then(setOwnId);
const ids = userIdsKey === "" ? [] : userIdsKey.split(",").map(Number); }, [load]);
void Promise.all(ids.map((id) => get(id))) const participantIds = ownId === undefined ? userIds : [ownId, ...userIds];
.then(async (users) => {
if (!active) {
return;
}
const ownId = await load("user_id");
const ownUser = await get(ownId);
setUsers([ownUser, ...users]);
})
.catch(async () => {
if (!active) {
return;
}
const ownId = await load("user_id");
const ownUser = await get(ownId);
setUsers([ownUser]);
});
return () => {
active = false;
};
}, [get, userIdsKey, load]);
return ( return (
<div className="w-full flex justify-between h-12"> <div className="w-full flex justify-between h-12">
<div className="flex gap-1 m-3 ml-7"> <div className="flex gap-1 m-3 ml-7">
{users.map((user) => ( {participantIds.map((userId) => (
<div key={user.UserId} className="-ml-4"> <ParticipantAvatar
<Tooltip> key={userId}
<TooltipTrigger userId={userId}
render={ portalContainer={portalContainer}
<Avatar className="size-7"> />
<AvatarImage src={user.Avatar} />
<AvatarFallback className="text-xs">
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
}
/>
<TooltipContent
side="bottom"
portalProps={{ container: portalContainer }}
>
{user.Display}
</TooltipContent>
</Tooltip>
</div>
))} ))}
</div> </div>
</div> </div>

View file

@ -19,7 +19,6 @@ export function getMediaShareAdapter(): MediaShareAdapter {
export type { export type {
MediaShareAdapter, MediaShareAdapter,
MediaShareCapabilities,
MediaShareKind, MediaShareKind,
MediaShareRequest, MediaShareRequest,
MediaShareSession, MediaShareSession,

View file

@ -70,7 +70,6 @@ type SendFn = (
data: Record<string, unknown>, data: Record<string, unknown>,
) => Promise<{ data: unknown }>; ) => Promise<{ data: unknown }>;
type LoadFn = (key: string) => Promise<unknown>; type LoadFn = (key: string) => Promise<unknown>;
type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>;
type RemoteVideoTrackSelector = Track.Kind | Track.Source; type RemoteVideoTrackSelector = Track.Kind | Track.Source;
type Runtime = { type Runtime = {
@ -80,7 +79,7 @@ type Runtime = {
}) => Promise<void>; }) => Promise<void>;
send: SendFn; send: SendFn;
load: LoadFn; load: LoadFn;
getUser: GetUserFn; getPublicKey: (userId: number) => Promise<string>;
}; };
let _keyProvider: ExternalE2EEKeyProvider | null = null; let _keyProvider: ExternalE2EEKeyProvider | null = null;
@ -671,9 +670,7 @@ export async function sendCallInvite(userId: number) {
throw new Error("Cannot send call invite without an active call."); throw new Error("Cannot send call invite without an active call.");
} }
const remotePublicKey = await runtime const remotePublicKey = await runtime.getPublicKey(userId);
.getUser(userId)
.then((data) => data.PublicKey);
const secretId = deriveCallSecretId(callId); const secretId = deriveCallSecretId(callId);
const wrapped = await wrapCallSecret({ const wrapped = await wrapCallSecret({
callSecret, callSecret,
@ -1226,7 +1223,7 @@ export const useCall = create<{
export function useInitializeCall() { export function useInitializeCall() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { send, subscribePush } = useMTP(); const { send, subscribe } = useMTP();
const { load } = useStorage(); const { load } = useStorage();
const { insertCall } = useSession(); const { insertCall } = useSession();
const { get } = useUser(); const { get } = useUser();
@ -1255,7 +1252,8 @@ export function useInitializeCall() {
navigate, navigate,
send: send as SendFn, send: send as SendFn,
load: load as LoadFn, load: load as LoadFn,
getUser: get as GetUserFn, getPublicKey: (userId) =>
get(userId, ["PublicKey"]).then((user) => user.PublicKey),
}); });
}, [get, load, navigate, send]); }, [get, load, navigate, send]);
@ -1315,14 +1313,9 @@ export function useInitializeCall() {
// listen to call invites // listen to call invites
useEffect(() => { useEffect(() => {
return subscribePush(async (message) => { return subscribe("CallInvite", async ({ data }) => {
if (message.type !== "CallInvite") return; const { CallId, CallSecret, SenderId } = data;
if (!CallId || !CallSecret || !SenderId) return;
const { CallId, CallSecret, SenderId } = message.data as {
CallId: string;
CallSecret: ProtocolCallSecret;
SenderId: number;
};
if (SenderId === Number(await load("user_id"))) { if (SenderId === Number(await load("user_id"))) {
return; return;
@ -1339,7 +1332,7 @@ export function useInitializeCall() {
SenderId, SenderId,
); );
}); });
}, [load, subscribePush, showCallingScreen]); }, [load, subscribe, showCallingScreen]);
// get callId from url // get callId from url
useEffect(() => { useEffect(() => {

View file

@ -1,54 +1,23 @@
import { useEffect, useState } from "react";
import { useCall } from "../store"; import { useCall } from "../store";
import { useUser, type User } from "@tensamin/user/context"; import { useUserFields } from "@tensamin/user/context";
const USER_FIELDS = ["Display"] as const;
function Participant({ userId }: { userId: number }) {
const { data: user } = useUserFields(userId, USER_FIELDS);
return user ? <p className="text-2xl">User: {user.Display}</p> : null;
}
export default function Preview() { export default function Preview() {
const { get } = useUser();
const currentCallData = useCall((state) => state.currentCallData); const currentCallData = useCall((state) => state.currentCallData);
const [data, setData] = useState<User[]>([]);
useEffect(() => {
let active = true;
if (!currentCallData?.exists) {
return () => {
active = false;
};
}
void Promise.all(currentCallData.UserIds.map((id) => get(id)))
.then((users) => {
if (!active) {
return;
}
setData(users);
})
.catch(() => {
if (!active) {
return;
}
setData([]);
});
return () => {
active = false;
};
}, [currentCallData, get]);
return ( return (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
{currentCallData?.exists ? ( {currentCallData?.exists ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{data.map((user) => { {currentCallData.UserIds.map((userId) => (
return ( <Participant key={userId} userId={userId} />
<p key={user.UserId} className="text-2xl"> ))}
User: {user.Display}
</p>
);
})}
</div> </div>
) : ( ) : (
<p className="text-2xl">Call expired</p> <p className="text-2xl">Call expired</p>

View file

@ -2,6 +2,9 @@ import { Input, type InputController } from "@methanium/ui/markdown";
import { import {
Card, Card,
CardHeader, CardHeader,
Drawer,
DrawerContent,
DrawerTrigger,
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
@ -18,9 +21,9 @@ import { cn, useIsMobile } from "@methanium/ui";
import { encryptChatText } from "@tensamin/crypto/chatSecret"; import { encryptChatText } from "@tensamin/crypto/chatSecret";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import GifPicker from "./gifPicker"; import EmojiPicker from "./emoji/emojiPicker";
import EmojiPicker from "./emojiPicker"; import { useEmojiRanks, useRecordEmojiUse } from "./emoji/emojiRanks";
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks"; import GifPicker from "./media/gifPicker";
import ReplyBox from "./replyBox"; import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys"; import { useHotkey } from "@tensamin/hotkeys";
import { editLastMessageHotkey } from "../hotkeys"; import { editLastMessageHotkey } from "../hotkeys";
@ -331,42 +334,65 @@ export default function InputComponent({
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}> {isMobile ? (
<PopoverTrigger <Drawer open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
render={({ onClick }) => ( <DrawerTrigger asChild>
<Button <div>
onClick={onClick} <Button className="w-9 h-9! p-0" variant="ghost">
className="w-9 h-9! p-0" <FileVideo size={20} />
variant="ghost" </Button>
> </div>
<FileVideo size={20} /> </DrawerTrigger>
</Button> <DrawerContent className="h-[80dvh]">
)} <div className="min-h-0 flex-1 p-3">
/> <GifPicker
<PopoverContent onSelect={(url) => {
ref={gifPopoverRef} void handleSubmit(url, true);
className="relative min-h-80 max-h-180 min-w-80 max-w-180 overflow-hidden" setGifPopoverOpen(false);
onTouchMoveCapture={(event) => event.stopPropagation()} }}
onWheelCapture={(event) => event.stopPropagation()} />
style={{ </div>
...gifPopoverSize, </DrawerContent>
maxHeight: gifPopoverSize?.height, </Drawer>
}} ) : (
> <Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
<div <PopoverTrigger
className="absolute left-0 top-0 z-10 h-4 w-4 cursor-nwse-resize" render={({ onClick }) => (
onPointerDown={handleGifPopoverResizeStart} <Button
onClick={onClick}
aria-label="Open GIF picker"
className="w-9 h-9! p-0"
variant="ghost"
>
<FileVideo size={20} />
</Button>
)}
/> />
<GifPicker <PopoverContent
resizeHeight={gifPopoverSize?.height} ref={gifPopoverRef}
resizeWidth={gifPopoverSize?.width} className="relative h-130 w-120 min-h-80 max-h-180 min-w-80 max-w-180 overflow-hidden"
onSelect={(url) => { onTouchMoveCapture={(event) => event.stopPropagation()}
void handleSubmit(url, true); onWheelCapture={(event) => event.stopPropagation()}
setGifPopoverOpen(false); style={{
...gifPopoverSize,
maxHeight: gifPopoverSize?.height,
}} }}
/> >
</PopoverContent> <div
</Popover> className="absolute left-0 top-0 z-10 h-4 w-4 cursor-nwse-resize"
onPointerDown={handleGifPopoverResizeStart}
/>
<GifPicker
resizeHeight={gifPopoverSize?.height}
resizeWidth={gifPopoverSize?.width}
onSelect={(url) => {
void handleSubmit(url, true);
setGifPopoverOpen(false);
}}
/>
</PopoverContent>
</Popover>
)}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>

View file

@ -8,9 +8,11 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@methanium/ui"; } from "@methanium/ui";
import type { ChatPickerMediaGroup } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { Loader2, Search } from "lucide-react"; import { ArrowLeft, Loader2, Search } from "lucide-react";
import MediaSaveButton from "./mediaSaveButton"; import MediaSaveButton from "./mediaSaveButton";
import { getUngroupedMedia } from "./mediaGroups";
import React, { import React, {
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
@ -443,21 +445,34 @@ function KlipyPanel({
} }
function SavedPanel({ function SavedPanel({
groups,
onSelect, onSelect,
onSavedMediaChange, onSavedMediaChange,
onSavedMediaGroupsChange,
resizeWidth, resizeWidth,
urls, urls,
}: { }: {
groups: ChatPickerMediaGroup[];
onSelect: (url: string) => void; onSelect: (url: string) => void;
onSavedMediaChange: (savedMedia: string[]) => void; onSavedMediaChange: (savedMedia: string[]) => void;
onSavedMediaGroupsChange: (groups: ChatPickerMediaGroup[]) => void;
resizeWidth?: number; resizeWidth?: number;
urls: string[]; urls: string[];
}) { }) {
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [scrollRef, measuredWidth] = useMeasuredWidth(); const [scrollRef, measuredWidth] = useMeasuredWidth();
const imageSizes = useImageSizes(urls); const selectedGroup = groups.find((group) => group.id === selectedGroupId);
const sortedGroups = [...groups].sort((left, right) =>
left.name.localeCompare(right.name),
);
const visibleUrls = selectedGroup
? selectedGroup.media.filter((url) => urls.includes(url))
: getUngroupedMedia(urls, groups);
const imageSizes = useImageSizes(visibleUrls);
const columnWidth = resizeWidth ?? measuredWidth; const columnWidth = resizeWidth ?? measuredWidth;
const columnCount = getColumnCount(columnWidth, urls.length); const columnCount = getColumnCount(columnWidth, visibleUrls.length);
const columns = distributeByHeight(urls, columnCount, (url) => { const groupColumnCount = getColumnCount(columnWidth, groups.length);
const columns = distributeByHeight(visibleUrls, columnCount, (url) => {
const size = imageSizes[url]; const size = imageSizes[url];
if (!size?.width || !size.height) return 1; if (!size?.width || !size.height) return 1;
@ -475,6 +490,50 @@ function SavedPanel({
return ( return (
<div ref={scrollRef} className="min-h-0 w-full flex-1 overflow-y-auto pr-1"> <div ref={scrollRef} className="min-h-0 w-full flex-1 overflow-y-auto pr-1">
{selectedGroup ? (
<div className="mb-2 flex items-center gap-2">
<Button
variant="ghost"
aria-label="Back to saved media groups"
className="h-8 w-8 p-0"
onClick={() => setSelectedGroupId(null)}
>
<ArrowLeft className="size-4" />
</Button>
<span className="font-medium">{selectedGroup.name}</span>
</div>
) : groups.length > 0 ? (
<div
className="mb-2 grid gap-2"
style={{
gridTemplateColumns: `repeat(${groupColumnCount}, minmax(0, 1fr))`,
}}
>
{sortedGroups.map((group) => (
<button
key={group.id}
className="relative h-24 overflow-hidden rounded-lg border bg-muted bg-cover bg-center p-1 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{
backgroundImage: `url(${JSON.stringify(group.media[0])})`,
}}
type="button"
onClick={() => setSelectedGroupId(group.id)}
>
<span className="absolute inset-0 bg-black/40" />
<span className="relative flex h-full items-center justify-center font-medium text-white drop-shadow-sm">
{group.name}
</span>
</button>
))}
</div>
) : null}
{visibleUrls.length === 0 ? (
<div className="flex min-h-32 items-center justify-center text-center text-muted-foreground">
{selectedGroup ? "This group is empty." : "No ungrouped media."}
</div>
) : null}
<div className="flex w-full items-start gap-2"> <div className="flex w-full items-start gap-2">
{columns.map((column, columnIndex) => ( {columns.map((column, columnIndex) => (
<div key={columnIndex} className="flex min-w-0 flex-1 flex-col gap-2"> <div key={columnIndex} className="flex min-w-0 flex-1 flex-col gap-2">
@ -504,6 +563,7 @@ function SavedPanel({
ariaLabel="Unsave media" ariaLabel="Unsave media"
defaultSaved defaultSaved
onSavedMediaChange={onSavedMediaChange} onSavedMediaChange={onSavedMediaChange}
onSavedMediaGroupsChange={onSavedMediaGroupsChange}
url={url} url={url}
/> />
</div> </div>
@ -530,6 +590,9 @@ export default function GifPicker({
const [searchString, setSearchString] = useState(""); const [searchString, setSearchString] = useState("");
const [debouncedSearchString, setDebouncedSearchString] = useState(""); const [debouncedSearchString, setDebouncedSearchString] = useState("");
const [savedMedia, setSavedMedia] = useState<string[]>([]); const [savedMedia, setSavedMedia] = useState<string[]>([]);
const [savedMediaGroups, setSavedMediaGroups] = useState<
ChatPickerMediaGroup[]
>([]);
const didLoadLastTabRef = useRef(false); const didLoadLastTabRef = useRef(false);
const searchDebouncerRef = useRef<Debouncer<(value: string) => void> | null>( const searchDebouncerRef = useRef<Debouncer<(value: string) => void> | null>(
null, null,
@ -555,7 +618,13 @@ export default function GifPicker({
useEffect(() => { useEffect(() => {
if (tab !== "saved") return; if (tab !== "saved") return;
void load("chat_picker_saved_media").then(setSavedMedia); void Promise.all([
load("chat_picker_saved_media"),
load("chat_picker_saved_media_groups"),
]).then(([nextSavedMedia, nextGroups]) => {
setSavedMedia(nextSavedMedia);
setSavedMediaGroups(nextGroups);
});
}, [load, tab]); }, [load, tab]);
useEffect(() => { useEffect(() => {
@ -627,8 +696,10 @@ export default function GifPicker({
className="flex min-h-0 w-full flex-col overflow-hidden" className="flex min-h-0 w-full flex-col overflow-hidden"
> >
<SavedPanel <SavedPanel
groups={savedMediaGroups}
onSelect={onSelect} onSelect={onSelect}
onSavedMediaChange={setSavedMedia} onSavedMediaChange={setSavedMedia}
onSavedMediaGroupsChange={setSavedMediaGroups}
resizeWidth={resizeWidth} resizeWidth={resizeWidth}
urls={savedMedia} urls={savedMedia}
/> />

View file

@ -24,7 +24,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useState, useMemo, useEffect, useRef, type WheelEvent } from "react"; import { useState, useMemo, useEffect, useRef, type WheelEvent } from "react";
import MediaSaveButton from "./mediaSaveButton"; import MediaSaveButton from "./mediaSaveButton";
import { type User, useUser } from "@tensamin/user/context"; import { useUserFields } from "@tensamin/user/context";
const zoomLevels = [1, 1.5, 2, 3]; const zoomLevels = [1, 1.5, 2, 3];
@ -46,12 +46,7 @@ export default function Media({
const wheelDelta = useRef(0); const wheelDelta = useRef(0);
const hostname = useMemo(() => new URL(link).hostname, [link]); const hostname = useMemo(() => new URL(link).hostname, [link]);
// Get user's const { data: user } = useUserFields(senderId, ["Avatar", "Display"]);
const { get } = useUser();
const [user, setUser] = useState<undefined | User>(undefined);
useEffect(() => {
get(senderId).then(setUser);
}, [senderId, get]);
const avatar = user?.Avatar const avatar = user?.Avatar
? `data:image/webp;base64,${user.Avatar}` ? `data:image/webp;base64,${user.Avatar}`
: undefined; : undefined;
@ -171,9 +166,9 @@ export default function Media({
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
onWheel={handleWheel} onWheel={handleWheel}
className="bg-black/50 border-0! w-screen! h-screen! max-w-screen! rounded-none! p-0!" className="inset-0! h-dvh! w-screen! max-h-none! max-w-none! translate-none! overflow-hidden! rounded-none! border-0! bg-black/50 p-0!"
> >
<div className="relative z-10 flex w-full h-full justify-between p-7 items-start pointer-events-none"> <div className="pointer-events-none relative z-10 flex h-full w-full items-start justify-between pt-[max(1.75rem,var(--ui-safe-area-top))] pr-[max(1.75rem,var(--ui-safe-area-right))] pb-[max(1.75rem,var(--ui-safe-area-bottom))] pl-[max(1.75rem,var(--ui-safe-area-left))]">
<div <div
className={`flex gap-2 items-center transition-opacity duration-200 ${zoomLevel === 0 ? "opacity-100" : "pointer-events-none opacity-0"}`} className={`flex gap-2 items-center transition-opacity duration-200 ${zoomLevel === 0 ? "opacity-100" : "pointer-events-none opacity-0"}`}
inert={zoomLevel !== 0} inert={zoomLevel !== 0}

View file

@ -0,0 +1,65 @@
import type { ChatPickerMediaGroup } from "@tensamin/shared/data";
function withoutMedia(
groups: ChatPickerMediaGroup[],
url: string,
keepGroupId?: string,
) {
return groups
.map((group) => ({
...group,
media: group.media.filter((item) => item !== url),
}))
.filter((group) => group.id === keepGroupId || group.media.length > 0);
}
export function assignMediaToGroup(
groups: ChatPickerMediaGroup[],
groupId: string | null,
url: string,
) {
const nextGroups = withoutMedia(groups, url, groupId ?? undefined);
if (groupId === null) return nextGroups;
return nextGroups.map((group) =>
group.id === groupId ? { ...group, media: [url, ...group.media] } : group,
);
}
export function createMediaGroup(
groups: ChatPickerMediaGroup[],
id: string,
name: string,
url: string,
) {
const trimmedName = name.trim();
if (
!trimmedName ||
groups.some(
(group) =>
group.name.toLocaleLowerCase() === trimmedName.toLocaleLowerCase(),
)
) {
return groups;
}
return [
...withoutMedia(groups, url),
{ id, name: trimmedName, media: [url] },
];
}
export function getMediaGroupId(groups: ChatPickerMediaGroup[], url: string) {
return groups.find((group) => group.media.includes(url))?.id ?? null;
}
export function getUngroupedMedia(
savedMedia: string[],
groups: ChatPickerMediaGroup[],
) {
const groupedMedia = new Set(groups.flatMap((group) => group.media));
return savedMedia.filter((url) => !groupedMedia.has(url));
}

View file

@ -0,0 +1,238 @@
import {
Button,
cn,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
useIsMobile,
} from "@methanium/ui";
import type { ChatPickerMediaGroup } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
import { Ellipsis, Star } from "lucide-react";
import { useEffect, useState } from "react";
import {
assignMediaToGroup,
createMediaGroup,
getMediaGroupId,
} from "./mediaGroups";
export default function MediaSaveButton({
ariaLabel,
className,
defaultSaved = false,
onSavedMediaChange,
onSavedMediaGroupsChange,
url,
}: {
ariaLabel?: string;
className?: string;
defaultSaved?: boolean;
onSavedMediaChange?: (savedMedia: string[]) => void;
onSavedMediaGroupsChange?: (groups: ChatPickerMediaGroup[]) => void;
url: string;
}) {
const { load, save } = useStorage();
const isMobile = useIsMobile();
const [savedMedia, setSavedMedia] = useState<string[] | null>(null);
const [groups, setGroups] = useState<ChatPickerMediaGroup[] | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [newGroupName, setNewGroupName] = useState("");
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
const sortedGroups = [...(groups ?? [])].sort((left, right) =>
left.name.localeCompare(right.name),
);
const normalizedNewGroupName = newGroupName.trim().toLocaleLowerCase();
const canCreateGroup =
normalizedNewGroupName.length > 0 &&
!(groups ?? []).some(
(group) => group.name.toLocaleLowerCase() === normalizedNewGroupName,
);
useEffect(() => {
void Promise.all([
load("chat_picker_saved_media"),
load("chat_picker_saved_media_groups"),
]).then(([nextSavedMedia, nextGroups]) => {
setSavedMedia(nextSavedMedia);
setGroups(nextGroups);
});
}, [load]);
async function handleSaveClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
const [currentSavedMedia, currentGroups] = await Promise.all([
load("chat_picker_saved_media"),
load("chat_picker_saved_media_groups"),
]);
const willUnsave = currentSavedMedia.includes(url);
const nextSavedMedia = willUnsave
? currentSavedMedia.filter((item) => item !== url)
: [url, ...currentSavedMedia];
setSavedMedia(nextSavedMedia);
onSavedMediaChange?.(nextSavedMedia);
if (willUnsave) {
const nextGroups = assignMediaToGroup(currentGroups, null, url);
setGroups(nextGroups);
onSavedMediaGroupsChange?.(nextGroups);
await Promise.all([
save("chat_picker_saved_media", nextSavedMedia),
save("chat_picker_saved_media_groups", nextGroups),
]);
return;
}
await save("chat_picker_saved_media", nextSavedMedia);
}
async function handleGroupClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
const currentGroups = await load("chat_picker_saved_media_groups");
setGroups(currentGroups);
setSelectedGroupId(getMediaGroupId(currentGroups, url));
setNewGroupName("");
setDialogOpen(true);
}
async function handleGroupSave() {
const currentGroups = await load("chat_picker_saved_media_groups");
const nextGroups = assignMediaToGroup(currentGroups, selectedGroupId, url);
setGroups(nextGroups);
onSavedMediaGroupsChange?.(nextGroups);
await save("chat_picker_saved_media_groups", nextGroups);
setDialogOpen(false);
}
async function handleCreateGroup() {
if (!canCreateGroup) return;
const currentGroups = await load("chat_picker_saved_media_groups");
const groupId = crypto.randomUUID();
const nextGroups = createMediaGroup(
currentGroups,
groupId,
newGroupName,
url,
);
setGroups(nextGroups);
setSelectedGroupId(groupId);
setNewGroupName("");
onSavedMediaGroupsChange?.(nextGroups);
await save("chat_picker_saved_media_groups", nextGroups);
setDialogOpen(false);
}
return (
<>
<div
className={cn(
"absolute right-1 top-1 z-10 flex gap-1 transition-all",
!isMobile &&
"-translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
className,
)}
>
<Button
variant="secondary"
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
className="h-9 w-9 rounded-sm border-0! p-0"
onClick={handleSaveClick}
>
<Star
color="var(--primary-foreground-alt)"
className={cn(
"size-4.5 stroke-1.5!",
isSaved ? "fill-(--primary)" : "",
)}
/>
</Button>
{isSaved ? (
<Button
variant="secondary"
aria-label="Choose saved media group"
className="h-9 w-9 rounded-sm border-0! p-0"
onClick={handleGroupClick}
>
<Ellipsis className="size-4.5 stroke-1.5!" />
</Button>
) : null}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent onClick={(event) => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>Organize saved media</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="saved-media-group">Select group</Label>
<Select
value={selectedGroupId ?? "ungrouped"}
onValueChange={(value) =>
setSelectedGroupId(value === "ungrouped" ? null : value)
}
>
<SelectTrigger id="saved-media-group" className="w-full">
<SelectValue>
{selectedGroupId === null
? "Ungrouped"
: groups?.find((group) => group.id === selectedGroupId)
?.name}
</SelectValue>
</SelectTrigger>
<SelectContent className="p-1">
<SelectItem value="ungrouped">Ungrouped</SelectItem>
{sortedGroups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="new-saved-media-group">New group</Label>
<div className="flex gap-2">
<Input
id="new-saved-media-group"
placeholder="Group name"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleCreateGroup();
}}
/>
<Button disabled={!canCreateGroup} onClick={handleCreateGroup}>
Create Group
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleGroupSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -1,63 +0,0 @@
import { Button, cn, useIsMobile } from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context";
import { Star } from "lucide-react";
import { useEffect, useState } from "react";
export default function MediaSaveButton({
ariaLabel,
className,
defaultSaved = false,
onSavedMediaChange,
url,
}: {
ariaLabel?: string;
className?: string;
defaultSaved?: boolean;
onSavedMediaChange?: (savedMedia: string[]) => void;
url: string;
}) {
const { load, save } = useStorage();
const isMobile = useIsMobile();
const [savedMedia, setSavedMedia] = useState<string[] | null>(null);
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
useEffect(() => {
void load("chat_picker_saved_media").then(setSavedMedia);
}, [load]);
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
const currentSavedMedia =
savedMedia ?? (await load("chat_picker_saved_media"));
const nextSavedMedia = currentSavedMedia.includes(url)
? currentSavedMedia.filter((item) => item !== url)
: [url, ...currentSavedMedia];
setSavedMedia(nextSavedMedia);
onSavedMediaChange?.(nextSavedMedia);
void save("chat_picker_saved_media", nextSavedMedia);
}
return (
<Button
variant="secondary"
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
className={cn(
"rounded-sm border-0! p-0 absolute right-1 top-1 z-10 h-9 w-9 transition-all",
!isMobile &&
"-translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
className,
)}
onClick={handleClick}
>
<Star
color="var(--primary-foreground-alt)"
className={cn(
"size-4.5 stroke-1.5!",
isSaved ? "fill-(--primary)" : "",
)}
/>
</Button>
);
}

View file

@ -1,7 +1,7 @@
import type { RawMessage } from "../values"; import type { RawMessage } from "../values";
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react"; import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { User } from "@tensamin/user/context"; import { type SelectedUser, useUserFields } from "@tensamin/user/context";
import { import {
Avatar, Avatar,
@ -13,15 +13,14 @@ import {
Skeleton, Skeleton,
} from "@methanium/ui"; } from "@methanium/ui";
import MessageContextMenu from "./messageContextMenu"; import MessageContextMenu from "./messageContextMenu";
import Media from "./media"; import Media from "./media/media";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { getMessage, useChat } from "../context"; import { getMessage, useChat } from "../context";
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret"; import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown"; import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
import { useRecordEmojiUse } from "./emojiRanks"; import { useRecordEmojiUse } from "./emoji/emojiRanks";
import { useUser } from "@tensamin/user/context";
import ReplyBox from "./replyBox"; import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys"; import { useHotkey } from "@tensamin/hotkeys";
import { cancelMessageEditHotkey } from "../hotkeys"; import { cancelMessageEditHotkey } from "../hotkeys";
@ -40,7 +39,7 @@ function MessageComponent({
decryptionFailed?: boolean; decryptionFailed?: boolean;
}; };
onSetEditing: (editing: boolean) => void; onSetEditing: (editing: boolean) => void;
user: User | null; user: SelectedUser<readonly ["UserId", "Avatar", "Display"]> | null;
}) { }) {
const actuallyFailed = const actuallyFailed =
(message.failed && message.MessageState === "awaiting") || (message.failed && message.MessageState === "awaiting") ||
@ -150,13 +149,14 @@ function MessageComponent({
removeReaction, removeReaction,
replyTo, replyTo,
} = useChat(); } = useChat();
const { get: getUser } = useUser();
const [replyMessage, setReplyMessage] = useState<RawMessage | null>(null); const [replyMessage, setReplyMessage] = useState<RawMessage | null>(null);
const [replyUser, setReplyUser] = useState<User | null>(null); const { data: replyUser } = useUserFields(replyMessage?.SenderId ?? null, [
"Avatar",
"Display",
]);
useEffect(() => { useEffect(() => {
if (!message.ReplyId || !ownId || !chatSecret) { if (!message.ReplyId || !ownId || !chatSecret) {
setReplyMessage(null); setReplyMessage(null);
setReplyUser(null);
return; return;
} }
@ -168,25 +168,20 @@ function MessageComponent({
send, send,
}) })
.then(async (reply) => { .then(async (reply) => {
const [Content, author] = await Promise.all([ const Content = await decryptChatText(chatSecret, reply.Content);
decryptChatText(chatSecret, reply.Content),
getUser(reply.SenderId),
]);
if (!active) return; if (!active) return;
setReplyMessage({ ...reply, Content }); setReplyMessage({ ...reply, Content });
setReplyUser(author);
}) })
.catch((err) => { .catch((err) => {
if (!active) return; if (!active) return;
setReplyMessage(null); setReplyMessage(null);
setReplyUser(null);
log(1, "chat", "red", "Failed to get replied-to message", err); log(1, "chat", "red", "Failed to get replied-to message", err);
}); });
return () => { return () => {
active = false; active = false;
}; };
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]); }, [chatSecret, message.ReplyId, ownId, send, userId]);
const recordUse = useRecordEmojiUse(); const recordUse = useRecordEmojiUse();
const editingRef = useRef(editing); const editingRef = useRef(editing);
const onSetEditingRef = useRef(onSetEditing); const onSetEditingRef = useRef(onSetEditing);
@ -386,8 +381,8 @@ function MessageComponent({
</div> </div>
)} )}
{editing ? ( {editing ? (
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1 py-1.5">
<Card className="p-2!"> <Card className="px-2! py-0! rounded-md">
<Input <Input
autoFocus autoFocus
className="w-full" className="w-full"
@ -405,8 +400,7 @@ function MessageComponent({
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button
size="xs" size="xs"
variant="link" variant="subtleDefault"
className="text-primary-foreground-alt"
onClick={() => { onClick={() => {
if (!editDraft.trim()) return; if (!editDraft.trim()) return;
if (editDraft !== message.Content) { if (editDraft !== message.Content) {
@ -420,8 +414,7 @@ function MessageComponent({
</Button> </Button>
<Button <Button
size="xs" size="xs"
variant="link" variant="outline"
className="text-muted-foreground"
onClick={cancelEditing} onClick={cancelEditing}
> >
Cancel Cancel

View file

@ -45,8 +45,8 @@ import type {
} from "react"; } from "react";
import { useChat } from "../context"; import { useChat } from "../context";
import { Emoji } from "@methanium/ui/markdown"; import { Emoji } from "@methanium/ui/markdown";
import EmojiPicker from "./emojiPicker"; import EmojiPicker from "./emoji/emojiPicker";
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks"; import { getRecentEmojis, useEmojiRanks } from "./emoji/emojiRanks";
async function copyText(text: string) { async function copyText(text: string) {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);

View file

@ -7,11 +7,13 @@ import {
Skeleton, Skeleton,
} from "@methanium/ui"; } from "@methanium/ui";
import { Text } from "@methanium/ui/markdown"; import { Text } from "@methanium/ui/markdown";
import type { User } from "@tensamin/user/context"; import type { SelectedUser } from "@tensamin/user/context";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
import { Forward, X } from "lucide-react"; import { Forward, X } from "lucide-react";
function ReplyUser({ user }: { user: User }) { type ReplyUserData = SelectedUser<readonly ["Avatar", "Display"]>;
function ReplyUser({ user }: { user: ReplyUserData }) {
return ( return (
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<Avatar className="h-5 w-5 shrink-0"> <Avatar className="h-5 w-5 shrink-0">
@ -40,7 +42,7 @@ export default function ReplyBox({
content?: string; content?: string;
loading?: boolean; loading?: boolean;
onDismiss?: () => void; onDismiss?: () => void;
user?: User; user?: ReplyUserData;
userId?: number; userId?: number;
variant: "composer" | "message"; variant: "composer" | "message";
}) { }) {
@ -71,6 +73,7 @@ export default function ReplyBox({
) : userId ? ( ) : userId ? (
<Wrapper <Wrapper
userId={userId} userId={userId}
fields={["Avatar", "Display"]}
loading={<Skeleton className="h-5 w-5 rounded-full" />} loading={<Skeleton className="h-5 w-5 rounded-full" />}
component={(resolvedUser) => <ReplyUser user={resolvedUser} />} component={(resolvedUser) => <ReplyUser user={resolvedUser} />}
/> />

View file

@ -226,7 +226,7 @@ export async function fetchReplyMessage({
export default function Provider({ children }: { children: ReactNode }) { export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage(); const { load } = useStorage();
const { send, subscribePush } = useMTP(); const { send, subscribe } = useMTP();
const { get: getUser } = useUser(); const { get: getUser } = useUser();
const { moveUserIdToTop } = useSession(); const { moveUserIdToTop } = useSession();
@ -458,7 +458,7 @@ export default function Provider({ children }: { children: ReactNode }) {
secretId, secretId,
version: CHAT_SECRET_VERSION, version: CHAT_SECRET_VERSION,
}); });
const peerUser = await getUser(userIdValue); const peerUser = await getUser(userIdValue, ["PublicKey"]);
const peerWrapped = await wrapChatSecret({ const peerWrapped = await wrapChatSecret({
chatSecret: rawSecret, chatSecret: rawSecret,
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle( recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
@ -912,124 +912,47 @@ export default function Provider({ children }: { children: ReactNode }) {
// Get live updates for message states // Get live updates for message states
useEffect(() => { useEffect(() => {
return subscribePush((message) => { const unsubscribeEdit = subscribe("MessageEditLive", ({ data }) => {
if (message.type === "MessageEditLive") { if (!currentChatSecret) return;
if (!currentChatSecret) return; if (data.ChatPartnerId !== userIdValue) {
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Content: string;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime);
if (!Number.isFinite(chatPartnerId) || !Number.isFinite(sendTime)) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to invalid data",
);
return;
}
if (chatPartnerId !== userIdValue) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to user ID mismatch",
{
expected: userIdValue,
received: chatPartnerId,
},
);
return;
}
void decryptChatText(currentChatSecret, rawData.Content)
.then((content) => {
editMessage(sendTime, { Content: content, Edited: true });
})
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt message edit", err, {
SendTime: sendTime,
});
});
return;
}
if (message.type === "MessageReactionLive") {
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Reaction: string;
SenderId: unknown;
Accepted: boolean;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime);
const senderId = Number(rawData.SenderId);
if (
chatPartnerId !== userIdValue ||
!Number.isFinite(sendTime) ||
!Number.isFinite(senderId)
) {
return;
}
applyLiveReaction(
sendTime,
rawData.Reaction,
senderId,
rawData.Accepted,
);
return;
}
if (message.type === "MessageDeleteLive") {
const data = message.data as {
ChatPartnerId: number;
SendTime: number;
};
if (data.ChatPartnerId !== userIdValue) return;
removeMessage(data.SendTime);
return;
}
if (message.type !== "MessageState") return;
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
MessageState: RawMessage["MessageState"];
};
const nextState = {
ChatPartnerId: Number(rawData.ChatPartnerId),
SendTime: Number(rawData.SendTime),
MessageState: rawData.MessageState,
};
if (
!Number.isFinite(nextState.ChatPartnerId) ||
!Number.isFinite(nextState.SendTime)
) {
log( log(
3, 3,
"chat", "chat",
"yellow", "yellow",
"Cancel message state update due to invalid data", "Cancel message edit update due to user ID mismatch",
{
expected: userIdValue,
received: data.ChatPartnerId,
},
); );
return; return;
} }
if (nextState.ChatPartnerId !== userIdValue) { void decryptChatText(currentChatSecret, data.Content)
.then((content) => {
editMessage(data.SendTime, { Content: content, Edited: true });
})
.catch((err) => {
log(1, "chat", "red", "Failed to decrypt message edit", err, {
SendTime: data.SendTime,
});
});
});
const unsubscribeReaction = subscribe("MessageReactionLive", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) return;
applyLiveReaction(
data.SendTime,
data.Reaction,
data.SenderId,
data.Accepted,
);
});
const unsubscribeDelete = subscribe("MessageDeleteLive", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) return;
removeMessage(data.SendTime);
});
const unsubscribeState = subscribe("MessageState", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) {
log( log(
3, 3,
"chat", "chat",
@ -1037,22 +960,27 @@ export default function Provider({ children }: { children: ReactNode }) {
"Cancel message state update due to user ID mismatch", "Cancel message state update due to user ID mismatch",
{ {
expected: userIdValue, expected: userIdValue,
received: nextState.ChatPartnerId, received: data.ChatPartnerId,
}, },
); );
return; return;
} }
editMessage(data.SendTime, {
editMessage(nextState.SendTime, { MessageState: data.MessageState,
MessageState: nextState.MessageState,
}); });
}); });
return () => {
unsubscribeEdit();
unsubscribeReaction();
unsubscribeDelete();
unsubscribeState();
};
}, [ }, [
currentChatSecret, currentChatSecret,
applyLiveReaction, applyLiveReaction,
editMessage, editMessage,
removeMessage, removeMessage,
subscribePush, subscribe,
userIdValue, userIdValue,
]); ]);

View file

@ -598,6 +598,7 @@ export default function Screen() {
<Wrapper <Wrapper
key={getMessageRenderKey(message)} key={getMessageRenderKey(message)}
userId={message.SenderId} userId={message.SenderId}
fields={["UserId", "Avatar", "Display"]}
loading={null} loading={null}
component={(user) => ( component={(user) => (
<> <>

View file

@ -7,3 +7,4 @@
- Drop any unique reactions above 10 - Drop any unique reactions above 10
- Reply jumping - Reply jumping
- Add emoji picker - Add emoji picker
- Improve arrow-up hotkey

View file

@ -12,9 +12,10 @@
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run", "test": "vitest run",
"build": "pnpm run test && 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"
} }

View file

@ -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,

View file

@ -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,

View file

@ -1,65 +0,0 @@
import { describe, expect, test, vi } from "vitest";
vi.mock("mtp", () => ({
crypto: {},
}));
import { createCryptoActions } from "./context";
/**
* Creates a rejected API getter used to verify initialization guards.
* @returns Null API reference.
*/
function getUninitializedApi(): null {
return null;
}
describe("createCryptoActions", () => {
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
test("throws when API is not initialized", async () => {
const actions = createCryptoActions(getUninitializedApi);
let failed = false;
try {
await actions.encrypt("ab", new TextEncoder().encode("plain"));
} catch (error) {
failed = (error as Error).message.includes("API not initialized");
}
expect(failed).toBe(true);
});
test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => {
const api = {
encrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}:${textDecoder.decode(input)}`),
decrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}|${textDecoder.decode(input)}`),
encryptText: async (secret: string, plaintext: string): Promise<string> =>
`${secret}:${plaintext}`,
decryptText: async (
secret: string,
ciphertext: string,
): Promise<string> => `${secret}|${ciphertext}`,
};
const actions = createCryptoActions(() => api);
expect(
textDecoder.decode(await actions.encrypt("s", textEncoder.encode("p"))),
).toBe("s:p");
expect(
textDecoder.decode(await actions.decrypt("s", textEncoder.encode("c"))),
).toBe("s|c");
expect(await actions.encryptText("s", "p")).toBe("s:p");
expect(await actions.decryptText("s", "c")).toBe("s|c");
});
});

View 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"
}
}

View 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>
);
}

View 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);
}

View 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));
}

View 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[].
*/
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;
}
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;
}
}

View 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>
);
}

View file

@ -0,0 +1 @@
- Improve the Input box

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View file

@ -9,18 +9,16 @@
"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 --passWithNoTests",
"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"

View file

@ -0,0 +1,529 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { toast as sonnerToast } from "@methanium/ui";
import { base64ToBytes, ConnectionState, MTPClient } from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue";
import {
mtp as mtpSchemas,
type Calls,
type Communities,
type Contacts,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context";
import {
type BoundSendFn,
MTPContext,
type MTPContextType,
type ProtocolMessage,
removeMissingContacts,
useMessageHandlers,
} from "./mtpContext";
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 createBrowserClient>>;
function createBrowserClient(
options: Omit<Parameters<typeof MTPClient.create>[0], "schemas">,
) {
return MTPClient.create({
...options,
schemas: mtpSchemas,
throwProtocolErrors: true,
onValidationError: (error) => {
log(1, "mtp", "red", "Failed to validate push message", error);
},
});
}
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)),
);
});
}
async function completeInitialSynchronization(
client: BrowserMtpClient,
subscribe: MTPContextType["subscribe"],
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 = subscribe("ClientStateSync", (message) => {
cleanup();
resolve(message);
});
unsubscribeNoIota = 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,
]);
await withDeadline(
client.request("ClientStateAck", {
SessionId: state.data.SessionId,
VersionNumber: state.data.VersionNumber,
}),
ackTimeoutMs,
"State acknowledgement timed out",
signal,
);
if (signal.aborted) throw abortError(signal);
return state;
}
function protocolErrorDetails(error: unknown) {
if (typeof error !== "object" || error === null || !("type" in error)) {
return null;
}
const protocolError = error as {
id?: unknown;
type?: unknown;
frame?: unknown;
};
return {
id: protocolError.id,
type: protocolError.type,
frame: protocolError.frame,
};
}
export function BrowserProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const { load } = useStorage();
const [readyState, setReadyState] = useState<number>(
ConnectionState.Disconnected,
);
const [identified, setIdentified] = useState(false);
const [identifying, setIdentifying] = useState(false);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const clientRef = useRef<BrowserMtpClient | null>(null);
const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } =
useMessageHandlers();
const connected = readyState === ConnectionState.Connected;
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => {
load("omega_url").then(setMtpUrl);
}, [load]);
const send: BoundSendFn = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
if (!client) throw new Error("mtp is not connected");
const response = await client.request(type, data, options);
if (response.type === "GetStates") {
setFreshContacts((contacts) =>
removeMissingContacts(
contacts,
response as ProtocolMessage<"GetStates">,
),
);
}
return response;
},
[],
);
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
if (!mtpUrl) return;
let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectScheduled = false;
let disposed = false;
let connectionGeneration = 0;
let cleanupConnection = () => {};
const clearReconnectTimer = () => {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
reconnectScheduled = false;
};
const clearReconnectResetTimer = () => {
if (!reconnectResetTimer) return;
clearTimeout(reconnectResetTimer);
reconnectResetTimer = null;
};
const scheduleReconnect = (error: unknown) => {
if (disposed || reconnectScheduled) return;
attempts += 1;
const shortRetry = attempts <= RECONNECT_TRIES;
if (!shortRetry) {
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
sonnerToast.error("Connection failed", {
id: "mtp-connection-toast",
description:
error instanceof Error
? `${error.message.split(":")[0]}. Retrying in the background.`
: "Connection lost. Retrying in the background.",
icon: null,
duration: Infinity,
closeButton: true,
promise: null,
} as unknown as Parameters<typeof sonnerToast.error>[1]);
} else {
sonnerToast.loading(
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
{ id: "mtp-connection-toast" },
);
}
const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL;
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
reconnectScheduled = true;
reconnectTimer = setTimeout(
() => {
reconnectScheduled = false;
reconnectTimer = null;
void connect();
},
Math.round(baseDelay * jitter),
);
};
async function connect() {
if (disposed || props.blockConnection) return;
const generation = ++connectionGeneration;
let client: BrowserMtpClient | null = null;
let failed = false;
let connectionReady = false;
let detachSubscriptions = () => {};
let unsubscribeNoIota = () => {};
const attemptAbort = new AbortController();
const cleanup = () => {
attemptAbort.abort(
new Error("Initial state synchronization was cancelled"),
);
unsubscribeNoIota();
detachSubscriptions();
client?.disconnect();
if (clientRef.current === client) clientRef.current = null;
clearReconnectResetTimer();
if (generation === connectionGeneration) {
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
}
};
cleanupConnection = cleanup;
try {
setIdentified(false);
setIdentifying(false);
const [userId, keyring] = await Promise.all([
load("user_id"),
load("mtp_keyring"),
]);
if (!userId || !keyring) throw new Error("Missing login credentials");
const forcedOmikronUrl = await load("forced_omikron_url");
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
let url = null;
let omikronPublicKey = null;
if (forcedOmikronUrl && forcedOmikronPublicKey) {
url = forcedOmikronUrl;
omikronPublicKey = forcedOmikronPublicKey;
} else {
log(2, "mtp", "purple", "Fetching Omikron data.");
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, {
signal: AbortSignal.any([
attemptAbort.signal,
AbortSignal.timeout(DISCOVERY_TIMEOUT),
]),
});
if (data.status === 404) {
throw new Error("No Omikron assignment is currently available");
}
if (!data.ok)
throw new Error(`Omikron discovery failed: HTTP ${data.status}`);
const omikronData = (await data.json()) as {
ip_address: string;
port: number;
public_key: string;
};
if (
!omikronData.ip_address ||
!omikronData.port ||
!omikronData.public_key
) {
throw new Error("Invalid Omikron data");
}
url = `https://${omikronData.ip_address}:${omikronData.port}`;
omikronPublicKey = omikronData.public_key;
}
if (!url || !omikronPublicKey) {
throw new Error("Missing Omikron URL or Public Key");
}
log(2, "mtp", "green", "Connecting to: " + url);
client = await createBrowserClient({
url,
credentials: { clientId: userId, keyring: base64ToBytes(keyring) },
hostPublicKey: { value: omikronPublicKey, encoding: "base64" },
descriptor: "client",
pings: true,
logger: (event) => {
if (event.type === "state") {
if (generation !== connectionGeneration) return;
const state = client?.state ?? ConnectionState.Disconnected;
setReadyState(state);
if (
state === ConnectionState.Disconnected &&
clientRef.current === client &&
!failed
) {
failed = true;
const error = new Error("MTP connection lost");
attemptAbort.abort(error);
if (connectionReady) {
cleanup();
scheduleReconnect(error);
}
}
}
if (event.type !== "Pong" && event.type !== "Ping") {
log(
2,
"mtp",
event.type === "state"
? "purple"
: event.direction === "recv"
? "cyan"
: event.direction === "send"
? "gray"
: "blue",
event.type === "state"
? event.data
: event.direction === "recv"
? "< " + event.type
: event.direction === "send"
? "> " + event.type
: event.type,
event,
);
}
},
});
if (disposed || generation !== connectionGeneration) {
client.disconnect();
return;
}
const activeClient = client;
clientRef.current = activeClient;
detachSubscriptions = attachSubscriptions(activeClient);
unsubscribeNoIota = subscribe("ErrorNoIota", () => {
if (clientRef.current !== activeClient || failed) return;
failed = true;
const error = new Error("No Iota is currently connected");
attemptAbort.abort(error);
cleanup();
scheduleReconnect(error);
});
setReadyState(activeClient.state);
setIdentifying(true);
const finalResponse = await completeInitialSynchronization(
activeClient,
subscribe,
attemptAbort.signal,
);
if (disposed || clientRef.current !== activeClient) return;
setFreshContacts(finalResponse.data.Contacts);
setFreshCommunities(finalResponse.data.Communities);
setFreshCalls(finalResponse.data.Calls);
connectionReady = true;
setIdentifying(false);
setIdentified(true);
clearReconnectTimer();
clearReconnectResetTimer();
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
resolveConnectionRef.current?.();
} catch (connectError) {
if (disposed || generation !== connectionGeneration) {
client?.disconnect();
return;
}
failed = true;
cleanup();
const message =
connectError instanceof Error
? connectError.message
: String(connectError ?? "Unknown error");
log(
0,
"mtp",
"red",
`Connection/authentication attempt failed: ${message}`,
protocolErrorDetails(connectError) ?? connectError,
);
scheduleReconnect(connectError);
}
}
void connect();
return () => {
disposed = true;
clearReconnectTimer();
clearReconnectResetTimer();
cleanupConnection();
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
sonnerToast.dismiss("mtp-connection-toast");
};
}, [attachSubscriptions, load, mtpUrl, props.blockConnection, subscribe]);
useEffect(() => {
return subscribe("ErrorNoIota", () => {
setIdentified(false);
setIdentifying(false);
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
});
}, [subscribe]);
useEffect(
() =>
subscribe("GetStates", (message) => {
setFreshContacts((contacts) =>
removeMissingContacts(contacts, message),
);
}),
[subscribe],
);
const loadingDescription = useMemo(() => {
if (!mtpUrl) return "Loading connection details";
if (readyState === ConnectionState.Connecting || !connected) {
return "Establishing transport channel";
}
if (identifying || !identified) return "Waiting for authenticated session";
return "Loading...";
}, [connected, identified, identifying, readyState, mtpUrl]);
const contextReady = connected && identified && mtpUrl !== null;
const mtpRef = useMemo(() => createAsyncQueue<{ send: typeof send }>(), []);
useEffect(() => {
if (connected && identified && mtpUrl) {
mtpRef.set({ send });
}
}, [connected, identified, mtpUrl, send, mtpRef]);
const sendQueued: BoundSendFn = useMemo(
() => async (type, data, options) => {
const mtp = await mtpRef.get();
const response = await mtp.send(type, data, options);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(interceptor({ type, data, response })).catch(
(error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
},
);
}
return response;
},
[interceptorsRef, mtpRef],
);
return (
<MTPContext.Provider
value={{
send: sendQueued,
subscribe,
addInterceptor,
readyState,
identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription,
}}
>
{props.children}
</MTPContext.Provider>
);
}

View file

@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { isPushType, validateResponse } from "./context";
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);
});
});

View file

@ -1,948 +1,41 @@
import { import { type ReactNode, useContext, useEffect, useState } from "react";
createContext, import { isTauri } from "@tauri-apps/api/core";
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { MTPClient } from "mtp"; import { MTPClient } from "mtp";
import { type z } from "zod";
import { ConnectionState } from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue";
import { toast as sonnerToast } from "@methanium/ui";
import { import { BrowserProvider } from "./browser";
type Calls, import { MTPContext, type MTPContextType } from "./mtpContext";
type Communities, import { TauriProvider } from "./tauri";
type Contacts,
mtp as schemas,
type MTP as Schemas,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
import { ProtocolError } from "@tensamin/shared/errors";
import { useStorage } from "@tensamin/storage/context";
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
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<
T extends keyof Schemas & string = keyof Schemas & string,
> = {
id?: number;
type: T | string;
data: z.infer<Schemas[T]["response"]>;
};
export type BoundSendFn = <T extends keyof Schemas & string>(
type: T,
data?: z.infer<Schemas[T]["request"]>,
options?: { id?: number },
) => Promise<ProtocolMessage<T>>;
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
const PUSH_TYPES = [
"MessageLive",
"MessageEditLive",
"MessageReactionLive",
"MessageDeleteLive",
"MessageState",
"CallInvite",
"GetStates",
"ClientChanged",
"ErrorNoIota",
] as const;
export function isPushType(type: string): boolean {
return (PUSH_TYPES as readonly string[]).includes(type);
}
function removeMissingContacts(
contacts: Contacts,
message: ProtocolMessage,
): Contacts {
if (message.type !== "GetStates") return contacts;
const data = message.data as { MissingUserIds?: unknown };
if (!Array.isArray(data.MissingUserIds)) return contacts;
const missing = new Set(
data.MissingUserIds.filter(
(userId): userId is number => typeof userId === "number",
),
);
return contacts.filter((contact) => !missing.has(contact.UserId));
}
export type MTPExchange = {
type: keyof Schemas & string;
data: unknown;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
type ContextType = {
send: BoundSendFn;
subscribe: <T extends keyof Schemas & string>(
type: T,
handler: (message: ProtocolMessage<T>) => void,
) => () => void;
subscribePush: (handler: PushHandler) => () => void;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;
identified: boolean;
freshContacts: Contacts;
freshCommunities: Communities;
freshCalls: Calls;
contextReady: boolean;
loadingDescription: string;
};
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 useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const lastInitialStateRef = useRef<ProtocolMessage | null>(null);
const subscribePush = useCallback((handler: PushHandler) => {
pushHandlersRef.current.add(handler);
const initialState = lastInitialStateRef.current;
if (initialState?.type === "GetStates") {
void Promise.resolve(handler(initialState)).catch(() => undefined);
}
return () => pushHandlersRef.current.delete(handler);
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
return {
addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
};
}
function BrowserProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const { load } = useStorage();
const [readyState, setReadyState] = useState<number>(
ConnectionState.Disconnected,
);
const [identified, setIdentified] = useState<boolean>(false);
const [identifying, setIdentifying] = useState<boolean>(false);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
null,
);
const {
addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
} = useMessageHandlers();
const connected = readyState === ConnectionState.Connected;
// MTP url
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => {
load("omega_url").then(setMtpUrl);
}, [load]);
// Validation override functions
const send: BoundSendFn = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
if (!client) {
throw new Error("mtp is not connected");
}
const message = await client.request(
type,
(data ?? {}) as Record<string, unknown>,
options,
);
const response = validateResponse(type, message);
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;
},
[],
);
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
const client = clientRef.current;
if (!client) {
return () => {};
}
return client.subscribe(type, (message) => {
handler(validateResponse(type, message));
});
}, []);
// Reconnect stuff
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
if (!mtpUrl) return;
let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectScheduled = false;
let disposed = false;
let connectionGeneration = 0;
const clearReconnectTimer = () => {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
reconnectScheduled = false;
};
const clearReconnectResetTimer = () => {
if (!reconnectResetTimer) return;
clearTimeout(reconnectResetTimer);
reconnectResetTimer = null;
};
const scheduleReconnect = (error: unknown) => {
if (disposed || reconnectScheduled) return;
if (attempts >= RECONNECT_TRIES) {
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
sonnerToast.error("Connection failed", {
id: "mtp-connection-toast",
description:
error instanceof Error
? error.message.split(":")[0]
: "Connection lost",
icon: null,
duration: Infinity,
closeButton: true,
promise: null,
} as unknown as Parameters<typeof sonnerToast.error>[1]);
return;
}
attempts += 1;
sonnerToast.loading(
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
{ id: "mtp-connection-toast" },
);
reconnectScheduled = true;
reconnectTimer = setTimeout(() => {
reconnectScheduled = false;
reconnectTimer = null;
void connect();
}, RETRY_INTERVAL);
};
async function connect() {
if (disposed || props.blockConnection) return;
const generation = ++connectionGeneration;
let client: Awaited<ReturnType<typeof MTPClient.create>> | null = null;
let failed = false;
const cleanup = () => {
client?.disconnect();
if (clientRef.current === client) {
clientRef.current = null;
}
clearReconnectResetTimer();
if (generation === connectionGeneration) {
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
}
};
try {
setIdentified(false);
setIdentifying(false);
const [userId, keyring] = await Promise.all([
load("user_id"),
load("mtp_keyring"),
]);
if (!userId || !keyring) {
throw new Error("Missing login credentials");
}
const forcedOmikronUrl = await load("forced_omikron_url");
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
let url = null;
let omikronPublicKey = null;
if (forcedOmikronUrl && forcedOmikronPublicKey) {
url = forcedOmikronUrl;
omikronPublicKey = forcedOmikronPublicKey;
} else {
log(2, "mtp", "purple", "Fetching Omikron data.");
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`);
if (data.status === 404) {
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
cleanup();
return;
}
const omikronData = (await data.json()) as {
id: number;
ip_address: string;
port: number;
public_key: string;
status: string;
};
if (
!omikronData.ip_address ||
!omikronData.port ||
!omikronData.public_key
)
throw new Error("Invalid Omikron data");
url = `https://${omikronData.ip_address}:${omikronData.port}`;
omikronPublicKey = omikronData.public_key;
}
//codec.decode(new Uint8Array(await res.arrayBuffer())),
if (!url || !omikronPublicKey)
throw new Error("Missing Omikron URL or Public Key");
log(2, "mtp", "green", "Connecting to: " + url);
client = await MTPClient.create({
url,
credentials: {
clientId: userId,
keyring: base64ToUint8Array(keyring),
},
hostPublicKey: omikronPublicKey,
descriptor: "client",
pings: true,
logger: (event) => {
if (event.type === "state") {
if (generation !== connectionGeneration) return;
const state = client?.state ?? ConnectionState.Disconnected;
setReadyState(state);
if (
state === ConnectionState.Disconnected &&
clientRef.current === client &&
!failed
) {
failed = true;
clientRef.current = null;
setIdentified(false);
setIdentifying(false);
scheduleReconnect(new Error("MTP connection lost"));
}
}
if (event.type !== "Pong" && event.type !== "Ping") {
log(
2,
"mtp",
event.type === "state"
? "purple"
: event.direction === "recv"
? "cyan"
: event.direction === "send"
? "gray"
: "blue",
event.type === "state"
? event.data
: event.direction === "recv"
? "< " + event.type
: event.direction === "send"
? "> " + event.type
: event.type,
event,
);
}
},
});
if (disposed || generation !== connectionGeneration) {
client.disconnect();
return;
}
const activeClient = client;
clientRef.current = activeClient;
for (const type of PUSH_TYPES) {
activeClient.subscribe(type, (message) => {
let validated: ProtocolMessage;
try {
validated = validateResponse(type, message);
} catch (error) {
log(1, "mtp", "red", "Failed to validate push message", error, {
type,
data: message.data,
});
return;
}
setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated),
);
for (const handler of [...pushHandlersRef.current]) {
void Promise.resolve()
.then(() => handler(validated))
.catch((error) => {
log(1, "mtp", "red", "Push handler failed", error, { type });
});
}
if (validated.type === "GetStates") {
lastInitialStateRef.current = validated;
}
});
}
setReadyState(activeClient.state);
clearReconnectTimer();
// Schedule reconnect reset
clearReconnectResetTimer();
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
setReadyState(activeClient.state);
setIdentifying(true);
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
(resolve, reject) => {
let unsubscribeStateSync = () => {};
let unsubscribeNoIota = () => {};
const cleanupStateSync = () => {
clearTimeout(timeout);
unsubscribeStateSync();
unsubscribeNoIota();
};
const timeout = setTimeout(() => {
cleanupStateSync();
reject(new Error("Initial state synchronization timed out"));
}, 120_000);
unsubscribeStateSync = activeClient.subscribe(
"ClientStateSync",
(message) => {
cleanupStateSync();
try {
resolve(validateResponse("ClientStateSync", message));
} catch (error) {
reject(error);
}
},
);
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}`,
);
}
const acknowledgement = await activeClient.request("ClientStateAck", {
SessionId: finalResponse.data.SessionId,
VersionNumber: finalResponse.data.VersionNumber,
});
if (acknowledgement.type.startsWith("Error")) {
throw new Error(
`State acknowledgement failed: ${acknowledgement.type}`,
);
}
if (disposed || clientRef.current !== activeClient) return;
setFreshContacts(finalResponse.data.Contacts);
setFreshCommunities(finalResponse.data.Communities);
setFreshCalls(finalResponse.data.Calls);
setIdentifying(false);
setIdentified(true);
resolveConnectionRef.current?.();
} catch (connectError) {
if (disposed || generation !== connectionGeneration) {
client?.disconnect();
return;
}
failed = true;
cleanup();
const connectErrorMessage =
connectError instanceof Error
? connectError.message
: String(connectError ?? "Unknown error");
log(
0,
"mtp",
"red",
`Connection/authentication attempt failed: ${connectErrorMessage}`,
getProtocolErrorDetails(connectError) ?? connectError,
);
scheduleReconnect(connectError);
}
}
void connect();
return () => {
disposed = true;
clearReconnectTimer();
clearReconnectResetTimer();
clientRef.current?.disconnect();
clientRef.current = null;
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
sonnerToast.dismiss("mtp-connection-toast");
};
}, [
lastInitialStateRef,
mtpUrl,
props.blockConnection,
load,
pushHandlersRef,
]);
// No Iota check
useEffect(() => {
if (!connected) return;
return subscribe("ErrorNoIota", () => {
setIdentified(false);
setIdentifying(false);
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
});
}, [connected, subscribe]);
// Async queue
const loadingDescription = useMemo(() => {
if (!mtpUrl) return "Loading connection details";
if (readyState === ConnectionState.Connecting || !connected) {
return "Establishing transport channel";
}
if (identifying || !identified) return "Waiting for authenticated session";
return "Loading...";
}, [connected, identified, identifying, readyState, mtpUrl]);
const contextReady = connected && identified && mtpUrl !== null;
const mtpRef = useMemo(
() =>
createAsyncQueue<{
send: typeof send;
subscribe: typeof subscribe;
subscribePush: typeof subscribePush;
}>(),
[],
);
useEffect(() => {
if (connected && identified && mtpUrl) {
mtpRef.set({
send,
subscribe,
subscribePush,
});
}
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
const sendQueued: BoundSendFn = useMemo(
() => async (type, data, options) => {
const mtp = await mtpRef.get();
const response = await mtp.send(type, data, options);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: response as ProtocolMessage }),
).catch((error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
});
}
return response;
},
[interceptorsRef, mtpRef],
);
return (
<MTPContext.Provider
value={{
send: sendQueued,
subscribe,
subscribePush,
addInterceptor,
readyState,
identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription,
}}
>
{props.children}
</MTPContext.Provider>
);
}
type NativeSnapshot = {
generation: number;
readyState: number;
identified: boolean;
state?: unknown;
error?: string;
};
function TauriProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
generation: 0,
readyState: ConnectionState.Disconnected,
identified: false,
});
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const generationRef = useRef(0);
const {
addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
} = useMessageHandlers();
const subscriptionsRef = useRef(
new Map<string, Set<(message: ProtocolMessage) => void>>(),
);
const applySnapshot = useCallback((next: NativeSnapshot) => {
if (next.generation < generationRef.current) return;
generationRef.current = next.generation;
if (next.error) {
log(0, "android", "orange", "MTP connection failed", next.error);
}
setSnapshot(next);
if (!next.identified || next.state === undefined) return;
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
if (!parsed.success) {
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
return;
}
setFreshContacts(parsed.data.Contacts);
setFreshCommunities(parsed.data.Communities);
setFreshCalls(parsed.data.Calls);
}, []);
const dispatchMessage = useCallback(
(raw: unknown) => {
if (!raw || typeof raw !== "object" || !("type" in raw)) return;
const message = raw as { id?: number; type: string; data: unknown };
let validated: ProtocolMessage;
try {
validated = validateResponse(
message.type as keyof Schemas & string,
message,
);
} catch (error) {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
return;
}
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;
}
},
[lastInitialStateRef, pushHandlersRef],
);
useEffect(() => {
if (props.blockConnection) return;
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
unlisten = await listen<
| { kind: "state"; snapshot: NativeSnapshot }
| { kind: "message"; generation: number; message: unknown }
| {
kind: "log";
level: number;
message: string;
details?: unknown;
}
>("mtp://event", ({ payload }) => {
if (disposed) return;
if (payload.kind === "state") {
applySnapshot(payload.snapshot);
return;
}
if (payload.kind === "message") {
if (payload.generation === generationRef.current) {
dispatchMessage(payload.message);
}
return;
}
log(
payload.level,
"android",
"orange",
payload.message,
payload.details,
);
});
const current = await invoke<NativeSnapshot>("mtp_status");
if (!disposed) applySnapshot(current);
})().catch((error) => {
log(0, "mtp", "red", "Failed to initialize native MTP bridge", error);
});
return () => {
disposed = true;
unlisten?.();
};
}, [applySnapshot, dispatchMessage, props.blockConnection]);
useEffect(() => {
if (props.blockConnection) return;
const updateVisibility = () => {
void invoke("mtp_set_ui_visible", {
visible: document.visibilityState === "visible" && document.hasFocus(),
});
};
updateVisibility();
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
window.removeEventListener("focus", updateVisibility);
window.removeEventListener("blur", updateVisibility);
void invoke("mtp_set_ui_visible", { visible: false });
};
}, [props.blockConnection]);
const send = useCallback<BoundSendFn>(
async (type, data, options) => {
const response = await invoke<ProtocolMessage>("mtp_request", {
typeName: type,
data: data ?? {},
id: options?.id,
});
const validated = validateResponse(type, response);
setFreshContacts((contacts) =>
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) {
void Promise.resolve(
interceptor({ type, data, response: validated as ProtocolMessage }),
).catch((error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
});
}
return validated;
},
[interceptorsRef],
);
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
const handlers =
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 contextReady = connected && snapshot.identified;
return (
<MTPContext.Provider
value={{
send,
subscribe,
subscribePush,
addInterceptor,
readyState: snapshot.readyState,
identified: snapshot.identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription: connected
? "Waiting for authenticated session"
: "Establishing native transport channel",
}}
>
{props.children}
</MTPContext.Provider>
);
}
export function Provider(props: { export function Provider(props: {
children: ReactNode; children: ReactNode;
blockConnection?: boolean; blockConnection?: boolean;
}) {
if (isTauri()) return <TauriProvider {...props} />;
return <BrowserWasmProvider {...props} />;
}
function BrowserWasmProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) { }) {
const [wasmReady, setWasmReady] = useState(false); const [wasmReady, setWasmReady] = useState(false);
const [wasmError, setWasmError] = useState<unknown>(); const [wasmError, setWasmError] = useState<unknown>();
useEffect(() => { useEffect(() => {
let active = true; let active = true;
void MTPClient.init().then( void MTPClient.init().then(
() => { () => active && setWasmReady(true),
if (active) setWasmReady(true); (error: unknown) => active && setWasmError(() => error),
},
(error: unknown) => {
if (active) setWasmError(() => error);
},
); );
return () => { return () => {
active = false; active = false;
}; };
}, []); }, []);
if (wasmError) throw wasmError; if (wasmError) throw wasmError;
if (!wasmReady) return null; return wasmReady ? <BrowserProvider {...props} /> : null;
return isTauri() ? (
<TauriProvider {...props} />
) : (
<BrowserProvider {...props} />
);
} }
export function useMTP(): ContextType { export function useMTP(): MTPContextType {
const context = useContext(MTPContext); const context = useContext(MTPContext);
if (!context) { if (!context) throw new Error("useMTP must be used within an MTPProvider");
throw new Error("useMTP must be used within an MTPProvider");
}
return context; return context;
} }

Some files were not shown because too many files have changed in this diff Show more