Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
98ae590a0f
fix(nix): tauri dev shell was missing libsoup3 2026-09-01 10:48:32 +02:00
bbf53e2b30
fix(tauri): build failure
All checks were successful
/ build-web (push) Successful in 4m37s
/ build-desktop (linux) (push) Successful in 8m28s
/ build-mobile (push) Successful in 19m4s
/ release (push) Successful in 2m4s
2026-09-01 08:49:25 +02:00
e0c2331af3
perf(react): remove lazy imports again
Some checks failed
/ build-web (push) Successful in 4m52s
/ build-mobile (push) Failing after 8m36s
/ build-desktop (linux) (push) Successful in 7m46s
/ release (push) Has been skipped
2026-08-31 23:09:02 +02:00
d88c554a2d
fix(storage): circual deps
Some checks failed
/ build-desktop (linux) (push) Failing after 1m57s
/ build-web (push) Failing after 2m1s
/ build-mobile (push) Failing after 3m58s
/ release (push) Has been skipped
2026-08-31 22:05:28 +02:00
0c936718bc
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev 2026-08-31 20:48:34 +02:00
85921a0a08
perf(vite): add dynamic imports 2026-08-31 20:48:32 +02:00
Alex Emmet
6e31989262
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Failing after 2m6s
/ build-desktop (linux) (push) Failing after 2m10s
/ build-mobile (push) Failing after 3m46s
/ release (push) Has been skipped
2026-08-30 21:28:51 +02:00
Alex Emmet
c155bbb534
[Fix] Durability 2026-08-30 21:28:36 +02:00
78e15f6218
chore(identity): remove dumb test
Some checks failed
/ build-web (push) Successful in 3m53s
/ build-mobile (push) Failing after 7m48s
/ build-desktop (linux) (push) Successful in 7m56s
/ release (push) Has been skipped
2026-08-30 19:34:07 +02:00
042141c781
feat(user): rename to identity
Some checks failed
/ build-web (push) Successful in 4m1s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
feat(identity): add getIota function with caching
2026-08-30 19:26:42 +02:00
Alex Emmet
c386e8d5cb
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Failing after 1m45s
/ build-desktop (linux) (push) Failing after 1m49s
/ build-mobile (push) Failing after 3m34s
/ release (push) Has been skipped
2026-08-30 18:26:42 +02:00
Alex Emmet
74fb46990e
[Fix] Connections 2026-08-30 18:26:32 +02:00
64 changed files with 991 additions and 510 deletions

View file

