Compare commits
134 changed files with 3506 additions and 9678 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
[env]
|
|
||||||
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
NIX_CONFIG: experimental-features = nix-command flakes
|
||||||
FORGEJO_TOKEN: ""
|
FORGEJO_TOKEN: ""
|
||||||
GITHUB_TOKEN: ""
|
GITHUB_TOKEN: ""
|
||||||
|
|
||||||
|
|
@ -13,10 +14,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
|
||||||
|
|
@ -25,36 +26,24 @@ jobs:
|
||||||
if: ${{ github.actor == 'rasensprenger' }}
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
name: Build desktop
|
name: Build desktop
|
||||||
runs-on: nixos
|
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 .#electron --command pnpm install --frozen-lockfile
|
|
||||||
- run: nix develop .#electron --command pnpm run build:packages
|
|
||||||
- run: nix develop .#electron --command pnpm run build:desktop
|
|
||||||
|
|
||||||
native-mtp:
|
|
||||||
if: ${{ github.actor == 'rasensprenger' }}
|
|
||||||
name: Test native MTP
|
|
||||||
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 pnpm install --frozen-lockfile
|
||||||
- run: nix develop .#electron --command bash -lc 'cd apps/tauri/src-tauri && cargo test'
|
- run: nix develop .#electron --command pnpm run build:packages
|
||||||
|
- run: nix develop .#electron --command pnpm run build:desktop
|
||||||
|
|
||||||
mobile:
|
mobile:
|
||||||
if: ${{ github.actor == 'rasensprenger' }}
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
name: Build mobile
|
name: Build mobile
|
||||||
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 .#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
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -34,6 +39,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -54,6 +62,8 @@ 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
|
||||||
|
|
@ -118,6 +128,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -168,6 +181,9 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,16 @@ 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
|
||||||
|
|
||||||
|
|
@ -34,6 +40,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -54,6 +63,8 @@ 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
|
||||||
|
|
@ -118,6 +129,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -166,6 +180,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -311,3 +328,58 @@ 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
|
||||||
|
|
|
||||||
BIN
apps/electron/build/icons/128x128.png
Normal file
BIN
apps/electron/build/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/electron/build/icons/128x128@2x.png
Normal file
BIN
apps/electron/build/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/electron/build/icons/32x32.png
Normal file
BIN
apps/electron/build/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/electron/build/icons/64x64.png
Normal file
BIN
apps/electron/build/icons/64x64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
apps/electron/build/icons/icon.icns
Normal file
BIN
apps/electron/build/icons/icon.icns
Normal file
Binary file not shown.
BIN
apps/electron/build/icons/icon.ico
Normal file
BIN
apps/electron/build/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/electron/build/icons/icon.png
Normal file
BIN
apps/electron/build/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
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 />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
/// <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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
@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))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
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",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
2
apps/tauri/.cargo/config.toml
Normal file
2
apps/tauri/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[env]
|
||||||
|
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }
|
||||||
|
|
@ -7,10 +7,14 @@
|
||||||
"./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": "adb reverse tcp:3000 tcp:3000 && tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
|
"dev:mobile:raw": "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",
|
||||||
|
|
@ -24,6 +28,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
608
apps/tauri/src-tauri/Cargo.lock
generated
608
apps/tauri/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,16 +15,17 @@ 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 = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] }
|
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "e2e585ad1196c9572f86ef39aae01ef4c3b1a762", 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.13", default-features = false, features = ["json", "rustls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
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 = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] }
|
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "7182272e3edb0079d7b74b45b2f62a298d2fee22", 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"
|
||||||
|
|
@ -35,14 +36,12 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
{
|
{
|
||||||
"identifier": "mobile-capability",
|
"identifier": "mobile-capability",
|
||||||
"platforms": ["android", "iOS"],
|
"platforms": [
|
||||||
"windows": ["main"],
|
"android",
|
||||||
|
"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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
|
@ -62,17 +61,14 @@ android {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget = JvmTarget.JVM_1_8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rust {
|
rust {
|
||||||
rootDirRel = "../../../"
|
rootDirRel = "../../../"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ 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.MotionEvent
|
import android.view.ViewGroup
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
|
|
@ -55,33 +56,7 @@ class MainActivity : TauriActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWebViewCreate(webView: WebView) {
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
webView.settings.apply {
|
webView.setInitialScale(290)
|
||||||
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")
|
||||||
|
|
@ -90,12 +65,11 @@ 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)
|
||||||
}
|
}
|
||||||
NativeAccessibilityBridge.nativeAttach(applicationContext)
|
super.onCreate(savedInstanceState)
|
||||||
|
NativeMtpBridge.nativeAttach(applicationContext)
|
||||||
installKeyboardResizeWorkaround()
|
installKeyboardResizeWorkaround()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,10 +90,7 @@ class MainActivity : TauriActivity() {
|
||||||
attachLayoutListener = null
|
attachLayoutListener = null
|
||||||
contentRoot = null
|
contentRoot = null
|
||||||
contentChild = null
|
contentChild = null
|
||||||
mediaWebView?.let {
|
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
|
||||||
NativeAccessibilityBridge.detach(it)
|
|
||||||
it.removeJavascriptInterface("tensaminMobileMedia")
|
|
||||||
}
|
|
||||||
mediaWebView = null
|
mediaWebView = null
|
||||||
MobileMediaEvents.detach()
|
MobileMediaEvents.detach()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
@ -232,20 +203,16 @@ 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 imeVisible = insets?.isVisible(WindowInsetsCompat.Type.ime()) == true
|
val imeHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
|
||||||
val imeHeight = if (imeVisible) {
|
val keyboardHeight = maxOf(imeHeight, rootHeight - visibleFrame.bottom)
|
||||||
insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
val keyboardVisible = keyboardHeight > rootHeight * 0.15
|
||||||
} else {
|
val usableHeight = if (keyboardVisible) rootHeight - keyboardHeight else ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
0
|
|
||||||
}
|
|
||||||
val usableHeight = if (imeHeight in 1 until rootHeight) {
|
|
||||||
rootHeight - imeHeight
|
|
||||||
} else {
|
|
||||||
WindowManager.LayoutParams.MATCH_PARENT
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousUsableHeight == usableHeight) return
|
if (previousUsableHeight == usableHeight) return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ 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
|
||||||
|
|
@ -108,12 +107,16 @@ class MediaProjectionService : Service() {
|
||||||
stopIntent,
|
stopIntent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
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(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(android.app.Notification.CATEGORY_SERVICE)
|
||||||
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
|
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ 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() {
|
||||||
|
|
@ -76,19 +74,10 @@ 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() {
|
||||||
|
|
@ -105,9 +94,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import com.android.build.api.dsl.LibraryExtension
|
import com.android.build.gradle.LibraryExtension
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
|
|
@ -6,7 +6,7 @@ buildscript {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("com.android.tools.build:gradle:8.11.0")
|
classpath("com.android.tools.build:gradle:8.13.2")
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20")
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,6 @@ repositories {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly(gradleApi())
|
compileOnly(gradleApi())
|
||||||
implementation("com.android.tools.build:gradle:8.11.0")
|
implementation("com.android.tools.build:gradle:8.13.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,8 @@ 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 @Inject constructor(
|
open class BuildTask : DefaultTask() {
|
||||||
private val execOperations: ExecOperations,
|
|
||||||
) : DefaultTask() {
|
|
||||||
@Input
|
@Input
|
||||||
var rootDirRel: String? = null
|
var rootDirRel: String? = null
|
||||||
@Input
|
@Input
|
||||||
|
|
@ -54,7 +50,7 @@ open class BuildTask @Inject constructor(
|
||||||
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");
|
||||||
|
|
||||||
execOperations.exec {
|
project.exec {
|
||||||
workingDir(File(project.projectDir, rootDirRel))
|
workingDir(File(project.projectDir, rootDirRel))
|
||||||
executable(executable)
|
executable(executable)
|
||||||
args(args)
|
args(args)
|
||||||
|
|
@ -69,4 +65,4 @@ open class BuildTask @Inject constructor(
|
||||||
args(listOf("--target", target))
|
args(listOf("--target", target))
|
||||||
}.assertNormalExitValue()
|
}.assertNormalExitValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
#[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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
mod accessibility_backend;
|
|
||||||
mod mtp_backend;
|
mod mtp_backend;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
|
@ -15,10 +14,11 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use lib::log;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
mobile_lib::run();
|
mobile_lib::run();
|
||||||
|
log("Test test 123")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::sync::{
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
Arc, Mutex, OnceLock, RwLock,
|
Arc, Mutex, OnceLock, RwLock,
|
||||||
};
|
};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::Duration;
|
||||||
|
|
||||||
use base64::{
|
use base64::{
|
||||||
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
|
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
|
||||||
|
|
@ -16,7 +16,6 @@ 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;
|
||||||
|
|
@ -25,9 +24,6 @@ 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\
|
||||||
|
|
@ -99,7 +95,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<ManagedConnection>>>,
|
connection: RwLock<Option<Arc<MTPConnection>>>,
|
||||||
snapshot: RwLock<MtpSnapshot>,
|
snapshot: RwLock<MtpSnapshot>,
|
||||||
generation: AtomicU64,
|
generation: AtomicU64,
|
||||||
enabled: AtomicBool,
|
enabled: AtomicBool,
|
||||||
|
|
@ -108,46 +104,6 @@ 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 {
|
||||||
|
|
@ -193,7 +149,7 @@ impl MtpManager {
|
||||||
.take()
|
.take()
|
||||||
{
|
{
|
||||||
self.runtime
|
self.runtime
|
||||||
.spawn(async move { connection.mtp.sender.close().await });
|
.spawn(async move { connection.sender.close().await });
|
||||||
}
|
}
|
||||||
self.set_snapshot(MtpSnapshot {
|
self.set_snapshot(MtpSnapshot {
|
||||||
generation,
|
generation,
|
||||||
|
|
@ -215,7 +171,7 @@ impl MtpManager {
|
||||||
.take()
|
.take()
|
||||||
{
|
{
|
||||||
self.runtime
|
self.runtime
|
||||||
.spawn(async move { connection.mtp.sender.close().await });
|
.spawn(async move { connection.sender.close().await });
|
||||||
}
|
}
|
||||||
self.set_snapshot(MtpSnapshot {
|
self.set_snapshot(MtpSnapshot {
|
||||||
generation: self.generation.load(Ordering::SeqCst),
|
generation: self.generation.load(Ordering::SeqCst),
|
||||||
|
|
@ -275,16 +231,20 @@ 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(&self, type_name: &str, data: Value) -> Result<Value, String> {
|
async fn request(
|
||||||
|
&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, connection.next_request_id().await?)?;
|
let request = json_to_frame(type_name, data, id)?;
|
||||||
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())?;
|
||||||
|
|
@ -301,12 +261,9 @@ 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(prepared) => {
|
Ok((connection, state)) => {
|
||||||
delay = Duration::from_secs(1);
|
delay = Duration::from_secs(1);
|
||||||
let connection = Arc::new(ManagedConnection {
|
let connection = Arc::new(connection);
|
||||||
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
|
||||||
|
|
@ -322,47 +279,22 @@ async fn supervise(generation: u64) {
|
||||||
generation,
|
generation,
|
||||||
ready_state: CONNECTED,
|
ready_state: CONNECTED,
|
||||||
identified: true,
|
identified: true,
|
||||||
state: Some(prepared.initial_state),
|
state: Some(state),
|
||||||
error: None,
|
error: None,
|
||||||
});
|
});
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if stale {
|
if stale {
|
||||||
connection.mtp.sender.close().await;
|
connection.sender.close().await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
android_status("Connected");
|
android_status("Connected");
|
||||||
manager.log(2, "Native MTP connection established", None);
|
manager.log(2, "Native MTP connection established", None);
|
||||||
|
|
||||||
let (notification_tx, mut notification_rx) =
|
|
||||||
mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
|
|
||||||
let notification_connection = connection.clone();
|
|
||||||
let notification_config = config.clone();
|
|
||||||
let notification_worker = tokio::spawn(async move {
|
|
||||||
while let Some(frame) = notification_rx.recv().await {
|
|
||||||
if !manager.is_current(generation) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Err(error) = notify_message(
|
|
||||||
¬ification_config,
|
|
||||||
notification_connection.clone(),
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
eprintln!("failed to create background message notification: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for frame in prepared.buffered_frames {
|
|
||||||
handle_push(generation, ¬ification_tx, frame).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
while manager.is_current(generation) {
|
while manager.is_current(generation) {
|
||||||
match connection.mtp.receive().await {
|
match connection.receive().await {
|
||||||
Ok(frame) => handle_push(generation, ¬ification_tx, frame).await,
|
Ok(frame) => handle_push(generation, connection.clone(), 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) {
|
||||||
|
|
@ -378,13 +310,6 @@ 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()
|
||||||
|
|
@ -414,21 +339,12 @@ async fn supervise(generation: u64) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
android_status("Reconnecting");
|
android_status("Reconnecting");
|
||||||
tokio::time::sleep(jittered_retry_delay(delay)).await;
|
tokio::time::sleep(delay).await;
|
||||||
delay = (delay * 2).min(Duration::from_secs(60));
|
delay = (delay * 2).min(Duration::from_secs(60));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jittered_retry_delay(delay: Duration) -> Duration {
|
async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
|
||||||
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}"))?;
|
||||||
|
|
@ -456,21 +372,43 @@ async fn connect(config: &MtpConfig) -> Result<PreparedConnection, 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 = tokio::time::timeout(
|
let connection = MTPClient::auth_connect(client_config, &keyring, &host_key)
|
||||||
Duration::from_secs(30),
|
.await
|
||||||
MTPClient::auth_connect(client_config, &keyring, &host_key),
|
.map_err(|error| format!("transport authentication failed: {error}"))?;
|
||||||
)
|
|
||||||
.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 (state, buffered_frames) = await_initial_state(&connection).await?;
|
let connected = CommunicationValue::new(CommunicationType::ClientConnected)
|
||||||
let request_ids = RequestIdAllocator::new();
|
.add_typed_default(
|
||||||
let (initial_state, ack) = prepare_initial_state_ack(&state, &request_ids)?;
|
DataType::SessionId,
|
||||||
let response = tokio::time::timeout(INITIAL_SYNC_TIMEOUT, connection.request(&ack, None))
|
DataValue::UnsignedNumber(current_millis() as u128),
|
||||||
|
)
|
||||||
|
.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()
|
||||||
|
|
@ -478,182 +416,7 @@ async fn connect(config: &MtpConfig) -> Result<PreparedConnection, String> {
|
||||||
{
|
{
|
||||||
return Err(format!("ClientStateAck failed: {response}"));
|
return Err(format!("ClientStateAck failed: {response}"));
|
||||||
}
|
}
|
||||||
Ok(PreparedConnection {
|
Ok((connection, frame_data_to_json(&state)?))
|
||||||
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> {
|
||||||
|
|
@ -670,19 +433,7 @@ 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 client = reqwest::Client::builder()
|
let response = reqwest::get(format!("{root}/api/get/omikron/{}", config.user_id))
|
||||||
.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() {
|
||||||
|
|
@ -695,11 +446,7 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_push(
|
async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: CommunicationValue) {
|
||||||
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 {
|
||||||
|
|
@ -712,7 +459,7 @@ async fn handle_push(
|
||||||
{
|
{
|
||||||
if let Some(partner_id) = frame
|
if let Some(partner_id) = frame
|
||||||
.get_data(DataType::ChatPartnerId)
|
.get_data(DataType::ChatPartnerId)
|
||||||
.and_then(DataValue::as_number)
|
.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) {
|
||||||
|
|
@ -721,39 +468,40 @@ async fn handle_push(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 notification_tx.try_send(frame).is_err() {
|
if let Err(error) = notify_message(connection, &frame).await {
|
||||||
eprintln!("background message notification queue is full");
|
eprintln!("failed to create background message notification: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn notify_message(
|
async fn notify_message(
|
||||||
config: &MtpConfig,
|
connection: Arc<MTPConnection>,
|
||||||
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)
|
||||||
.and_then(DataValue::as_number)
|
.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
|
let message = frame.get_data(DataType::Message);
|
||||||
.get_data(DataType::Message)
|
let content = container_value(message, DataType::Content)
|
||||||
.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())?;
|
||||||
|
|
@ -765,7 +513,7 @@ async fn notify_message(
|
||||||
}
|
}
|
||||||
let version = secret
|
let version = secret
|
||||||
.get_data(DataType::VersionNumber)
|
.get_data(DataType::VersionNumber)
|
||||||
.and_then(DataValue::as_number)
|
.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)
|
||||||
|
|
@ -793,14 +541,11 @@ 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)
|
let user_request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
|
||||||
.with_id(connection.next_request_id().await?)
|
DataType::UserId,
|
||||||
.add_typed_default(
|
DataValue::UnsignedNumber(sender_id as u128),
|
||||||
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())?;
|
||||||
|
|
@ -888,17 +633,17 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> {
|
||||||
value.get_field(id)
|
value.get_field(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn container_value_by_name<'a>(value: &'a DataValue, field: &str) -> Option<&'a DataValue> {
|
fn json_to_frame(
|
||||||
container_value(value, DataType::from_name(field)?)
|
type_name: &str,
|
||||||
}
|
data: Value,
|
||||||
|
id: Option<u32>,
|
||||||
fn communication_type(name: &str) -> Result<CommunicationType, String> {
|
) -> Result<CommunicationValue, String> {
|
||||||
CommunicationType::from_name(name).ok_or_else(|| format!("unknown communication type: {name}"))
|
let comm_type = CommunicationType::from_name(type_name)
|
||||||
}
|
.ok_or_else(|| format!("unknown communication type: {type_name}"))?;
|
||||||
|
let mut frame = CommunicationValue::new(comm_type);
|
||||||
fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
|
if let Some(id) = id {
|
||||||
let comm_type = communication_type(type_name)?;
|
frame = frame.with_id(id);
|
||||||
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());
|
||||||
};
|
};
|
||||||
|
|
@ -973,8 +718,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 let Some(id) = frame.id() {
|
if frame.get_id() != 0 {
|
||||||
result.insert("id".into(), Value::from(id));
|
result.insert("id".into(), Value::from(frame.get_id()));
|
||||||
}
|
}
|
||||||
result.insert(
|
result.insert(
|
||||||
"type".into(),
|
"type".into(),
|
||||||
|
|
@ -987,10 +732,7 @@ 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();
|
||||||
let entries = frame
|
for (id, value) in frame.data() {
|
||||||
.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))?;
|
||||||
|
|
@ -1040,15 +782,16 @@ 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 mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
use super::{decode_browser_base64, decode_sdk_bytes};
|
||||||
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() {
|
||||||
|
|
@ -1061,137 +804,11 @@ 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) -> Result<Value, String> {
|
pub async fn mtp_request(type_name: String, data: Value, id: Option<u32>) -> Result<Value, String> {
|
||||||
manager().request(&type_name, data).await
|
manager().request(&type_name, data, id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
|
||||||
38
apps/tauri/src/qrCodeScanner.tsx
Normal file
38
apps/tauri/src/qrCodeScanner.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,8 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="./favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
<meta
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<title>Tensamin</title>
|
<title>Tensamin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"dev": "vite",
|
"dev": "vite --port 3000 --host 0.0.0.0",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"build": "pnpm --filter @tensamin/pwa build && pnpm run test && tsc -b && vite build",
|
"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,7 +18,6 @@
|
||||||
"@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:*",
|
||||||
|
|
@ -55,7 +54,6 @@
|
||||||
"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"
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,7 @@ export function Basic({
|
||||||
user,
|
user,
|
||||||
extra,
|
extra,
|
||||||
}: {
|
}: {
|
||||||
user: Pick<
|
user: User;
|
||||||
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";
|
||||||
|
|
@ -60,9 +57,8 @@ export function Basic({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-start justify-center text-[15px]">
|
<div className="flex w-full flex-col items-start justify-center gap-1 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>
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,7 @@ 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({
|
export default function Profile({ user }: { user: User }) {
|
||||||
user,
|
|
||||||
}: {
|
|
||||||
user: Pick<
|
|
||||||
User,
|
|
||||||
| "UserId"
|
|
||||||
| "Display"
|
|
||||||
| "Username"
|
|
||||||
| "Avatar"
|
|
||||||
| "About"
|
|
||||||
| "IotaId"
|
|
||||||
| "PublicKey"
|
|
||||||
>;
|
|
||||||
}) {
|
|
||||||
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
|
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ 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">
|
||||||
|
|
@ -92,15 +91,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,8 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
import {
|
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||||
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({
|
||||||
|
|
@ -38,6 +35,55 @@ 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);
|
||||||
|
|
@ -51,12 +97,27 @@ export default function Form() {
|
||||||
if (loginPendingRef.current) return false;
|
if (loginPendingRef.current) return false;
|
||||||
loginPendingRef.current = true;
|
loginPendingRef.current = true;
|
||||||
try {
|
try {
|
||||||
await persistMtpCredentials({
|
if (domain) await save("omega_url", `https://${domain}/`);
|
||||||
storage: { load, save },
|
if (isTauri()) {
|
||||||
userId,
|
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] =
|
||||||
keyring: privateKey,
|
await Promise.all([
|
||||||
domain,
|
domain ? `https://${domain}/` : load("omega_url"),
|
||||||
});
|
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 {
|
||||||
|
|
@ -87,11 +148,6 @@ 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> => {
|
||||||
|
|
@ -230,10 +286,32 @@ 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">
|
||||||
{isMobile ? (
|
{isTauri() && isMobile ? (
|
||||||
<Button onClick={() => uploadRef.current?.click()}>
|
<>
|
||||||
Select .tu file
|
<QrCodeScanner
|
||||||
</Button>
|
onData={async (data) => {
|
||||||
|
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()}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -34,7 +35,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 { useUser, type User } from "@tensamin/user/context";
|
import 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 {
|
||||||
|
|
@ -63,7 +64,7 @@ function StatusDialog({
|
||||||
saveSucceeded,
|
saveSucceeded,
|
||||||
setSaveSucceeded,
|
setSaveSucceeded,
|
||||||
}: {
|
}: {
|
||||||
user: Pick<User, "UserId" | "OnlineStatus" | "Status">;
|
user: User;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
send: ReturnType<typeof useMTP>["send"];
|
send: ReturnType<typeof useMTP>["send"];
|
||||||
|
|
@ -76,8 +77,6 @@ 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}
|
||||||
|
|
@ -138,10 +137,9 @@ function StatusDialog({
|
||||||
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const validation = mtp.ChangeUserData.request.safeParse({
|
const payload = { UserState: draftOnlineStatus };
|
||||||
OnlineStatus: draftOnlineStatus,
|
|
||||||
Status: draftStatus,
|
const validation = mtp.SetUserState.request.safeParse(payload);
|
||||||
});
|
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
setSaveSucceeded(false);
|
setSaveSucceeded(false);
|
||||||
|
|
@ -152,9 +150,17 @@ function StatusDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await send("ChangeUserData", validation.data);
|
await send("SetUserState", validation.data);
|
||||||
updateState(user.UserId, draftOnlineStatus);
|
const profileValidation = mtp.ChangeUserData.request.safeParse({
|
||||||
await updateProfile(user.UserId, { Status: draftStatus });
|
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) {
|
||||||
|
|
@ -193,20 +199,16 @@ export default function Sidebar() {
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<SidebarContent className="pt-2">
|
<SidebarContent
|
||||||
|
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
|
||||||
|
|
@ -304,7 +306,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 pt-[var(--ui-safe-area-top)] pr-[var(--ui-safe-area-right)] pl-[var(--ui-safe-area-left)] text-sidebar-foreground"
|
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 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,
|
||||||
|
|
|
||||||
|
|
@ -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: "On the toilet", value: "user_wc" },
|
{ label: "Away", value: "user_wc" },
|
||||||
{ label: "Offline", value: "user_invisible" },
|
{ label: "Offline", value: "user_invisible" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ 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";
|
||||||
|
|
||||||
|
|
@ -49,6 +48,7 @@ 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,14 +254,19 @@ function RootShell() {
|
||||||
parentThemeStorageKey={null}
|
parentThemeStorageKey={null}
|
||||||
designStorageKey={null}
|
designStorageKey={null}
|
||||||
>
|
>
|
||||||
<div
|
<div className="w-screen h-dvh overflow-hidden">
|
||||||
data-pwa-root
|
<Toaster
|
||||||
className="box-border w-screen h-dvh overflow-hidden"
|
position={isMobile ? "top-center" : "bottom-right"}
|
||||||
>
|
{...(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>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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));
|
||||||
|
|
@ -71,9 +70,9 @@ export default defineConfig({
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: "0.0.0.0",
|
host: host || "0.0.0.0",
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
|
|
@ -126,7 +125,6 @@ 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
181
flake.nix
|
|
@ -17,6 +17,8 @@
|
||||||
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 (
|
||||||
|
|
@ -30,6 +32,8 @@
|
||||||
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
|
||||||
|
|
@ -71,130 +75,71 @@
|
||||||
libXtst
|
libXtst
|
||||||
libxcb
|
libxcb
|
||||||
];
|
];
|
||||||
electron = pkgs.electron;
|
packageDeb =
|
||||||
pnpm = pkgs.pnpm;
|
src:
|
||||||
mtpTypeMaps = pkgs.fetchgit {
|
pkgs.stdenv.mkDerivation {
|
||||||
url = "https://git.methanium.net/tensamin/mtp-type-maps";
|
pname = "tensamin";
|
||||||
rev = "6e5122fe44f793c0e0d3229b3d34145ce17c2d31";
|
inherit version src;
|
||||||
hash = "sha256-/4n8F0YLJaLncefKL907P5l+en1CTjpgdFmLBF3gbiQ=";
|
|
||||||
};
|
nativeBuildInputs = with pkgs; [
|
||||||
mtpSource = pkgs.fetchzip {
|
autoPatchelfHook
|
||||||
url = "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz";
|
dpkg
|
||||||
hash = "sha256-XLTa8DxP93Q4hBHRCLUzCPOqkbdb4V3aZxE5Iuq+kW0=";
|
makeWrapper
|
||||||
};
|
];
|
||||||
mtpCargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
buildInputs = electronRuntimeLibs;
|
||||||
src = mtpSource;
|
|
||||||
hash = "sha256-8MZ65N/EtWPAggal0JkGDx3WSn+LxWQORinkqVbsrys=";
|
dontConfigure = true;
|
||||||
};
|
dontBuild = true;
|
||||||
wasmBindgenCliSource = pkgs.fetchCrate {
|
|
||||||
pname = "wasm-bindgen-cli";
|
unpackPhase = ''
|
||||||
version = "0.2.127";
|
runHook preUnpack
|
||||||
hash = "sha256-di+qBAdd7pENLiIB9CoZoab+W5xeDoByMREcCGTSzWo=";
|
dpkg-deb -x "$src" .
|
||||||
};
|
runHook postUnpack
|
||||||
wasmBindgenCli = pkgs.buildWasmBindgenCli {
|
'';
|
||||||
src = wasmBindgenCliSource;
|
|
||||||
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
installPhase = ''
|
||||||
src = wasmBindgenCliSource;
|
runHook preInstall
|
||||||
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 (
|
||||||
desktopItem = pkgs.makeDesktopItem {
|
pkgs.fetchurl {
|
||||||
name = "tensamin";
|
url = "${forgejoBaseUrl}/${debArtifact}";
|
||||||
desktopName = "Tensamin";
|
hash = x86_64DebHash;
|
||||||
exec = "tensamin";
|
}
|
||||||
icon = "tensamin";
|
);
|
||||||
startupWMClass = "Tensamin";
|
localDebPath = builtins.getEnv "TENSAMIN_DEB";
|
||||||
categories = [ "Network" ];
|
localPathPackage =
|
||||||
};
|
if localDebPath == "" then
|
||||||
defaultPackage = pkgs.stdenv.mkDerivation (finalAttrs: {
|
pkgs.writeShellScriptBin "tensamin" ''
|
||||||
pname = "tensamin";
|
echo "Set TENSAMIN_DEB to a local .deb path and run with --impure." >&2
|
||||||
inherit version;
|
exit 1
|
||||||
src = self;
|
''
|
||||||
|
else
|
||||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
packageDeb (
|
||||||
inherit (finalAttrs) pname version src;
|
builtins.path {
|
||||||
inherit pnpm;
|
path = localDebPath;
|
||||||
fetcherVersion = 4;
|
name = debArtifact;
|
||||||
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;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
||||||
|
|
@ -117,6 +117,15 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
|
@ -1723,4 +1754,4 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +160,19 @@
|
||||||
"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",
|
||||||
|
|
@ -665,4 +678,4 @@
|
||||||
"sourcePackageDir": "packages/call/node_modules/zustand"
|
"sourcePackageDir": "packages/call/node_modules/zustand"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c
|
Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e
|
||||||
23
package.json
23
package.json
|
|
@ -25,27 +25,26 @@
|
||||||
"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.2.0",
|
"@types/node": "^26.1.2",
|
||||||
"@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.67.0",
|
"@typescript-eslint/parser": "^8.66.0",
|
||||||
"eslint": "^10.8.1",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"fallow": "^3.17.0",
|
"fallow": "^3.14.0",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.9.0",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.67.0",
|
"typescript-eslint": "^8.66.0",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz",
|
"@methanium/ui": "*",
|
||||||
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz",
|
"mtp": "*",
|
||||||
"sonner": "^2.0.8"
|
"sonner": "^2.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
packages/cache/package.json
vendored
3
packages/cache/package.json
vendored
|
|
@ -12,7 +12,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"test": "vitest run",
|
||||||
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
|
|
|
||||||
50
packages/cache/src/helpers.test.ts
vendored
Normal file
50
packages/cache/src/helpers.test.ts
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
packages/cache/src/sync.tsx
vendored
18
packages/cache/src/sync.tsx
vendored
|
|
@ -21,7 +21,8 @@ export function removeMissingContactSnapshots<T extends { UserId: number }>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CacheSync() {
|
export default function CacheSync() {
|
||||||
const { addInterceptor, contextReady, freshContacts, subscribe } = useMTP();
|
const { addInterceptor, contextReady, freshContacts, subscribePush } =
|
||||||
|
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());
|
||||||
|
|
@ -320,19 +321,10 @@ export default function CacheSync() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId || !contextReady) return;
|
if (!accountId || !contextReady) return;
|
||||||
const handleMessage = (message: ProtocolMessage) => {
|
return subscribePush((message) => {
|
||||||
void enqueue(() => synchronizePush(message));
|
void enqueue(() => synchronizePush(message));
|
||||||
};
|
});
|
||||||
const unsubscribers = [
|
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ 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}>
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,13 @@ 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 { type SelectedUser, useUserFields } from "@tensamin/user/context";
|
import { useUser, type User } 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,
|
||||||
|
|
@ -115,7 +113,7 @@ function Overlay({
|
||||||
participant,
|
participant,
|
||||||
}: {
|
}: {
|
||||||
type: "user" | "stream";
|
type: "user" | "stream";
|
||||||
user: SelectedUser<typeof USER_FIELDS>;
|
user: User;
|
||||||
participant: Participant;
|
participant: Participant;
|
||||||
}) {
|
}) {
|
||||||
const isAdmin = getRoomMetadata()?.admins.includes(user.UserId) === true;
|
const isAdmin = getRoomMetadata()?.admins.includes(user.UserId) === true;
|
||||||
|
|
@ -165,16 +163,12 @@ 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 participantId = Number(participant?.identity);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
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);
|
||||||
|
|
@ -197,6 +191,31 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ export default function ContextMenu({
|
||||||
user,
|
user,
|
||||||
ownId,
|
ownId,
|
||||||
}: {
|
}: {
|
||||||
user: Readonly<{ UserId: number }>;
|
user: User;
|
||||||
ownId: number;
|
ownId: number;
|
||||||
}) {
|
}) {
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,10 @@ import {
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { ScreenShareOff } from "lucide-react";
|
import { ScreenShareOff } from "lucide-react";
|
||||||
import { useUserFields } from "@tensamin/user/context";
|
import { useUser, type User } 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,
|
||||||
|
|
@ -64,12 +62,10 @@ 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 { data: lastSpeakingUser } = useUserFields(
|
const [lastSpeakingUser, setLastSpeakingUser] = useState<User | null>(null);
|
||||||
lastSpeakingParticipantId,
|
|
||||||
USER_FIELDS,
|
|
||||||
);
|
|
||||||
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
@ -87,6 +83,25 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useUserFields } from "@tensamin/user/context";
|
import { useUser, type User } 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,43 +11,8 @@ 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);
|
||||||
|
|
@ -66,23 +31,65 @@ export default function TopBar() {
|
||||||
: null;
|
: null;
|
||||||
},
|
},
|
||||||
).filter((participantId): participantId is number => participantId != null);
|
).filter((participantId): participantId is number => participantId != null);
|
||||||
const [ownId, setOwnId] = useState<number>();
|
const userIdsKey = userIds.join(",");
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load("user_id").then(setOwnId);
|
let active = true;
|
||||||
}, [load]);
|
const ids = userIdsKey === "" ? [] : userIdsKey.split(",").map(Number);
|
||||||
|
|
||||||
const participantIds = ownId === undefined ? userIds : [ownId, ...userIds];
|
void Promise.all(ids.map((id) => get(id)))
|
||||||
|
.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">
|
||||||
{participantIds.map((userId) => (
|
{users.map((user) => (
|
||||||
<ParticipantAvatar
|
<div key={user.UserId} className="-ml-4">
|
||||||
key={userId}
|
<Tooltip>
|
||||||
userId={userId}
|
<TooltipTrigger
|
||||||
portalContainer={portalContainer}
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export function getMediaShareAdapter(): MediaShareAdapter {
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
MediaShareAdapter,
|
MediaShareAdapter,
|
||||||
|
MediaShareCapabilities,
|
||||||
MediaShareKind,
|
MediaShareKind,
|
||||||
MediaShareRequest,
|
MediaShareRequest,
|
||||||
MediaShareSession,
|
MediaShareSession,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ 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 = {
|
||||||
|
|
@ -79,7 +80,7 @@ type Runtime = {
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
send: SendFn;
|
send: SendFn;
|
||||||
load: LoadFn;
|
load: LoadFn;
|
||||||
getPublicKey: (userId: number) => Promise<string>;
|
getUser: GetUserFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
let _keyProvider: ExternalE2EEKeyProvider | null = null;
|
let _keyProvider: ExternalE2EEKeyProvider | null = null;
|
||||||
|
|
@ -670,7 +671,9 @@ 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.getPublicKey(userId);
|
const remotePublicKey = await runtime
|
||||||
|
.getUser(userId)
|
||||||
|
.then((data) => data.PublicKey);
|
||||||
const secretId = deriveCallSecretId(callId);
|
const secretId = deriveCallSecretId(callId);
|
||||||
const wrapped = await wrapCallSecret({
|
const wrapped = await wrapCallSecret({
|
||||||
callSecret,
|
callSecret,
|
||||||
|
|
@ -1223,7 +1226,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, subscribe } = useMTP();
|
const { send, subscribePush } = useMTP();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { insertCall } = useSession();
|
const { insertCall } = useSession();
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
|
|
@ -1252,8 +1255,7 @@ export function useInitializeCall() {
|
||||||
navigate,
|
navigate,
|
||||||
send: send as SendFn,
|
send: send as SendFn,
|
||||||
load: load as LoadFn,
|
load: load as LoadFn,
|
||||||
getPublicKey: (userId) =>
|
getUser: get as GetUserFn,
|
||||||
get(userId, ["PublicKey"]).then((user) => user.PublicKey),
|
|
||||||
});
|
});
|
||||||
}, [get, load, navigate, send]);
|
}, [get, load, navigate, send]);
|
||||||
|
|
||||||
|
|
@ -1313,9 +1315,14 @@ export function useInitializeCall() {
|
||||||
|
|
||||||
// listen to call invites
|
// listen to call invites
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribe("CallInvite", async ({ data }) => {
|
return subscribePush(async (message) => {
|
||||||
const { CallId, CallSecret, SenderId } = data;
|
if (message.type !== "CallInvite") return;
|
||||||
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;
|
||||||
|
|
@ -1332,7 +1339,7 @@ export function useInitializeCall() {
|
||||||
SenderId,
|
SenderId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [load, subscribe, showCallingScreen]);
|
}, [load, subscribePush, showCallingScreen]);
|
||||||
|
|
||||||
// get callId from url
|
// get callId from url
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,54 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useCall } from "../store";
|
import { useCall } from "../store";
|
||||||
import { useUserFields } from "@tensamin/user/context";
|
import { useUser, type User } 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">
|
||||||
{currentCallData.UserIds.map((userId) => (
|
{data.map((user) => {
|
||||||
<Participant key={userId} userId={userId} />
|
return (
|
||||||
))}
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,9 @@ 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 { ArrowLeft, Loader2, Search } from "lucide-react";
|
import { 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,
|
||||||
|
|
@ -445,34 +443,21 @@ 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 selectedGroup = groups.find((group) => group.id === selectedGroupId);
|
const imageSizes = useImageSizes(urls);
|
||||||
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, visibleUrls.length);
|
const columnCount = getColumnCount(columnWidth, urls.length);
|
||||||
const groupColumnCount = getColumnCount(columnWidth, groups.length);
|
const columns = distributeByHeight(urls, columnCount, (url) => {
|
||||||
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;
|
||||||
|
|
@ -490,50 +475,6 @@ 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">
|
||||||
|
|
@ -563,7 +504,6 @@ function SavedPanel({
|
||||||
ariaLabel="Unsave media"
|
ariaLabel="Unsave media"
|
||||||
defaultSaved
|
defaultSaved
|
||||||
onSavedMediaChange={onSavedMediaChange}
|
onSavedMediaChange={onSavedMediaChange}
|
||||||
onSavedMediaGroupsChange={onSavedMediaGroupsChange}
|
|
||||||
url={url}
|
url={url}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -590,9 +530,6 @@ 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,
|
||||||
|
|
@ -618,13 +555,7 @@ export default function GifPicker({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== "saved") return;
|
if (tab !== "saved") return;
|
||||||
|
|
||||||
void Promise.all([
|
void load("chat_picker_saved_media").then(setSavedMedia);
|
||||||
load("chat_picker_saved_media"),
|
|
||||||
load("chat_picker_saved_media_groups"),
|
|
||||||
]).then(([nextSavedMedia, nextGroups]) => {
|
|
||||||
setSavedMedia(nextSavedMedia);
|
|
||||||
setSavedMediaGroups(nextGroups);
|
|
||||||
});
|
|
||||||
}, [load, tab]);
|
}, [load, tab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -696,10 +627,8 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
@ -2,9 +2,6 @@ import { Input, type InputController } from "@methanium/ui/markdown";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Drawer,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerTrigger,
|
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
|
|
@ -21,9 +18,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 EmojiPicker from "./emoji/emojiPicker";
|
import GifPicker from "./gifPicker";
|
||||||
import { useEmojiRanks, useRecordEmojiUse } from "./emoji/emojiRanks";
|
import EmojiPicker from "./emojiPicker";
|
||||||
import GifPicker from "./media/gifPicker";
|
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||||
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";
|
||||||
|
|
@ -334,65 +331,42 @@ export default function InputComponent({
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{isMobile ? (
|
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||||
<Drawer open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
<PopoverTrigger
|
||||||
<DrawerTrigger asChild>
|
render={({ onClick }) => (
|
||||||
<div>
|
<Button
|
||||||
<Button className="w-9 h-9! p-0" variant="ghost">
|
onClick={onClick}
|
||||||
<FileVideo size={20} />
|
className="w-9 h-9! p-0"
|
||||||
</Button>
|
variant="ghost"
|
||||||
</div>
|
>
|
||||||
</DrawerTrigger>
|
<FileVideo size={20} />
|
||||||
<DrawerContent className="h-[80dvh]">
|
</Button>
|
||||||
<div className="min-h-0 flex-1 p-3">
|
)}
|
||||||
<GifPicker
|
/>
|
||||||
onSelect={(url) => {
|
<PopoverContent
|
||||||
void handleSubmit(url, true);
|
ref={gifPopoverRef}
|
||||||
setGifPopoverOpen(false);
|
className="relative min-h-80 max-h-180 min-w-80 max-w-180 overflow-hidden"
|
||||||
}}
|
onTouchMoveCapture={(event) => event.stopPropagation()}
|
||||||
/>
|
onWheelCapture={(event) => event.stopPropagation()}
|
||||||
</div>
|
style={{
|
||||||
</DrawerContent>
|
...gifPopoverSize,
|
||||||
</Drawer>
|
maxHeight: gifPopoverSize?.height,
|
||||||
) : (
|
}}
|
||||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
>
|
||||||
<PopoverTrigger
|
<div
|
||||||
render={({ onClick }) => (
|
className="absolute left-0 top-0 z-10 h-4 w-4 cursor-nwse-resize"
|
||||||
<Button
|
onPointerDown={handleGifPopoverResizeStart}
|
||||||
onClick={onClick}
|
|
||||||
aria-label="Open GIF picker"
|
|
||||||
className="w-9 h-9! p-0"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<FileVideo size={20} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<PopoverContent
|
<GifPicker
|
||||||
ref={gifPopoverRef}
|
resizeHeight={gifPopoverSize?.height}
|
||||||
className="relative h-130 w-120 min-h-80 max-h-180 min-w-80 max-w-180 overflow-hidden"
|
resizeWidth={gifPopoverSize?.width}
|
||||||
onTouchMoveCapture={(event) => event.stopPropagation()}
|
onSelect={(url) => {
|
||||||
onWheelCapture={(event) => event.stopPropagation()}
|
void handleSubmit(url, true);
|
||||||
style={{
|
setGifPopoverOpen(false);
|
||||||
...gifPopoverSize,
|
|
||||||
maxHeight: gifPopoverSize?.height,
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div
|
</PopoverContent>
|
||||||
className="absolute left-0 top-0 z-10 h-4 w-4 cursor-nwse-resize"
|
</Popover>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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 { useUserFields } from "@tensamin/user/context";
|
import { type User, useUser } from "@tensamin/user/context";
|
||||||
|
|
||||||
const zoomLevels = [1, 1.5, 2, 3];
|
const zoomLevels = [1, 1.5, 2, 3];
|
||||||
|
|
||||||
|
|
@ -46,7 +46,12 @@ 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]);
|
||||||
|
|
||||||
const { data: user } = useUserFields(senderId, ["Avatar", "Display"]);
|
// Get user's
|
||||||
|
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;
|
||||||
|
|
@ -166,9 +171,9 @@ export default function Media({
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
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!"
|
className="bg-black/50 border-0! w-screen! h-screen! max-w-screen! rounded-none! p-0!"
|
||||||
>
|
>
|
||||||
<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 className="relative z-10 flex w-full h-full justify-between p-7 items-start pointer-events-none">
|
||||||
<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}
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
63
packages/chat/src/components/mediaSaveButton.tsx
Normal file
63
packages/chat/src/components/mediaSaveButton.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 SelectedUser, useUserFields } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -13,14 +13,15 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import MessageContextMenu from "./messageContextMenu";
|
import MessageContextMenu from "./messageContextMenu";
|
||||||
import Media from "./media/media";
|
import Media from "./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 "./emoji/emojiRanks";
|
import { useRecordEmojiUse } from "./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";
|
||||||
|
|
@ -39,7 +40,7 @@ function MessageComponent({
|
||||||
decryptionFailed?: boolean;
|
decryptionFailed?: boolean;
|
||||||
};
|
};
|
||||||
onSetEditing: (editing: boolean) => void;
|
onSetEditing: (editing: boolean) => void;
|
||||||
user: SelectedUser<readonly ["UserId", "Avatar", "Display"]> | null;
|
user: User | null;
|
||||||
}) {
|
}) {
|
||||||
const actuallyFailed =
|
const actuallyFailed =
|
||||||
(message.failed && message.MessageState === "awaiting") ||
|
(message.failed && message.MessageState === "awaiting") ||
|
||||||
|
|
@ -149,14 +150,13 @@ 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 { data: replyUser } = useUserFields(replyMessage?.SenderId ?? null, [
|
const [replyUser, setReplyUser] = useState<User | null>(null);
|
||||||
"Avatar",
|
|
||||||
"Display",
|
|
||||||
]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!message.ReplyId || !ownId || !chatSecret) {
|
if (!message.ReplyId || !ownId || !chatSecret) {
|
||||||
setReplyMessage(null);
|
setReplyMessage(null);
|
||||||
|
setReplyUser(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,20 +168,25 @@ function MessageComponent({
|
||||||
send,
|
send,
|
||||||
})
|
})
|
||||||
.then(async (reply) => {
|
.then(async (reply) => {
|
||||||
const Content = await decryptChatText(chatSecret, reply.Content);
|
const [Content, author] = await Promise.all([
|
||||||
|
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, message.ReplyId, ownId, send, userId]);
|
}, [chatSecret, getUser, 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);
|
||||||
|
|
@ -381,8 +386,8 @@ function MessageComponent({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex w-full flex-col gap-1 py-1.5">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<Card className="px-2! py-0! rounded-md">
|
<Card className="p-2!">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|
@ -400,7 +405,8 @@ function MessageComponent({
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtleDefault"
|
variant="link"
|
||||||
|
className="text-primary-foreground-alt"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!editDraft.trim()) return;
|
if (!editDraft.trim()) return;
|
||||||
if (editDraft !== message.Content) {
|
if (editDraft !== message.Content) {
|
||||||
|
|
@ -414,7 +420,8 @@ function MessageComponent({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline"
|
variant="link"
|
||||||
|
className="text-muted-foreground"
|
||||||
onClick={cancelEditing}
|
onClick={cancelEditing}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
|
||||||
|
|
@ -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 "./emoji/emojiPicker";
|
import EmojiPicker from "./emojiPicker";
|
||||||
import { getRecentEmojis, useEmojiRanks } from "./emoji/emojiRanks";
|
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||||
|
|
||||||
async function copyText(text: string) {
|
async function copyText(text: string) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,11 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@methanium/ui";
|
} from "@methanium/ui";
|
||||||
import { Text } from "@methanium/ui/markdown";
|
import { Text } from "@methanium/ui/markdown";
|
||||||
import type { SelectedUser } from "@tensamin/user/context";
|
import type { User } 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";
|
||||||
|
|
||||||
type ReplyUserData = SelectedUser<readonly ["Avatar", "Display"]>;
|
function ReplyUser({ user }: { user: User }) {
|
||||||
|
|
||||||
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">
|
||||||
|
|
@ -42,7 +40,7 @@ export default function ReplyBox({
|
||||||
content?: string;
|
content?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
user?: ReplyUserData;
|
user?: User;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
variant: "composer" | "message";
|
variant: "composer" | "message";
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -73,7 +71,6 @@ 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} />}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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, subscribe } = useMTP();
|
const { send, subscribePush } = 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, ["PublicKey"]);
|
const peerUser = await getUser(userIdValue);
|
||||||
const peerWrapped = await wrapChatSecret({
|
const peerWrapped = await wrapChatSecret({
|
||||||
chatSecret: rawSecret,
|
chatSecret: rawSecret,
|
||||||
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
|
recipientKemPublicKey: kemPublicKeyFromPublicKeyBundle(
|
||||||
|
|
@ -912,47 +912,124 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
// Get live updates for message states
|
// Get live updates for message states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribeEdit = subscribe("MessageEditLive", ({ data }) => {
|
return subscribePush((message) => {
|
||||||
if (!currentChatSecret) return;
|
if (message.type === "MessageEditLive") {
|
||||||
if (data.ChatPartnerId !== userIdValue) {
|
if (!currentChatSecret) return;
|
||||||
log(
|
|
||||||
3,
|
const rawData = message.data as {
|
||||||
"chat",
|
ChatPartnerId: unknown;
|
||||||
"yellow",
|
SendTime: unknown;
|
||||||
"Cancel message edit update due to user ID mismatch",
|
Content: string;
|
||||||
{
|
};
|
||||||
expected: userIdValue,
|
|
||||||
received: data.ChatPartnerId,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void decryptChatText(currentChatSecret, data.Content)
|
if (message.type === "MessageDeleteLive") {
|
||||||
.then((content) => {
|
const data = message.data as {
|
||||||
editMessage(data.SendTime, { Content: content, Edited: true });
|
ChatPartnerId: number;
|
||||||
})
|
SendTime: number;
|
||||||
.catch((err) => {
|
};
|
||||||
log(1, "chat", "red", "Failed to decrypt message edit", err, {
|
|
||||||
SendTime: data.SendTime,
|
if (data.ChatPartnerId !== userIdValue) return;
|
||||||
});
|
|
||||||
});
|
removeMessage(data.SendTime);
|
||||||
});
|
return;
|
||||||
const unsubscribeReaction = subscribe("MessageReactionLive", ({ data }) => {
|
}
|
||||||
if (data.ChatPartnerId !== userIdValue) return;
|
|
||||||
applyLiveReaction(
|
if (message.type !== "MessageState") return;
|
||||||
data.SendTime,
|
|
||||||
data.Reaction,
|
const rawData = message.data as {
|
||||||
data.SenderId,
|
ChatPartnerId: unknown;
|
||||||
data.Accepted,
|
SendTime: unknown;
|
||||||
);
|
MessageState: RawMessage["MessageState"];
|
||||||
});
|
};
|
||||||
const unsubscribeDelete = subscribe("MessageDeleteLive", ({ data }) => {
|
|
||||||
if (data.ChatPartnerId !== userIdValue) return;
|
const nextState = {
|
||||||
removeMessage(data.SendTime);
|
ChatPartnerId: Number(rawData.ChatPartnerId),
|
||||||
});
|
SendTime: Number(rawData.SendTime),
|
||||||
const unsubscribeState = subscribe("MessageState", ({ data }) => {
|
MessageState: rawData.MessageState,
|
||||||
if (data.ChatPartnerId !== userIdValue) {
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isFinite(nextState.ChatPartnerId) ||
|
||||||
|
!Number.isFinite(nextState.SendTime)
|
||||||
|
) {
|
||||||
|
log(
|
||||||
|
3,
|
||||||
|
"chat",
|
||||||
|
"yellow",
|
||||||
|
"Cancel message state update due to invalid data",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextState.ChatPartnerId !== userIdValue) {
|
||||||
log(
|
log(
|
||||||
3,
|
3,
|
||||||
"chat",
|
"chat",
|
||||||
|
|
@ -960,27 +1037,22 @@ 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: data.ChatPartnerId,
|
received: nextState.ChatPartnerId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editMessage(data.SendTime, {
|
|
||||||
MessageState: data.MessageState,
|
editMessage(nextState.SendTime, {
|
||||||
|
MessageState: nextState.MessageState,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => {
|
|
||||||
unsubscribeEdit();
|
|
||||||
unsubscribeReaction();
|
|
||||||
unsubscribeDelete();
|
|
||||||
unsubscribeState();
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
currentChatSecret,
|
currentChatSecret,
|
||||||
applyLiveReaction,
|
applyLiveReaction,
|
||||||
editMessage,
|
editMessage,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
subscribe,
|
subscribePush,
|
||||||
userIdValue,
|
userIdValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -598,7 +598,6 @@ 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) => (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,3 @@
|
||||||
- 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
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,9 @@
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mtp": "*",
|
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,11 @@ export function deriveCallSecretId(callId: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
||||||
return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
|
return crypto.keyringToKeys(keyring).kemPublicKey;
|
||||||
.kemPublicKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
||||||
return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
|
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey;
|
||||||
.kemPublicKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wrapCallSecret(args: {
|
export async function wrapCallSecret(args: {
|
||||||
|
|
@ -74,10 +72,7 @@ export async function unwrapCallSecret(args: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownKeys = crypto.keyringToKeys({
|
const ownKeys = crypto.keyringToKeys(args.keyring);
|
||||||
value: args.keyring,
|
|
||||||
encoding: "base64",
|
|
||||||
});
|
|
||||||
const sharedSecret = crypto.decapsulate(
|
const sharedSecret = crypto.decapsulate(
|
||||||
ownKeys.kemSecretKey,
|
ownKeys.kemSecretKey,
|
||||||
args.kemCiphertext,
|
args.kemCiphertext,
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,11 @@ export function randomChatSecret(): Uint8Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
|
||||||
return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
|
return crypto.keyringToKeys(keyring).kemPublicKey;
|
||||||
.kemPublicKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
|
||||||
return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
|
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey;
|
||||||
.kemPublicKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wrapChatSecret(args: {
|
export async function wrapChatSecret(args: {
|
||||||
|
|
@ -81,10 +79,7 @@ export async function unwrapChatSecret(args: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownKeys = crypto.keyringToKeys({
|
const ownKeys = crypto.keyringToKeys(args.keyring);
|
||||||
value: args.keyring,
|
|
||||||
encoding: "base64",
|
|
||||||
});
|
|
||||||
const sharedSecret = crypto.decapsulate(
|
const sharedSecret = crypto.decapsulate(
|
||||||
ownKeys.kemSecretKey,
|
ownKeys.kemSecretKey,
|
||||||
args.kemCiphertext,
|
args.kemCiphertext,
|
||||||
|
|
|
||||||
65
packages/crypto/src/context.test.ts
Normal file
65
packages/crypto/src/context.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,850 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,815 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { useMemo, type CSSProperties } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ensureMarkdownStyles,
|
|
||||||
parseMarkdownBlocks,
|
|
||||||
renderBlocks,
|
|
||||||
} from "./markdown";
|
|
||||||
|
|
||||||
export type TextProps = {
|
|
||||||
value: string;
|
|
||||||
fontSize?: CSSProperties["fontSize"];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes Text.
|
|
||||||
* @param props Parameter props.
|
|
||||||
* @returns unknown.
|
|
||||||
*/
|
|
||||||
export default function Text(props: TextProps) {
|
|
||||||
ensureMarkdownStyles();
|
|
||||||
|
|
||||||
const blocks = useMemo(() => parseMarkdownBlocks(props.value), [props.value]);
|
|
||||||
const renderedBlocks = useMemo(() => renderBlocks(blocks), [blocks]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tm-md-root" style={{ fontSize: props.fontSize }}>
|
|
||||||
{renderedBlocks}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
- Improve the Input box
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -9,16 +9,18 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"test": "vitest run --passWithNoTests",
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
"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"
|
||||||
|
|
|
||||||
|
|
@ -1,529 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
21
packages/mtp/src/context.test.tsx
Normal file
21
packages/mtp/src/context.test.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,41 +1,948 @@
|
||||||
import { type ReactNode, useContext, useEffect, useState } from "react";
|
import {
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
createContext,
|
||||||
|
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 { BrowserProvider } from "./browser";
|
import {
|
||||||
import { MTPContext, type MTPContextType } from "./mtpContext";
|
type Calls,
|
||||||
import { TauriProvider } from "./tauri";
|
type Communities,
|
||||||
|
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),
|
() => {
|
||||||
(error: unknown) => active && setWasmError(() => error),
|
if (active) setWasmReady(true);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (active) setWasmError(() => error);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (wasmError) throw wasmError;
|
if (wasmError) throw wasmError;
|
||||||
return wasmReady ? <BrowserProvider {...props} /> : null;
|
if (!wasmReady) return null;
|
||||||
|
|
||||||
|
return isTauri() ? (
|
||||||
|
<TauriProvider {...props} />
|
||||||
|
) : (
|
||||||
|
<BrowserProvider {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMTP(): MTPContextType {
|
export function useMTP(): ContextType {
|
||||||
const context = useContext(MTPContext);
|
const context = useContext(MTPContext);
|
||||||
if (!context) throw new Error("useMTP must be used within an MTPProvider");
|
if (!context) {
|
||||||
|
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
Loading…
Reference in a new issue