(feat): add native notifications
All checks were successful
/ build-web (push) Successful in 1m9s
/ build-desktop (push) Successful in 11m16s
/ build-mobile (push) Successful in 15m50s
/ release (push) Successful in 23s

(feat): add navbar profile popover
(fix): chat input box click area to slim
(qol): format
This commit is contained in:
Alois 2026-05-24 18:31:27 +02:00
commit 7ea3b32286
9 changed files with 131 additions and 59 deletions

View file

@ -12,9 +12,7 @@ const tauriConfigPath = path.resolve(__dirname, "./src-tauri/tauri.conf.json");
const isUnrender = process.argv.includes("--unrender"); const isUnrender = process.argv.includes("--unrender");
// Version Source // Version Source
const packageJson = JSON.parse( const packageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, "utf8"));
fs.readFileSync(rootPackageJsonPath, "utf8")
);
const packageVersion: string = packageJson.version; const packageVersion: string = packageJson.version;
@ -22,9 +20,7 @@ if (!packageVersion && !isUnrender) {
throw new Error("No version found in package.json"); throw new Error("No version found in package.json");
} }
const targetVersion = isUnrender const targetVersion = isUnrender ? PLACEHOLDER_VERSION : packageVersion;
? PLACEHOLDER_VERSION
: packageVersion;
// Helpers // Helpers
function updateCargoToml(content: string): string { function updateCargoToml(content: string): string {
@ -34,10 +30,7 @@ function updateCargoToml(content: string): string {
throw new Error("Could not find version field in Cargo.toml"); throw new Error("Could not find version field in Cargo.toml");
} }
return content.replace( return content.replace(regex, `version = "${targetVersion}"`);
regex,
`version = "${targetVersion}"`
);
} }
function updateTauriConfig(content: string): string { function updateTauriConfig(content: string): string {
@ -47,10 +40,7 @@ function updateTauriConfig(content: string): string {
throw new Error("Could not find version field in tauri.conf.json"); throw new Error("Could not find version field in tauri.conf.json");
} }
return content.replace( return content.replace(regex, `"version": "${targetVersion}"`);
regex,
`"version": "${targetVersion}"`
);
} }
// Update Cargo.toml // Update Cargo.toml
@ -72,4 +62,4 @@ if (isUnrender) {
console.log(`Unrendered versions back to ${PLACEHOLDER_VERSION}`); console.log(`Unrendered versions back to ${PLACEHOLDER_VERSION}`);
} else { } else {
console.log(`Rendered version ${targetVersion}`); console.log(`Rendered version ${targetVersion}`);
} }

View file

@ -0,0 +1,34 @@
import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import Text from "@tensamin/markdown/text";
export default function Profile({ user }: { user: User }) {
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Avatar className="size-10">
<AvatarImage src={user.avatar} />
<AvatarFallback className="text-lg">
{user.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-lg font-semibold">{user.display}</p>
<p className="text-muted-foreground">{user.username}</p>
</div>
</div>
<Text value={user.about || ""} />
<div className="flex flex-col">
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
iota: {user.iota_id}
</p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
user: {user.user_id}
</p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
pub key: {user.public_key}
</p>
</div>
</div>
);
}

View file