@ -92,6 +92,11 @@ function emitIcons(): Plugin {
attrs: { name: "apple-mobile-web-app-capable", content: "yes" },
injectTo: "head",
},
{
tag: "meta",
attrs: { name: "mobile-web-app-capable", content: "yes" },
injectTo: "head",
},
{
tag: "meta",
attrs: {

View file

@ -20,6 +20,7 @@ pub fn run() {
accessibility_backend::accessibility_get_initial_scale,
accessibility_backend::accessibility_set_initial_scale,
mtp_backend::mtp_request,
mtp_backend::mtp_send_sealed_relay,
mtp_backend::mtp_status,
mtp_backend::mtp_store_credentials,
mtp_backend::mtp_has_credentials,

View file

@ -9,9 +9,12 @@ use base64::{
Engine as _,
};
use mtp::client::{ClientConfig, MTPClient, MTPConnection, Policy, SendMode};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataValue, SealedRelayBuilder, TypeMap,
};
use mtp::crypto::{
derive_encryption_key, AeadDecrypt, ChaCha20Poly1305, HybridKem, Keyring, PublicKeyBundle,
derive_encryption_key, AeadDecrypt, ChaCha20Poly1305, DualSigner, HybridKem, Keyring,
PublicKeyBundle,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@ -28,6 +31,9 @@ const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha2
const INITIAL_SYNC_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_BUFFERED_INITIAL_FRAMES: usize = 1_000;
const NOTIFICATION_QUEUE_CAPACITY: usize = 32;
const ROUTE_TARGET_ID_MASK: u64 = (1_u64 << 48) - 1;
const USER_ROUTE_TARGET_KIND: u64 = 0x4000_0000_0000_0000;
const IOTA_ROUTE_TARGET_KIND: u64 = 0x8000_0000_0000_0000;
#[cfg(target_os = "android")]
const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\
MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\
@ -79,6 +85,44 @@ pub struct MtpSnapshot {
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EncodedKeyMaterial {
value: String,
encoding: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum RelayTargetDto {
User { id: u64 },
Iota { id: u64 },
}
impl RelayTargetDto {
fn wire_id(self) -> Result<u64, String> {
let (kind, id) = match self {
Self::User { id } => (USER_ROUTE_TARGET_KIND, id),
Self::Iota { id } => (IOTA_ROUTE_TARGET_KIND, id),
};
if id == 0 || id > ROUTE_TARGET_ID_MASK {
return Err("relay target ID must be a non-zero 48-bit integer".into());
}
Ok(kind | id)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SealedRelayRequest {
type_name: String,
data: Value,
next_hop: RelayTargetDto,
final_recipient_id: u64,
metadata_recipients: Vec<EncodedKeyMaterial>,
content_recipients: Vec<EncodedKeyMaterial>,
}
#[derive(Clone, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
enum MtpEvent {
@ -695,6 +739,88 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String
))
}
fn decode_key_material(value: EncodedKeyMaterial) -> Result<PublicKeyBundle, String> {
let bytes = match value.encoding.as_str() {
"base64" => decode_browser_base64(&value.value)?,
"hex" => decode_sdk_bytes(&value.value)?,
encoding => return Err(format!("unsupported key material encoding: {encoding}")),
};
let key = PublicKeyBundle::from_bytes(&bytes)
.map_err(|error| format!("invalid relay recipient public key: {error}"))?;
key.validate()
.map_err(|error| format!("invalid relay recipient public key: {error}"))?;
Ok(key)
}
async fn send_sealed_relay(request: SealedRelayRequest) -> Result<Value, String> {
if request.metadata_recipients.is_empty() || request.content_recipients.is_empty() {
return Err("sealed relay requires metadata and content recipients".into());
}
if request.final_recipient_id == 0 {
return Err("sealed relay final recipient ID must be non-zero".into());
}
let manager = manager();
let connection = manager
.connection
.read()
.map_err(|_| "MTP connection lock is unavailable")?
.clone()
.ok_or_else(|| "MTP is not connected".to_string())?;
let config = manager
.config
.read()
.map_err(|_| "MTP configuration lock is unavailable")?
.clone()
.ok_or_else(|| "MTP credentials are unavailable".to_string())?;
let keyring_bytes = decode_browser_base64(&config.keyring)?;
let keyring = Keyring::from_bytes(&keyring_bytes)
.map_err(|error| format!("invalid MTP keyring: {error}"))?;
let signer = DualSigner::new(
&keyring.sig_cl_secret_key,
&keyring.sig_pq_secret_key,
&keyring.sig_pq_public_key,
)
.map_err(|error| format!("invalid MTP signing key: {error}"))?;
let next_hop_id = request.next_hop.wire_id()?;
let type_map = TypeMap::latest();
let content = json_to_frame(&request.type_name, request.data, 1)?.into_payload();
let metadata_recipients = request
.metadata_recipients
.into_iter()
.map(decode_key_material)
.collect::<Result<Vec<_>, _>>()?;
let content_recipients = request
.content_recipients
.into_iter()
.map(decode_key_material)
.collect::<Result<Vec<_>, _>>()?;
let created_at = mtp::common::unix_time_millis()
.map_err(|error| format!("failed to get relay timestamp: {error}"))?;
let request_id = connection.next_request_id().await?;
let message_id = format!("tensamin-relay-{created_at}-{request_id}");
let frame = SealedRelayBuilder::new(
request.type_name,
content,
config.user_id,
request.final_recipient_id,
next_hop_id,
&signer,
)
.message_id(message_id)
.created_at(created_at)
.metadata_recipients(metadata_recipients)
.content_recipients(content_recipients)
.type_map(&type_map)
.build()
.map_err(|error| format!("failed to build sealed relay: {error}"))?;
let response = connection
.mtp
.request(&frame, None)
.await
.map_err(|error| format!("sealed relay request failed: {error}"))?;
frame_to_json(&response)
}
async fn handle_push(
generation: u64,
notification_tx: &mpsc::Sender<CommunicationValue>,
@ -1047,7 +1173,8 @@ mod tests {
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,
jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RelayTargetDto,
RequestIdAllocator,
};
#[test]
@ -1070,6 +1197,20 @@ mod tests {
assert_eq!(ids.next().unwrap(), 2);
}
#[test]
fn iota_relay_target_encodes_its_namespace() {
assert_eq!(
RelayTargetDto::Iota { id: 42 }.wire_id().unwrap(),
0x8000_0000_0000_002a
);
}
#[test]
fn relay_target_rejects_raw_or_out_of_range_ids() {
assert!(RelayTargetDto::Iota { id: 0 }.wire_id().is_err());
assert!(RelayTargetDto::User { id: 1_u64 << 48 }.wire_id().is_err());
}
#[test]
fn retry_jitter_stays_within_policy_bounds() {
let delay = jittered_retry_delay(std::time::Duration::from_secs(10));
@ -1194,6 +1335,11 @@ pub async fn mtp_request(type_name: String, data: Value) -> Result<Value, String
manager().request(&type_name, data).await
}
#[tauri::command]
pub async fn mtp_send_sealed_relay(request: SealedRelayRequest) -> Result<Value, String> {
send_sealed_relay(request).await
}
#[tauri::command]
pub fn mtp_status() -> MtpSnapshot {
manager().snapshot()

1
apps/web/.gitignore vendored
View file

@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.mtp
dist
dist-ssr
*.local

View file

@ -24,6 +24,7 @@
"@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/notifications": "workspace:*",
"@tensamin/onboarding": "workspace:*",
@ -32,7 +33,7 @@
"@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*",
"@tensamin/tauth": "workspace:*",
"@tensamin/user": "workspace:*",
"@tensamin/identity": "workspace:*",
"decimal.js-light": "^2.5.1",
"eventemitter3": "^5.0.4",
"lucide-react": "^1.29.0",

View file

@ -0,0 +1,8 @@
import { useCall } from "@tensamin/call/state";
import Popout from "@tensamin/call/popout";
export default function CallPopout() {
const active = useCall((state) => state.state !== "closed");
return active ? <Popout /> : null;
}

View file

@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { useTheme } from "@methanium/ui";
import { useIsSpeaking } from "@tensamin/call/speakingState";
import { useCall, useInitializeCall } from "@tensamin/call/store";
import { useStorage } from "@tensamin/storage/context";
function createCallTrayIcon(color: string, speaking: boolean) {
const canvas = document.createElement("canvas");
canvas.width = 32;
canvas.height = 32;
const context = canvas.getContext("2d");
if (!context) return undefined;
context.globalAlpha = speaking ? 1 : 0.55;
context.fillStyle = color;
context.beginPath();
context.arc(16, 16, 13, 0, Math.PI * 2);
context.fill();
if (speaking) {
context.globalAlpha = 0.3;
context.fillStyle = "#ffffff";
context.fill();
}
return canvas.toDataURL("image/png");
}
export default function CallRuntimeInit() {
const callInvitePopup = useInitializeCall();
const { load } = useStorage();
const {
themeColor,
themePalette,
themePrimaryColor,
themePolarity,
themeTint,
themeCustomCss,
} = useTheme();
const [localUserId, setLocalUserId] = useState(-1);
const [primaryColor, setPrimaryColor] = useState("");
const inCall = useCall((state) => state.state === "open");
const speaking = useIsSpeaking(localUserId);
useEffect(() => {
let active = true;
load("user_id").then((userId) => {
if (active) setLocalUserId(userId);
});
return () => {
active = false;
};
}, [load]);
useEffect(() => {
const frame = requestAnimationFrame(() => {
setPrimaryColor(
getComputedStyle(document.documentElement)
.getPropertyValue("--primary")
.trim(),
);
});
return () => cancelAnimationFrame(frame);
}, [
themeColor,
themeCustomCss,
themePalette,
themePolarity,
themePrimaryColor,
themeTint,
]);
useEffect(() => {
const iconDataUrl = primaryColor
? createCallTrayIcon(primaryColor, speaking)
: undefined;
void window.tensaminDesktop?.call
?.setStatus?.({
inCall,
speaking: inCall && speaking,
iconDataUrl: inCall ? iconDataUrl : undefined,
})
.catch((error: unknown) => {
console.error("Failed to update desktop call status", error);
});
}, [inCall, primaryColor, speaking]);
return callInvitePopup;
}

View file

@ -0,0 +1,8 @@
import { useCall } from "@tensamin/call/state";
import SidebarBox from "@tensamin/call/sidebarBox";
export default function CallSidebarBox() {
const active = useCall((state) => state.state !== "closed");
return active ? <SidebarBox /> : null;
}

View file

@ -1,4 +1,4 @@
import type { User } from "@tensamin/user/context";
import type { User } from "@tensamin/identity/context";
import {
Avatar,
AvatarImage,

View file

@ -1,6 +1,6 @@
import type { User } from "@tensamin/user/context";
import type { User } from "@tensamin/identity/context";
import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui";
import { Text } from "@methanium/ui/markdown";
import Text from "@tensamin/markdown/text";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";

View file

@ -14,8 +14,8 @@ import {
User,
} from "lucide-react";
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { joinCall, useCall } from "@tensamin/call/store";
import Wrapper from "@tensamin/user/wrapper";
import { useCall } from "@tensamin/call/state";
import Wrapper from "@tensamin/identity/wrapper";
import { Skeleton } from "@methanium/ui";
import {
Select,
@ -27,7 +27,8 @@ import { displayCallId } from "@tensamin/call/utils";
import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@methanium/ui";
import { WindowControls as Controls } from "@methanium/ui";
import { useSession } from "@tensamin/storage/session";
import { useSession } from "@tensamin/identity/session";
import { joinCall } from "@tensamin/call/store";
import Profile from "./modals/profile";
export default function Navbar({ forMobile }: { forMobile: boolean }) {
@ -139,9 +140,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
{currentCalls.length === 0 ? (
<Button
disabled={callState !== "closed"}
onClick={() => {
void joinCall(id);
}}
onClick={() => void joinCall(id)}
className="w-9 h-9! aspect-square rounded-lg"
variant="outline"
>
@ -181,9 +180,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<SelectItem
value={call.CallId}
key={call.CallId}
onSelect={() => {
void joinCall(id, call.CallSecret, call.CallId);
}}
onSelect={() =>
void joinCall(id, call.CallSecret, call.CallId)
}
>
{displayCallId(call.CallId)}
</SelectItem>

View file

@ -0,0 +1,15 @@
import { Loader2 } from "lucide-react";
export default function RouteLoader() {
return (
<div
className="flex h-full min-h-40 w-full items-center justify-center bg-background"
role="status"
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span>Loading...</span>
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
import Wrapper from "@tensamin/user/wrapper";
import Wrapper from "@tensamin/identity/wrapper";
import { Basic, Loading } from "./modals/basic";
import CallSidebarBox from "./callSidebarBox";
import List from "@/features/conversation/list/body";
import {
@ -30,11 +31,10 @@ import {
import { useIsMobile } from "@methanium/ui";
import { MobileNavbar } from "./navbar";
import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
import { Ellipsis, Check } from "lucide-react";
import { useState } from "react";
import { useUser, type User } from "@tensamin/user/context";
import { useUser, type User } from "@tensamin/identity/context";
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp";
import {
@ -285,7 +285,7 @@ export default function Sidebar() {
)}
{!isMobile && (
<SidebarFooter>
<SidebarBox />
<CallSidebarBox />
</SidebarFooter>
)}
</>

View file

@ -5,7 +5,7 @@ import Switch from "./switch";
import ConversationModal from "../modal/conversation";
import CommunityModal from "../modal/community";
import { Loader2 } from "lucide-react";
import { useSession } from "@tensamin/storage/session";
import { useSession } from "@tensamin/identity/session";
export default function List() {
const [category, setCategory] = useState<"conversations" | "communities">(

View file

@ -1,5 +1,5 @@
import { Basic, Loading } from "@/components/modals/basic";
import Wrapper from "@tensamin/user/wrapper";
import Wrapper from "@tensamin/identity/wrapper";
import {
ContextMenu,
ContextMenuContent,

View file

@ -12,26 +12,17 @@ import "./index.css";
import "@methanium/ui/index.css";
import NotFound from "@/routes/404";
import AppLayout from "@/routes/app/layout";
import { createSettingsRoute } from "@tensamin/settings";
import OnboardingGate from "@tensamin/onboarding";
import RouteLoader from "@/components/routeLoader";
import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen";
import CallScreen from "@tensamin/call/screen";
import AppShell from "@/routes/app/shell";
import Login from "@/routes/screens/login";
import ChatContext from "@tensamin/chat/context";
import { useCall, useInitializeCall } from "@tensamin/call/store";
import { useIsSpeaking } from "@tensamin/call/speakingState";
import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime";
import { createSettingsRoute } from "@tensamin/settings";
import ChatScreen from "@tensamin/chat/screen";
import CallScreen from "@tensamin/call/screen";
import TAuthWrapper from "@tensamin/tauth/context";
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
import PwaRuntime from "@tensamin/pwa/runtime";
import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui";
import z from "zod";
@ -39,13 +30,8 @@ import z from "zod";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Storage from "@tensamin/storage/context";
import Session from "@tensamin/storage/session";
import Crypto from "@tensamin/crypto/context";
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import { log } from "@tensamin/shared/log";
import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
@ -108,7 +94,7 @@ function LoginWrapper({ children }: { children: ReactNode }) {
}, [load, location.pathname, navigate, secureStorage]);
if (loggedIn !== true && location.pathname !== "/login") {
return null;
return <RouteLoader />;
}
return children;
@ -276,155 +262,6 @@ function RootShell() {
);
}
function AppShell() {
return (
<OnboardingGate>
<Crypto>
<DesktopMediaProvider>
<MTPProvider>
<CacheSync />
<DeeplinkNavigator />
<Session>
<UserProvider>
<CallInit />
<TAuthWrapper>
<AppLayout>
<ChatContext>
<NotificationsProvider>
<Outlet />
</NotificationsProvider>
</ChatContext>
</AppLayout>
</TAuthWrapper>
</UserProvider>
</Session>
</MTPProvider>
</DesktopMediaProvider>
</Crypto>
</OnboardingGate>
);
}
function DeeplinkNavigator() {
const { deeplinks } = useDeeplinks();
const navigate = useNavigate();
const handledCount = useRef(0);
useEffect(() => {
const links = deeplinks.slice(handledCount.current);
handledCount.current = deeplinks.length;
for (const link of links) {
try {
const url = new URL(link);
const id = Number(url.searchParams.get("id"));
if (
url.protocol === "tensamin:" &&
url.hostname === "chat" &&
Number.isSafeInteger(id) &&
id > 0
) {
void navigate({ to: "/chat", search: { id } });
}
} catch {
// Ignore malformed URLs delivered by the platform.
}
}
}, [deeplinks, navigate]);
return null;
}
function createCallTrayIcon(color: string, speaking: boolean) {
const canvas = document.createElement("canvas");
canvas.width = 32;
canvas.height = 32;
const context = canvas.getContext("2d");
if (!context) return undefined;
context.globalAlpha = speaking ? 1 : 0.55;
context.fillStyle = color;
context.beginPath();
context.arc(16, 16, 13, 0, Math.PI * 2);
context.fill();
if (speaking) {
context.globalAlpha = 0.3;
context.fillStyle = "#ffffff";
context.fill();
}
return canvas.toDataURL("image/png");
}
function CallInit() {
const callInvitePopup = useInitializeCall();
const { load } = useStorage();
const {
themeColor,
themePalette,
themePrimaryColor,
themePolarity,
themeTint,
themeCustomCss,
} = useTheme();
const [localUserId, setLocalUserId] = useState(-1);
const [primaryColor, setPrimaryColor] = useState("");
const inCall = useCall((state) => state.state === "open");
const speaking = useIsSpeaking(localUserId);
useEffect(() => {
let active = true;
load("user_id").then((userId) => {
if (active) setLocalUserId(userId);
});
return () => {
active = false;
};
}, [load]);
useEffect(() => {
const frame = requestAnimationFrame(() => {
setPrimaryColor(
getComputedStyle(document.documentElement)
.getPropertyValue("--primary")
.trim(),
);
});
return () => cancelAnimationFrame(frame);
}, [
themeColor,
themeCustomCss,
themePalette,
themePolarity,
themePrimaryColor,
themeTint,
]);
useEffect(() => {
const iconDataUrl = primaryColor
? createCallTrayIcon(primaryColor, speaking)
: undefined;
void window.tensaminDesktop?.call
?.setStatus?.({
inCall,
speaking: inCall && speaking,
iconDataUrl: inCall ? iconDataUrl : undefined,
})
.catch((error: unknown) => {
console.error("Failed to update desktop call status", error);
});
}, [inCall, primaryColor, speaking]);
return callInvitePopup;
}
const rootRoute = createRootRoute({
component: RootShell,
errorComponent: ({ error }: { error: Error }) => (

View file

@ -11,13 +11,16 @@ import {
useIsMobile,
} from "@methanium/ui";
import z from "zod";
import { useMTP } from "@tensamin/mtp";
import { MTPProtocolError } from "mtp";
import { RelayRejectedError, requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { log } from "@tensamin/shared/log";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session";
import { useSession } from "@tensamin/identity/session";
import { useStorage } from "@tensamin/storage/context";
import { ShieldAlert } from "lucide-react";
import { useUser } from "@tensamin/identity/context";
// The page
export default function Page() {
@ -51,8 +54,10 @@ export default function Page() {
// Add Conversation Button Component
function AddConversationButton() {
const { send } = useMTP();
const { send, sendSealedRelay } = useMTP();
const { contacts, insertContact } = useSession();
const { load } = useStorage();
const { getIota } = useUser();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -64,7 +69,7 @@ function AddConversationButton() {
// username check
const schema = z
.string()
.min(1, "Username is too short")
.regex(/^[a-z0-9]+$/, "Username must use lowercase letters and numbers")
.max(15, "Username is too long");
const result = schema.safeParse(username?.toLowerCase().trim());
@ -74,22 +79,27 @@ function AddConversationButton() {
return;
}
// user existence check
const user = await send("GetUserData", {
Username: result.data,
})
.then((data) => {
if (data.type === "ErrorNotFound" || data.data.UserId === 0) {
throw new Error();
let user;
try {
user = await send("GetUserData", { Username: result.data });
} catch (error) {
if (error instanceof MTPProtocolError && error.type === "ErrorNotFound") {
setError("User not found");
} else if (error instanceof MTPProtocolError) {
setError(`User lookup failed: ${error.type}`);
} else {
setError("User lookup failed: connection error");
}
return data;
})
.catch(() => {
return;
}
if (user.type === "ErrorNotFound") {
setError("User not found");
return;
});
if (!user) return;
}
if (user.type !== "GetUserData") {
setError(`User lookup failed: ${user.type}`);
return;
}
// alrady added check
if (contacts.some((contact) => contact.UserId === user.data.UserId)) {
@ -100,25 +110,36 @@ function AddConversationButton() {
// add the conv
const timeout = setTimeout(() => setLoading(true), 500);
send("AddConversation", {
ChatPartnerId: user.data.UserId,
})
.then(() => {
try {
const userId = await load("user_id");
const iota = await getIota(userId);
const response = await sendSealedRelay(
"AddConversation",
{ ChatPartnerId: user.data.UserId },
{
nextHop: { kind: "iota", id: iota.IotaId },
finalRecipientId: userId,
metadataRecipients: [{ value: iota.PublicKey, encoding: "base64" }],
contentRecipients: [{ value: iota.PublicKey, encoding: "base64" }],
},
);
requireRelaySuccess(response);
insertContact(user.data.UserId);
setOpen(false);
})
.catch((error) => {
if (String(error).includes("error_not_found")) {
setError("User not found");
return;
} catch (error) {
if (error instanceof RelayRejectedError) {
setError(
`Could not route the request to your Iota: ${error.responseType}`,
);
} else {
log(1, "mtp", "red", "Add conversation failed", error);
const detail = error instanceof Error ? error.message : "unknown error";
setError(`Add conversation failed: ${detail}`);
}
setError(String(error));
})
.finally(() => {
} finally {
clearTimeout(timeout);
setLoading(false);
});
}
}
return (

View file

@ -2,8 +2,8 @@ import { type ReactNode } from "react";
import Sidebar from "@/components/sidebar";
import Navbar, { MobileNavbar } from "@/components/navbar";
import CallPopout from "@/components/callPopout";
import { useShowMobileNavbar } from "./useShowMobileNavbar";
import CallPopout from "@tensamin/call/popout";
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";

View file

@ -0,0 +1,75 @@
import { useEffect, useRef } from "react";
import { Outlet, useNavigate } from "@tanstack/react-router";
import AppLayout from "./layout";
import CallInit from "@/components/callRuntimeInit";
import CacheSync from "@tensamin/cache/sync";
import ChatContext from "@tensamin/chat/context";
import Crypto from "@tensamin/crypto/context";
import UserProvider from "@tensamin/identity/context";
import { Provider as MTPProvider } from "@tensamin/mtp";
import NotificationsProvider from "@tensamin/notifications/context";
import OnboardingGate from "@tensamin/onboarding";
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import Session from "@tensamin/identity/session";
import { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import TAuthWrapper from "@tensamin/tauth/context";
function DeeplinkNavigator() {
const { deeplinks } = useDeeplinks();
const navigate = useNavigate();
const handledCount = useRef(0);
useEffect(() => {
const links = deeplinks.slice(handledCount.current);
handledCount.current = deeplinks.length;
for (const link of links) {
try {
const url = new URL(link);
const id = Number(url.searchParams.get("id"));
if (
url.protocol === "tensamin:" &&
url.hostname === "chat" &&
Number.isSafeInteger(id) &&
id > 0
) {
void navigate({ to: "/chat", search: { id } });
}
} catch {
// Ignore malformed URLs delivered by the platform.
}
}
}, [deeplinks, navigate]);
return null;
}
export default function AppShell() {
return (
<OnboardingGate>
<Crypto>
<DesktopMediaProvider>
<MTPProvider>
<CacheSync />
<DeeplinkNavigator />
<Session>
<UserProvider>
<CallInit />
<TAuthWrapper>
<AppLayout>
<ChatContext>
<NotificationsProvider>
<Outlet />
</NotificationsProvider>
</ChatContext>
</AppLayout>
</TAuthWrapper>
</UserProvider>
</Session>
</MTPProvider>
</DesktopMediaProvider>
</Crypto>
</OnboardingGate>
);
}

View file

@ -65,7 +65,7 @@ export default defineConfig({
"@tensamin/settings",
"@tensamin/storage",
"@tensamin/mtp",
"@tensamin/user",
"@tensamin/identity",
"@tensamin/tauri",
"@tensamin/chat",
],
@ -118,18 +118,50 @@ export default defineConfig({
"@tensamin/storage/context",
"@tensamin/tauri",
"@tensamin/tauth",
"@tensamin/user",
"@tensamin/identity",
],
},
build: {
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
chunkSizeWarningLimit: 550,
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{
name: "codemirror-core",
test: /node_modules\/.pnpm\/@codemirror\+(?:state|view|language)@/,
priority: 20,
},
{
name: "codemirror-editor",
test: /node_modules\/.pnpm\/@codemirror\+(?:autocomplete|commands|lang-markdown)@|node_modules\/.pnpm\/codemirror@/,
priority: 20,
},
{
name: "livekit-client",
test: /node_modules\/.pnpm\/livekit-client@/,
priority: 20,
},
{
name: "livekit-support",
test: /node_modules\/.pnpm\/@livekit\+/,
priority: 20,
},
],
},
},
},
},
plugins: [
...tensaminPwa(),
methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
mtp({
typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml"),
outDir: ".mtp",
}),
{
name: "workspace-realpath-resolution",
enforce: "post",

View file

@ -372,6 +372,7 @@
git
jq
curl
libsoup_3
]
++ [
android.androidsdk

View file

@ -45,7 +45,7 @@
},
"dependencies": {
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz",
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz",
"mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz",
"sonner": "^2.0.8"
}
}

View file

@ -5,6 +5,7 @@
"type": "module",
"exports": {
"./store": "./src/store.tsx",
"./state": "./src/state.ts",
"./speakingState": "./src/speakingState.ts",
"./screen": "./src/screen.tsx",
"./utils": "./src/utils.ts",
@ -24,7 +25,7 @@
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/user": "workspace:*",
"@tensamin/identity": "workspace:*",
"deepfilternet3-noise-filter": "1.3.0",
"livekit-client": "^2.21.0",
"lucide-react": "^1.29.0",

View file

@ -8,11 +8,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@methanium/ui";
import Wrapper from "@tensamin/user/wrapper";
import Wrapper from "@tensamin/identity/wrapper";
import { Mail } from "lucide-react";
import { sendCallInvite, useCall } from "../../store";
import { log, toast } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session";
import { useSession } from "@tensamin/identity/session";
export default function InviteButton({
className,

View file

@ -6,7 +6,7 @@ import {
Dialog,
DialogContent,
} from "@methanium/ui";
import Wrapper from "@tensamin/user/wrapper";
import Wrapper from "@tensamin/identity/wrapper";
import { PhoneIncoming, X } from "lucide-react";
export default function InvitePopup({

View file

@ -16,7 +16,7 @@ import {
} from "../../store";
import { Track, type Participant } from "livekit-client";
import { useEffect, useRef, useState } from "react";
import { type SelectedUser, useUserFields } from "@tensamin/user/context";
import { type SelectedUser, useUserFields } from "@tensamin/identity/context";
import { useIsSpeaking } from "../../speakingState";
import VideoViewer from "../videoViewer";
import { HeadphoneOff, MicOff, Monitor, Plus, Shield } from "lucide-react";

View file

@ -14,7 +14,7 @@ import {
useSidebar,
} from "@methanium/ui";
import { ScreenShareOff } from "lucide-react";
import { useUserFields } from "@tensamin/user/context";
import { useUserFields } from "@tensamin/identity/context";
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
import { getAverageImageColor } from "./modals/base";

View file

@ -1,4 +1,4 @@
import { useUserFields } from "@tensamin/user/context";
import { useUserFields } from "@tensamin/identity/context";
import { useCall, getRoom } from "../store";
import { useEffect, useState } from "react";
import { useStorage } from "@tensamin/storage/context";

View file

@ -0,0 +1,93 @@
import type { RefObject } from "react";
import { create } from "zustand";
import type { LocalMediaShareSession } from "./mediaShare/controller";
export type CallView = "preview" | "focused" | "grid";
export type WrappedCallSecret = {
secretId: string;
versionNumber: number;
encryptedSecret: Uint8Array;
kemCiphertext: Uint8Array;
wrappingScheme: string;
};
export type CallRuntime = {
navigate: (options: {
to: string;
search?: Record<string, unknown>;
}) => Promise<void>;
send: (
type: string,
data: Record<string, unknown>,
) => Promise<{ data: unknown }>;
load: (key: string) => Promise<unknown>;
getPublicKey: (userId: number) => Promise<string>;
};
export const useCall = create<{
state: "closed" | "closing" | "connecting" | "open" | "encrypting";
view: CallView;
invitedUserId: number | null;
callId: string | null;
incomingCallInvite: {
callId: string;
callSecret: WrappedCallSecret;
senderId: number;
} | null;
callSecret: string | null;
livekitToken: string | null;
currentCallData: { UserIds: number[]; exists: boolean } | null;
deaf: boolean;
micEnabled: boolean;
cameraEnabled: boolean;
screenShareEnabled: boolean;
screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
disabledCameraParticipantIds: number[];
focusedParticipantId: number | null;
focusedParticipantType: "user" | "stream" | null;
usersInFocusedViewHidden: boolean;
watchedStreamParticipantIds: number[];
pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[];
isEncrypted: boolean;
ownCallSecretInvitePending: boolean;
callIsFullscreen: boolean;
callIsPopout: boolean;
layoutVersion: number;
screenRef: RefObject<HTMLDivElement | null> | null;
runtime: CallRuntime | null;
lastFocusedParticipantId: number | null;
}>(() => ({
state: "closed",
view: "preview",
invitedUserId: null,
callId: null,
incomingCallInvite: null,
callSecret: null,
livekitToken: null,
currentCallData: null,
deaf: false,
micEnabled: false,
cameraEnabled: false,
screenShareEnabled: false,
screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
watchedStreamParticipantIds: [],
pendingWatchedParticipantIds: [],
activeScreenShareParticipantIds: [],
isEncrypted: false,
ownCallSecretInvitePending: false,
callIsFullscreen: false,
callIsPopout: false,
layoutVersion: 0,
screenRef: null,
runtime: null,
lastFocusedParticipantId: null,
}));

View file

@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { create } from "zustand";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
@ -13,8 +12,8 @@ import {
wrapCallSecret,
} from "@tensamin/crypto/callSecret";
import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context";
import { useSession } from "@tensamin/identity/session";
import { useUser } from "@tensamin/identity/context";
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
import {
ExternalE2EEKeyProvider,
@ -33,7 +32,6 @@ import {
import z from "zod";
import {
createMediaShareController,
type LocalMediaShareSession,
} from "./mediaShare/controller";
import type { MediaShareRequest } from "./mediaShare";
import {
@ -41,6 +39,14 @@ import {
disposeSpeakingDetector,
} from "./speakingIndicator";
import InvitePopup from "./components/invitePopup";
import {
useCall,
type CallRuntime as Runtime,
type CallView,
type WrappedCallSecret,
} from "./state";
export { useCall } from "./state";
// logging
setLogExtension(
@ -51,19 +57,9 @@ setLogExtension(
getLogger("tensamin"),
);
type CallView = "preview" | "focused" | "grid";
type ProtocolCallSecret = NonNullable<
z.infer<typeof mtp.CallInvite.response>["CallSecret"]
>;
type WrappedCallSecret = {
secretId: string;
versionNumber: number;
encryptedSecret: Uint8Array;
kemCiphertext: Uint8Array;
wrappingScheme: string;
};
type CurrentCallData =
(z.infer<typeof mtp.CallData.response> & { exists: boolean }) | null;
type SendFn = (
type: string,
@ -72,16 +68,6 @@ type SendFn = (
type LoadFn = (key: string) => Promise<unknown>;
type RemoteVideoTrackSelector = Track.Kind | Track.Source;
type Runtime = {
navigate: (options: {
to: string;
search?: Record<string, unknown>;
}) => Promise<void>;
send: SendFn;
load: LoadFn;
getPublicKey: (userId: number) => Promise<string>;
};
let _keyProvider: ExternalE2EEKeyProvider | null = null;
let _e2eeWorker: Worker | null = null;
let _room: Room | null = null;
@ -632,7 +618,7 @@ export function setCallId(callId: string | null) {
// Cache server call metadata used by the preview screen.
export function setCurrentCallData(
currentCallData: CurrentCallData & { exists: boolean },
currentCallData: { UserIds: number[]; exists: boolean },
) {
useCall.setState({ currentCallData });
}
@ -1153,72 +1139,6 @@ async function ensureNoiseFilter(
}
}
export const useCall = create<{
state: "closed" | "closing" | "connecting" | "open" | "encrypting";
view: CallView;
invitedUserId: number | null;
callId: string | null;
incomingCallInvite: {
callId: string;
callSecret: WrappedCallSecret;
senderId: number;
} | null;
callSecret: string | null;
livekitToken: string | null;
currentCallData: CurrentCallData;
deaf: boolean;
micEnabled: boolean;
cameraEnabled: boolean;
screenShareEnabled: boolean;
screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
disabledCameraParticipantIds: number[];
focusedParticipantId: number | null;
focusedParticipantType: "user" | "stream" | null;
usersInFocusedViewHidden: boolean;
watchedStreamParticipantIds: number[];
pendingWatchedParticipantIds: number[];
activeScreenShareParticipantIds: number[];
isEncrypted: boolean;
ownCallSecretInvitePending: boolean;
callIsFullscreen: boolean;
callIsPopout: boolean;
layoutVersion: number;
screenRef: React.RefObject<HTMLDivElement | null> | null;
runtime: Runtime | null;
lastFocusedParticipantId: number | null;
}>(() => ({
state: "closed",
view: "preview",
invitedUserId: null,
callId: null,
incomingCallInvite: null,
callSecret: null,
livekitToken: null,
currentCallData: null,
deaf: false,
micEnabled: false,
cameraEnabled: false,
screenShareEnabled: false,
screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null,
focusedParticipantType: null,
usersInFocusedViewHidden: false,
watchedStreamParticipantIds: [],
pendingWatchedParticipantIds: [],
activeScreenShareParticipantIds: [],
isEncrypted: false,
ownCallSecretInvitePending: false,
callIsFullscreen: false,
callIsPopout: false,
layoutVersion: 0,
screenRef: null,
runtime: null,
lastFocusedParticipantId: null,
}));
// Register app-level call listeners and wire React dependencies into the store.
export function useInitializeCall() {
const navigate = useNavigate();

View file

@ -1,5 +1,5 @@
import { useCall } from "../store";
import { useUserFields } from "@tensamin/user/context";
import { useUserFields } from "@tensamin/identity/context";
const USER_FIELDS = ["Display"] as const;

View file

@ -24,10 +24,11 @@
"@tensamin/cache": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/user": "workspace:*",
"@tensamin/identity": "workspace:*",
"lucide-react": "^1.29.0",
"motion": "^13.0.0",
"react": "^19.2.8",

View file

@ -1,5 +1,5 @@
import { Button } from "@methanium/ui";
import { Emoji } from "@methanium/ui/markdown";
import Emoji from "@tensamin/markdown/emoji";
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
export default function EmojiPicker({

View file

@ -1,6 +1,6 @@
import { useStorage } from "@tensamin/storage/context";
import { useCallback, useEffect, useState } from "react";
import { normalizeShortcode } from "@methanium/ui/markdown";
import { normalizeShortcode } from "@tensamin/markdown/emoji";
const RANKS_CHANGED_EVENT = "tensamin-reaction-ranks-changed";
let recordQueue = Promise.resolve();

View file

@ -1,4 +1,4 @@
import { Input, type InputController } from "@methanium/ui/markdown";
import Input, { type InputController } from "@tensamin/markdown/input";
import {
Card,
CardHeader,
@ -15,18 +15,19 @@ import { Button } from "@methanium/ui";
import { Plus, Laugh, FileVideo, SendHorizonal } from "lucide-react";
import { useChat, useReplyMessage } from "../context";
import { useMTP } from "@tensamin/mtp";
import { requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { cn, useIsMobile } from "@methanium/ui";
import { encryptChatText } from "@tensamin/crypto/chatSecret";
import { useSession } from "@tensamin/storage/session";
import EmojiPicker from "./emoji/emojiPicker";
import { useSession } from "@tensamin/identity/session";
import { useEmojiRanks, useRecordEmojiUse } from "./emoji/emojiRanks";
import GifPicker from "./media/gifPicker";
import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys";
import { editLastMessageHotkey } from "../hotkeys";
import { useUser } from "@tensamin/identity/context";
import EmojiPicker from "./emoji/emojiPicker";
import GifPicker from "./media/gifPicker";
export default function InputComponent({
value,
@ -39,7 +40,8 @@ export default function InputComponent({
}) {
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
const { send } = useMTP();
const { sendSealedRelay } = useMTP();
const { getIota } = useUser();
const {
addLiveMessage,
chatSecret,
@ -174,19 +176,43 @@ export default function InputComponent({
log(3, "chat", "purple", "Content encrypted, sending message...");
send("MessageSend", {
try {
const [ownIota, peerIota] = await Promise.all([
getIota(ownId),
getIota(userId),
]);
const response = await sendSealedRelay(
"MessageSend",
{
Content: encryptedContent,
ReceiverId: userId,
SendTime: time,
...(replyTo && { ReplyId: replyTo }),
}).catch((e) => {
},
{
nextHop: { kind: "iota", id: ownIota.IotaId },
finalRecipientId: userId,
metadataRecipients: [
{ value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.PublicKey, encoding: "base64" },
],
contentRecipients: [
{ value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.PublicKey, encoding: "base64" },
],
},
);
requireRelaySuccess(response);
reference.setMessageState("sent");
} catch (e) {
log(0, "Chat", "red", "Failed to send message", e, {
ReceiverId: userId,
SendTime: time,
});
reference.setFailed(true);
toast("error", "Failed to send message");
});
return;
}
if (replyTo) {
setReplyTo(undefined);

View file

@ -1,4 +1,4 @@
import { Text } from "@methanium/ui/markdown";
import Text from "@tensamin/markdown/text";
import { useStorage } from "@tensamin/storage/context";
import {
Avatar,
@ -24,7 +24,7 @@ import {
} from "lucide-react";
import { useState, useMemo, useEffect, useRef, type WheelEvent } from "react";
import MediaSaveButton from "./mediaSaveButton";
import { useUserFields } from "@tensamin/user/context";
import { useUserFields } from "@tensamin/identity/context";
const zoomLevels = [1, 1.5, 2, 3];

View file

@ -1,7 +1,11 @@
import type { RawMessage } from "../values";
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { type SelectedUser, useUserFields } from "@tensamin/user/context";
import {
type SelectedUser,
useUser,
useUserFields,
} from "@tensamin/identity/context";
import {
Avatar,
@ -15,11 +19,13 @@ import {
import MessageContextMenu from "./messageContextMenu";
import Media from "./media/media";
import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp";
import { requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { getMessage, useChat } from "../context";
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
import { log, toast } from "@tensamin/shared/log";
import { Emoji, Input, normalizeShortcode, Text } from "@methanium/ui/markdown";
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
import Input from "@tensamin/markdown/input";
import Text from "@tensamin/markdown/text";
import { useRecordEmojiUse } from "./emoji/emojiRanks";
import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys";
@ -42,6 +48,7 @@ function MessageComponent({
user: SelectedUser<readonly ["UserId", "Avatar", "Display"]> | null;
}) {
const actuallyFailed =
Boolean(message.ErrorType) ||
(message.failed && message.MessageState === "awaiting") ||
message.decryptionFailed;
@ -65,7 +72,8 @@ function MessageComponent({
// Message states
const { load } = useStorage();
const { send } = useMTP();
const { send, sendSealedRelay } = useMTP();
const { getIota } = useUser();
const [ownId, setOwnId] = useState(0);
useEffect(() => {
load("user_id").then(setOwnId);
@ -73,22 +81,34 @@ function MessageComponent({
const messageStateReadUpdate = useCallback(async () => {
const readConfirmations = await load("settings.read_confirmations");
if (readConfirmations) {
if (!user?.UserId) return;
await send("MessageState", {
ChatPartnerId: user?.UserId,
SendTime: message.SendTime,
MessageState: "read",
});
} else {
await send("MessageState", {
ChatPartnerId: user?.UserId,
SendTime: message.SendTime,
MessageState: "received",
});
}
}, [load, message.SendTime, user?.UserId, send]);
if (!user?.UserId || ownId === 0) return;
const [ownIota, peerIota] = await Promise.all([
getIota(ownId),
getIota(user.UserId),
]);
const response = await sendSealedRelay(
"MessageState",
{
ChatPartnerId: user.UserId,
EventAt: Date.now(),
MessageState: readConfirmations ? "read" : "received",
ReceiverId: user.UserId,
},
{
nextHop: { kind: "iota", id: ownIota.IotaId },
finalRecipientId: user.UserId,
metadataRecipients: [
{ value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.PublicKey, encoding: "base64" },
],
contentRecipients: [
{ value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.PublicKey, encoding: "base64" },
],
},
);
requireRelaySuccess(response);
}, [getIota, load, ownId, sendSealedRelay, user]);
useEffect(() => {
if (message.SenderId === ownId || ownId === 0) return;

View file

@ -44,7 +44,7 @@ import type {
Ref,
} from "react";
import { useChat } from "../context";
import { Emoji } from "@methanium/ui/markdown";
import Emoji from "@tensamin/markdown/emoji";
import EmojiPicker from "./emoji/emojiPicker";
import { getRecentEmojis, useEmojiRanks } from "./emoji/emojiRanks";

View file

@ -6,9 +6,9 @@ import {
cn,
Skeleton,
} from "@methanium/ui";
import { Text } from "@methanium/ui/markdown";
import type { SelectedUser } from "@tensamin/user/context";
import Wrapper from "@tensamin/user/wrapper";
import Text from "@tensamin/markdown/text";
import type { SelectedUser } from "@tensamin/identity/context";
import Wrapper from "@tensamin/identity/wrapper";
import { Forward, X } from "lucide-react";
type ReplyUserData = SelectedUser<readonly ["Avatar", "Display"]>;

View file

@ -23,10 +23,10 @@ import {
wrapChatSecret,
} from "@tensamin/crypto/chatSecret";
import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp";
import { requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context";
import { useSession } from "@tensamin/identity/session";
import { useUser } from "@tensamin/identity/context";
import { createCache, type ChatDraft } from "@tensamin/cache";
import { secureValueCodec } from "@tensamin/storage/secure";
@ -69,6 +69,14 @@ type MessageEdit = Partial<
>
>;
const messageStateRank: Record<RawMessage["MessageState"], number> = {
awaiting: 0,
sending: 0,
sent: 1,
received: 2,
read: 3,
};
function updateMessagesBySendTime<T extends EditableMessage>(
messages: T[],
sendTime: number,
@ -81,6 +89,13 @@ function updateMessagesBySendTime<T extends EditableMessage>(
return item;
}
if (
edit.MessageState !== undefined &&
messageStateRank[edit.MessageState] < messageStateRank[item.MessageState]
) {
return item;
}
const entries = Object.entries(edit) as Array<
[keyof MessageEdit, MessageEdit[keyof MessageEdit]]
>;
@ -226,8 +241,8 @@ export async function fetchReplyMessage({
export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage();
const { send, subscribe } = useMTP();
const { get: getUser } = useUser();
const { send, sendSealedRelay, subscribe } = useMTP();
const { get: getUser, getIota } = useUser();
const { moveUserIdToTop } = useSession();
const [error, setError] = useState("");
@ -469,9 +484,11 @@ export default function Provider({ children }: { children: ReactNode }) {
version: CHAT_SECRET_VERSION,
});
assertProtocolSuccess(
const ownIota = await getIota(ownUserId);
const peerIota = await getIota(userIdValue);
const response = await sendSealedRelay(
"SetChatSecret",
await send("SetChatSecret", {
{
ChatId: chatId,
SecretId: secretId,
VersionNumber: CHAT_SECRET_VERSION,
@ -489,8 +506,21 @@ export default function Provider({ children }: { children: ReactNode }) {
KemCiphertext: protocolBytes(peerWrapped.kemCiphertext),
},
],
}),
},
{
nextHop: { kind: "iota", id: ownIota.IotaId },
finalRecipientId: userIdValue,
metadataRecipients: [
{ value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.PublicKey, encoding: "base64" },
],
contentRecipients: [
{ value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.PublicKey, encoding: "base64" },
],
},
);
requireRelaySuccess(response);
if (active) {
setCurrentChatSecretState({ userId: userIdValue, value: rawSecret });
@ -508,7 +538,7 @@ export default function Provider({ children }: { children: ReactNode }) {
return () => {
active = false;
};
}, [getUser, load, send, userIdValue]);
}, [getIota, getUser, load, send, sendSealedRelay, userIdValue]);
const getChatSecret = useCallback(
async (userId: number): Promise<Uint8Array | null> => {
@ -901,6 +931,9 @@ export default function Provider({ children }: { children: ReactNode }) {
setFailed: (failed: boolean) => {
editMessage(message.SendTime, { failed });
},
setMessageState: (MessageState: RawMessage["MessageState"]) => {
editMessage(message.SendTime, { MessageState });
},
};
},
[editMessage, userIdValue, moveUserIdToTop, ownId],
@ -965,9 +998,7 @@ export default function Provider({ children }: { children: ReactNode }) {
);
return;
}
editMessage(data.SendTime, {
MessageState: data.MessageState,
});
editMessage(data.SendTime, { MessageState: data.MessageState });
});
return () => {
unsubscribeEdit();
@ -1021,6 +1052,7 @@ type contextType = {
liveMessages: () => LiveMessage[];
addLiveMessage: (message: RawMessage) => {
setFailed: (failed: boolean) => void;
setMessageState: (messageState: RawMessage["MessageState"]) => void;
};
editMessage: (sendTime: number, edit: MessageEdit) => void;
deleteMessage: (sendTime: number) => void;

View file

@ -19,7 +19,7 @@ import {
type LiveMessage,
type RawMessage,
} from "./values";
import Wrapper from "@tensamin/user/wrapper";
import Wrapper from "@tensamin/identity/wrapper";
function shouldFetchPreviousPage({
entry,

View file

@ -1,10 +1,11 @@
{
"name": "@tensamin/user",
"name": "@tensamin/identity",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./context": "./src/context.tsx",
"./session": "./src/session.tsx",
"./wrapper": "./src/wrapper.tsx",
"./values": "./src/values.ts"
},

View file

@ -19,12 +19,13 @@ import {
import type z from "zod";
import { createCache } from "@tensamin/cache";
import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session";
import { useSession } from "./session";
import { getChangedUserFields, selectUserFields } from "./selection";
export { getChangedUserFields, selectUserFields } from "./selection";
export type User = z.infer<typeof schemas.GetUserData.response>;
export type Iota = z.infer<typeof schemas.GetIotaData.response>;
type ClientUserState = z.infer<typeof clientUserStateSchema>;
export type UserField = keyof User;
export type UserFields = readonly [UserField, ...UserField[]];
@ -44,7 +45,7 @@ export function mergeTransientPresence<T extends { UserId: number }>(
return state === undefined ? user : ({ ...user, OnlineStatus: state } as T);
}
const USER_CACHE_MAX_AGE = 5 * 60 * 1000;
const DATA_CACHE_MAX_AGE = 5 * 60 * 1000;
interface contextValue {
get<const Fields extends UserFields>(
@ -61,6 +62,7 @@ interface contextValue {
listener: () => void,
): () => void;
getVersion(userId: number, fields: readonly UserField[]): string;
getIota(userId: number): Promise<Iota>;
updateProfile(userId: number, patch: UserProfilePatch): Promise<void>;
updateState(userId: number, state: ClientUserState): void;
}
@ -75,6 +77,9 @@ const UserContext = createContext<contextValue | undefined>(undefined);
export default function UserProvider(props: { children: ReactNode }) {
const storageRef = useRef<Record<number, User>>({});
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({});
const iotaStorageRef = useRef<Record<number, Iota>>({});
const pendingIotaRef = useRef<Record<number, Promise<Iota> | undefined>>({});
const iotaCheckedAtRef = useRef<Record<number, number>>({});
const profileUpdateQueuesRef = useRef(new Map<number, Promise<void>>());
const profileGenerationsRef = useRef(new Map<number, number>());
const checkedAtRef = useRef<Record<number, number>>({});
@ -230,7 +235,7 @@ export default function UserProvider(props: { children: ReactNode }) {
if (cached) {
installProfile(cached);
const checkedAt = checkedAtRef.current[userId];
if (!checkedAt || Date.now() - checkedAt < USER_CACHE_MAX_AGE) {
if (!checkedAt || Date.now() - checkedAt < DATA_CACHE_MAX_AGE) {
checkedAtRef.current[userId] = Date.now();
return storageRef.current[userId];
}
@ -289,6 +294,52 @@ export default function UserProvider(props: { children: ReactNode }) {
[loadUser],
);
const getIota = useCallback(
async (userId: number): Promise<Iota> => {
if (userId == null) {
throw new Error("userId is required");
}
const pendingIota = pendingIotaRef.current[userId];
if (pendingIota !== undefined) {
return pendingIota;
}
const cached = iotaStorageRef.current[userId];
const checkedAt = iotaCheckedAtRef.current[userId];
if (cached && checkedAt && Date.now() - checkedAt < DATA_CACHE_MAX_AGE) {
return cached;
}
const request = (async () => {
try {
const response = await send("GetIotaData", { UserId: userId });
if (response.type !== "GetIotaData") {
throw new Error(`GetIotaData failed: ${response.type}`);
}
const iota = schemas.GetIotaData.response.parse(response.data);
iotaStorageRef.current[userId] = iota;
iotaCheckedAtRef.current[userId] = Date.now();
return iota;
} catch (error) {
if (cached) {
iotaCheckedAtRef.current[userId] = Date.now();
return cached;
}
throw error;
}
})();
pendingIotaRef.current[userId] = request;
try {
return await request;
} finally {
delete pendingIotaRef.current[userId];
}
},
[send],
);
const peek = useCallback(
<const Fields extends UserFields>(userId: number, fields: Fields) => {
const user = storageRef.current[userId];
@ -387,13 +438,14 @@ export default function UserProvider(props: { children: ReactNode }) {
const value = useMemo(
() => ({
get,
getIota,
peek,
subscribe,
getVersion,
updateProfile,
updateState,
}),
[get, getVersion, peek, subscribe, updateProfile, updateState],
[get, getIota, getVersion, peek, subscribe, updateProfile, updateState],
);
return (

View file

@ -6,10 +6,10 @@ import {
useContext,
useEffect,
} from "react";
import { useStorage } from "./context";
import { useStorage } from "@tensamin/storage/context";
import type { Contacts, Communities, Calls } from "@tensamin/shared/data";
import { createCache } from "@tensamin/cache";
import { secureValueCodec } from "./secure";
import { secureValueCodec } from "@tensamin/storage/secure";
interface SessionContextType {
contacts: Contacts;

View file

@ -9,6 +9,7 @@ import {
export type TextProps = {
value: string;
fontSize?: CSSProperties["fontSize"];
showEditedIndicator?: boolean;
};
/**
@ -25,6 +26,9 @@ export default function Text(props: TextProps) {
return (
<div className="tm-md-root" style={{ fontSize: props.fontSize }}>
{renderedBlocks}
{props.showEditedIndicator && (
<span className="ml-1 text-xs text-muted-foreground">(edited)</span>
)}
</div>
);
}

View file

@ -1,6 +1,10 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { toast as sonnerToast } from "@methanium/ui";
import { base64ToBytes, ConnectionState, MTPClient } from "mtp";
import {
base64ToBytes,
ConnectionState,
MTPClient,
} from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue";
import {
mtp as mtpSchemas,
@ -17,6 +21,7 @@ import {
type MTPContextType,
type ProtocolMessage,
removeMissingContacts,
type SealedRelaySend,
useMessageHandlers,
} from "./mtpContext";
import {
@ -201,6 +206,20 @@ export function BrowserProvider(props: {
},
[],
);
const sendSealedRelay: SealedRelaySend = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
if (!client) throw new Error("mtp is not connected");
await client.sendSealedRelay(type, data, {
nextHopId: options.nextHop.id,
finalRecipientId: options.finalRecipientId,
metadataRecipients: options.metadataRecipients,
contentRecipients: options.contentRecipients,
});
return { type: "Success", data: {} };
},
[],
);
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
@ -338,6 +357,7 @@ export function BrowserProvider(props: {
hostPublicKey: { value: omikronPublicKey, encoding: "base64" },
descriptor: "client",
pings: true,
securityProfile: { protectedSignatureSuite: "dual" },
logger: (event) => {
if (event.type === "state") {
if (generation !== connectionGeneration) return;
@ -512,6 +532,7 @@ export function BrowserProvider(props: {
<MTPContext.Provider
value={{
send: sendQueued,
sendSealedRelay,
subscribe,
addInterceptor,
readyState,

View file

@ -1,7 +1,12 @@
export { Provider, useMTP } from "./context";
export { RelayRejectedError, requireRelaySuccess } from "./mtpContext";
export type {
BoundSendFn,
MTPExchange,
MTPInterceptor,
ProtocolMessage,
RelayTarget,
SealedRelayOptions,
SealedRelayResult,
SealedRelaySend,
} from "./mtpContext";

View file

@ -1,5 +1,8 @@
import { createContext, useCallback, useRef } from "react";
import type {
MTPDataValueInput,
MTPEncodedBytesInput,
MTPFrame,
MTPRequestFunction,
MTPResponseFrame,
MTPSubscriptionFunction,
@ -19,6 +22,56 @@ export type ProtocolMessage<
export type BoundSendFn = MTPRequestFunction<typeof mtpSchemas>;
export type RelayTarget =
| { kind: "user"; id: number }
| { kind: "iota"; id: number };
export type SealedRelayOptions = {
nextHop: RelayTarget;
finalRecipientId: number;
metadataRecipients: MTPEncodedBytesInput[];
contentRecipients: MTPEncodedBytesInput[];
};
export type SealedRelayResult = {
type: string;
data: Record<string, unknown>;
};
export function sealedRelayResultFromFrame(frame: MTPFrame): SealedRelayResult {
return {
type: frame.type,
data:
typeof frame.data === "object" &&
frame.data !== null &&
!Array.isArray(frame.data)
? (frame.data as Record<string, unknown>)
: {},
};
}
export class RelayRejectedError extends Error {
public readonly responseType: string;
constructor(responseType: string) {
super(`Relay rejected with ${responseType}`);
this.responseType = responseType;
this.name = "RelayRejectedError";
}
}
export function requireRelaySuccess(response: SealedRelayResult): void {
if (response.type !== "Success") {
throw new RelayRejectedError(response.type);
}
}
export type SealedRelaySend = (
type: string,
data: MTPDataValueInput,
options: SealedRelayOptions,
) => Promise<SealedRelayResult>;
export type MTPExchange = {
type: keyof typeof mtpSchemas & string;
data: unknown;
@ -29,6 +82,7 @@ export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
export type MTPContextType = {
send: BoundSendFn;
sendSealedRelay: SealedRelaySend;
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;

View file

@ -26,6 +26,8 @@ import {
MTPContext,
type ProtocolMessage,
removeMissingContacts,
type SealedRelayResult,
type SealedRelaySend,
useMessageHandlers,
} from "./mtpContext";
@ -233,12 +235,26 @@ export function TauriProvider(props: {
},
[connection, interceptorsRef],
);
const sendSealedRelay = useCallback<SealedRelaySend>(
async (type, data, options) => {
return invoke<SealedRelayResult>("mtp_send_sealed_relay", {
typeName: type,
data,
nextHop: options.nextHop,
finalRecipientId: options.finalRecipientId,
metadataRecipients: options.metadataRecipients,
contentRecipients: options.contentRecipients,
});
},
[],
);
const connected = snapshot.readyState === ConnectionState.Connected;
return (
<MTPContext.Provider
value={{
send,
sendSealedRelay,
subscribe,
addInterceptor,
readyState: snapshot.readyState,

View file

@ -21,7 +21,7 @@
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/user": "workspace:*",
"@tensamin/identity": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"sonner": "^2.0.7"

View file

@ -1,5 +1,5 @@
import { useStorage } from "@tensamin/storage/context";
import { useUser } from "@tensamin/user/context";
import { useUser } from "@tensamin/identity/context";
import { useChat } from "@tensamin/chat/context";
import { useMTP } from "@tensamin/mtp";
import { createContext, useEffect, useContext } from "react";
@ -11,7 +11,7 @@ import {
requestPermission as requestTauriNotificationPermission,
sendNotification as sendTauriNotification,
} from "@tauri-apps/plugin-notification";
import { useSession } from "@tensamin/storage/session";
import { useSession } from "@tensamin/identity/session";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { decryptChatText } from "@tensamin/crypto/chatSecret";
import { log } from "@tensamin/shared/log";
@ -29,7 +29,7 @@ async function requestNotificationPermission() {
}
export default function Provider(props: { children: React.ReactNode }) {
const { subscribe, send } = useMTP();
const { subscribe } = useMTP();
const { load } = useStorage();
const { get } = useUser();
const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
@ -79,11 +79,6 @@ export default function Provider(props: { children: React.ReactNode }) {
// todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(data.SenderId);
if (await load("settings.receive_confirmations")) {
void send("MessageState", {
MessageState: "received",
});
}
}
const user = await get(data.SenderId, [
@ -180,7 +175,6 @@ export default function Provider(props: { children: React.ReactNode }) {
load,
location.pathname,
navigate,
send,
moveUserIdToTop,
subscribe,
getChatSecret,

View file

@ -17,10 +17,11 @@
"@tauri-apps/api": "^2.11.1",
"@tensamin/cache": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/user": "workspace:*",
"@tensamin/identity": "workspace:*",
"lucide-react": "^1.29.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"

View file

@ -1,14 +1,15 @@
import Accessibility from "./pages/accessibility";
import Cache from "./pages/cache";
import Call from "./pages/call";
import Chat from "./pages/chat";
import Index from "./pages/index";
import Licenses from "./pages/licenses";
import { settingsNavigation } from "./navigation";
import SettingsIndex from "./pages/index";
import Profile from "./pages/profile";
import Security from "./pages/security";
import Chat from "./pages/chat";
import Call from "./pages/call";
import Cache from "./pages/cache";
import Theme from "./pages/theme";
import Accessibility from "./pages/accessibility";
import Hotkeys from "./pages/hotkeys";
import { settingsNavigation } from "./navigation";
import Licenses from "./pages/licenses";
const pageComponents = {
profile: Profile,
@ -23,7 +24,7 @@ const pageComponents = {
} as const;
export const settingsPages = [
{ path: "/", component: Index },
{ path: "/", component: SettingsIndex },
...settingsNavigation.map((page) => ({
...page,
component: pageComponents[page.path],

View file

@ -1,4 +1,4 @@
import { Input as MDInput } from "@methanium/ui/markdown";
import MDInput from "@tensamin/markdown/input";
import { useMTP } from "@tensamin/mtp";
import { mtp } from "@tensamin/shared/data";
import { useStorage } from "@tensamin/storage/context";
@ -15,7 +15,7 @@ import {
useUser,
useUserFields,
type SelectedUser,
} from "@tensamin/user/context";
} from "@tensamin/identity/context";
import { Check } from "lucide-react";
import { useEffect, useRef, useState } from "react";

View file

@ -70,6 +70,7 @@ const callSecretEnvelopeRequest = z.object({
export const Reaction = z.object({
Reaction: z.string(),
SenderId: z.number(),
RelayMessageId: z.string().optional(),
});
export const Message = z.object({
@ -82,6 +83,8 @@ export const Message = z.object({
Avatar: z.boolean().optional(),
Display: z.boolean().optional(),
ReplyId: z.number().optional(),
UpdatedAt: z.number().optional(),
ErrorType: z.string().optional(),
MessageState: z
.enum(["read", "received", "sent", "sending", "awaiting"]) // awaiting for 'internal' use
.default("received"),
@ -107,6 +110,7 @@ const authPayload = z.object({
.array(
z.object({
LastMessageAt: z.number().default(0),
CreatedAt: z.number().optional(),
UserId: z.number(),
LastMessage: z
.object({
@ -148,7 +152,7 @@ const userFields = {
About: z.string().max(255).optional(),
Avatar: z.string().optional(),
Display: z.string().min(1).max(15),
IotaId: z.number(),
IotaId: z.number().optional(),
OmikronConnections: z.array(z.number()),
OmikronId: z.number().optional(),
PublicKey: z.base64(),
@ -240,6 +244,27 @@ export const mtp = {
}),
response: userSchema,
},
GetIotaData: {
request: z
.object({
IotaId: z.number().int().positive().optional(),
UserId: z.number().int().positive().optional(),
Username: z.string().min(1).max(15).optional(),
})
.refine(
({ IotaId, UserId, Username }) =>
[IotaId, UserId, Username].filter((value) => value !== undefined)
.length === 1,
"GetIotaData requires exactly one selector",
),
response: z.object({
IotaId: z.number().int().positive(),
PublicKey: z.base64(),
OmikronConnections: z.array(z.number().int().positive()).optional(),
UserId: z.number().int().positive().optional(),
Username: z.string().optional(),
}),
},
GetStates: {
request: z.object({
SessionId: z.number().int().positive(),
@ -376,17 +401,12 @@ export const mtp = {
response: z.object({}),
},
MessageState: {
request: z
.object({
request: z.object({
ChatPartnerId: z.number(),
SendTime: z.number(),
MessageState: Message.shape.MessageState,
})
.or(
z.object({
MessageState: Message.shape.MessageState,
RelayMessageId: z.string(),
EventAt: z.number(),
MessageState: z.enum(["received", "read"]),
}),
),
response: z.object({
ChatPartnerId: z.number(),
MessageState: Message.shape.MessageState,

View file

@ -4,7 +4,6 @@
"version": "0.0.0",
"type": "module",
"exports": {
"./session": "./src/session.tsx",
"./context": "./src/context.tsx",
"./secure": "./src/secure.ts",
"./browserSecure": "./src/browserSecure.ts",
@ -18,8 +17,6 @@
"dependencies": {
"@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1",
"@tensamin/cache": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8"

View file

@ -19,7 +19,7 @@
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*",
"@tensamin/user": "workspace:*",
"@tensamin/identity": "workspace:*",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}

View file

@ -1,7 +1,7 @@
import { type ReactNode } from "react";
/**
import { useEffect, useState, type ReactNode } from "react";
import { useUser } from "@tensamin/user/context";
import { useUser } from "@tensamin/identity/context";
import { useStorage } from "@tensamin/storage/context";
import {
Button,

View file

@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import type { User } from "./context";
import { getChangedUserFields, selectUserFields } from "./selection";
const user = {
Avatar: undefined,
Display: "Alice",
IotaId: 1,
OmikronConnections: [],
OnlineStatus: "user_online",
PublicKey: "AA==",
SubEnd: 0,
SubLevel: 0,
UserId: 1,
Username: "alice",
} satisfies User;
describe("presence selection", () => {
it("notifies OnlineStatus selections when presence changes", () => {
const next = { ...user, OnlineStatus: "user_dnd" as const };
expect(getChangedUserFields(user, next)).toEqual(["OnlineStatus"]);
expect(selectUserFields(next, ["OnlineStatus"])).toEqual({
OnlineStatus: "user_dnd",
});
});
});

99
pnpm-lock.yaml generated
View file

@ -198,6 +198,12 @@ importers:
'@tensamin/hotkeys':
specifier: workspace:*
version: link:../../packages/hotkeys
'@tensamin/identity':
specifier: workspace:*
version: link:../../packages/identity
'@tensamin/markdown':
specifier: workspace:*
version: link:../../packages/markdown
'@tensamin/mtp':
specifier: workspace:*
version: link:../../packages/mtp
@ -225,9 +231,6 @@ importers:
'@tensamin/tauth':
specifier: workspace:*
version: link:../../packages/tauth
'@tensamin/user':
specifier: workspace:*
version: link:../../packages/user
decimal.js-light:
specifier: ^2.5.1
version: 2.5.1
@ -334,6 +337,9 @@ importers:
'@tensamin/crypto':
specifier: workspace:*
version: link:../crypto
'@tensamin/identity':
specifier: workspace:*
version: link:../identity
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
@ -343,9 +349,6 @@ importers:
'@tensamin/storage':
specifier: workspace:*
version: link:../storage
'@tensamin/user':
specifier: workspace:*
version: link:../user
deepfilternet3-noise-filter:
specifier: 1.3.0
version: 1.3.0(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))
@ -400,6 +403,12 @@ importers:
'@tensamin/hotkeys':
specifier: workspace:*
version: link:../hotkeys
'@tensamin/identity':
specifier: workspace:*
version: link:../identity
'@tensamin/markdown':
specifier: workspace:*
version: link:../markdown
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
@ -409,9 +418,6 @@ importers:
'@tensamin/storage':
specifier: workspace:*
version: link:../storage
'@tensamin/user':
specifier: workspace:*
version: link:../user
lucide-react:
specifier: ^1.29.0
version: 1.30.0(react@19.2.8)
@ -459,6 +465,30 @@ importers:
specifier: ^8.2.1
version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)
packages/identity:
dependencies:
'@tensamin/cache':
specifier: workspace:*
version: link:../cache
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
'@tensamin/shared':
specifier: workspace:*
version: link:../shared
'@tensamin/storage':
specifier: workspace:*
version: link:../storage
react:
specifier: ^19.2.8
version: 19.2.8
react-dom:
specifier: ^19.2.8
version: 19.2.8(react@19.2.8)
zod:
specifier: ^4.4.3
version: 4.4.3
packages/markdown:
dependencies:
'@codemirror/autocomplete':
@ -543,6 +573,9 @@ importers:
'@tensamin/crypto':
specifier: workspace:*
version: link:../crypto
'@tensamin/identity':
specifier: workspace:*
version: link:../identity
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
@ -552,9 +585,6 @@ importers:
'@tensamin/storage':
specifier: workspace:*
version: link:../storage
'@tensamin/user':
specifier: workspace:*
version: link:../user
react:
specifier: ^19.2.8
version: 19.2.8
@ -612,6 +642,12 @@ importers:
'@tensamin/hotkeys':
specifier: workspace:*
version: link:../hotkeys
'@tensamin/identity':
specifier: workspace:*
version: link:../identity
'@tensamin/markdown':
specifier: workspace:*
version: link:../markdown
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
@ -621,9 +657,6 @@ importers:
'@tensamin/storage':
specifier: workspace:*
version: link:../storage
'@tensamin/user':
specifier: workspace:*
version: link:../user
lucide-react:
specifier: ^1.29.0
version: 1.30.0(react@19.2.8)
@ -664,12 +697,6 @@ importers:
'@tauri-apps/api':
specifier: ^2.11.1
version: 2.11.1
'@tensamin/cache':
specifier: workspace:*
version: link:../cache
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
'@tensamin/shared':
specifier: workspace:*
version: link:../shared
@ -691,6 +718,9 @@ importers:
'@tensamin/crypto':
specifier: workspace:*
version: link:../crypto
'@tensamin/identity':
specifier: workspace:*
version: link:../identity
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
@ -703,9 +733,6 @@ importers:
'@tensamin/tauri':
specifier: workspace:*
version: link:../../apps/tauri
'@tensamin/user':
specifier: workspace:*
version: link:../user
react:
specifier: ^19.2.8
version: 19.2.8
@ -713,30 +740,6 @@ importers:
specifier: ^19.2.8
version: 19.2.8(react@19.2.8)
packages/user:
dependencies:
'@tensamin/cache':
specifier: workspace:*
version: link:../cache
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
'@tensamin/shared':
specifier: workspace:*
version: link:../shared
'@tensamin/storage':
specifier: workspace:*
version: link:../storage
react:
specifier: ^19.2.8
version: 19.2.8
react-dom:
specifier: ^19.2.8
version: 19.2.8(react@19.2.8)
zod:
specifier: ^4.4.3
version: 4.4.3
packages:
'@apideck/better-ajv-errors@0.3.7':

View file

@ -5,6 +5,8 @@ allowBuilds:
electron: true
electron-winstaller: true
esbuild: true
ffi: false
ref: false
overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz"