Compare commits

..
Author SHA1 Message Date
891d54c29f chore(deps): update mtp digest to f6a8b46
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 2m12s
Dependency builds / Build desktop (pull_request) Failing after 2m24s
Dependency builds / Test native MTP (pull_request) Failing after 3m51s
Dependency builds / Build mobile (pull_request) Failing after 5m16s
2026-09-01 00:00:28 +03: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
38 changed files with 482 additions and 346 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

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

View file

@ -0,0 +1,15 @@
import { lazy, Suspense } from "react";
import { useCall } from "@tensamin/call/state";
const Popout = lazy(() => import("@tensamin/call/popout"));
export default function CallPopout() {
const active = useCall((state) => state.state !== "closed");
return active ? (
<Suspense fallback={null}>
<Popout />
</Suspense>
) : 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,15 @@
import { lazy, Suspense } from "react";
import { useCall } from "@tensamin/call/state";
const SidebarBox = lazy(() => import("@tensamin/call/sidebarBox"));
export default function CallSidebarBox() {
const active = useCall((state) => state.state !== "closed");
return active ? (
<Suspense fallback={null}>
<SidebarBox />
</Suspense>
) : null;
}

View file

@ -1,6 +1,6 @@
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,7 +14,7 @@ import {
User,
} from "lucide-react";
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { joinCall, useCall } from "@tensamin/call/store";
import { useCall } from "@tensamin/call/state";
import Wrapper from "@tensamin/identity/wrapper";
import { Skeleton } from "@methanium/ui";
import {
@ -27,7 +27,7 @@ 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 Profile from "./modals/profile";
export default function Navbar({ forMobile }: { forMobile: boolean }) {
@ -140,7 +140,9 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<Button
disabled={callState !== "closed"}
onClick={() => {
void joinCall(id);
void import("@tensamin/call/store").then(({ joinCall }) =>
joinCall(id),
);
}}
className="w-9 h-9! aspect-square rounded-lg"
variant="outline"
@ -151,10 +153,12 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<Button
disabled={callState !== "closed"}
onClick={() => {
void joinCall(
id,
currentCalls[0].CallSecret,
currentCalls[0].CallId,
void import("@tensamin/call/store").then(({ joinCall }) =>
joinCall(
id,
currentCalls[0].CallSecret,
currentCalls[0].CallId,
),
);
}}
className="w-9 h-9! aspect-square rounded-lg"
@ -182,7 +186,10 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
value={call.CallId}
key={call.CallId}
onSelect={() => {
void joinCall(id, call.CallSecret, call.CallId);
void import("@tensamin/call/store").then(
({ joinCall }) =>
joinCall(id, call.CallSecret, call.CallId),
);
}}
>
{displayCallId(call.CallId)}

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/identity/wrapper";
import { Basic, Loading } from "./modals/basic";
import CallSidebarBox from "./callSidebarBox";
import List from "@/features/conversation/list/body";
import {
@ -30,7 +31,6 @@ 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";
@ -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

@ -12,45 +12,40 @@ import "./index.css";
import "@methanium/ui/index.css";
import NotFound from "@/routes/404";
import RouteLoader from "@/components/routeLoader";
import AppLayout from "@/routes/app/layout";
import { createSettingsRoute } from "@tensamin/settings";
import OnboardingGate from "@tensamin/onboarding";
import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen";
import CallScreen from "@tensamin/call/screen";
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/identity/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context";
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
import PwaRuntime from "@tensamin/pwa/runtime";
import TAuthWrapper from "@tensamin/tauth/context";
import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui";
import z from "zod";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
lazy,
Suspense,
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";
import { HotkeysProvider } from "@tensamin/hotkeys";
const Home = lazy(() => import("@/routes/app/home"));
const ChatScreen = lazy(() => import("@tensamin/chat/screen"));
const CallScreen = lazy(() => import("@tensamin/call/screen"));
const Login = lazy(() => import("@/routes/screens/login"));
const appShellImport = import("@/routes/app/shell");
const AppShell = lazy(() => appShellImport);
const wrapper = document.getElementById("root");
if (!wrapper) {
@ -108,7 +103,7 @@ function LoginWrapper({ children }: { children: ReactNode }) {
}, [load, location.pathname, navigate, secureStorage]);
if (loggedIn !== true && location.pathname !== "/login") {
return null;
return <RouteLoader />;
}
return children;
@ -265,7 +260,9 @@ function RootShell() {
<HotkeysProvider>
<ThemeStorageBridge />
<LoginWrapper>
<Outlet />
<Suspense fallback={<RouteLoader />}>
<Outlet />
</Suspense>
</LoginWrapper>
</HotkeysProvider>
</Storage>
@ -276,155 +273,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

@ -17,7 +17,7 @@ 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";

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,78 @@
import { Suspense, useEffect, useRef } from "react";
import { Outlet, useNavigate } from "@tanstack/react-router";
import RouteLoader from "@/components/routeLoader";
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>
<Suspense fallback={<RouteLoader />}>
<Outlet />
</Suspense>
</NotificationsProvider>
</ChatContext>
</AppLayout>
</TAuthWrapper>
</UserProvider>
</Session>
</MTPProvider>
</DesktopMediaProvider>
</Crypto>
</OnboardingGate>
);
}

View file

@ -124,6 +124,35 @@ export default defineConfig({
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(),

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

View file

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

@ -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,7 +12,7 @@ import {
wrapCallSecret,
} from "@tensamin/crypto/callSecret";
import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session";
import { useSession } from "@tensamin/identity/session";
import { useUser } from "@tensamin/identity/context";
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
import {
@ -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

@ -24,6 +24,7 @@
"@tensamin/cache": "workspace:*",
"@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

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,
@ -10,7 +10,14 @@ import {
PopoverTrigger,
} from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context";
import React, { useCallback, useEffect, useState, useRef } from "react";
import React, {
lazy,
Suspense,
useCallback,
useEffect,
useState,
useRef,
} from "react";
import { Button } from "@methanium/ui";
import { Plus, Laugh, FileVideo, SendHorizonal } from "lucide-react";
@ -20,15 +27,16 @@ 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";
const EmojiPicker = lazy(() => import("./emoji/emojiPicker"));
const GifPicker = lazy(() => import("./media/gifPicker"));
export default function InputComponent({
value,
setValue,
@ -351,13 +359,15 @@ export default function InputComponent({
)}
/>
<PopoverContent className="w-auto p-0">
<EmojiPicker
onSelect={(shortcode) => {
setValue(`${value}${shortcode} `);
recordUse(shortcode);
setEmojiPopoverOpen(false);
}}
/>
<Suspense fallback={null}>
<EmojiPicker
onSelect={(shortcode) => {
setValue(`${value}${shortcode} `);
recordUse(shortcode);
setEmojiPopoverOpen(false);
}}
/>
</Suspense>
</PopoverContent>
</Popover>
{isMobile ? (
@ -371,12 +381,14 @@ export default function InputComponent({
</DrawerTrigger>
<DrawerContent className="h-[80dvh]">
<div className="min-h-0 flex-1 p-3">
<GifPicker
onSelect={(url) => {
void handleSubmit(url, true);
setGifPopoverOpen(false);
}}
/>
<Suspense fallback={null}>
<GifPicker
onSelect={(url) => {
void handleSubmit(url, true);
setGifPopoverOpen(false);
}}
/>
</Suspense>
</div>
</DrawerContent>
</Drawer>
@ -408,14 +420,16 @@ export default function InputComponent({
className="absolute left-0 top-0 z-10 h-4 w-4 cursor-nwse-resize"
onPointerDown={handleGifPopoverResizeStart}
/>
<GifPicker
resizeHeight={gifPopoverSize?.height}
resizeWidth={gifPopoverSize?.width}
onSelect={(url) => {
void handleSubmit(url, true);
setGifPopoverOpen(false);
}}
/>
<Suspense fallback={null}>
<GifPicker
resizeHeight={gifPopoverSize?.height}
resizeWidth={gifPopoverSize?.width}
onSelect={(url) => {
void handleSubmit(url, true);
setGifPopoverOpen(false);
}}
/>
</Suspense>
</PopoverContent>
</Popover>
)}

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,

View file

@ -19,7 +19,9 @@ 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";

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,7 +6,7 @@ import {
cn,
Skeleton,
} from "@methanium/ui";
import { Text } from "@methanium/ui/markdown";
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";

View file

@ -25,7 +25,7 @@ import {
import { useStorage } from "@tensamin/storage/context";
import { requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session";
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";

View file

@ -5,6 +5,7 @@
"type": "module",
"exports": {
"./context": "./src/context.tsx",
"./session": "./src/session.tsx",
"./wrapper": "./src/wrapper.tsx",
"./values": "./src/values.ts"
},

View file

@ -19,7 +19,7 @@ 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";

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

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

View file

@ -17,6 +17,7 @@
"@tauri-apps/api": "^2.11.1",
"@tensamin/cache": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",

View file

@ -1,29 +1,20 @@
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 Profile from "./pages/profile";
import Security from "./pages/security";
import Theme from "./pages/theme";
import Hotkeys from "./pages/hotkeys";
import { lazy } from "react";
import { settingsNavigation } from "./navigation";
const pageComponents = {
profile: Profile,
security: Security,
chat: Chat,
call: Call,
cache: Cache,
theme: Theme,
accessibility: Accessibility,
hotkeys: Hotkeys,
licenses: Licenses,
profile: lazy(() => import("./pages/profile")),
security: lazy(() => import("./pages/security")),
chat: lazy(() => import("./pages/chat")),
call: lazy(() => import("./pages/call")),
cache: lazy(() => import("./pages/cache")),
theme: lazy(() => import("./pages/theme")),
accessibility: lazy(() => import("./pages/accessibility")),
hotkeys: lazy(() => import("./pages/hotkeys")),
licenses: lazy(() => import("./pages/licenses")),
} as const;
export const settingsPages = [
{ path: "/", component: Index },
{ path: "/", component: lazy(() => import("./pages/index")) },
...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";

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"

15
pnpm-lock.yaml generated
View file

@ -201,6 +201,9 @@ importers:
'@tensamin/identity':
specifier: workspace:*
version: link:../../packages/identity
'@tensamin/markdown':
specifier: workspace:*
version: link:../../packages/markdown
'@tensamin/mtp':
specifier: workspace:*
version: link:../../packages/mtp
@ -403,6 +406,9 @@ importers:
'@tensamin/identity':
specifier: workspace:*
version: link:../identity
'@tensamin/markdown':
specifier: workspace:*
version: link:../markdown
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
@ -639,6 +645,9 @@ importers:
'@tensamin/identity':
specifier: workspace:*
version: link:../identity
'@tensamin/markdown':
specifier: workspace:*
version: link:../markdown
'@tensamin/mtp':
specifier: workspace:*
version: link:../mtp
@ -688,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