Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 816a06ed5e | |||
|
5f44b89043 |
|||
|
31ff5a542f |
|||
|
99c38162a6 |
|||
|
ef24ea4ca0 |
|||
|
45d39cb47c |
|||
|
c182f2e9ac |
|||
|
ec019a4dff |
|||
|
b76143e2e2 |
|||
| 01b68300ab | |||
| 3954d776ce | |||
| 860fd2ec08 | |||
| dfb82373c6 | |||
| 927a719281 | |||
| 7f64659e23 | |||
| 35769b1aae | |||
| a731f8ee42 |
30 changed files with 2308 additions and 2383 deletions
49
.forgejo/workflows/dependency-builds.yml
Normal file
49
.forgejo/workflows/dependency-builds.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: Dependency builds
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NIX_CONFIG: experimental-features = nix-command flakes
|
||||
FORGEJO_TOKEN: ""
|
||||
GITHUB_TOKEN: ""
|
||||
|
||||
jobs:
|
||||
web:
|
||||
if: ${{ github.actor == 'renovate' }}
|
||||
name: Build web
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- run: nix profile add nixpkgs#nodejs_24
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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:web
|
||||
|
||||
desktop:
|
||||
if: ${{ github.actor == 'renovate' }}
|
||||
name: Build desktop
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- run: nix profile add nixpkgs#nodejs_24
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- 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
|
||||
|
||||
mobile:
|
||||
if: ${{ github.actor == 'renovate' }}
|
||||
name: Build mobile
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- run: nix profile add nixpkgs#nodejs_24
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||
- run: nix develop .#tauri --command pnpm run build:packages
|
||||
- run: nix develop .#tauri --command pnpm --dir apps/tauri run build:mobile:ci
|
||||
|
|
@ -28,12 +28,11 @@
|
|||
"validate:raw": "pnpm run build && pnpm run package:linux:raw",
|
||||
"validate": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#electron --command pnpm run validate:raw; else pnpm run validate:raw; fi"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.28.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"electron": "^43.3.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"build": {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
|
||||
"start-adb:mobile:raw": "adb devices",
|
||||
"build:mobile:raw": "tauri android build",
|
||||
"build:mobile:ci": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && tauri android build --debug",
|
||||
"dev:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run dev:mobile:raw; else pnpm run dev:mobile:raw; fi",
|
||||
"start-adb:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run start-adb:mobile:raw; else pnpm run start-adb:mobile:raw; fi",
|
||||
"build:mobile": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run build:mobile:raw; else pnpm run build:mobile:raw; fi",
|
||||
|
|
@ -25,18 +26,16 @@
|
|||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-barcode-scanner": "~2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-log": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-barcode-scanner": "~2.4.5",
|
||||
"@tauri-apps/plugin-deep-link": "~2.4.9",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^25.9.1"
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^26.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -923,28 +923,33 @@ mod android {
|
|||
use std::sync::OnceLock;
|
||||
|
||||
use jni::{
|
||||
objects::{GlobalRef, JClass, JObject, JString, JValue},
|
||||
JNIEnv, JavaVM,
|
||||
jni_sig, jni_str,
|
||||
objects::{Global, JClass, JObject, JString, JValue},
|
||||
Env, EnvUnowned, JavaVM,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct Host {
|
||||
vm: JavaVM,
|
||||
context: GlobalRef,
|
||||
bridge: GlobalRef,
|
||||
context: Global<JObject<'static>>,
|
||||
bridge: Global<JObject<'static>>,
|
||||
}
|
||||
|
||||
static HOST: OnceLock<Host> = OnceLock::new();
|
||||
|
||||
pub fn attach(env: &mut JNIEnv, context: JObject) -> Result<(), String> {
|
||||
pub fn attach(env: &mut Env, context: JObject) -> Result<(), String> {
|
||||
if HOST.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let class = env
|
||||
.find_class("net/tensamin/client/NativeMtpBridge")
|
||||
.find_class(jni_str!("net/tensamin/client/NativeMtpBridge"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let bridge = env
|
||||
.get_static_field(class, "INSTANCE", "Lnet/tensamin/client/NativeMtpBridge;")
|
||||
.get_static_field(
|
||||
class,
|
||||
jni_str!("INSTANCE"),
|
||||
jni_sig!("Lnet/tensamin/client/NativeMtpBridge;"),
|
||||
)
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|e| e.to_string())?;
|
||||
HOST.set(Host {
|
||||
|
|
@ -955,15 +960,11 @@ mod android {
|
|||
.map_err(|_| "Android MTP host is already attached".to_string())
|
||||
}
|
||||
|
||||
fn with_env<T>(
|
||||
call: impl FnOnce(&mut JNIEnv, &Host) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
fn with_env<T>(call: impl FnOnce(&mut Env, &Host) -> Result<T, String>) -> Result<T, String> {
|
||||
let host = HOST.get().ok_or("Android MTP host is not attached")?;
|
||||
let mut env = host
|
||||
.vm
|
||||
.attach_current_thread_as_daemon()
|
||||
.map_err(|e| e.to_string())?;
|
||||
call(&mut env, host)
|
||||
host.vm
|
||||
.attach_current_thread(|env| Ok::<_, jni::errors::Error>(call(env, host)))
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
pub fn store_config(config: &str) -> Result<(), String> {
|
||||
|
|
@ -971,8 +972,8 @@ mod android {
|
|||
let value = env.new_string(config).map_err(|e| e.to_string())?;
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"storeConfig",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
jni_str!("storeConfig"),
|
||||
jni_sig!("(Landroid/content/Context;Ljava/lang/String;)V"),
|
||||
&[
|
||||
JValue::Object(host.context.as_obj()),
|
||||
JValue::Object(&value),
|
||||
|
|
@ -987,8 +988,8 @@ mod android {
|
|||
with_env(|env, host| {
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"hasConfig",
|
||||
"(Landroid/content/Context;)Z",
|
||||
jni_str!("hasConfig"),
|
||||
jni_sig!("(Landroid/content/Context;)Z"),
|
||||
&[JValue::Object(host.context.as_obj())],
|
||||
)
|
||||
.and_then(|value| value.z())
|
||||
|
|
@ -1000,8 +1001,8 @@ mod android {
|
|||
with_env(|env, host| {
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"setServiceEnabled",
|
||||
"(Landroid/content/Context;Z)V",
|
||||
jni_str!("setServiceEnabled"),
|
||||
jni_sig!("(Landroid/content/Context;Z)V"),
|
||||
&[
|
||||
JValue::Object(host.context.as_obj()),
|
||||
JValue::Bool(enabled.into()),
|
||||
|
|
@ -1017,8 +1018,8 @@ mod android {
|
|||
let status = env.new_string(status).map_err(|e| e.to_string())?;
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"updateServiceStatus",
|
||||
"(Landroid/content/Context;Ljava/lang/String;)V",
|
||||
jni_str!("updateServiceStatus"),
|
||||
jni_sig!("(Landroid/content/Context;Ljava/lang/String;)V"),
|
||||
&[
|
||||
JValue::Object(host.context.as_obj()),
|
||||
JValue::Object(&status),
|
||||
|
|
@ -1043,8 +1044,8 @@ mod android {
|
|||
.map_err(|e| e.to_string())?;
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"postMessageNotification",
|
||||
"(Landroid/content/Context;JLjava/lang/String;Ljava/lang/String;[B)V",
|
||||
jni_str!("postMessageNotification"),
|
||||
jni_sig!("(Landroid/content/Context;JLjava/lang/String;Ljava/lang/String;[B)V"),
|
||||
&[
|
||||
JValue::Object(host.context.as_obj()),
|
||||
JValue::Long(sender_id as i64),
|
||||
|
|
@ -1062,8 +1063,8 @@ mod android {
|
|||
with_env(|env, host| {
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"cancelMessageNotification",
|
||||
"(Landroid/content/Context;J)V",
|
||||
jni_str!("cancelMessageNotification"),
|
||||
jni_sig!("(Landroid/content/Context;J)V"),
|
||||
&[
|
||||
JValue::Object(host.context.as_obj()),
|
||||
JValue::Long(sender_id as i64),
|
||||
|
|
@ -1078,8 +1079,8 @@ mod android {
|
|||
with_env(|env, host| {
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"isIgnoringBatteryOptimizations",
|
||||
"(Landroid/content/Context;)Z",
|
||||
jni_str!("isIgnoringBatteryOptimizations"),
|
||||
jni_sig!("(Landroid/content/Context;)Z"),
|
||||
&[JValue::Object(host.context.as_obj())],
|
||||
)
|
||||
.and_then(|value| value.z())
|
||||
|
|
@ -1091,8 +1092,8 @@ mod android {
|
|||
with_env(|env, host| {
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
"requestBatteryExemption",
|
||||
"(Landroid/content/Context;)V",
|
||||
jni_str!("requestBatteryExemption"),
|
||||
jni_sig!("(Landroid/content/Context;)V"),
|
||||
&[JValue::Object(host.context.as_obj())],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -1101,32 +1102,36 @@ mod android {
|
|||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeAttach(
|
||||
mut env: JNIEnv,
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeAttach<'caller>(
|
||||
mut env: EnvUnowned<'caller>,
|
||||
_class: JClass,
|
||||
context: JObject,
|
||||
context: JObject<'caller>,
|
||||
) {
|
||||
let _ =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| attach(&mut env, context)));
|
||||
let _ = env.with_env(|env| {
|
||||
let _ = attach(env, context);
|
||||
Ok::<_, jni::errors::Error>(())
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeStart(
|
||||
mut env: JNIEnv,
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeStart<'caller>(
|
||||
mut env: EnvUnowned<'caller>,
|
||||
_class: JClass,
|
||||
config: JString,
|
||||
config: JString<'caller>,
|
||||
) {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let config: String = env.get_string(&config).map_err(|e| e.to_string())?.into();
|
||||
let config = serde_json::from_str(&config).map_err(|e| e.to_string())?;
|
||||
let _ = env.with_env(|env| {
|
||||
if let Ok(config) = config.mutf8_chars(env) {
|
||||
if let Ok(config) = serde_json::from_str(config.to_str().as_ref()) {
|
||||
super::manager().configure_and_start(config);
|
||||
Ok::<_, String>(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok::<_, jni::errors::Error>(())
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeStop(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_class: JClass,
|
||||
) {
|
||||
let _ = std::panic::catch_unwind(|| super::manager().stop());
|
||||
|
|
@ -1134,31 +1139,34 @@ mod android {
|
|||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeSetUiState(
|
||||
_env: JNIEnv,
|
||||
_env: EnvUnowned,
|
||||
_class: JClass,
|
||||
visible: jni::sys::jboolean,
|
||||
) {
|
||||
super::manager().set_ui_visible(visible != 0);
|
||||
super::manager().set_ui_visible(visible);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeLog(
|
||||
mut env: JNIEnv,
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeMtpBridge_nativeLog<'caller>(
|
||||
mut env: EnvUnowned<'caller>,
|
||||
_class: JClass,
|
||||
level: jni::sys::jint,
|
||||
message: JString,
|
||||
details: JString,
|
||||
message: JString<'caller>,
|
||||
details: JString<'caller>,
|
||||
) {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let message: String = env.get_string(&message).map_err(|e| e.to_string())?.into();
|
||||
let details: String = env.get_string(&details).map_err(|e| e.to_string())?.into();
|
||||
let _ = env.with_env(|env| {
|
||||
if let (Ok(message), Ok(details)) = (message.mutf8_chars(env), details.mutf8_chars(env))
|
||||
{
|
||||
let message = message.to_str();
|
||||
let details = details.to_str();
|
||||
super::manager().log(
|
||||
level.clamp(0, 3) as u8,
|
||||
message,
|
||||
(!details.is_empty()).then(|| Value::String(details)),
|
||||
message.into_owned(),
|
||||
(!details.is_empty()).then(|| Value::String(details.into_owned())),
|
||||
);
|
||||
Ok::<_, String>(())
|
||||
}));
|
||||
}
|
||||
Ok::<_, jni::errors::Error>(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,135 +12,51 @@
|
|||
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@base-ui/utils": "0.3.2",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/public-sans": "^5.2.7",
|
||||
"@floating-ui/core": "^1.7.0",
|
||||
"@floating-ui/dom": "^1.7.0",
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"@radix-ui/primitive": "^1.1.0",
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-context": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dismissable-layer": "^1.1.11",
|
||||
"@radix-ui/react-focus-guards": "^1.1.4",
|
||||
"@radix-ui/react-focus-scope": "^1.1.11",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-portal": "^1.1.13",
|
||||
"@radix-ui/react-presence": "^1.1.6",
|
||||
"@radix-ui/react-primitive": "^2.0.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-use-callback-ref": "^1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.3",
|
||||
"@radix-ui/react-use-effect-event": "^0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "^1.1.0",
|
||||
"@reduxjs/toolkit": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/devtools-event-client": "^0.5.0",
|
||||
"@tanstack/query-core": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/history": "1.162.0",
|
||||
"@tanstack/react-store": "^0.11.0",
|
||||
"@tanstack/router-core": "^1.169.1",
|
||||
"@tanstack/store": "^0.11.0",
|
||||
"@tanstack/virtual-core": "^3.13.24",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/call": "workspace:*",
|
||||
"@fontsource-variable/public-sans": "^5.3.0",
|
||||
"@methanium/ui": "*",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/call": "workspace:*",
|
||||
"@tensamin/chat": "workspace:*",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/hotkeys": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/settings": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/tauth": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/notifications": "workspace:*",
|
||||
"@tensamin/onboarding": "workspace:*",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie-es": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-format": "^3.1.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-path": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"d3-timer": "^3.0.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"@tensamin/settings": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/tauth": "workspace:*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"detect-node-es": "^1.1.0",
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"embla-carousel": "8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"embla-carousel-reactive-utils": "8.6.0",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"get-nonce": "^1.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"internmap": "^2.0.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pngjs": "^5.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-is": "^19.0.0",
|
||||
"react-redux": "^9.0.0",
|
||||
"react-remove-scroll": "^2.7.2",
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"recharts": "3.10.1",
|
||||
"redux": "^5.0.0",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "5.2.0",
|
||||
"scheduler": "^0.27.0",
|
||||
"seroval": "^1.5.4",
|
||||
"seroval-plugins": "^1.5.4",
|
||||
"shadcn": "^4.11.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"lucide-react": "^1.29.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-is": "^19.2.8",
|
||||
"react-redux": "^9.3.0",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"tslib": "^2.8.1",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"vaul": "^1.1.2",
|
||||
"victory-vendor": "^37.0.2",
|
||||
"yargs": "^15.3.1",
|
||||
"zod": "^4.3.6"
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^10.0.3",
|
||||
"globals": "^17.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.8.0",
|
||||
"globals": "^17.9.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.10"
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,23 +36,19 @@ import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
|
|||
import { Ellipsis, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
import type z from "zod";
|
||||
import { mtp } from "@tensamin/shared/data";
|
||||
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import {
|
||||
onlineStatusLabels,
|
||||
onlineStatusOptions,
|
||||
type OnlineStatus,
|
||||
} from "./status-options";
|
||||
|
||||
type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>;
|
||||
|
||||
const onlineStatusLabels: Record<OnlineStatus, string> = {
|
||||
user_online: "Online",
|
||||
user_offline: "Offline",
|
||||
user_dnd: "Do not disturb",
|
||||
user_idle: "Idle",
|
||||
user_wc: "Away",
|
||||
user_borked: "Borked",
|
||||
iota_offline: "Iota offline",
|
||||
iota_online: "Iota online",
|
||||
iota_borked: "Iota borked",
|
||||
};
|
||||
function accountPreference(status: User["OnlineStatus"]): OnlineStatus {
|
||||
return userPresencePreferenceSchema.safeParse(status).success
|
||||
? (status as OnlineStatus)
|
||||
: "user_online";
|
||||
}
|
||||
|
||||
function StatusDialog({
|
||||
user,
|
||||
|
|
@ -87,7 +83,7 @@ function StatusDialog({
|
|||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDraftStatus(user.Status ?? "");
|
||||
setDraftOnlineStatus(user.OnlineStatus);
|
||||
setDraftOnlineStatus(accountPreference(user.OnlineStatus));
|
||||
setErrorMessage("");
|
||||
setSaveSucceeded(false);
|
||||
}
|
||||
|
|
@ -126,10 +122,11 @@ function StatusDialog({
|
|||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="user_online">Online</SelectItem>
|
||||
<SelectItem value="user_offline">Offline</SelectItem>
|
||||
<SelectItem value="user_idle">Idle</SelectItem>
|
||||
<SelectItem value="user_dnd">Do not disturb</SelectItem>
|
||||
{onlineStatusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -140,12 +137,9 @@ function StatusDialog({
|
|||
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const payload = {
|
||||
...(draftStatus && { status: draftStatus }),
|
||||
OnlineStatus: draftOnlineStatus,
|
||||
};
|
||||
const payload = { UserState: draftOnlineStatus };
|
||||
|
||||
const validation = mtp.ChangeUserData.request.safeParse(payload);
|
||||
const validation = mtp.SetUserState.request.safeParse(payload);
|
||||
|
||||
if (!validation.success) {
|
||||
setSaveSucceeded(false);
|
||||
|
|
@ -156,7 +150,17 @@ function StatusDialog({
|
|||
}
|
||||
|
||||
try {
|
||||
await send("ChangeUserData", validation.data);
|
||||
await send("SetUserState", validation.data);
|
||||
const profileValidation = mtp.ChangeUserData.request.safeParse({
|
||||
Status: draftStatus,
|
||||
});
|
||||
if (!profileValidation.success) {
|
||||
throw new Error(
|
||||
profileValidation.error.issues[0]?.message ??
|
||||
"Invalid status data",
|
||||
);
|
||||
}
|
||||
await send("ChangeUserData", profileValidation.data);
|
||||
setSaveSucceeded(true);
|
||||
setErrorMessage("");
|
||||
} catch (err) {
|
||||
|
|
@ -227,7 +231,9 @@ export default function Sidebar() {
|
|||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDraftStatus(user.Status ?? "");
|
||||
setDraftOnlineStatus(user.OnlineStatus);
|
||||
setDraftOnlineStatus(
|
||||
accountPreference(user.OnlineStatus),
|
||||
);
|
||||
setStatusErrorMessage("");
|
||||
setStatusSaveSucceeded(false);
|
||||
setDialogOpen(true);
|
||||
|
|
@ -246,7 +252,9 @@ export default function Sidebar() {
|
|||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDraftStatus(user.Status ?? "");
|
||||
setDraftOnlineStatus(user.OnlineStatus);
|
||||
setDraftOnlineStatus(
|
||||
accountPreference(user.OnlineStatus),
|
||||
);
|
||||
setStatusErrorMessage("");
|
||||
setStatusSaveSucceeded(false);
|
||||
}
|
||||
|
|
|
|||
13
apps/web/src/components/status-options.ts
Normal file
13
apps/web/src/components/status-options.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export const onlineStatusOptions = [
|
||||
{ label: "Online", value: "user_online" },
|
||||
{ label: "Idle", value: "user_idle" },
|
||||
{ label: "Do not disturb", value: "user_dnd" },
|
||||
{ label: "Away", value: "user_wc" },
|
||||
{ label: "Offline", value: "user_invisible" },
|
||||
] as const;
|
||||
|
||||
export type OnlineStatus = (typeof onlineStatusOptions)[number]["value"];
|
||||
|
||||
export const onlineStatusLabels = Object.fromEntries(
|
||||
onlineStatusOptions.map((option) => [option.value, option.label]),
|
||||
) as Record<OnlineStatus, string>;
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 11a1d79409857b734e948dd8c3e28e6ba721d15f
|
||||
Subproject commit 486541b9483356ff49ff3ec7016f87d3ecbeaa0e
|
||||
24
package.json
24
package.json
|
|
@ -29,23 +29,21 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"eslint": "^10.2.1",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"fallow": "^2.86.0",
|
||||
"globals": "^17.5.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"prettier": "^3.8.3",
|
||||
"fallow": "^3.14.0",
|
||||
"globals": "^17.9.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vitest": "^4.0.8",
|
||||
"yaml": "^2.8.2"
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz",
|
||||
"@methanium/ui": "*",
|
||||
"mtp": "*",
|
||||
"sonner": "^2.0.7"
|
||||
}
|
||||
|
|
|
|||
4
packages/cache/package.json
vendored
4
packages/cache/package.json
vendored
|
|
@ -19,7 +19,7 @@
|
|||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"react": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
"react": "^19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
packages/cache/src/sync.tsx
vendored
57
packages/cache/src/sync.tsx
vendored
|
|
@ -12,6 +12,14 @@ function isError(message: ProtocolMessage) {
|
|||
return message.type.startsWith("Error");
|
||||
}
|
||||
|
||||
export function removeMissingContactSnapshots<T extends { UserId: number }>(
|
||||
contacts: T[],
|
||||
missingUserIds: readonly number[],
|
||||
): T[] {
|
||||
const missing = new Set(missingUserIds);
|
||||
return contacts.filter((contact) => !missing.has(contact.UserId));
|
||||
}
|
||||
|
||||
export default function CacheSync() {
|
||||
const { addInterceptor, contextReady, freshContacts, subscribePush } =
|
||||
useMTP();
|
||||
|
|
@ -37,6 +45,20 @@ export default function CacheSync() {
|
|||
[accountId],
|
||||
);
|
||||
|
||||
const removeMissingContacts = useCallback(
|
||||
async (userIds: number[]) => {
|
||||
if (userIds.length === 0) return;
|
||||
const cache = secureCache();
|
||||
const contacts = await cache.contacts.get();
|
||||
if (!contacts) return;
|
||||
const remaining = removeMissingContactSnapshots(contacts, userIds);
|
||||
if (remaining.length === contacts.length) return;
|
||||
await cache.contacts.replace(remaining);
|
||||
await cache.conversations.replaceSelected(remaining);
|
||||
},
|
||||
[secureCache],
|
||||
);
|
||||
|
||||
const replaceMessage = useCallback(
|
||||
async (
|
||||
partnerId: number,
|
||||
|
|
@ -110,6 +132,15 @@ export default function CacheSync() {
|
|||
const request = (data ?? {}) as Record<string, unknown>;
|
||||
const result = response.data as Record<string, unknown>;
|
||||
|
||||
if (type === "GetStates" && Array.isArray(result.MissingUserIds)) {
|
||||
await removeMissingContacts(
|
||||
result.MissingUserIds.filter(
|
||||
(userId): userId is number => typeof userId === "number",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "GetUserData") {
|
||||
await createCache(String(accountId)).profiles.put(
|
||||
result as unknown as UserProfile,
|
||||
|
|
@ -196,7 +227,14 @@ export default function CacheSync() {
|
|||
return;
|
||||
}
|
||||
},
|
||||
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||
[
|
||||
accountId,
|
||||
insertMessage,
|
||||
removeMissingContacts,
|
||||
removeMessage,
|
||||
replaceMessage,
|
||||
secureCache,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -210,6 +248,14 @@ export default function CacheSync() {
|
|||
async (message: ProtocolMessage) => {
|
||||
if (!accountId || isError(message)) return;
|
||||
const data = message.data as Record<string, unknown>;
|
||||
if (message.type === "GetStates" && Array.isArray(data.MissingUserIds)) {
|
||||
await removeMissingContacts(
|
||||
data.MissingUserIds.filter(
|
||||
(userId): userId is number => typeof userId === "number",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.type === "MessageLive") {
|
||||
await insertMessage(
|
||||
Number(data.SenderId),
|
||||
|
|
@ -263,7 +309,14 @@ export default function CacheSync() {
|
|||
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
|
||||
}
|
||||
},
|
||||
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||
[
|
||||
accountId,
|
||||
insertMessage,
|
||||
removeMessage,
|
||||
removeMissingContacts,
|
||||
replaceMessage,
|
||||
secureCache,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -17,21 +17,21 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.20",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@livekit/components-react": "^2.9.23",
|
||||
"@methanium/ui": "*",
|
||||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"deepfilternet3-noise-filter": "1.3.0",
|
||||
"livekit-client": "^2.18.8",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.8"
|
||||
"livekit-client": "^2.21.0",
|
||||
"lucide-react": "^1.29.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"recharts": "^3.10.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,23 +16,23 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tanstack/pacer": "^0.21.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
"@tanstack/react-virtual": "^3.0.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/hotkeys": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"motion": "^12.42.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
"lucide-react": "^1.29.0",
|
||||
"motion": "^13.0.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,15 +15,15 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@methanium/ui": "*",
|
||||
"@twemoji/api": "^17.0.3",
|
||||
"emojibase-data": "^17.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,18 +12,17 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@methanium/ui": "*",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mtp": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.4.2"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^10.0.3"
|
||||
"eslint": "^10.8.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
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";
|
||||
|
|
@ -62,9 +63,30 @@ const PUSH_TYPES = [
|
|||
"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;
|
||||
|
|
@ -110,7 +132,7 @@ function getProtocolErrorDetails(error: unknown) {
|
|||
}
|
||||
|
||||
// Zod schema validation
|
||||
function validateResponse<T extends keyof Schemas & string>(
|
||||
export function validateResponse<T extends keyof Schemas & string>(
|
||||
type: T,
|
||||
message: { id?: number; type: string; data: unknown },
|
||||
): ProtocolMessage<T> {
|
||||
|
|
@ -142,15 +164,26 @@ function validateResponse<T extends keyof Schemas & string>(
|
|||
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, pushHandlersRef, subscribePush };
|
||||
return {
|
||||
addInterceptor,
|
||||
interceptorsRef,
|
||||
lastInitialStateRef,
|
||||
pushHandlersRef,
|
||||
subscribePush,
|
||||
};
|
||||
}
|
||||
|
||||
function BrowserProvider(props: {
|
||||
|
|
@ -172,8 +205,13 @@ function BrowserProvider(props: {
|
|||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||
null,
|
||||
);
|
||||
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
||||
useMessageHandlers();
|
||||
const {
|
||||
addInterceptor,
|
||||
interceptorsRef,
|
||||
lastInitialStateRef,
|
||||
pushHandlersRef,
|
||||
subscribePush,
|
||||
} = useMessageHandlers();
|
||||
|
||||
const connected = readyState === ConnectionState.Connected;
|
||||
|
||||
|
|
@ -197,7 +235,20 @@ function BrowserProvider(props: {
|
|||
(data ?? {}) as Record<string, unknown>,
|
||||
options,
|
||||
);
|
||||
return validateResponse(type, message);
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -418,6 +469,9 @@ function BrowserProvider(props: {
|
|||
return;
|
||||
}
|
||||
|
||||
setFreshContacts((contacts) =>
|
||||
removeMissingContacts(contacts, validated),
|
||||
);
|
||||
for (const handler of [...pushHandlersRef.current]) {
|
||||
void Promise.resolve()
|
||||
.then(() => handler(validated))
|
||||
|
|
@ -425,6 +479,9 @@ function BrowserProvider(props: {
|
|||
log(1, "mtp", "red", "Push handler failed", error, { type });
|
||||
});
|
||||
}
|
||||
if (validated.type === "GetStates") {
|
||||
lastInitialStateRef.current = validated;
|
||||
}
|
||||
});
|
||||
}
|
||||
setReadyState(activeClient.state);
|
||||
|
|
@ -537,7 +594,13 @@ function BrowserProvider(props: {
|
|||
setIdentifying(false);
|
||||
sonnerToast.dismiss("mtp-connection-toast");
|
||||
};
|
||||
}, [mtpUrl, props.blockConnection, load, pushHandlersRef]);
|
||||
}, [
|
||||
lastInitialStateRef,
|
||||
mtpUrl,
|
||||
props.blockConnection,
|
||||
load,
|
||||
pushHandlersRef,
|
||||
]);
|
||||
|
||||
// No Iota check
|
||||
useEffect(() => {
|
||||
|
|
@ -644,8 +707,13 @@ function TauriProvider(props: {
|
|||
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
|
||||
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||
const generationRef = useRef(0);
|
||||
const { addInterceptor, interceptorsRef, pushHandlersRef, subscribePush } =
|
||||
useMessageHandlers();
|
||||
const {
|
||||
addInterceptor,
|
||||
interceptorsRef,
|
||||
lastInitialStateRef,
|
||||
pushHandlersRef,
|
||||
subscribePush,
|
||||
} = useMessageHandlers();
|
||||
const subscriptionsRef = useRef(
|
||||
new Map<string, Set<(message: ProtocolMessage) => void>>(),
|
||||
);
|
||||
|
|
@ -686,7 +754,10 @@ function TauriProvider(props: {
|
|||
[]) {
|
||||
handler(validated);
|
||||
}
|
||||
if (!(PUSH_TYPES as readonly string[]).includes(validated.type)) return;
|
||||
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, {
|
||||
|
|
@ -694,8 +765,11 @@ function TauriProvider(props: {
|
|||
});
|
||||
});
|
||||
}
|
||||
if (validated.type === "GetStates") {
|
||||
lastInitialStateRef.current = validated;
|
||||
}
|
||||
},
|
||||
[pushHandlersRef],
|
||||
[lastInitialStateRef, pushHandlersRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -770,6 +844,20 @@ function TauriProvider(props: {
|
|||
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 }),
|
||||
|
|
|
|||
|
|
@ -12,19 +12,18 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/chat": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-notification": "~2.3.3",
|
||||
"@tensamin/chat": "workspace:*",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"sonner": "^2.0.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@methanium/ui": "*",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-notification": "~2.3.3",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
"lucide-react": "^1.29.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,20 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@methanium/ui": "*",
|
||||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/hotkeys": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"lucide-react": "^1.29.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"exports": {
|
||||
"./asyncQueue": "./src/asyncQueue.ts",
|
||||
"./code": "./src/code.ts",
|
||||
"./errors": "./src/errors.ts",
|
||||
"./data": "./src/data.ts",
|
||||
"./desktopMedia": "./src/desktopMedia.tsx",
|
||||
"./log": "./src/log.tsx",
|
||||
|
|
@ -22,9 +23,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@methanium/ui": "*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
"lucide-react": "^1.29.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,34 +143,71 @@ export type Contacts = z.infer<typeof authPayload.shape.Contacts>;
|
|||
export type Communities = z.infer<typeof authPayload.shape.Communities>;
|
||||
export type Calls = z.infer<typeof authPayload.shape.Calls>;
|
||||
|
||||
|
||||
|
||||
// MTP
|
||||
const user = z.object({
|
||||
const userFields = {
|
||||
About: z.string().max(255).optional(),
|
||||
Avatar: z.string().optional(),
|
||||
Display: z.string().min(1).max(15),
|
||||
IotaId: z.number(),
|
||||
OmikronConnections: z.array(z.number()),
|
||||
OmikronId: z.number().optional(),
|
||||
OnlineStatus: z.enum([
|
||||
"user_offline",
|
||||
"user_online",
|
||||
"user_dnd",
|
||||
"user_idle",
|
||||
"user_wc",
|
||||
"user_borked",
|
||||
"iota_offline",
|
||||
"iota_online",
|
||||
"iota_borked",
|
||||
]),
|
||||
PublicKey: z.base64(),
|
||||
Status: z.string().max(15).optional(),
|
||||
SubEnd: z.number(),
|
||||
SubLevel: z.number(),
|
||||
UserId: z.number(),
|
||||
Username: z.string().min(1).max(15),
|
||||
};
|
||||
|
||||
export const publicUserStateSchema = z.enum([
|
||||
"user_online",
|
||||
"user_idle",
|
||||
"user_dnd",
|
||||
"user_wc",
|
||||
"user_offline",
|
||||
"iota_offline",
|
||||
]);
|
||||
|
||||
export const userPresencePreferenceSchema = z.enum([
|
||||
"user_online",
|
||||
"user_idle",
|
||||
"user_dnd",
|
||||
"user_wc",
|
||||
"user_invisible",
|
||||
]);
|
||||
|
||||
export const clientUserStateSchema = z.union([
|
||||
publicUserStateSchema,
|
||||
userPresencePreferenceSchema,
|
||||
]);
|
||||
|
||||
export const publicUserSchema = z.object({
|
||||
...userFields,
|
||||
OnlineStatus: z.enum([
|
||||
"user_online",
|
||||
"user_idle",
|
||||
"user_dnd",
|
||||
"user_wc",
|
||||
"user_offline",
|
||||
"iota_offline",
|
||||
"user_borked",
|
||||
"iota_online",
|
||||
"iota_borked",
|
||||
]),
|
||||
});
|
||||
|
||||
export const accountUserSchema = z.object({
|
||||
...userFields,
|
||||
OnlineStatus: userPresencePreferenceSchema,
|
||||
});
|
||||
|
||||
export const userSchema = z.union([publicUserSchema, accountUserSchema]);
|
||||
|
||||
export const userStateEntrySchema = z.object({
|
||||
UserId: z.number().int().positive(),
|
||||
UserState: publicUserStateSchema,
|
||||
});
|
||||
|
||||
export const mtp = {
|
||||
IdentificationResponse: {
|
||||
request: z.object({}).optional(),
|
||||
|
|
@ -201,10 +238,47 @@ export const mtp = {
|
|||
UserId: z.number().optional(),
|
||||
Username: z.string().optional(),
|
||||
}),
|
||||
response: user,
|
||||
response: userSchema,
|
||||
},
|
||||
GetStates: {
|
||||
request: z.object({
|
||||
SessionId: z.number().int().positive(),
|
||||
UserIds: z.array(z.number().int().positive()),
|
||||
}),
|
||||
response: z.object({
|
||||
SessionId: z.number().int().positive(),
|
||||
// Keep valid entries when one entry in a server snapshot is malformed.
|
||||
UserStates: z.array(userStateEntrySchema.nullable().catch(null)),
|
||||
MissingUserIds: z.array(z.number().int().positive()).optional(),
|
||||
}),
|
||||
},
|
||||
ClientChanged: {
|
||||
request: z.object({}).optional(),
|
||||
response: z.object({
|
||||
SessionId: z.number().int().positive(),
|
||||
UserId: z.number().int().positive(),
|
||||
UserState: clientUserStateSchema,
|
||||
}),
|
||||
},
|
||||
SetUserState: {
|
||||
request: z.object({
|
||||
UserState: userPresencePreferenceSchema,
|
||||
}),
|
||||
response: z.object({
|
||||
UserState: userPresencePreferenceSchema,
|
||||
}),
|
||||
},
|
||||
ChangeUserData: {
|
||||
request: user.partial(),
|
||||
request: z
|
||||
.object({
|
||||
About: userFields.About,
|
||||
Avatar: userFields.Avatar,
|
||||
Display: userFields.Display,
|
||||
PublicKey: userFields.PublicKey,
|
||||
Status: userFields.Status,
|
||||
Username: userFields.Username,
|
||||
})
|
||||
.partial(),
|
||||
response: z.object({}),
|
||||
},
|
||||
MessageDelete: {
|
||||
|
|
@ -472,7 +546,7 @@ export interface Storage extends SettingsStorageDefaults {
|
|||
| "base0E"
|
||||
| "base0F",
|
||||
string
|
||||
> | null;
|
||||
> | null;
|
||||
theme_primary_color: string;
|
||||
theme_polarity: "dark" | "light" | "system";
|
||||
theme_tint: "soft" | "hard" | "extreme";
|
||||
|
|
@ -585,7 +659,9 @@ export const storageDefaults: Storage = {
|
|||
|
||||
// User Status
|
||||
export function getStatusColor(
|
||||
status: z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>,
|
||||
status:
|
||||
| z.infer<typeof publicUserSchema.shape.OnlineStatus>
|
||||
| z.infer<typeof userPresencePreferenceSchema>,
|
||||
) {
|
||||
switch (status) {
|
||||
case "user_online":
|
||||
|
|
|
|||
25
packages/shared/src/errors.ts
Normal file
25
packages/shared/src/errors.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export class ProtocolError extends Error {
|
||||
readonly type: string;
|
||||
readonly id: number | undefined;
|
||||
readonly communicationType: string;
|
||||
readonly requestId: number | undefined;
|
||||
readonly errorType: string | undefined;
|
||||
|
||||
constructor(options: {
|
||||
type: string;
|
||||
requestId?: number;
|
||||
errorType?: string;
|
||||
}) {
|
||||
super(
|
||||
options.errorType
|
||||
? `${options.type}: ${options.errorType}`
|
||||
: options.type,
|
||||
);
|
||||
this.name = "ProtocolError";
|
||||
this.type = options.type;
|
||||
this.id = options.requestId;
|
||||
this.communicationType = options.type;
|
||||
this.requestId = options.requestId;
|
||||
this.errorType = options.errorType;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,12 +14,12 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,17 +12,15 @@
|
|||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.0.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@methanium/ui": "*",
|
||||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,29 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { useMTP, type ProtocolMessage } from "@tensamin/mtp";
|
||||
|
||||
import { mtp as schemas } from "@tensamin/shared/data";
|
||||
import {
|
||||
clientUserStateSchema,
|
||||
mtp as schemas,
|
||||
publicUserStateSchema,
|
||||
userStateEntrySchema,
|
||||
} from "@tensamin/shared/data";
|
||||
import type z from "zod";
|
||||
import { createCache } from "@tensamin/cache";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
|
||||
export type User = z.infer<typeof schemas.GetUserData.response>;
|
||||
type ClientUserState = z.infer<typeof clientUserStateSchema>;
|
||||
|
||||
export function mergeTransientPresence<T extends { UserId: number }>(
|
||||
user: T,
|
||||
presence: ReadonlyMap<number, ClientUserState>,
|
||||
): T {
|
||||
const state = presence.get(user.UserId);
|
||||
return state === undefined ? user : ({ ...user, OnlineStatus: state } as T);
|
||||
}
|
||||
|
||||
const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
|
||||
|
||||
|
|
@ -35,19 +49,105 @@ export default function UserProvider(props: { children: ReactNode }) {
|
|||
const storageRef = useRef<Record<number, User>>({});
|
||||
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({});
|
||||
const checkedAtRef = useRef<Record<number, number>>({});
|
||||
const presenceRef = useRef(new Map<number, ClientUserState>());
|
||||
const durableProfileRef = useRef<Record<number, User>>({});
|
||||
|
||||
const { send } = useMTP();
|
||||
const { send, subscribePush } = useMTP();
|
||||
const { load } = useStorage();
|
||||
const { contacts } = useSession();
|
||||
const [accountId, setAccountId] = useState<number | null>(null);
|
||||
const [cacheVersion, setCacheVersion] = useState(0);
|
||||
const [sessionId, setSessionId] = useState<number | null>(null);
|
||||
const contactsRef = useRef(contacts);
|
||||
const initialStatesRef = useRef(
|
||||
new Map<number, z.infer<typeof publicUserStateSchema>>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
contactsRef.current = contacts;
|
||||
}, [contacts]);
|
||||
|
||||
const mergeUserPresence = useCallback((user: User): User => {
|
||||
return mergeTransientPresence(user, presenceRef.current);
|
||||
}, []);
|
||||
|
||||
const applyUserState = useCallback(
|
||||
(userId: number, state: ClientUserState, privateState = false) => {
|
||||
const known =
|
||||
userId === accountId ||
|
||||
contactsRef.current.some((contact) => contact.UserId === userId);
|
||||
if (!known) return false;
|
||||
|
||||
if (state === "user_invisible" && userId !== accountId) return false;
|
||||
if (userId === accountId && !privateState) return false;
|
||||
|
||||
presenceRef.current.set(userId, state);
|
||||
const current = storageRef.current[userId];
|
||||
if (current) storageRef.current[userId] = mergeUserPresence(current);
|
||||
setCacheVersion((version) => version + 1);
|
||||
return true;
|
||||
},
|
||||
[accountId, mergeUserPresence],
|
||||
);
|
||||
|
||||
const removePresence = useCallback((userId: number) => {
|
||||
presenceRef.current.delete(userId);
|
||||
initialStatesRef.current.delete(userId);
|
||||
delete checkedAtRef.current[userId];
|
||||
setCacheVersion((version) => version + 1);
|
||||
}, []);
|
||||
|
||||
const handleStatePush = useCallback(
|
||||
async (message: ProtocolMessage) => {
|
||||
if (!accountId || !sessionId) return;
|
||||
const data = message.data as Record<string, unknown>;
|
||||
if (message.type === "GetStates") {
|
||||
if (Number(data.SessionId) !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(data.MissingUserIds)) {
|
||||
for (const userId of data.MissingUserIds) {
|
||||
if (typeof userId === "number") removePresence(userId);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(data.UserStates)) return;
|
||||
for (const entry of data.UserStates) {
|
||||
const parsed = userStateEntrySchema.safeParse(entry);
|
||||
if (!parsed.success) continue;
|
||||
if (parsed.data.UserId === accountId) continue;
|
||||
initialStatesRef.current.set(
|
||||
parsed.data.UserId,
|
||||
parsed.data.UserState,
|
||||
);
|
||||
if (applyUserState(parsed.data.UserId, parsed.data.UserState)) {
|
||||
initialStatesRef.current.delete(parsed.data.UserId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type !== "ClientChanged") return;
|
||||
if (Number(data.SessionId) !== sessionId) return;
|
||||
const parsed = schemas.ClientChanged.response.safeParse(data);
|
||||
if (!parsed.success) return;
|
||||
applyUserState(parsed.data.UserId, parsed.data.UserState, true);
|
||||
},
|
||||
[accountId, applyUserState, removePresence, sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load("user_id").then((accountId) => {
|
||||
setAccountId(accountId);
|
||||
});
|
||||
void load("session_id").then((value) => {
|
||||
setSessionId(value);
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId || !sessionId) return;
|
||||
return subscribePush(handleStatePush);
|
||||
}, [accountId, handleStatePush, sessionId, subscribePush]);
|
||||
|
||||
/**
|
||||
* Executes get.
|
||||
* @param userId Parameter userId.
|
||||
|
|
@ -73,17 +173,20 @@ export default function UserProvider(props: { children: ReactNode }) {
|
|||
schemas.GetUserData.response.safeParse(cachedValue);
|
||||
const cached = cachedResult.success ? cachedResult.data : undefined;
|
||||
if (cached) {
|
||||
storageRef.current[userId] = cached;
|
||||
durableProfileRef.current[userId] = cached;
|
||||
const merged = mergeUserPresence(cached);
|
||||
storageRef.current[userId] = merged;
|
||||
const checkedAt = checkedAtRef.current[userId];
|
||||
if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) {
|
||||
checkedAtRef.current[userId] = Date.now();
|
||||
return cached;
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const userData = await send("GetUserData", { UserId: userId });
|
||||
if (userData.type === "ErrorNotFound" || userData.data.UserId === 0) {
|
||||
delete storageRef.current[userId];
|
||||
delete durableProfileRef.current[userId];
|
||||
delete checkedAtRef.current[userId];
|
||||
await cache.profiles.delete(userId);
|
||||
throw new Error("GetUserData failed: user not found");
|
||||
|
|
@ -91,14 +194,15 @@ export default function UserProvider(props: { children: ReactNode }) {
|
|||
if (userData.type.startsWith("Error")) {
|
||||
throw new Error(`GetUserData failed: ${userData.type}`);
|
||||
}
|
||||
const user = userData.data;
|
||||
const user = mergeUserPresence(userData.data);
|
||||
durableProfileRef.current[userId] = userData.data;
|
||||
storageRef.current[userId] = user;
|
||||
checkedAtRef.current[userId] = Date.now();
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (cached && storageRef.current[userId]) {
|
||||
checkedAtRef.current[userId] = Date.now();
|
||||
return cached;
|
||||
return storageRef.current[userId];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -112,23 +216,48 @@ export default function UserProvider(props: { children: ReactNode }) {
|
|||
delete pendingRef.current[userId];
|
||||
}
|
||||
},
|
||||
[accountId, cacheVersion, send],
|
||||
[accountId, cacheVersion, mergeUserPresence, send],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (user: User) => {
|
||||
await createCache(String(accountId ?? user.UserId)).profiles.put(user);
|
||||
storageRef.current[user.UserId] = user;
|
||||
const durableProfile = {
|
||||
...user,
|
||||
OnlineStatus:
|
||||
durableProfileRef.current[user.UserId]?.OnlineStatus ??
|
||||
user.OnlineStatus,
|
||||
} as User;
|
||||
await createCache(String(accountId ?? user.UserId)).profiles.put(
|
||||
durableProfile,
|
||||
);
|
||||
durableProfileRef.current[user.UserId] = durableProfile;
|
||||
storageRef.current[user.UserId] = mergeUserPresence(durableProfile);
|
||||
checkedAtRef.current[user.UserId] = Date.now();
|
||||
setCacheVersion((version) => version + 1);
|
||||
},
|
||||
[accountId],
|
||||
[accountId, mergeUserPresence],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
for (const contact of contacts) void get(contact.UserId);
|
||||
}, [accountId, contacts, get]);
|
||||
void (async () => {
|
||||
const userIds = [
|
||||
accountId,
|
||||
...contacts.map((contact) => contact.UserId),
|
||||
].filter((userId, index, all) => all.indexOf(userId) === index);
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await get(userId);
|
||||
const state = initialStatesRef.current.get(userId);
|
||||
if (state && applyUserState(userId, state)) {
|
||||
initialStatesRef.current.delete(userId);
|
||||
}
|
||||
} catch {
|
||||
// The normal user loading path reports profile failures to its caller.
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [accountId, applyUserState, contacts, get]);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ get, update }}>
|
||||
|
|
|
|||
3548
pnpm-lock.yaml
generated
3548
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue