From 74eb0d0f913483783734c7100dd28b692bc40209 Mon Sep 17 00:00:00 2001 From: forgejo-actions Date: Fri, 29 May 2026 13:21:14 +0000 Subject: [PATCH 1/4] (qol): update release flake hash --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 3773515..41b621a 100644 --- a/flake.nix +++ b/flake.nix @@ -6,8 +6,8 @@ outputs = {nixpkgs, ...}: let systems = ["x86_64-linux"]; forAllSystems = nixpkgs.lib.genAttrs systems; - version = "0.0.6"; - x86_64DebHash = "sha256-rNlyNZFgYVRvJzX1sBz/qLHUdZoyfvHl5xuwb2BtU48="; + version = "0.0.7"; + x86_64DebHash = "sha256-gRkUEx1T7mrCgFEVp0X/ZiaGrKWzO+cCvv33DKCkB+o="; forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}"; in { packages = forAllSystems (system: let From 25e47604b3442ffabf2ce7732dd70d4be92c686a Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 1 Jun 2026 11:13:13 +0200 Subject: [PATCH 2/4] (feat): add message context menu --- packages/chat/src/components/message.tsx | 121 ++++----- .../src/components/messageContextMenu.tsx | 242 ++++++++++++++++++ packages/chat/todo.md | 4 +- 3 files changed, 300 insertions(+), 67 deletions(-) create mode 100644 packages/chat/src/components/messageContextMenu.tsx diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 18cf792..60afa75 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -12,14 +12,9 @@ import { TooltipContent, TooltipTrigger, } from "@tensamin/ui"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@tensamin/ui"; import Wrapper from "@tensamin/user/wrapper"; import { useChat } from "../context"; +import MessageContextMenu from "./messageContextMenu"; function MessageComponent({ grouped, @@ -38,72 +33,66 @@ function MessageComponent({ // pt-3 is to get a gap between messages className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${actuallyFailed || message.message_state === "awaiting" ? "opacity-50" : ""}`} > - - - ( - <> - {grouped ? ( -

+ +

+ ( + <> + {grouped ? ( +

+ {new Date(message.send_time).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+ ) : ( + + + + {user.display.slice(0, 2).toUpperCase()} + + + )} + {message.failed && message.message_state === "awaiting" && ( + + +

Failed to send message

+
+ } /> +
+ )} +
+ {!grouped && ( +
+

{user.display}

+

{new Date(message.send_time).toLocaleString([], { hour: "2-digit", minute: "2-digit", })}

- ) : ( - - - - {user.display.slice(0, 2).toUpperCase()} - - - )} - {message.failed && message.message_state === "awaiting" && ( - - -

Failed to send message

-
- } /> -
- )} -
- {!grouped && ( -
-

{user.display}

-

- {new Date(message.send_time).toLocaleString([], { - hour: "2-digit", - minute: "2-digit", - })} -

-
- )} -
- - )} - /> -
- } - /> - - - - - - + )} + +
+ + )} + /> +
+ ); } diff --git a/packages/chat/src/components/messageContextMenu.tsx b/packages/chat/src/components/messageContextMenu.tsx new file mode 100644 index 0000000..dcbc707 --- /dev/null +++ b/packages/chat/src/components/messageContextMenu.tsx @@ -0,0 +1,242 @@ +import { + cn, + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, + Drawer, + DrawerContent, + DrawerTrigger, + useIsMobile, +} from "@tensamin/ui"; +import { Pin, Clipboard, Pen, Reply, Forward, Trash } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ReactElement, ReactNode } from "react"; + +async function copyText(text: string) { + await navigator.clipboard.writeText(text); +} + +type MenuComponents = { + Content: (props: { className?: string; children: ReactNode }) => ReactElement; + Group: (props: { children: ReactNode }) => ReactElement; + Item: (props: { + children: ReactNode; + className?: string; + disabled?: boolean; + onClick?: () => void | Promise; + variant?: "default" | "destructive"; + }) => ReactElement; + Separator: (props: { className?: string }) => ReactElement; + Sub: (props: { children: ReactNode }) => ReactElement; + SubTrigger: (props: { + children: ReactNode; + onClick?: () => void | Promise; + }) => ReactElement; + SubContent: (props: { children: ReactNode }) => ReactElement; +}; + +const desktopMenuComponents: MenuComponents = { + Content: ContextMenuContent, + Group: ContextMenuGroup, + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +}; + +function getMobileMenuComponents(onClose: () => void): MenuComponents { + return { + Content: ({ className, children }) => ( + +
{children}
+
+ ), + Group: ({ children }) =>
{children}
, + Item: ({ children, className, disabled, onClick, variant = "default" }) => { + async function handleClick() { + await onClick?.(); + onClose(); + } + + return ( + + ); + }, + Separator: ({ className }) => ( +
+ ), + Sub: ({ children }) =>
{children}
, + SubTrigger: ({ children, onClick }) => { + async function handleClick() { + await onClick?.(); + onClose(); + } + + return ( + + ); + }, + SubContent: ({ children }) =>
{children}
, + }; +} + +function ReactionItems({ Item }: { Item: MenuComponents["Item"] }) { + return Cool item; +} + +function MessageMenuContent({ + components, + content, + devEnabled, + messageId, + onAddReaction, + showReactionItems = true, +}: { + components: MenuComponents; + content: string; + devEnabled: boolean; + messageId: number; + onAddReaction?: () => void | Promise; + showReactionItems?: boolean; +}) { + const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } = + components; + + return ( + + + + Add Reaction + {showReactionItems && ( + + + + )} + + +

Pin Message

+
+ copyText(content)} + > +

Copy Raw

+
+
+ + + +

Edit Message

+
+ +

Reply

+
+ +

Forward

+
+
+ + + +

Delete Message

+
+
+ {devEnabled && ( + <> + + + copyText(String(messageId))} + > +

Copy ID

+
+
+ + )} +
+ ); +} + +export default function MessageContextMenu({ + children, + content, + messageId, +}: { + children: ReactElement; + content: string; + messageId: number; +}) { + const isMobile = useIsMobile(); + const devEnabled = useMemo( + () => Number(localStorage.getItem("log_level")) >= 3, + [], + ); + const [mainDrawerOpen, setMainDrawerOpen] = useState(false); + const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false); + const mainDrawerComponents = getMobileMenuComponents(() => + setMainDrawerOpen(false), + ); + const reactionDrawerComponents = getMobileMenuComponents(() => + setReactionDrawerOpen(false), + ); + + if (isMobile) { + return ( + <> + + {children} + setReactionDrawerOpen(true)} + showReactionItems={false} + /> + + + + + + + + ); + } + + return ( + + + + + ); +} diff --git a/packages/chat/todo.md b/packages/chat/todo.md index 122f14e..4bd036a 100644 --- a/packages/chat/todo.md +++ b/packages/chat/todo.md @@ -1 +1,3 @@ -- Add context menu to messages +- Implement context menu features +- Add default-emoji-hotkey in settings +- On mobile, turn ContextMenu into Drawer From 6fda7f1c71bea4ce9c63aac197ac0c97cdb02d77 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 1 Jun 2026 14:13:43 +0200 Subject: [PATCH 3/4] (feat): replace toast with popup for call invites (qol): update todo --- apps/web/src/index.tsx | 4 +- packages/call/src/components/invitePopup.tsx | 58 +++++++++++++++ packages/call/src/store.tsx | 77 ++++++++++++++------ packages/chat/todo.md | 1 - 4 files changed, 113 insertions(+), 27 deletions(-) create mode 100644 packages/call/src/components/invitePopup.tsx diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 7ea7afc..6487ba1 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -163,9 +163,7 @@ function AppShell() { } function CallInit() { - useInitializeCall(); - - return null; + return useInitializeCall(); } const rootRoute = createRootRoute({ diff --git a/packages/call/src/components/invitePopup.tsx b/packages/call/src/components/invitePopup.tsx new file mode 100644 index 0000000..f531913 --- /dev/null +++ b/packages/call/src/components/invitePopup.tsx @@ -0,0 +1,58 @@ +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + Dialog, + DialogContent, +} from "@tensamin/ui"; +import Wrapper from "@tensamin/user/wrapper"; +import { PhoneIncoming, PhoneMissed } from "lucide-react"; + +export default function InvitePopup({ + open, + setOpen, + onAccept, + user, +}: { + open: boolean; + setOpen: (value: boolean) => void; + onAccept: (value: boolean) => void; + user: number; +}) { + return ( + ( + + + + + + {user.display.slice(0, 2).toUpperCase()} + + +

{user.display}

+
+ + +
+
+
+ )} + /> + ); +} diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 0d28c61..e2595b6 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -24,7 +24,6 @@ import { getLogger, } from "livekit-client"; import z from "zod"; -import { toast as sonnerToast } from "sonner"; import { createScreenShareController, type ScreenShareSession, @@ -33,6 +32,7 @@ import { getSpeakingDetector, disposeSpeakingDetector, } from "./speakingIndicator"; +import InvitePopup from "./components/invitePopup"; // logging setLogExtension( @@ -45,6 +45,11 @@ setLogExtension( type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallView = "preview" | "focused" | "grid"; +type IncomingCallInvite = { + callId: string; + callSecret: string; + senderId: number; +}; type CurrentCallData = | (z.infer & { exists: boolean }) | null; @@ -83,6 +88,7 @@ type CallStore = { view: CallView; invitedUserId: number | null; callId: string | null; + incomingCallInvite: IncomingCallInvite | null; callSecret: string | null; livekitToken: string | null; currentCallData: CurrentCallData; @@ -837,6 +843,7 @@ export async function disconnect() { state: "closing", invitedUserId: null, callId: null, + incomingCallInvite: null, callSecret: null, livekitToken: null, currentCallData: null, @@ -999,6 +1006,7 @@ export function resetCallState() { view: "preview", invitedUserId: null, callId: null, + incomingCallInvite: null, callSecret: null, livekitToken: null, currentCallData: null, @@ -1050,6 +1058,7 @@ export const useCall = create(() => ({ view: "preview", invitedUserId: null, callId: null, + incomingCallInvite: null, callSecret: null, livekitToken: null, currentCallData: null, @@ -1084,6 +1093,7 @@ export function useInitializeCall() { const callId = useCall((state) => state.callId); const view = useCall((state) => state.view); + const incomingCallInvite = useCall((state) => state.incomingCallInvite); const listenersRegistered = useRef(false); const noiseFilter = useMemo( @@ -1113,32 +1123,44 @@ export function useInitializeCall() { }, [decryptText, encryptText, get, getSharedSecret, load, navigate, send]); const showCallingScreen = useCallback( - async (callId: string, callSecret: string, senderId: number) => { - const senderName = await get(senderId).then((data) => data.display); - - sonnerToast(`Incoming call from ${senderName}`, { - action: { - label: "Accept", - onClick: () => { - joinCall(senderId, callSecret, callId, false).catch((err) => { - log(1, "call", "red", "Failed to join call", err); - }); - }, - }, - cancel: { - label: "Decline", - onClick: () => { - log(2, "call", "purple", "Declined call invite", { - callId, - senderId, - }); - }, - }, + (callId: string, callSecret: string, senderId: number) => { + useCall.setState({ + incomingCallInvite: { callId, callSecret, senderId }, }); }, - [get], + [], ); + const setInvitePopupOpen = useCallback((open: boolean) => { + if (!open) { + useCall.setState({ incomingCallInvite: null }); + } + }, []); + + const respondToInvite = useCallback((accepted: boolean) => { + const invite = useCall.getState().incomingCallInvite; + + useCall.setState({ incomingCallInvite: null }); + + if (!invite) { + return; + } + + if (accepted) { + joinCall(invite.senderId, invite.callSecret, invite.callId, false).catch( + (err) => { + log(1, "call", "red", "Failed to join call", err); + }, + ); + return; + } + + log(2, "call", "purple", "Declined call invite", { + callId: invite.callId, + senderId: invite.senderId, + }); + }, []); + // listen to call invites useEffect(() => { subscribePush(async (message) => { @@ -1482,4 +1504,13 @@ export function useInitializeCall() { }); }); }, [callId, send, view]); + + return incomingCallInvite ? ( + + ) : null; } diff --git a/packages/chat/todo.md b/packages/chat/todo.md index 4bd036a..63466f3 100644 --- a/packages/chat/todo.md +++ b/packages/chat/todo.md @@ -1,3 +1,2 @@ - Implement context menu features - Add default-emoji-hotkey in settings -- On mobile, turn ContextMenu into Drawer From ed2fd09defa21a12076cbd223eaf87c0da5af6b4 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 1 Jun 2026 14:26:13 +0200 Subject: [PATCH 4/4] (feat): track call invite when it is received (fix): call invites getting sent by non-call-creators --- packages/call/src/store.tsx | 52 ++++++++++++++++++++------------ packages/storage/src/session.tsx | 24 ++++++++++++++- 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index e2595b6..6f3955c 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -6,6 +6,7 @@ import { log, toast } from "@tensamin/shared/log"; import { ttp } from "@tensamin/shared/data"; import { useCrypto } from "@tensamin/crypto/context"; import { useStorage } from "@tensamin/storage/context"; +import { useSession } from "@tensamin/storage/session"; import { useUser } from "@tensamin/user/context"; import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; import { @@ -890,7 +891,7 @@ export async function joinCall( log(2, "call", "purple", "Call creation initialised"); useCall.setState({ state: "encrypting", - invitedUserId: sendInvite ? userId : null, + invitedUserId: sendInvite && !existingCallId ? userId : null, }); if (callSecret) { @@ -1089,6 +1090,7 @@ export function useInitializeCall() { const { send, subscribePush } = useTTP(); const { getSharedSecret, decryptText, encryptText } = useCrypto(); const { load } = useStorage(); + const { insertCall } = useSession(); const { get } = useUser(); const callId = useCall((state) => state.callId); @@ -1137,29 +1139,41 @@ export function useInitializeCall() { } }, []); - const respondToInvite = useCallback((accepted: boolean) => { - const invite = useCall.getState().incomingCallInvite; + const respondToInvite = useCallback( + (accepted: boolean) => { + const invite = useCall.getState().incomingCallInvite; - useCall.setState({ incomingCallInvite: null }); + useCall.setState({ incomingCallInvite: null }); - if (!invite) { - return; - } + if (!invite) { + return; + } - if (accepted) { - joinCall(invite.senderId, invite.callSecret, invite.callId, false).catch( - (err) => { + insertCall({ + call_id: invite.callId, + call_secret: invite.callSecret, + call_members: [invite.senderId], + }); + + if (accepted) { + joinCall( + invite.senderId, + invite.callSecret, + invite.callId, + false, + ).catch((err) => { log(1, "call", "red", "Failed to join call", err); - }, - ); - return; - } + }); + return; + } - log(2, "call", "purple", "Declined call invite", { - callId: invite.callId, - senderId: invite.senderId, - }); - }, []); + log(2, "call", "purple", "Declined call invite", { + callId: invite.callId, + senderId: invite.senderId, + }); + }, + [insertCall], + ); // listen to call invites useEffect(() => { diff --git a/packages/storage/src/session.tsx b/packages/storage/src/session.tsx index d992a03..892f3e6 100644 --- a/packages/storage/src/session.tsx +++ b/packages/storage/src/session.tsx @@ -15,6 +15,7 @@ interface SessionContextType { calls: Calls; moveUserIdToTop: (userId: number) => void; insertContact: (userId: number) => void; + insertCall: (call: Calls[number]) => void; } const SessionContext = createContext(undefined); @@ -24,6 +25,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) { const { load, save } = useStorage(); const [contacts, setContacts] = useState([]); const [communities, setCommunities] = useState([]); + const [calls, setCalls] = useState([]); // Get cached data and merge fresh data useEffect(() => { @@ -51,6 +53,15 @@ export default function SessionProvider({ children }: { children: ReactNode }) { }); }, [load, freshContacts, freshCommunities]); + useEffect(() => { + setCalls((prevCalls) => [ + ...freshCalls, + ...prevCalls.filter( + (call) => !freshCalls.some((fresh) => fresh.call_id === call.call_id), + ), + ]); + }, [freshCalls]); + // Save data useEffect(() => { save("cached_contacts", contacts); @@ -86,14 +97,25 @@ export default function SessionProvider({ children }: { children: ReactNode }) { }); }; + const insertCall = (call: Calls[number]) => { + setCalls((prevCalls) => { + if (prevCalls.some((prevCall) => prevCall.call_id === call.call_id)) { + return prevCalls; + } + + return [call, ...prevCalls]; + }); + }; + return ( {children}