@ -1,7 +1,8 @@
import { Button } from "@tensamin/ui"; import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
import { import {
ArrowLeft, ArrowLeft,
ChevronDown, ChevronDown,
ChevronUp,
House, House,
Phone, Phone,
Settings, Settings,
@ -17,6 +18,7 @@ import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@tensamin/ui"; import { SidebarTrigger, useSidebar } from "@tensamin/ui";
import { WindowControls as Controls } from "@tensamin/ui"; import { WindowControls as Controls } from "@tensamin/ui";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import Profile from "./modals/profile";
export default function Navbar({ forMobile }: { forMobile: boolean }) { export default function Navbar({ forMobile }: { forMobile: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,6 +35,8 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const [selectOpen, setSelectOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false);
const [userInfoOpen, setUserInfoOpen] = useState(false);
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
@ -69,16 +73,27 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<Wrapper <Wrapper
userId={id} userId={id}
component={(user) => ( component={(user) => (
<Button <>
variant="link" <Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
className="px-0! text-foreground" <PopoverTrigger
style={{ render={
textDecorationLine: "none", <Button
}} variant="link"
> className="px-0! text-foreground"
<p className="font-medium text-md">{user?.display}</p> style={{
<ChevronDown /> textDecorationLine: "none",
</Button> }}
>
<p className="font-medium text-md">{user?.display}</p>
{userInfoOpen ? <ChevronUp /> : <ChevronDown />}
</Button>
}
/>
<PopoverContent side="bottom">
<Profile user={user} />
</PopoverContent>
</Popover>
</>
)} )}
loading={<Skeleton className="ml-3 w-40 h-5" />} loading={<Skeleton className="ml-3 w-40 h-5" />}
/> />

View file

@ -171,6 +171,7 @@
"name": "@tensamin/notifications", "name": "@tensamin/notifications",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.169.1",
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",
"@tensamin/chat": "workspace:*", "@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*", "@tensamin/crypto": "workspace:*",

View file

@ -295,13 +295,15 @@ async function updateLocalParticipantAttributes(
screenSharePreviewLength: attributes.screenSharePreview?.length ?? 0, screenSharePreviewLength: attributes.screenSharePreview?.length ?? 0,
}); });
await getRoom().localParticipant.setAttributes(attributes).catch((error) => { await getRoom()
log(1, "call", "red", "Failed to update local participant attributes", { .localParticipant.setAttributes(attributes)
attributes: Object.keys(attributes), .catch((error) => {
error, log(1, "call", "red", "Failed to update local participant attributes", {
attributes: Object.keys(attributes),
error,
});
throw error;
}); });
throw error;
});
log(2, "call", "purple", "Updated local participant attributes", { log(2, "call", "purple", "Updated local participant attributes", {
attributeKeys: Object.keys(attributes), attributeKeys: Object.keys(attributes),
@ -795,11 +797,13 @@ export async function connect(callId: string) {
syncAllRemoteTrackSubscriptions(); syncAllRemoteTrackSubscriptions();
await getRoom().localParticipant.setMicrophoneEnabled(true).catch((error) => { await getRoom()
log(1, "call", "red", "Failed to enable microphone", error); .localParticipant.setMicrophoneEnabled(true)
toast("error", "Failed to enable microphone."); .catch((error) => {
throw error; log(1, "call", "red", "Failed to enable microphone", error);
}); toast("error", "Failed to enable microphone.");
throw error;
});
syncParticipantState(); syncParticipantState();
} }

View file

@ -97,6 +97,7 @@ export default function InputComponent({
> >
<CardHeader className="relative p-0 flex flex-col"> <CardHeader className="relative p-0 flex flex-col">
<Input <Input
className="w-full"
paddingY="13px" paddingY="13px"
paddingX="13px" paddingX="13px"
placeholder="Send a message..." placeholder="Send a message..."

View file

@ -12,6 +12,7 @@
"build": "tsc -p tsconfig.json --noEmit" "build": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.169.1",
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",
"@tensamin/crypto": "workspace:*", "@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",

View file

@ -10,16 +10,17 @@ import { message as messageSchema } from "@tensamin/shared/data";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui"; import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import { useNavigate } from "@tanstack/react-router";
export const context = createContext<contextType | undefined>(undefined); export const context = createContext<contextType | undefined>(undefined);
function reduceDisplay(display: string) { async function requestNotificationPermission() {
const words = display.split(" "); if (!("Notification" in window)) return false;
if (words.length === 1) {
return display.slice(0, 2).toUpperCase(); if (Notification.permission === "granted") return true;
} else {
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase(); const permission = await Notification.requestPermission();
} return permission === "granted";
} }
export default function Provider(props: { children: React.ReactNode }) { export default function Provider(props: { children: React.ReactNode }) {
@ -29,6 +30,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const { decryptText, getSharedSecret } = useCrypto(); const { decryptText, getSharedSecret } = useCrypto();
const { addLiveMessage, userId } = useChat(); const { addLiveMessage, userId } = useChat();
const { moveUserIdToTop } = useSession(); const { moveUserIdToTop } = useSession();
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
return subscribePush(async (ttpMessage) => { return subscribePush(async (ttpMessage) => {
@ -54,7 +56,7 @@ export default function Provider(props: { children: React.ReactNode }) {
(await load("settings.read_confirmations")) && (await load("settings.read_confirmations")) &&
userId === sender_id userId === sender_id
) { ) {
await send( void send(
"message_state", "message_state",
{ {
message_state: "read", message_state: "read",
@ -64,7 +66,7 @@ export default function Provider(props: { children: React.ReactNode }) {
}, },
); );
} else { } else {
await send( void send(
"message_state", "message_state",
{ {
message_state: "received", message_state: "received",
@ -92,18 +94,41 @@ export default function Provider(props: { children: React.ReactNode }) {
if (isTauri()) { if (isTauri()) {
console.log("weewoo"); console.log("weewoo");
} else { } else {
sonnerToast(user.display, { const hasPermissions = await requestNotificationPermission();
classNames: {
content: "pl-4", if (hasPermissions) {
}, const notification = new Notification(user.display, {
description: decryptedContent, body: decryptedContent,
icon: ( icon: user.avatar || user.display.slice(0, 2).toUpperCase(),
<Avatar> badge: user.avatar || user.display.slice(0, 2).toUpperCase(),
<AvatarImage src={user.avatar} /> tag: `message-${user.user_id}`,
<AvatarFallback>{reduceDisplay(user.display)}</AvatarFallback> silent: true,
</Avatar> });
),
}); notification.onclick = () => {
window.focus();
navigate({
to: `/chat?id=${user.user_id}`,
});
notification.close();
};
} else {
sonnerToast(user.display, {
classNames: {
content: "pl-4",
},
description: decryptedContent,
icon: (
<Avatar>
<AvatarImage src={user.avatar} />
<AvatarFallback>
{user.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
),
});
}
} }
} }
}); });
@ -117,6 +142,7 @@ export default function Provider(props: { children: React.ReactNode }) {
userId, userId,
moveUserIdToTop, moveUserIdToTop,
send, send,
navigate,
]); ]);
return ( return (

View file

@ -257,7 +257,7 @@ export function Provider(props: {
setErrorDescription( setErrorDescription(
"You could try to restart your Iota, check for updates or check your network connection.", "You could try to restart your Iota, check for updates or check your network connection.",
); );
}) });
}, [connected, subscribePush]); }, [connected, subscribePush]);
useEffect(() => { useEffect(() => {