Compare commits

..
Author SHA1 Message Date
06d79f44fc chore(deps): update dependency gradle to v9
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Successful in 4m36s
Dependency builds / Test native MTP (pull_request) Failing after 3m26s
Dependency builds / Build desktop (pull_request) Successful in 7m43s
Dependency builds / Build mobile (pull_request) Failing after 5m34s
2026-08-30 21:00:43 +03: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
40 changed files with 613 additions and 696 deletions

View file

@ -32,7 +32,7 @@
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*", "@tensamin/tauri": "workspace:*",
"@tensamin/tauth": "workspace:*", "@tensamin/tauth": "workspace:*",
"@tensamin/user": "workspace:*", "@tensamin/identity": "workspace:*",
"decimal.js-light": "^2.5.1", "decimal.js-light": "^2.5.1",
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",
"lucide-react": "^1.29.0", "lucide-react": "^1.29.0",

View file

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

View file

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

View file

@ -15,7 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { joinCall, useCall } from "@tensamin/call/store"; import { joinCall, useCall } from "@tensamin/call/store";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/identity/wrapper";
import { Skeleton } from "@methanium/ui"; import { Skeleton } from "@methanium/ui";
import { import {
Select, Select,

View file

@ -1,4 +1,4 @@
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/identity/wrapper";
import { Basic, Loading } from "./modals/basic"; import { Basic, Loading } from "./modals/basic";
import List from "@/features/conversation/list/body"; import List from "@/features/conversation/list/body";
@ -34,7 +34,7 @@ import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar"; import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
import { Ellipsis, Check } from "lucide-react"; import { Ellipsis, Check } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useUser, type User } from "@tensamin/user/context"; import { useUser, type User } from "@tensamin/identity/context";
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data"; import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { import {

View file

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

View file

@ -26,7 +26,7 @@ import ChatContext from "@tensamin/chat/context";
import { useCall, useInitializeCall } from "@tensamin/call/store"; import { useCall, useInitializeCall } from "@tensamin/call/store";
import { useIsSpeaking } from "@tensamin/call/speakingState"; import { useIsSpeaking } from "@tensamin/call/speakingState";
import { Provider as MTPProvider } from "@tensamin/mtp"; import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context"; import UserProvider from "@tensamin/identity/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context"; import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime"; import PwaRuntime from "@tensamin/pwa/runtime";

View file

@ -20,6 +20,7 @@ import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { ShieldAlert } from "lucide-react"; import { ShieldAlert } from "lucide-react";
import { useUser } from "@tensamin/identity/context";
// The page // The page
export default function Page() { export default function Page() {
@ -56,6 +57,7 @@ function AddConversationButton() {
const { send, sendSealedRelay } = useMTP(); const { send, sendSealedRelay } = useMTP();
const { contacts, insertContact } = useSession(); const { contacts, insertContact } = useSession();
const { load } = useStorage(); const { load } = useStorage();
const { getIota } = useUser();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -110,23 +112,15 @@ function AddConversationButton() {
try { try {
const userId = await load("user_id"); const userId = await load("user_id");
const iota = await send("GetIotaData", { UserId: userId }); const iota = await getIota(userId);
if (iota.type !== "GetIotaData") {
setError(`Iota lookup failed: ${iota.type}`);
return;
}
const response = await sendSealedRelay( const response = await sendSealedRelay(
"AddConversation", "AddConversation",
{ ChatPartnerId: user.data.UserId }, { ChatPartnerId: user.data.UserId },
{ {
nextHop: { kind: "iota", id: iota.data.IotaId }, nextHop: { kind: "iota", id: iota.IotaId },
finalRecipientId: userId, finalRecipientId: userId,
metadataRecipients: [ metadataRecipients: [{ value: iota.PublicKey, encoding: "base64" }],
{ value: iota.data.PublicKey, encoding: "base64" }, contentRecipients: [{ value: iota.PublicKey, encoding: "base64" }],
],
contentRecipients: [
{ value: iota.data.PublicKey, encoding: "base64" },
],
}, },
); );
requireRelaySuccess(response); requireRelaySuccess(response);

View file

@ -65,7 +65,7 @@ export default defineConfig({
"@tensamin/settings", "@tensamin/settings",
"@tensamin/storage", "@tensamin/storage",
"@tensamin/mtp", "@tensamin/mtp",
"@tensamin/user", "@tensamin/identity",
"@tensamin/tauri", "@tensamin/tauri",
"@tensamin/chat", "@tensamin/chat",
], ],
@ -118,7 +118,7 @@ export default defineConfig({
"@tensamin/storage/context", "@tensamin/storage/context",
"@tensamin/tauri", "@tensamin/tauri",
"@tensamin/tauth", "@tensamin/tauth",
"@tensamin/user", "@tensamin/identity",
], ],
}, },
build: { build: {

View file

@ -45,7 +45,7 @@
}, },
"dependencies": { "dependencies": {
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz", "@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" "sonner": "^2.0.8"
} }
} }

View file

@ -24,7 +24,7 @@
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/user": "workspace:*", "@tensamin/identity": "workspace:*",
"deepfilternet3-noise-filter": "1.3.0", "deepfilternet3-noise-filter": "1.3.0",
"livekit-client": "^2.21.0", "livekit-client": "^2.21.0",
"lucide-react": "^1.29.0", "lucide-react": "^1.29.0",

View file

@ -8,7 +8,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@methanium/ui"; } from "@methanium/ui";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/identity/wrapper";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { sendCallInvite, useCall } from "../../store"; import { sendCallInvite, useCall } from "../../store";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";

View file

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

View file

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

View file

@ -14,7 +14,7 @@ import {
useSidebar, useSidebar,
} from "@methanium/ui"; } from "@methanium/ui";
import { ScreenShareOff } from "lucide-react"; import { ScreenShareOff } from "lucide-react";
import { useUserFields } from "@tensamin/user/context"; import { useUserFields } from "@tensamin/identity/context";
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState"; import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
import { getAverageImageColor } from "./modals/base"; 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 { useCall, getRoom } from "../store";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";

View file

@ -14,7 +14,7 @@ import {
} from "@tensamin/crypto/callSecret"; } from "@tensamin/crypto/callSecret";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context"; import { useUser } from "@tensamin/identity/context";
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
import { import {
ExternalE2EEKeyProvider, ExternalE2EEKeyProvider,

View file

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

View file

@ -27,7 +27,7 @@
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/user": "workspace:*", "@tensamin/identity": "workspace:*",
"lucide-react": "^1.29.0", "lucide-react": "^1.29.0",
"motion": "^13.0.0", "motion": "^13.0.0",
"react": "^19.2.8", "react": "^19.2.8",

View file

@ -27,6 +27,7 @@ import GifPicker from "./media/gifPicker";
import ReplyBox from "./replyBox"; import ReplyBox from "./replyBox";
import { useHotkey } from "@tensamin/hotkeys"; import { useHotkey } from "@tensamin/hotkeys";
import { editLastMessageHotkey } from "../hotkeys"; import { editLastMessageHotkey } from "../hotkeys";
import { useUser } from "@tensamin/identity/context";
export default function InputComponent({ export default function InputComponent({
value, value,
@ -39,7 +40,8 @@ export default function InputComponent({
}) { }) {
const [invertEnterBehavior, setInvertEnterBehavior] = useState(false); const [invertEnterBehavior, setInvertEnterBehavior] = useState(false);
const { send, sendSealedRelay } = useMTP(); const { sendSealedRelay } = useMTP();
const { getIota } = useUser();
const { const {
addLiveMessage, addLiveMessage,
chatSecret, chatSecret,
@ -176,12 +178,9 @@ export default function InputComponent({
try { try {
const [ownIota, peerIota] = await Promise.all([ const [ownIota, peerIota] = await Promise.all([
send("GetIotaData", { UserId: ownId }), getIota(ownId),
send("GetIotaData", { UserId: userId }), getIota(userId),
]); ]);
if (ownIota.type !== "GetIotaData" || peerIota.type !== "GetIotaData") {
throw new Error("Could not resolve an Iota for this message");
}
const response = await sendSealedRelay( const response = await sendSealedRelay(
"MessageSend", "MessageSend",
{ {
@ -191,15 +190,15 @@ export default function InputComponent({
...(replyTo && { ReplyId: replyTo }), ...(replyTo && { ReplyId: replyTo }),
}, },
{ {
nextHop: { kind: "iota", id: ownIota.data.IotaId }, nextHop: { kind: "iota", id: ownIota.IotaId },
finalRecipientId: userId, finalRecipientId: userId,
metadataRecipients: [ metadataRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" }, { value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" }, { value: peerIota.PublicKey, encoding: "base64" },
], ],
contentRecipients: [ contentRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" }, { value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" }, { value: peerIota.PublicKey, encoding: "base64" },
], ],
}, },
); );

View file

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

View file

@ -1,7 +1,7 @@
import type { RawMessage } from "../values"; import type { RawMessage } from "../values";
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react"; import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import { type SelectedUser, useUserFields } from "@tensamin/user/context"; import { type SelectedUser, useUserFields } from "@tensamin/identity/context";
import { import {
Avatar, Avatar,

View file

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

View file

@ -26,7 +26,7 @@ import { useStorage } from "@tensamin/storage/context";
import { requireRelaySuccess, useMTP } from "@tensamin/mtp"; import { requireRelaySuccess, useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import { useUser } from "@tensamin/user/context"; import { useUser } from "@tensamin/identity/context";
import { createCache, type ChatDraft } from "@tensamin/cache"; import { createCache, type ChatDraft } from "@tensamin/cache";
import { secureValueCodec } from "@tensamin/storage/secure"; import { secureValueCodec } from "@tensamin/storage/secure";
@ -227,7 +227,7 @@ export async function fetchReplyMessage({
export default function Provider({ children }: { children: ReactNode }) { export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage(); const { load } = useStorage();
const { send, sendSealedRelay, subscribe } = useMTP(); const { send, sendSealedRelay, subscribe } = useMTP();
const { get: getUser } = useUser(); const { get: getUser, getIota } = useUser();
const { moveUserIdToTop } = useSession(); const { moveUserIdToTop } = useSession();
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -469,14 +469,8 @@ export default function Provider({ children }: { children: ReactNode }) {
version: CHAT_SECRET_VERSION, version: CHAT_SECRET_VERSION,
}); });
const ownIota = await send("GetIotaData", { UserId: ownUserId }); const ownIota = await getIota(ownUserId);
if (ownIota.type !== "GetIotaData") { const peerIota = await getIota(userIdValue);
throw new Error(`Own Iota lookup failed: ${ownIota.type}`);
}
const peerIota = await send("GetIotaData", { UserId: userIdValue });
if (peerIota.type !== "GetIotaData") {
throw new Error(`Peer Iota lookup failed: ${peerIota.type}`);
}
const response = await sendSealedRelay( const response = await sendSealedRelay(
"SetChatSecret", "SetChatSecret",
{ {
@ -499,15 +493,15 @@ export default function Provider({ children }: { children: ReactNode }) {
], ],
}, },
{ {
nextHop: { kind: "iota", id: ownIota.data.IotaId }, nextHop: { kind: "iota", id: ownIota.IotaId },
finalRecipientId: userIdValue, finalRecipientId: userIdValue,
metadataRecipients: [ metadataRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" }, { value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" }, { value: peerIota.PublicKey, encoding: "base64" },
], ],
contentRecipients: [ contentRecipients: [
{ value: ownIota.data.PublicKey, encoding: "base64" }, { value: ownIota.PublicKey, encoding: "base64" },
{ value: peerIota.data.PublicKey, encoding: "base64" }, { value: peerIota.PublicKey, encoding: "base64" },
], ],
}, },
); );
@ -529,7 +523,7 @@ export default function Provider({ children }: { children: ReactNode }) {
return () => { return () => {
active = false; active = false;
}; };
}, [getUser, load, send, sendSealedRelay, userIdValue]); }, [getIota, getUser, load, send, sendSealedRelay, userIdValue]);
const getChatSecret = useCallback( const getChatSecret = useCallback(
async (userId: number): Promise<Uint8Array | null> => { async (userId: number): Promise<Uint8Array | null> => {

View file

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

View file

@ -1,5 +1,5 @@
{ {
"name": "@tensamin/user", "name": "@tensamin/identity",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",

View file

@ -25,6 +25,7 @@ import { getChangedUserFields, selectUserFields } from "./selection";
export { getChangedUserFields, selectUserFields } from "./selection"; export { getChangedUserFields, selectUserFields } from "./selection";
export type User = z.infer<typeof schemas.GetUserData.response>; export type User = z.infer<typeof schemas.GetUserData.response>;
export type Iota = z.infer<typeof schemas.GetIotaData.response>;
type ClientUserState = z.infer<typeof clientUserStateSchema>; type ClientUserState = z.infer<typeof clientUserStateSchema>;
export type UserField = keyof User; export type UserField = keyof User;
export type UserFields = readonly [UserField, ...UserField[]]; 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); 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 { interface contextValue {
get<const Fields extends UserFields>( get<const Fields extends UserFields>(
@ -61,6 +62,7 @@ interface contextValue {
listener: () => void, listener: () => void,
): () => void; ): () => void;
getVersion(userId: number, fields: readonly UserField[]): string; getVersion(userId: number, fields: readonly UserField[]): string;
getIota(userId: number): Promise<Iota>;
updateProfile(userId: number, patch: UserProfilePatch): Promise<void>; updateProfile(userId: number, patch: UserProfilePatch): Promise<void>;
updateState(userId: number, state: ClientUserState): void; updateState(userId: number, state: ClientUserState): void;
} }
@ -75,6 +77,9 @@ const UserContext = createContext<contextValue | undefined>(undefined);
export default function UserProvider(props: { children: ReactNode }) { export default function UserProvider(props: { children: ReactNode }) {
const storageRef = useRef<Record<number, User>>({}); const storageRef = useRef<Record<number, User>>({});
const pendingRef = useRef<Record<number, Promise<User> | undefined>>({}); 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 profileUpdateQueuesRef = useRef(new Map<number, Promise<void>>());
const profileGenerationsRef = useRef(new Map<number, number>()); const profileGenerationsRef = useRef(new Map<number, number>());
const checkedAtRef = useRef<Record<number, number>>({}); const checkedAtRef = useRef<Record<number, number>>({});
@ -230,7 +235,7 @@ export default function UserProvider(props: { children: ReactNode }) {
if (cached) { if (cached) {
installProfile(cached); installProfile(cached);
const checkedAt = checkedAtRef.current[userId]; 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(); checkedAtRef.current[userId] = Date.now();
return storageRef.current[userId]; return storageRef.current[userId];
} }
@ -289,6 +294,52 @@ export default function UserProvider(props: { children: ReactNode }) {
[loadUser], [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 peek = useCallback(
<const Fields extends UserFields>(userId: number, fields: Fields) => { <const Fields extends UserFields>(userId: number, fields: Fields) => {
const user = storageRef.current[userId]; const user = storageRef.current[userId];
@ -387,13 +438,14 @@ export default function UserProvider(props: { children: ReactNode }) {
const value = useMemo( const value = useMemo(
() => ({ () => ({
get, get,
getIota,
peek, peek,
subscribe, subscribe,
getVersion, getVersion,
updateProfile, updateProfile,
updateState, updateState,
}), }),
[get, getVersion, peek, subscribe, updateProfile, updateState], [get, getIota, getVersion, peek, subscribe, updateProfile, updateState],
); );
return ( return (

View file

@ -4,7 +4,6 @@ import {
base64ToBytes, base64ToBytes,
ConnectionState, ConnectionState,
MTPClient, MTPClient,
MTPProtocolError,
} from "mtp"; } from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue"; import createAsyncQueue from "@tensamin/shared/asyncQueue";
import { import {
@ -22,7 +21,6 @@ import {
type MTPContextType, type MTPContextType,
type ProtocolMessage, type ProtocolMessage,
removeMissingContacts, removeMissingContacts,
sealedRelayResultFromFrame,
type SealedRelaySend, type SealedRelaySend,
useMessageHandlers, useMessageHandlers,
} from "./mtpContext"; } from "./mtpContext";
@ -212,16 +210,13 @@ export function BrowserProvider(props: {
() => async (type, data, options) => { () => async (type, data, options) => {
const client = clientRef.current; const client = clientRef.current;
if (!client) throw new Error("mtp is not connected"); if (!client) throw new Error("mtp is not connected");
try { await client.sendSealedRelay(type, data, {
return sealedRelayResultFromFrame( nextHopId: options.nextHop.id,
await client.requestSealedRelay(type, data, options), finalRecipientId: options.finalRecipientId,
); metadataRecipients: options.metadataRecipients,
} catch (error) { contentRecipients: options.contentRecipients,
if (error instanceof MTPProtocolError) { });
return sealedRelayResultFromFrame(error.frame); return { type: "Success", data: {} };
}
throw error;
}
}, },
[], [],
); );

View file

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

View file

@ -1,5 +1,5 @@
import { useStorage } from "@tensamin/storage/context"; 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 { useChat } from "@tensamin/chat/context";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { createContext, useEffect, useContext } from "react"; import { createContext, useEffect, useContext } from "react";

View file

@ -20,7 +20,7 @@
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/user": "workspace:*", "@tensamin/identity": "workspace:*",
"lucide-react": "^1.29.0", "lucide-react": "^1.29.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"

View file

@ -15,7 +15,7 @@ import {
useUser, useUser,
useUserFields, useUserFields,
type SelectedUser, type SelectedUser,
} from "@tensamin/user/context"; } from "@tensamin/identity/context";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";

View file

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

View file

@ -1,7 +1,7 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
/** /**
import { useEffect, useState, 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 { useStorage } from "@tensamin/storage/context";
import { import {
Button, 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",
});
});
});

1071
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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