diff --git a/apps/chrome-extension/src/components/WalletTab.tsx b/apps/chrome-extension/src/components/WalletTab.tsx index d5a2205d8..581d6d521 100644 --- a/apps/chrome-extension/src/components/WalletTab.tsx +++ b/apps/chrome-extension/src/components/WalletTab.tsx @@ -239,7 +239,6 @@ const WalletTab = () => { onClose={handleMnemonicModalClose} /> - + + + + + ); +} diff --git a/apps/messaging/src/components/settings/Appearance.tsx b/apps/messaging/src/components/settings/Appearance.tsx new file mode 100644 index 000000000..8ae6c7bce --- /dev/null +++ b/apps/messaging/src/components/settings/Appearance.tsx @@ -0,0 +1,33 @@ +import { Box, Flex, Heading, IconButton, HStack, Text } from "@chakra-ui/react"; +import { LuArrowLeft } from "react-icons/lu"; +import { ColorModeButton, useColorMode } from "../../contexts/ColorModeProvider"; +import SlideInRight from "../transitions/SlideInRight"; + +export interface AppearanceProps { + isOpen: boolean; + onClose: () => void; +} + +export default function Appearance({ isOpen, onClose }: AppearanceProps) { + const { colorMode } = useColorMode(); + + return ( + + + + + + Appearance + + + + + + Dark Mode + + + + + + ); +} diff --git a/apps/messaging/src/components/settings/Services.tsx b/apps/messaging/src/components/settings/Services.tsx new file mode 100644 index 000000000..b4cd10f87 --- /dev/null +++ b/apps/messaging/src/components/settings/Services.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; +import { Box, Button, Field, Flex, Heading, IconButton, Input } from "@chakra-ui/react"; +import { LuArrowLeft } from "react-icons/lu"; +import { useColorMode } from "../../contexts/ColorModeProvider"; +import { useWalletContext } from "../../contexts/WalletProvider"; +import { useSnackbar } from "../../contexts/SnackbarProvider"; +import { + DEFAULT_GATEKEEPER_URL, + DEFAULT_SEARCH_SERVER_URL, + GATEKEEPER_KEY, + SEARCH_SERVER_KEY, +} from "../../constants"; +import {useVariablesContext} from "../../contexts/VariablesProvider"; +import SlideInRight from "../transitions/SlideInRight"; + +export interface ServicesSettingsProps { + isOpen: boolean; + onClose: () => void; +} + +export default function Services({ isOpen, onClose }: ServicesSettingsProps) { + const { colorMode } = useColorMode(); + const { initialiseServices, initialiseWallet } = useWalletContext(); + const { setError, setSuccess } = useSnackbar(); + const { refreshAll } = useVariablesContext(); + + const [gatekeeperUrl, setGatekeeperUrl] = useState(DEFAULT_GATEKEEPER_URL); + const [searchServerUrl, setSearchServerUrl] = useState(DEFAULT_SEARCH_SERVER_URL); + + useEffect(() => { + if (!isOpen) { + return; + } + try { + const gatekeeper = localStorage.getItem(GATEKEEPER_KEY) || DEFAULT_GATEKEEPER_URL; + const search = localStorage.getItem(SEARCH_SERVER_KEY) || DEFAULT_SEARCH_SERVER_URL; + setGatekeeperUrl(gatekeeper); + setSearchServerUrl(search); + } catch {} + }, [isOpen]); + + async function handleSave() { + const gatekeeper = gatekeeperUrl.trim(); + const search = searchServerUrl.trim(); + if (!gatekeeper || !search) { + setError("Both Gatekeeper URL and Search Server URL are required"); + return; + } + + try { + localStorage.setItem(GATEKEEPER_KEY, gatekeeper); + localStorage.setItem(SEARCH_SERVER_KEY, search); + await initialiseServices(); + await initialiseWallet(); + setSuccess("Services updated"); + await refreshAll(); + } catch (e: any) { + setError(e); + } + } + + return ( + + + + + + Services + + + + + Gatekeeper URL + setGatekeeperUrl(e.target.value)} + placeholder={DEFAULT_GATEKEEPER_URL} + /> + + + + Search Server URL + setSearchServerUrl(e.target.value)} + placeholder={DEFAULT_SEARCH_SERVER_URL} + /> + + + + + + + + ); +} diff --git a/apps/messaging/src/components/settings/SettingsMenu.tsx b/apps/messaging/src/components/settings/SettingsMenu.tsx new file mode 100644 index 000000000..512cc3fbf --- /dev/null +++ b/apps/messaging/src/components/settings/SettingsMenu.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { Box, HStack, Text, Button, Flex, IconButton, Heading } from "@chakra-ui/react"; +import { useColorMode } from "../../contexts/ColorModeProvider"; +import { LuArrowLeft, LuChevronRight, LuWallet, LuServer, LuPalette } from "react-icons/lu"; +import Wallet from "./Wallet"; +import Services from "./Services"; +import Appearance from "./Appearance"; +import SlideInRight from "../transitions/SlideInRight"; + +export interface SettingsProps { + isOpen: boolean; + onClose: () => void; +} + +export default function SettingsMenu({ isOpen, onClose }: SettingsProps) { + const { colorMode } = useColorMode(); + + const [isWalletOpen, setIsWalletOpen] = useState(false); + const [isServicesOpen, setIsServicesOpen] = useState(false); + const [isAppearanceOpen, setIsAppearanceOpen] = useState(false); + + return ( + <> + setIsWalletOpen(false)} + /> + + setIsServicesOpen(false)} + /> + + setIsAppearanceOpen(false)} + /> + + + + + + + Settings + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/messaging/src/components/settings/Wallet.tsx b/apps/messaging/src/components/settings/Wallet.tsx new file mode 100644 index 000000000..4ecec6e4e --- /dev/null +++ b/apps/messaging/src/components/settings/Wallet.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Box, Button, Flex, Heading, IconButton } from "@chakra-ui/react"; +import { LuArrowLeft } from "react-icons/lu"; +import { useColorMode } from "../../contexts/ColorModeProvider"; +import WarningModal from "../../modals/WarningModal"; +import MnemonicModal from "../../modals/MnemonicModal"; +import { useWalletContext } from "../../contexts/WalletProvider"; +import { useSnackbar } from "../../contexts/SnackbarProvider"; +import SlideInRight from "../transitions/SlideInRight"; + +export interface WalletSettingsProps { + isOpen: boolean; + onClose: () => void; +} + +export default function Wallet({ isOpen, onClose }: WalletSettingsProps) { + const { colorMode } = useColorMode(); + const { keymaster, wipeWallet } = useWalletContext(); + const { setError, setSuccess } = useSnackbar(); + + const [resetOpen, setResetOpen] = useState(false); + const [backupOpen, setBackupOpen] = useState(false); + const [mnemonicOpen, setMnemonicOpen] = useState(false); + const [mnemonic, setMnemonic] = useState(""); + + const handleConfirmReset = async () => { + onClose(); + setResetOpen(false); + wipeWallet(); + }; + + const handleRevealMnemonic = async () => { + if (!keymaster) { + return; + } + try { + const m = await keymaster.decryptMnemonic(); + setMnemonic(m); + setMnemonicOpen(true); + } catch (e: any) { + setError(e); + } + }; + + async function handleConfirmBackup() { + if (!keymaster) return; + try { + await keymaster.backupWallet(); + setSuccess("Wallet backup created"); + } catch (error: any) { + setError(error); + } finally { + setBackupOpen(false); + } + } + + return ( + <> + setBackupOpen(false)} + /> + + setResetOpen(false)} + /> + + { + setMnemonicOpen(false); + setMnemonic(""); + }} + errorText={""} + mnemonic={mnemonic} + /> + + + + + + + Wallet + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/messaging/src/components/transitions/SlideInRight.tsx b/apps/messaging/src/components/transitions/SlideInRight.tsx new file mode 100644 index 000000000..bcdc16622 --- /dev/null +++ b/apps/messaging/src/components/transitions/SlideInRight.tsx @@ -0,0 +1,33 @@ +import { PropsWithChildren } from "react"; +import { Box } from "@chakra-ui/react"; + +export interface SlideInRightProps { + isOpen: boolean; + zIndex?: number; + bottomOffset?: string | number; + bg?: string; + position?: "absolute" | "fixed"; +} + +export default function SlideInRight({ isOpen, zIndex = 1200, bottomOffset = "46px", bg = "white", position = "absolute", children }: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/apps/messaging/src/constants.ts b/apps/messaging/src/constants.ts new file mode 100644 index 000000000..3cd97b2de --- /dev/null +++ b/apps/messaging/src/constants.ts @@ -0,0 +1,18 @@ +import {Capacitor} from "@capacitor/core"; + +const platform = Capacitor.getPlatform(); +const isAndroid = platform === 'android'; +const HOST = isAndroid ? '10.0.2.2' : 'localhost'; + +const ENV_GATEKEEPER = (import.meta.env.VITE_GATEKEEPER_URL as string) || ''; +const ENV_SEARCH = (import.meta.env.VITE_SEARCH_URL as string) || ''; + +export const DEFAULT_GATEKEEPER_URL = ENV_GATEKEEPER || `http://${HOST}:4224`; +export const DEFAULT_SEARCH_SERVER_URL = ENV_SEARCH || `http://${HOST}:4002`; +export const GATEKEEPER_KEY = 'mdip-messaging-gatekeeper-url'; +export const SEARCH_SERVER_KEY = 'mdip-messaging-search-server-url'; + +export const CHAT_SUBJECT = "mdip-messaging"; +export const WALLET_NAME = "mdip-messaging-wallet"; + +export const MESSAGING_PROFILE = "mdip-messaging-profile"; diff --git a/apps/messaging/src/contexts/ColorModeProvider.tsx b/apps/messaging/src/contexts/ColorModeProvider.tsx new file mode 100644 index 000000000..fd56e06fa --- /dev/null +++ b/apps/messaging/src/contexts/ColorModeProvider.tsx @@ -0,0 +1,68 @@ +"use client" + +import { forwardRef } from "react" +import type { ReactNode } from "react" +import type { IconButtonProps } from "@chakra-ui/react" +import { ClientOnly, IconButton, Skeleton } from "@chakra-ui/react" +import { ThemeProvider, useTheme, type ThemeProviderProps } from "next-themes" +import { LuMoon, LuSun } from "react-icons/lu" + +export interface ColorModeProviderProps + extends Omit { + children?: ReactNode +} + +export function ColorModeProvider({ children, ...themeProps }: ColorModeProviderProps) { + return ( + + {children} + + ) +} + +export type ColorMode = "light" | "dark" + +export interface UseColorModeReturn { + colorMode: ColorMode + setColorMode: (colorMode: ColorMode) => void + toggleColorMode: () => void +} + +export function useColorMode(): UseColorModeReturn { + const { resolvedTheme, setTheme } = useTheme() + const colorMode = (resolvedTheme as ColorMode) || "light" + const toggleColorMode = () => setTheme(colorMode === "dark" ? "light" : "dark") + return { + colorMode, + setColorMode: setTheme as (mode: ColorMode) => void, + toggleColorMode, + } +} + +export function ColorModeIcon() { + const { colorMode } = useColorMode() + return colorMode === "dark" ? : +} + +interface ColorModeButtonProps extends Omit {} + +export const ColorModeButton = forwardRef( + function ColorModeButton(props, ref) { + const { toggleColorMode } = useColorMode() + return ( + }> + + + + + ) + } +) diff --git a/apps/messaging/src/contexts/ContextProviders.tsx b/apps/messaging/src/contexts/ContextProviders.tsx new file mode 100644 index 000000000..99ffc966d --- /dev/null +++ b/apps/messaging/src/contexts/ContextProviders.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from "react"; +import { UIProvider } from "./UIProvider"; +import { WalletProvider } from "./WalletProvider"; +import { VariablesProvider } from "./VariablesProvider"; +import { SafeAreaProvider } from "./SafeAreaContext"; +import { SnackbarProvider } from "./SnackbarProvider"; + +export function ContextProviders( + { + children + }: { + children: ReactNode + }) { + return ( + + + + + + {children} + + + + + + ); +} diff --git a/apps/messaging/src/contexts/SafeAreaContext.tsx b/apps/messaging/src/contexts/SafeAreaContext.tsx new file mode 100644 index 000000000..83ad81d17 --- /dev/null +++ b/apps/messaging/src/contexts/SafeAreaContext.tsx @@ -0,0 +1,94 @@ +import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; + +export type SafeAreaInsets = { + top: number; + bottom: number; + left: number; + right: number; +}; + +const SafeAreaContext = createContext(null); + +function measureEnvInset(side: 'top' | 'bottom' | 'left' | 'right'): number { + try { + const el = document.createElement('div'); + const cssProp = side === 'top' + ? 'padding-top' + : side === 'bottom' + ? 'padding-bottom' + : side === 'left' + ? 'padding-left' + : 'padding-right'; + el.setAttribute('style', `position:fixed; visibility:hidden; pointer-events:none; ${cssProp}: env(safe-area-inset-${side}); ${cssProp}: constant(safe-area-inset-${side});`); + document.body.appendChild(el); + const styles = window.getComputedStyle(el); + const val = parseFloat(styles.getPropertyValue(cssProp)) || 0; + document.body.removeChild(el); + return Math.max(0, Math.round(val)); + } catch { + return 0; + } +} + +function computeVisualViewportInsets(): SafeAreaInsets { + const view = window.visualViewport; + if (!view) { + return {top: 0, bottom: 0, left: 0, right: 0}; + } + const top = Math.max(0, Math.round(view.offsetTop)); + const left = Math.max(0, Math.round(view.offsetLeft)); + const bottom = Math.max(0, Math.round((window.innerHeight - view.height - view.offsetTop))); + const right = Math.max(0, Math.round((window.innerWidth - view.width - view.offsetLeft))); + return { top, bottom, left, right }; +} + +function getInsets(): SafeAreaInsets { + const envTop = measureEnvInset('top'); + const envBottom = measureEnvInset('bottom'); + const envLeft = measureEnvInset('left'); + const envRight = measureEnvInset('right'); + const view = computeVisualViewportInsets(); + return { + top: Math.max(envTop, view.top), + bottom: Math.max(envBottom, view.bottom), + left: Math.max(envLeft, view.left), + right: Math.max(envRight, view.right), + }; +} + +export function SafeAreaProvider({ children }: { children: ReactNode }) { + const [insets, setInsets] = useState({ top: 0, bottom: 0, left: 0, right: 0 }); + + useEffect(() => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const update = () => setInsets(getInsets()); + + update(); + + const view = window.visualViewport; + view?.addEventListener('resize', update); + view?.addEventListener('scroll', update); + window.addEventListener('resize', update); + window.addEventListener('orientationchange', update); + + return () => { + view?.removeEventListener('resize', update); + view?.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + window.removeEventListener('orientationchange', update); + }; + }, []); + + return {children}; +} + +export function useSafeArea(): SafeAreaInsets { + const ctx = useContext(SafeAreaContext); + if (!ctx) { + return {top: 0, bottom: 0, left: 0, right: 0}; + } + return ctx; +} diff --git a/apps/messaging/src/contexts/SnackbarProvider.tsx b/apps/messaging/src/contexts/SnackbarProvider.tsx new file mode 100644 index 000000000..bbbf777d4 --- /dev/null +++ b/apps/messaging/src/contexts/SnackbarProvider.tsx @@ -0,0 +1,63 @@ +import { createContext, ReactNode, useContext, useMemo } from "react"; +import { toaster } from "../modals/Toaster"; + +interface SnackbarContextValue { + setError: (error: any) => void; + setWarning: (warning: string) => void; + setSuccess: (message: string) => void; +} + +const SnackbarContext = createContext(null); + +function extractMessage(error: any): string { + return ( + error?.error || + error?.message || + (typeof error === "string" ? error : JSON.stringify(error)) + ); +} + +export function SnackbarProvider({ children }: { children: ReactNode }) { + const defaultDuration = 5000; + + const value = useMemo( + () => ({ + setSuccess: (message: string) => { + toaster.create({ + type: "success", + title: message, + duration: defaultDuration, + }); + }, + setWarning: (warning: string) => { + toaster.create({ + type: "warning", + title: warning, + duration: defaultDuration, + }); + }, + setError: (error: any) => { + toaster.create({ + type: "error", + title: extractMessage(error), + duration: defaultDuration, + }); + }, + }), + [] + ); + + return ( + + {children} + + ); +} + +export function useSnackbar() { + const ctx = useContext(SnackbarContext); + if (!ctx) { + throw new Error("useSnackbar must be used within SnackbarProvider"); + } + return ctx; +} diff --git a/apps/messaging/src/contexts/UIProvider.tsx b/apps/messaging/src/contexts/UIProvider.tsx new file mode 100644 index 000000000..c70857426 --- /dev/null +++ b/apps/messaging/src/contexts/UIProvider.tsx @@ -0,0 +1,13 @@ +import {ChakraProvider, defaultSystem} from "@chakra-ui/react"; +import { + ColorModeProvider, + type ColorModeProviderProps, +} from "./ColorModeProvider" + +export function UIProvider(props: ColorModeProviderProps) { + return ( + + + + ) +} diff --git a/apps/messaging/src/contexts/VariablesProvider.tsx b/apps/messaging/src/contexts/VariablesProvider.tsx new file mode 100644 index 000000000..e7266a270 --- /dev/null +++ b/apps/messaging/src/contexts/VariablesProvider.tsx @@ -0,0 +1,693 @@ +import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useState, useRef, useEffect, useCallback } from "react"; +import { DmailItem } from "@mdip/keymaster/types"; +import { useWalletContext } from "./WalletProvider"; +import { useSnackbar } from "./SnackbarProvider"; +import { CHAT_SUBJECT, MESSAGING_PROFILE } from "../constants"; +import { parseChatPayload } from "../utils/utils"; + +const REFRESH_INTERVAL = 5_000; +const UNREAD = "unread"; + +interface VariablesContextValue { + currentId: string; + setCurrentId: Dispatch>; + currentDID: string; + setCurrentDID: Dispatch>; + registries: string[]; + setRegistries: Dispatch>; + idList: string[]; + setIdList: Dispatch>; + heldList: string[]; + setHeldList: Dispatch>; + schemaList: string[]; + setSchemaList: Dispatch>; + vaultList: string[]; + setVaultList: Dispatch>; + groupList: Record; + setGroupList: Dispatch>>; + imageList: string[]; + setImageList: Dispatch>; + documentList: string[]; + setDocumentList: Dispatch>; + issuedList: string[]; + setIssuedList: Dispatch>; + dmailList: Record; + setDmailList: Dispatch>>; + aliasName: string; + setAliasName: Dispatch>; + aliasDID: string; + setAliasDID: Dispatch>; + nameList: Record; + setNameList: Dispatch>>; + displayNameList: Record; + setDisplayNameList: Dispatch>>; + nameRegistry: Record; + setNameRegistry: Dispatch>>; + agentList: string[]; + setAgentList: Dispatch>; + profileList: Record; + setProfileList: Dispatch>>; + pollList: string[]; + setPollList: Dispatch>; + activePeer: string; + setActivePeer: Dispatch>; + resolveAvatar: (assetDid: string) => Promise, + refreshAll: () => Promise; + refreshHeld: () => Promise; + refreshNames: () => Promise; + refreshInbox: () => Promise; + refreshCurrentID: () => Promise; +} + +const VariablesContext = createContext(null); + +type GroupInfo = { + name: string; + members: string[]; +}; + +export function VariablesProvider({ children }: { children: ReactNode }) { + const [currentId, setCurrentId] = useState(""); + const [currentDID, setCurrentDID] = useState(""); + const [idList, setIdList] = useState([]); + const [registries, setRegistries] = useState([]); + const [heldList, setHeldList] = useState([]); + const [nameList, setNameList] = useState>({}); + const [displayNameList, setDisplayNameList] = useState>({}); + const [nameRegistry, setNameRegistry] = useState>({}); + const [agentList, setAgentList] = useState([]); + const [profileList, setProfileList] = useState>({}); + const [pollList, setPollList] = useState([]); + const [groupList, setGroupList] = useState>({}); + const [imageList, setImageList] = useState([]); + const [documentList, setDocumentList] = useState([]); + const [schemaList, setSchemaList] = useState([]); + const [vaultList, setVaultList] = useState([]); + const [issuedList, setIssuedList] = useState([]); + const [aliasName, setAliasName] = useState(""); + const [aliasDID, setAliasDID] = useState(""); + const [dmailList, setDmailList] = useState>({}); + const [activePeer, setActivePeer] = useState(""); + const [namesReady, setNamesReady] = useState(false); + const inboxRefreshingRef = useRef(false); + const { + keymaster, + setManifest, + } = useWalletContext(); + const { setError } = useSnackbar(); + + const avatarCache = useRef>(new Map()); + + useEffect(() => { + const refresh = async () => { + await refreshAll(); + }; + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function getProfileName( + storedAlias: string, + didDocumentData: Record + ): string { + const profile = didDocumentData[MESSAGING_PROFILE] as { name?: unknown } | undefined; + + if (!profile || typeof profile.name !== "string") { + return storedAlias; + } + + const trimmed = profile.name.trim(); + return trimmed || storedAlias; + } + + const refreshNames = useCallback( + async () => { + if (!keymaster) { + return; + } + + const canonical = await keymaster.listNames({ includeIDs: true }); + const canonicalSorted = Object.fromEntries( + Object.entries(canonical).sort(([a], [b]) => a.localeCompare(b)) + ) as Record; + + const registryMap: Record = {}; + const profileListLocal: Record = {}; + const canonicalAliasToDid: Record = { ...canonicalSorted }; + + const displayNameToDid: Record = {}; + const didToDisplay: Record = {}; + const agentDisplayNames: string[] = []; + + const idNames = await keymaster.listIds(); + setIdList([...idNames]); + + const schemaList = []; + const imageList = []; + const groupListLocal: Record = {}; + const vaultList = []; + const pollList = []; + const documentList = []; + + const allocDisplayName = (baseName: string, did: string) => { + let display = baseName; + + if (displayNameToDid[display] && displayNameToDid[display] !== did) { + let suffix = 2; + while (displayNameToDid[`${baseName} #${suffix}`] && displayNameToDid[`${baseName} #${suffix}`] !== did) { + suffix++; + } + display = `${baseName} #${suffix}`; + } + + displayNameToDid[display] = did; + didToDisplay[did] = display; + agentDisplayNames.push(display); + return display; + }; + + for (const idName of idNames) { + try { + const doc = await keymaster.resolveDID(idName); + if (doc.mdip?.type !== "agent") { + continue; + } + + const did = doc.didDocument?.id; + if (!did) { + continue; + } + + if (doc.didDocumentData) { + const base = getProfileName(idName, doc.didDocumentData); + const display = didToDisplay[did] ?? allocDisplayName(base, did); + canonicalAliasToDid[idName] = did; + await populateAgentProfile(display, doc.didDocumentData, profileListLocal); + } + } catch {} + } + + for (const [alias, did] of Object.entries(canonicalSorted)) { + try { + const doc = await keymaster.resolveDID(alias); + + const reg = doc.mdip?.registry; + if (reg) { + registryMap[alias] = reg; + } + + const data = doc.didDocumentData as Record; + + if (doc.mdip?.type === "agent") { + const base = getProfileName(alias, data); + + const display = didToDisplay[did] ?? allocDisplayName(base, did); + canonicalAliasToDid[alias] = did; + + await populateAgentProfile(display, data, profileListLocal); + continue; + } + + displayNameToDid[alias] = did; + canonicalAliasToDid[alias] = did; + + if (data.group) { + const group = await keymaster.getGroup(alias); + if (group?.members) { + const name = typeof group.name === "string" && group.name.trim() + ? group.name.trim() + : alias; + groupListLocal[did] = { name, members: group.members }; + } + continue; + } + + if (data.schema) { + schemaList.push(alias); + continue; + } + + if (data.image) { + imageList.push(alias); + continue; + } + + if (data.document) { + documentList.push(alias); + continue; + } + + if (data.groupVault) { + vaultList.push(alias); + continue; + } + + if (data.poll) { + pollList.push(alias); + continue; + } + } + catch {} + } + + try { + const ownedGroups = await keymaster.listGroups(); + for (const groupDid of ownedGroups) { + if (groupListLocal[groupDid]) { + continue; + } + const group = await keymaster.getGroup(groupDid); + if (!group?.members) { + continue; + } + const name = typeof group.name === "string" && group.name.trim() + ? group.name.trim() + : groupDid; + groupListLocal[groupDid] = { name, members: group.members }; + } + } catch {} + + setNameList(canonicalAliasToDid); + setDisplayNameList(displayNameToDid); + setNameRegistry(registryMap); + setProfileList(profileListLocal); + + const uniqueSortedAgents = [...new Set(agentDisplayNames)] + .sort((a, b) => a.localeCompare(b)); + setAgentList(uniqueSortedAgents); + + const mergedGroupList: Record = { ...groupListLocal }; + for (const [groupId, info] of Object.entries(groupList)) { + if (!mergedGroupList[groupId]) { + mergedGroupList[groupId] = info; + } + } + setGroupList(mergedGroupList); + setSchemaList(schemaList); + setImageList(imageList); + setDocumentList(documentList); + setVaultList(vaultList); + setPollList(pollList); + setNamesReady(true); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [keymaster, currentId, groupList] + ); + + const refreshInbox = useCallback( async() => { + if (!keymaster || !namesReady || !currentId) { + return; + } + + if (inboxRefreshingRef.current) { + return; + } + + inboxRefreshingRef.current = true; + let needsRefresh = false; + + try { + const msgs = await keymaster.listDmail(); + const filtered: Record = {}; + const updates: Array<{ did: string; tags: string[] }> = []; + const groupUpdates: Record = {}; + const knownGroups = new Map(Object.entries(groupList)); + + for (const [did, item] of Object.entries(msgs)) { + if (item.message?.subject !== CHAT_SUBJECT) { + continue; + } + + const tags = item.tags ?? []; + const payload = parseChatPayload(item.message?.body ?? ""); + + if (!payload) { + if (tags.includes(UNREAD)) { + updates.push({ did, tags: tags.filter(tag => tag !== UNREAD) }); + } + continue; + } + + const messageText = typeof payload.message === "string" ? payload.message.trim() : ""; + const groupId = typeof payload.groupId === "string" ? payload.groupId.trim() : ""; + const groupName = typeof payload.groupName === "string" ? payload.groupName.trim() : ""; + const toDids = item.message?.to ?? []; + const isGroupDelivery = toDids.length > 1; + + if (isGroupDelivery) { + if (!groupId) { + if (tags.includes(UNREAD)) { + updates.push({ did, tags: tags.filter(tag => tag !== UNREAD) }); + } + continue; + } + + let groupInfo = groupUpdates[groupId] ?? knownGroups.get(groupId); + if (!groupInfo) { + try { + const group = await keymaster.getGroup(groupId); + if (group?.members) { + groupInfo = { + name: groupName || groupId, + members: group.members, + }; + } + } catch {} + } + + if (!groupInfo) { + groupInfo = { name: groupName || groupId, members: [] }; + } else if (groupName && groupInfo.name !== groupName) { + groupInfo = { ...groupInfo, name: groupName }; + } + + groupUpdates[groupId] = groupInfo; + if (!messageText) { + if (tags.includes(UNREAD)) { + updates.push({ did, tags: tags.filter(tag => tag !== UNREAD) }); + } + continue; + } + } else if (!messageText) { + if (tags.includes(UNREAD)) { + updates.push({ did, tags: tags.filter(tag => tag !== UNREAD) }); + } + continue; + } + + filtered[did] = item; + + if (!isGroupDelivery) { + const senderDid = item.docs?.didDocument?.controller; + if (senderDid && senderDid !== currentDID) { + const alreadyKnown = Object.values(nameList).includes(senderDid); + if (!alreadyKnown) { + try { + const name = senderDid.slice(-20); + await keymaster.addName(name, senderDid); + needsRefresh = true; + } catch {} + } + } + } + } + + if (Object.keys(groupUpdates).length > 0) { + setGroupList(prev => { + let changed = false; + const merged = { ...prev }; + for (const [groupId, info] of Object.entries(groupUpdates)) { + const existing = merged[groupId]; + let membersChanged = false; + if (!existing) { + membersChanged = true; + } else { + membersChanged = existing.members.length !== info.members.length + || existing.members.some((member, idx) => member !== info.members[idx]); + } + + if (!existing || existing.name !== info.name || membersChanged) { + merged[groupId] = info; + changed = true; + } + } + return changed ? merged : prev; + }); + } + + setDmailList(prev => + JSON.stringify(prev) === JSON.stringify(filtered) ? prev : filtered + ); + + if (updates.length > 0) { + await Promise.all(updates.map(({ did, tags }) => keymaster.fileDmail(did, tags))); + } + + if (needsRefresh) { + await refreshNames(); + } + } catch (err: any) { + setError(err); + } finally { + inboxRefreshingRef.current = false; + } + }, [keymaster, currentId, currentDID, namesReady, nameList, groupList, refreshNames, setError]); + + useEffect(() => { + if (!keymaster || !namesReady) { + return; + } + + const refresh = async () => { + try { + await keymaster.refreshNotices(); + await refreshInbox(); + } catch {} + } + + void refresh(); + + const interval = setInterval(() => { + if (!keymaster) { + return; + } + void refresh(); + }, REFRESH_INTERVAL); + + return () => clearInterval(interval); + }, [keymaster, namesReady, refreshInbox]); + + async function refreshHeld() { + if (!keymaster) { + return; + } + try { + const heldList = await keymaster.listCredentials(); + setHeldList(heldList); + } catch (error: any) { + setError(error); + } + } + + async function refreshIssued() { + if (!keymaster) { + return; + } + try { + const issuedList = await keymaster.listIssued(); + setIssuedList(issuedList); + } catch (error: any) { + setError(error); + } + } + + async function resolveAvatar(assetDid: string): Promise { + if (!assetDid || !keymaster) { + return null; + } + + if (avatarCache.current.has(assetDid)) { + return avatarCache.current.get(assetDid)!; + } + + try { + const imageAsset = await keymaster.getImage(assetDid); + + if (!imageAsset || !imageAsset.data) { + return null; + } + + const mimeType = imageAsset.type || 'image/png'; + const raw: any = imageAsset.data as any; + const dataPart: BlobPart = raw?.buffer ? new Uint8Array(raw.buffer, raw.byteOffset ?? 0, raw.byteLength ?? raw.length) : new Uint8Array(raw as ArrayBufferLike); + const blob = new Blob([dataPart], { type: mimeType }); + const url = URL.createObjectURL(blob); + + avatarCache.current.set(assetDid, url); + return url; + } catch { + return null; + } + } + + async function populateAgentProfile( + name: string, + didDocumentData: Record, + profileList: Record + ): Promise { + const profile = didDocumentData[MESSAGING_PROFILE] as { avatar?: string; name?: string } | undefined; + if (!profile) { + return; + } + + const entry: { avatar?: string; name?: string } = { + ...(profileList[name] ?? {}), + }; + + if (profile.name) { + entry.name = profile.name; + } + + if (profile.avatar) { + const blobUrl = await resolveAvatar(profile.avatar); + if (blobUrl) { + entry.avatar = blobUrl; + } + } + + if (entry.name || entry.avatar) { + profileList[name] = entry; + } + } + + async function refreshCurrentDID(cid: string) { + if (!keymaster) { + return; + } + try { + const docs = await keymaster.resolveDID(cid); + if (!docs.didDocument || !docs.didDocument.id) { + setError("Failed to set current DID and manifest"); + return; + } + setCurrentDID(docs.didDocument.id); + + const docData = docs.didDocumentData as {manifest?: Record}; + setManifest(docData.manifest); + } catch (error: any) { + setError(error); + } + } + + async function refreshCurrentIDInternal(cid: string) { + if (!keymaster) { + return; + } + setCurrentId(cid); + await refreshHeld(); + await refreshCurrentDID(cid); + await refreshNames(); + await refreshIssued(); + } + + function wipeUserState() { + setCurrentId(""); + setCurrentDID(""); + setManifest({}); + setNameList({}); + setSchemaList([]); + setAgentList([]); + setProfileList({}); + setHeldList([]); + setVaultList([]); + setPollList([]); + setGroupList({}); + setAliasName(""); + setAliasDID(""); + setNamesReady(false); + } + + async function refreshCurrentID() { + if (!keymaster) { + return; + } + try { + const cid = await keymaster.getCurrentId(); + if (cid) { + await refreshCurrentIDInternal(cid); + } else { + wipeUserState(); + } + } catch (error: any) { + setError(error); + return false; + } + + return true; + } + + async function refreshAll() { + if (!keymaster) { + return; + } + + try { + const regs = await keymaster.listRegistries(); + setRegistries(regs); + } catch (error: any) { + setError(error); + } + + try { + await refreshCurrentID(); + } catch (error: any) { + setError(error); + } + } + + + const value: VariablesContextValue = { + currentId, + setCurrentId, + currentDID, + setCurrentDID, + registries, + setRegistries, + idList, + setIdList, + heldList, + setHeldList, + groupList, + setGroupList, + imageList, + setImageList, + documentList, + setDocumentList, + schemaList, + setSchemaList, + vaultList, + setVaultList, + issuedList, + setIssuedList, + aliasName, + setAliasName, + aliasDID, + setAliasDID, + nameList, + setNameList, + displayNameList, + setDisplayNameList, + nameRegistry, + setNameRegistry, + agentList, + setAgentList, + profileList, + setProfileList, + pollList, + setPollList, + dmailList, + setDmailList, + activePeer, + setActivePeer, + resolveAvatar, + refreshAll, + refreshHeld, + refreshNames, + refreshInbox, + refreshCurrentID, + } + + return ( + + {children} + + ); +} + +export function useVariablesContext() { + const ctx = useContext(VariablesContext); + if (!ctx) { + throw new Error('useVariablesContext must be used within VariablesProvider'); + } + return ctx; +} diff --git a/apps/messaging/src/contexts/WalletProvider.tsx b/apps/messaging/src/contexts/WalletProvider.tsx new file mode 100644 index 000000000..3575930a3 --- /dev/null +++ b/apps/messaging/src/contexts/WalletProvider.tsx @@ -0,0 +1,306 @@ +import { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import GatekeeperClient from "@mdip/gatekeeper/client"; +import Keymaster from "@mdip/keymaster"; +import SearchClient from "@mdip/keymaster/search"; +import CipherWeb from "@mdip/cipher"; +import WalletWeb from "@mdip/keymaster/wallet/web"; +import PassphraseModal from "../modals/PassphraseModal"; +import { + DEFAULT_GATEKEEPER_URL, + DEFAULT_SEARCH_SERVER_URL, + GATEKEEPER_KEY, + SEARCH_SERVER_KEY, + WALLET_NAME +} from "../constants"; +import { + getSessionPassphrase, + setSessionPassphrase, + clearSessionPassphrase, +} from "../utils/sessionPassphrase"; +import OnboardingModal from "../modals/OnboardingModal"; +import MnemonicModal from "../modals/MnemonicModal"; +import {useSnackbar} from "./SnackbarProvider"; +import VerifyMnemonicModal from "../modals/VerifyMnemonicModal"; + +const gatekeeper = new GatekeeperClient(); +const cipher = new CipherWeb(); + +interface WalletContextValue { + manifest: Record | undefined; + setManifest: Dispatch | undefined>>; + registry: string; + setRegistry: Dispatch>; + wipeWallet: () => void; + restoreMnemonic: (mnemonic: string) => Promise; + initialiseWallet: () => Promise; + initialiseServices: () => Promise; + keymaster: Keymaster | null; + search?: SearchClient; +} + +const WalletContext = createContext(null); + +let search: SearchClient | undefined; + +export function WalletProvider({ children }: { children: ReactNode }) { + const [manifest, setManifest] = useState | undefined>(undefined); + const [registry, setRegistry] = useState("hyperswarm"); + const [passphraseErrorText, setPassphraseErrorText] = useState(""); + const [modalAction, setModalAction] = useState(null); + const [isReady, setIsReady] = useState(false); + const [isOnboardingOpen, setIsOnboardingOpen] = useState(false); + const [isMnemonicOpen, setIsMnemonicOpen] = useState(false); + const [mnemonicError, setMnemonicError] = useState(""); + const [useMnemonic, setUseMnemonic] = useState(false); + const [newWallet, setNewWallet] = useState(false); + const [revealMnemonic, setRevealMnemonic] = useState(""); + const [isVerifyMnemonicOpen, setIsVerifyMnemonicOpen] = useState(false); + + const { setError } = useSnackbar(); + + const keymasterRef = useRef(null); + + const walletWeb = new WalletWeb(WALLET_NAME); + + useEffect(() => { + async function init() { + await initialiseServices(); + const walletData = await walletWeb.loadWallet(); + if (!walletData) { + setIsOnboardingOpen(true); + } else { + await initialiseWallet(); + } + } + init(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function initialiseWallet() { + const walletData = await walletWeb.loadWallet(); + + if (!walletData) { + // eslint-disable-next-line sonarjs/no-duplicate-string + setModalAction('set-passphrase'); + return; + } + + const pass = getSessionPassphrase(); + if (pass) { + let res = await buildKeymaster(pass); + if (res) { + return; + } + setPassphraseErrorText(""); + clearSessionPassphrase(); + } + + setModalAction('decrypt'); + } + + async function initialiseServices() { + const gatekeeperUrl = localStorage.getItem(GATEKEEPER_KEY) || DEFAULT_GATEKEEPER_URL; + const searchServerUrl = localStorage.getItem(SEARCH_SERVER_KEY) || DEFAULT_SEARCH_SERVER_URL; + localStorage.setItem(GATEKEEPER_KEY, gatekeeperUrl); + localStorage.setItem(SEARCH_SERVER_KEY, searchServerUrl); + await gatekeeper.connect({ url: gatekeeperUrl }); + search = await SearchClient.create({ url: searchServerUrl }); + } + + const buildKeymaster = async (passphrase: string) => { + const instance = new Keymaster({gatekeeper, wallet: walletWeb, cipher, search, passphrase}); + + try { + // check pass + await instance.loadWallet(); + } catch { + setPassphraseErrorText("Incorrect passphrase"); + return false; + } + + setModalAction(null); + setPassphraseErrorText(""); + keymasterRef.current = instance; + setSessionPassphrase(passphrase); + + if (useMnemonic) { + setIsMnemonicOpen(true); + } else if (newWallet) { + try { + const mnemonic = await instance.decryptMnemonic(); + setRevealMnemonic(mnemonic); + setIsMnemonicOpen(true); + } catch (e: any) { + setError(e); + setNewWallet(false); + wipeWallet(); + } + } else { + setIsReady(true); + } + + return true; + }; + + async function handlePassphraseClose() { + setPassphraseErrorText(""); + setModalAction(null); + + const walletData = await walletWeb.loadWallet(); + if (!walletData) { + setIsOnboardingOpen(true); + } + } + + async function restoreMnemonic(mnemonic: string) { + const keymaster = keymasterRef.current; + if (!keymaster) { + throw new Error("Keymaster not initialised"); + } + + await keymaster.newWallet(mnemonic, true); + await keymaster.recoverWallet(); + } + + + const handleOpenNew = async () => { + setModalAction(null); + setIsOnboardingOpen(false); + setNewWallet(true); + await initialiseWallet(); + }; + + const handleOpenImport = () => { + setMnemonicError(""); + setIsOnboardingOpen(false); + setUseMnemonic(true); + setModalAction('set-passphrase'); + }; + + const handleImportMnemonic = async (mnemonic: string) => { + try { + await restoreMnemonic(mnemonic); + } catch (e) { + setMnemonicError("Invalid mnemonic"); + return; + } + + setIsMnemonicOpen(false); + setIsReady(true); + setUseMnemonic(false); + }; + + const handleCloseMnemonic = () => { + setIsMnemonicOpen(false); + + if (useMnemonic) { + // Temp wallet was created so wipe it + wipeWallet(); + setUseMnemonic(false); + } + + if (newWallet) { + setIsVerifyMnemonicOpen(true); + } + } + + const handleVerifyMnemonic = async () => { + setIsVerifyMnemonicOpen(false); + setIsReady(true); + setNewWallet(false); + setRevealMnemonic(""); + } + + const handleVerifyMnemonicBack = () => { + setIsVerifyMnemonicOpen(false); + setIsMnemonicOpen(true); + } + + const wipeWallet = () => { + try { + window.localStorage.removeItem(WALLET_NAME); + } catch (e: any) { + setError(e); + return; + } + + setIsReady(false); + setIsOnboardingOpen(true); + }; + + const value: WalletContextValue = { + registry, + setRegistry, + manifest, + setManifest, + restoreMnemonic, + wipeWallet, + initialiseWallet, + initialiseServices, + keymaster: keymasterRef.current, + search, + }; + + return ( + <> + + + + + + + {newWallet && ( + + )} + + {isReady && ( + + {children} + + )} + + ); +} + +export function useWalletContext() { + const context = useContext(WalletContext); + if (!context) { + throw new Error("Failed to get context from WalletContext.Provider"); + } + return context; +} diff --git a/apps/messaging/src/hooks/useAvatarUploader.ts b/apps/messaging/src/hooks/useAvatarUploader.ts new file mode 100644 index 000000000..2425d8151 --- /dev/null +++ b/apps/messaging/src/hooks/useAvatarUploader.ts @@ -0,0 +1,93 @@ +import React, { useRef, useState, useMemo } from "react"; +import { Buffer } from "buffer"; +import { useWalletContext } from "../contexts/WalletProvider"; +import { useSnackbar } from "../contexts/SnackbarProvider"; +import { useVariablesContext } from "../contexts/VariablesProvider"; +import { MESSAGING_PROFILE } from "../constants"; +import { avatarDataUrl } from "../utils/utils"; + +export function useAvatarUploader() { + const { keymaster } = useWalletContext(); + const { setError, setSuccess } = useSnackbar(); + const { + currentDID, + currentId, + refreshNames, + profileList, + displayNameList, + } = useVariablesContext(); + + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const currentDisplayName = useMemo(() => { + if (!currentDID) { + return currentId; + } + + const hit = Object.entries(displayNameList).find(([, did]) => did === currentDID); + return hit?.[0] ?? currentId; + }, [displayNameList, currentDID, currentId]); + + const handleAvatarClick = () => { + if (!isUploading && fileInputRef.current) fileInputRef.current.click(); + }; + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !keymaster || !currentDID) { + return; + } + + try { + setIsUploading(true); + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const assetDid = await keymaster.createImage(buffer); + + const doc = await keymaster.resolveDID(currentDID); + const data: Record = doc.didDocumentData ?? {}; + + const existingProfile: Record = data[MESSAGING_PROFILE] ?? {}; + data[MESSAGING_PROFILE] = { + ...existingProfile, + avatar: assetDid, + }; + + doc.didDocumentData = data; + await keymaster.updateDID(doc); + + setSuccess("Profile picture updated!"); + await refreshNames(); + } catch (e: any) { + setError(e); + } finally { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + const userAvatar = useMemo(() => { + const profile = + profileList[currentDisplayName] ?? + profileList[currentId]; + + const custom = profile?.avatar; + + return custom ? custom : (currentDID ? avatarDataUrl(currentDID) : ""); + }, [profileList, currentDisplayName, currentId, currentDID]); + + return { + isUploading, + fileInputRef, + handleAvatarClick, + handleFileChange, + userAvatar, + }; +} + +export default useAvatarUploader; diff --git a/apps/messaging/src/main.tsx b/apps/messaging/src/main.tsx new file mode 100644 index 000000000..dd096151e --- /dev/null +++ b/apps/messaging/src/main.tsx @@ -0,0 +1,30 @@ +import ReactDOM from "react-dom/client"; +import BrowserContent from "./BrowserContent"; +import { ContextProviders } from "./contexts/ContextProviders"; +import "./utils/polyfills"; +import { BarcodeScanner } from '@capacitor-mlkit/barcode-scanning'; +import { Toaster } from "./modals/Toaster"; + +import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; +import "./styles/chatscope.dark.css"; + +(async () => { + try { + const has = await BarcodeScanner.isGoogleBarcodeScannerModuleAvailable(); + if (!has) { + await BarcodeScanner.installGoogleBarcodeScannerModule(); + } + } catch {} +})(); + +const BrowserUI = () => { + return ( + + + + + ); +}; + +const root = ReactDOM.createRoot(document.getElementById("root")!); +root.render(); diff --git a/apps/messaging/src/modals/AddUserModal.tsx b/apps/messaging/src/modals/AddUserModal.tsx new file mode 100644 index 000000000..a3ba1325f --- /dev/null +++ b/apps/messaging/src/modals/AddUserModal.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from "react"; +import {Button, Input, Text, Field, Group, Dialog, Portal } from "@chakra-ui/react"; +import { LuCamera } from "react-icons/lu"; +import { scanQrCode } from "../utils/utils"; + +interface AddUserModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (did: string) => void; + errorText: string; +} + +const AddUserModal: React.FC = ({ isOpen, onClose, onSubmit, errorText }) => { + const [did, setDid] = useState(""); + const [localError, setLocalError] = useState(""); + const combinedError = localError || errorText || ""; + + useEffect(() => { + if (!isOpen) { + setDid(""); + setLocalError(""); + } + }, [isOpen]); + + function handleConfirm(e: React.FormEvent) { + e.preventDefault(); + const d = did.trim(); + if (!d) { + setLocalError("DID is required"); + return; + } + onSubmit(d); + } + + async function scanQR() { + const qr = await scanQrCode(); + if (!qr) { + setLocalError("Failed to scan QR code"); + return; + } + + setDid(qr); + } + + return ( + + + + + + + Add User + + + + {combinedError && ( + + {combinedError} + + )} +
+ + DID + + setDid(e.target.value)} + placeholder="did:mdip:..." + /> + + + +
+
+ + + + +
+
+
+
+ ); +}; + +export default AddUserModal; diff --git a/apps/messaging/src/modals/CreateGroupModal.tsx b/apps/messaging/src/modals/CreateGroupModal.tsx new file mode 100644 index 000000000..e17115559 --- /dev/null +++ b/apps/messaging/src/modals/CreateGroupModal.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect } from "react"; +import { Dialog, Box, Flex, IconButton, Text, Field, Input, Button, Portal } from "@chakra-ui/react"; +import { useVariablesContext } from "../contexts/VariablesProvider"; +import { useWalletContext } from "../contexts/WalletProvider"; +import { useSnackbar } from "../contexts/SnackbarProvider"; +import { LuX } from "react-icons/lu"; +import { CHAT_SUBJECT } from "../constants"; +import { stringifyChatPayload } from "../utils/utils"; + +interface CreateGroupModalProps { + isOpen: boolean; + onClose: () => void; +} + +const CreateGroupModal: React.FC = ({ isOpen, onClose }) => { + const { + agentList, + currentId, + currentDID, + nameList, + displayNameList, + refreshNames, + } = useVariablesContext(); + const { keymaster, registry } = useWalletContext(); + const { setError, setSuccess } = useSnackbar(); + + const [groupName, setGroupName] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedMembers, setSelectedMembers] = useState([]); + + useEffect(() => { + if (isOpen) { + setGroupName(""); + setSearchTerm(""); + setSelectedMembers([]); + } + }, [isOpen]); + + const handleAddMember = (memberName?: string) => { + const trimmed = (memberName || searchTerm).trim(); + if (!trimmed) { + return; + } + + if (!agentList.includes(trimmed)) { + setError("User not found in agent list"); + return; + } + + if (selectedMembers.includes(trimmed)) { + setError("User already added to group"); + return; + } + + setSelectedMembers([...selectedMembers, trimmed]); + setSearchTerm(""); + }; + + const handleRemoveMember = (member: string) => { + setSelectedMembers(selectedMembers.filter(m => m !== member)); + }; + + const handleCreate = async () => { + const trimmed = groupName.trim(); + if (!trimmed) { + setError("Group name is required"); + return; + } + + if (selectedMembers.length === 0) { + setError("At least one member is required"); + return; + } + + if (!keymaster) { + setError("Keymaster not available"); + return; + } + + try { + const groupId = await keymaster.createGroup(trimmed); + const memberDids = selectedMembers + .filter(member => member !== currentId) + .map(member => displayNameList[member] ?? nameList[member] ?? member) + .filter(member => !!member); + const recipients = Array.from(new Set([currentDID, ...memberDids])); + + for (const memberDid of recipients) { + await keymaster.addGroupMember(groupId, memberDid); + } + + const body = stringifyChatPayload({ + groupId, + groupName: trimmed, + }); + const dmail = { + to: recipients, + cc: [], + subject: CHAT_SUBJECT, + body, + }; + const did = await keymaster.createDmail(dmail, { registry }); + await keymaster.sendDmail(did); + + await refreshNames(); + setSuccess(`Group "${trimmed}" created`); + onClose(); + } catch (error: any) { + setError(error); + } + }; + + const handleOpenChange = (e: { open: boolean }) => { + if (!e.open) onClose(); + }; + + const filteredAgents = agentList.filter(agent => + agent.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + + + + + + + + + Cancel + + + Create + + + + Create Group + + + + + + Group Name + setGroupName(e.target.value)} + placeholder="Enter group name" + /> + + + + Add Members + + setSearchTerm(e.target.value)} + placeholder="Search users..." + list="agent-suggestions" + /> + + + {searchTerm && filteredAgents.length > 0 && ( + + {filteredAgents.slice(0, 5).map(agent => ( + handleAddMember(agent)} + > + {agent} + + ))} + + )} + + + {selectedMembers.length > 0 && ( + + Selected Members ({selectedMembers.length}) + + {selectedMembers.map(member => ( + + {member} + handleRemoveMember(member)} + > + + + + ))} + + + )} + + + + + + + ); +}; + +export default CreateGroupModal; diff --git a/apps/messaging/src/modals/EditProfileModal.tsx b/apps/messaging/src/modals/EditProfileModal.tsx new file mode 100644 index 000000000..8f5faa6fe --- /dev/null +++ b/apps/messaging/src/modals/EditProfileModal.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useState } from "react"; +import { Dialog, Box, Flex, IconButton, Text, Field, Input, Spinner } from "@chakra-ui/react"; +import { Avatar } from "@chatscope/chat-ui-kit-react"; +import useAvatarUploader from "../hooks/useAvatarUploader"; +import { useVariablesContext } from "../contexts/VariablesProvider"; +import { useWalletContext } from "../contexts/WalletProvider"; +import { useSnackbar } from "../contexts/SnackbarProvider"; +import {MESSAGING_PROFILE} from "../constants"; + +interface EditProfileModalProps { + isOpen: boolean; + onClose: () => void; +} + +const EditProfileModal: React.FC = ({ isOpen, onClose }) => { + const { currentId, refreshAll } = useVariablesContext(); + const { keymaster } = useWalletContext(); + const { setError } = useSnackbar(); + + const { isUploading, fileInputRef, handleAvatarClick, handleFileChange, userAvatar } = useAvatarUploader(); + const [name, setName] = useState(currentId); + + useEffect(() => { + if (isOpen) { + setName(currentId); + } + }, [isOpen, currentId]); + + const handleSave = async () => { + const trimmed = name.trim(); + if (!keymaster || !trimmed || trimmed === currentId) { + onClose(); + return; + } + try { + await keymaster.renameId(currentId, trimmed); + const doc = await keymaster.resolveDID(trimmed); + const data: Record = doc.didDocumentData ?? {}; + const existingProfile: Record = data[MESSAGING_PROFILE] ?? {}; + + data[MESSAGING_PROFILE] = { + ...existingProfile, + name: trimmed, + }; + + doc.didDocumentData = data; + await keymaster.updateDID(doc); + + await refreshAll(); + } catch (e: any) { + setError(e); + } finally { + onClose(); + } + }; + + const handleOpenChange = (e: { open: boolean }) => { + if (!e.open) onClose(); + }; + + return ( + + + + + + + Cancel + + + Save + + + + + + {isUploading ? ( + + ) : null} + + + + Edit profile picture + + + + + + Name + setName(e.target.value)} /> + + + + + + + + ); +}; + +export default EditProfileModal; diff --git a/apps/messaging/src/modals/MnemonicModal.tsx b/apps/messaging/src/modals/MnemonicModal.tsx new file mode 100644 index 000000000..ed3dded25 --- /dev/null +++ b/apps/messaging/src/modals/MnemonicModal.tsx @@ -0,0 +1,179 @@ +import React, { MouseEvent, useEffect, useState } from "react"; +import { Button, Text, Input, SimpleGrid, Dialog, HStack, IconButton } from "@chakra-ui/react"; +import { LuCopy, LuEye, LuEyeOff } from "react-icons/lu"; +import { useSnackbar } from "../contexts/SnackbarProvider"; +import * as bip39 from 'bip39'; + +interface MnemonicModalProps { + isOpen: boolean; + onSubmit?: (mnemonic: string) => void; + onClose: () => void; + errorText: string, + mnemonic?: string, +} + +const MnemonicModal: React.FC = ({ isOpen, onSubmit, onClose, errorText, mnemonic }) => { + const [words, setWords] = useState(Array(12).fill("")); + const [showWord, setShowWord] = useState(Array(12).fill(false)); + const [localError, setLocalError] = useState(""); + const { setSuccess } = useSnackbar(); + const combinedError = localError || errorText || ""; + + const isImportMode = !!onSubmit && !mnemonic; + const allFilled = words.every((w) => w.trim().length > 0); + const assembledMnemonic = words.map((w) => w.trim()).join(" "); + const canConfirm = allFilled && bip39.validateMnemonic(assembledMnemonic); + + const handleWordChange = (index: number, value: string) => { + setWords((prev) => { + const next = [...prev]; + next[index] = value; + return next; + }); + }; + + const handleSubmit = (event: MouseEvent) => { + if (!onSubmit) { + return; + } + event.preventDefault(); + const mnemonic = words.map((w) => w.trim()).join(" "); + if (!bip39.validateMnemonic(mnemonic)) { + setLocalError("Mnemonic is not valid, check for spelling mistakes"); + return; + } + onSubmit(mnemonic); + }; + + useEffect(() => { + if (!isOpen) { + setWords(Array(12).fill("")); + setShowWord(Array(12).fill(false)); + setLocalError(""); + return; + } + if (mnemonic) { + const parts = mnemonic.trim().split(/\s+/); + const next = Array(12).fill(""); + for (let i = 0; i < Math.min(12, parts.length); i++) { + next[i] = parts[i]; + } + setWords(next); + setShowWord(Array(12).fill(false)); + } + }, [isOpen, mnemonic]); + + useEffect(() => { + if (!isImportMode) { + setLocalError(""); + return; + } + if (!allFilled) { + setLocalError(""); + return; + } + const isValid = bip39.validateMnemonic(assembledMnemonic); + if (!isValid) { + setLocalError("Mnemonic is not valid, check for spelling mistakes"); + } else { + setLocalError(""); + } + }, [isImportMode, allFilled, assembledMnemonic]); + + const toggleReveal = (index: number) => { + setShowWord((prev) => { + const next = [...prev]; + next[index] = !next[index]; + return next; + }); + }; + + const handleCopy = async () => { + if (!mnemonic) { + return; + } + try { + await navigator.clipboard.writeText(mnemonic); + setSuccess("Mnemonic copied"); + } catch {} + }; + + return ( + + + + + {mnemonic ? "Your Mnemonic" : "Import Mnemonic"} + + + + {combinedError && ( + {combinedError} + )} + + {mnemonic ? "Tap the eye to reveal each word or copy the full phrase" : "Enter your 12 word mnemonic"} + + + {words.map((value, idx) => ( + + + {idx + 1}. + + handleWordChange(idx, e.target.value)} + placeholder={`Word ${idx + 1}`} + autoComplete="off" + inputMode="text" + type={mnemonic ? (showWord[idx] ? "text" : "password") : "text"} + readOnly={!!mnemonic} + /> + {mnemonic && ( + toggleReveal(idx)} + > + {showWord[idx] ? : } + + )} + + ))} + + + + {mnemonic && ( + <> + + + + )} + + {isImportMode && + <> + + + + } + + + + ); +}; + +export default MnemonicModal; diff --git a/apps/messaging/src/modals/OnboardingModal.tsx b/apps/messaging/src/modals/OnboardingModal.tsx new file mode 100644 index 000000000..38889224a --- /dev/null +++ b/apps/messaging/src/modals/OnboardingModal.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Button, Dialog, Stack, Text } from "@chakra-ui/react"; + +interface OnboardingModalProps { + isOpen: boolean; + onNew: () => void; + onImport: () => void; +} + +const OnboardingModal: React.FC = ({ isOpen, onNew, onImport }) => { + return ( + + + + + Welcome + + + + + Choose how you want to get started + + + + + + + ); +}; + +export default OnboardingModal; diff --git a/apps/messaging/src/modals/PassphraseModal.tsx b/apps/messaging/src/modals/PassphraseModal.tsx new file mode 100644 index 000000000..420653bbf --- /dev/null +++ b/apps/messaging/src/modals/PassphraseModal.tsx @@ -0,0 +1,172 @@ +import React, { FormEvent, useState, useEffect } from "react"; +import { Button, Input, Text, Field, Dialog } from "@chakra-ui/react"; + +interface PassphraseModalProps { + isOpen: boolean, + title: string, + errorText: string, + onSubmit: (passphrase: string) => void, + onClose: () => void, + encrypt: boolean, +} + +const PassphraseModal: React.FC = ( + { + isOpen, + title, + errorText, + onSubmit, + onClose, + encrypt + }) => { + const [passphrase, setPassphrase] = useState(""); + const [confirmPassphrase, setConfirmPassphrase] = useState(""); + const [localError, setLocalError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const combinedError = localError || errorText || ""; + + useEffect(() => { + if (!isOpen) { + setPassphrase(""); + setConfirmPassphrase(""); + setLocalError(""); + } + }, [isOpen]); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (submitting) { + return; + } + + setSubmitting(true); + await new Promise(requestAnimationFrame); + + try { + onSubmit(passphrase); + setPassphrase(""); + setConfirmPassphrase(""); + setLocalError(""); + } finally { + setSubmitting(false); + } + } + + const handleClose = () => { + if (submitting) { + return; + } + onClose(); + }; + + function checkPassphraseMismatch(newPass: string, newConfirm: string) { + if (!encrypt) { + return; + } + if (!newPass || !newConfirm) { + setLocalError(""); + return; + } + if (newPass !== newConfirm) { + setLocalError("Passphrases do not match"); + } else { + setLocalError(""); + } + } + + function handlePassphraseChange(newValue: string) { + setPassphrase(newValue); + checkPassphraseMismatch(newValue, confirmPassphrase); + } + + function handleConfirmChange(newValue: string) { + setConfirmPassphrase(newValue); + checkPassphraseMismatch(passphrase, newValue); + } + + const isSubmitDisabled = () => { + if (!passphrase) { + return true; + } + if (encrypt) { + if (!confirmPassphrase) { + return true; + } + if (passphrase !== confirmPassphrase) { + return true; + } + } + return false; + }; + + const handleOpenChange = (e: { open: boolean }) => { + if (!e.open) { + handleClose(); + } + }; + + return ( + + + + + {title} + + + + {combinedError && ( + {combinedError} + )} +
+ + Passphrase + handlePassphraseChange(e.target.value)} + required + disabled={submitting} + /> + + + {encrypt && ( + + Confirm Passphrase + handleConfirmChange(e.target.value)} + required + disabled={submitting} + /> + + )} +
+
+ + {encrypt && ( + + )} + + +
+
+ ); +}; + +export default PassphraseModal; diff --git a/apps/messaging/src/modals/QRCodeModal.tsx b/apps/messaging/src/modals/QRCodeModal.tsx new file mode 100644 index 000000000..478115807 --- /dev/null +++ b/apps/messaging/src/modals/QRCodeModal.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Dialog, Box, Flex, Heading, IconButton, Button, Text, Portal } from "@chakra-ui/react"; +import { LuArrowLeft } from "react-icons/lu"; +import { QRCodeSVG } from "qrcode.react"; +import { Avatar } from "@chatscope/chat-ui-kit-react"; +import { useSnackbar } from "../contexts/SnackbarProvider"; +import { truncateMiddle } from "../utils/utils"; + +interface QRCodeModalProps { + isOpen: boolean; + onClose: () => void; + did: string; + name: string; + userAvatar: string; +} + + +const QRCodeModal: React.FC = ({ isOpen, onClose, did, name, userAvatar }) => { + const { setSuccess } = useSnackbar(); + + const handleOpenChange = (e: { open: boolean }) => { + if (!e.open) onClose(); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(did); + setSuccess("Identifier copied to clipboard"); + } catch { } + }; + + return ( + + + {/* Ensure this modal appears above ChatWindow (zIndex 2200) and menus (2300) */} + + + + + + + + + Profile QR code + + + + + + + + {name} + + + + + + + {truncateMiddle(did)} + + + + + + + + + + + + + + ); +}; + +export default QRCodeModal; diff --git a/apps/messaging/src/modals/TextInputModal.tsx b/apps/messaging/src/modals/TextInputModal.tsx new file mode 100644 index 000000000..738822531 --- /dev/null +++ b/apps/messaging/src/modals/TextInputModal.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Button, Input, Text, Field, Dialog, Portal } from "@chakra-ui/react"; + +interface TextInputModalProps { + isOpen: boolean; + title: string; + description?: string; + confirmText?: string; + defaultValue?: string; + onSubmit: (value: string) => void; + onClose?: () => void; +} + +const TextInputModal: React.FC = ( + { + isOpen, + title, + description, + confirmText = "Confirm", + defaultValue = "", + onSubmit, + onClose, + }) => { + const [value, setValue] = useState(defaultValue); + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setValue(defaultValue); + } + }, [isOpen, defaultValue]); + + const handleConfirm = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(value.trim()); + }; + + return ( + + + + + + + {title} + + + +
+ {description && ( + {description} + )} + + setValue(e.target.value)} + /> + +
+
+ + {onClose && ( + + )} + + +
+
+
+
+ ); +}; + +export default TextInputModal; diff --git a/apps/messaging/src/modals/Toaster.tsx b/apps/messaging/src/modals/Toaster.tsx new file mode 100644 index 000000000..d23031816 --- /dev/null +++ b/apps/messaging/src/modals/Toaster.tsx @@ -0,0 +1,34 @@ +import { + Toaster as ChakraToaster, + Portal, + Spinner, + Stack, + Toast, + createToaster, +} from "@chakra-ui/react" + +export const toaster = createToaster({ + placement: "bottom", + pauseOnPageIdle: true, + offsets: { bottom: "50px", left: "0px", right: "0px", top: "0px" }, +}) + +export const Toaster = () => { + return ( + + + {(toast) => ( + + {toast.type === "loading" ? : } + + {toast.title && {toast.title}} + {toast.description && {toast.description}} + + {toast.action && {toast.action.label}} + {toast.closable && } + + )} + + + ); +}; diff --git a/apps/messaging/src/modals/VerifyMnemonicModal.tsx b/apps/messaging/src/modals/VerifyMnemonicModal.tsx new file mode 100644 index 000000000..dedfcd3f3 --- /dev/null +++ b/apps/messaging/src/modals/VerifyMnemonicModal.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Button, Dialog, HStack, Input, Stack, Text } from "@chakra-ui/react"; + +interface VerifyMnemonicModalProps { + isOpen: boolean; + mnemonic: string; + onBack: () => void; + onSuccess: () => void; +} + +function pickThreeDistinct(maxExclusive: number): number[] { + const set = new Set(); + while (set.size < 3) { + set.add(Math.floor(Math.random() * maxExclusive)); + } + return Array.from(set).sort((a, b) => a - b); +} + +const VerifyMnemonicModal: React.FC = ({ isOpen, mnemonic, onBack, onSuccess }) => { + const allWords = useMemo(() => mnemonic.trim().split(/\s+/).slice(0, 12), [mnemonic]); + const [indices, setIndices] = useState([]); + const [inputs, setInputs] = useState(["", "", ""]); + + useEffect(() => { + if (!isOpen) { + setInputs(["", "", ""]); + return; + } + setIndices(pickThreeDistinct(12)); + setInputs(["", "", ""]); + }, [isOpen]); + + const handleChange = (slot: number, val: string) => { + setInputs((prev) => { + const next = [...prev]; + next[slot] = val; + return next; + }); + }; + + const isMatch = useMemo(() => { + if (indices.length !== 3 || allWords.length !== 12) return false; + return inputs.every((val, i) => + val.trim().toLowerCase() === (allWords[indices[i]] || "").toLowerCase() + ); + }, [inputs, indices, allWords]); + + return ( + + + + + Verify Your Mnemonic + + + + + + Please enter the requested words from your 12 word mnemonic to confirm you've saved it. + + {indices.map((wordIndex, i) => ( + + + Word {wordIndex + 1} + + handleChange(i, e.target.value)} + autoComplete="off" + inputMode="text" + /> + + ))} + + + + + + + + + ); +}; + +export default VerifyMnemonicModal; diff --git a/apps/messaging/src/modals/WarningModal.tsx b/apps/messaging/src/modals/WarningModal.tsx new file mode 100644 index 000000000..ae2934e3d --- /dev/null +++ b/apps/messaging/src/modals/WarningModal.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Button, Text, Dialog, Portal } from "@chakra-ui/react"; + +interface WarningModalProps { + isOpen: boolean; + title: string; + warningText: string; + onSubmit: () => void; + onClose: () => void; +} + +const WarningModal: React.FC = ({ isOpen, title, warningText, onSubmit, onClose }) => { + const handleOpenChange = (e: { open: boolean }) => { + if (!e.open) { + onClose?.(); + } + }; + + return ( + + + {/* Ensure backdrop overlays any sticky footer (e.g., zIndex 2000 in HomePage) */} + + + + {title} + + + + {warningText} + + + + + + + + + ); +}; + +export default WarningModal; diff --git a/apps/messaging/src/styles/chatscope.dark.css b/apps/messaging/src/styles/chatscope.dark.css new file mode 100644 index 000000000..85d227f66 --- /dev/null +++ b/apps/messaging/src/styles/chatscope.dark.css @@ -0,0 +1,104 @@ +.dark .cs-main-container, +.dark .cs-chat-container { + background: #0b0f14; + color: #e7e9ee; +} + +.dark .cs-sidebar, +.dark .cs-conversation-list { + background: #0e131a; + border-right: 1px solid #1f2733; +} + +.dark .cs-conversation { color: #cfd6e4; background: transparent; } +.dark .cs-conversation:hover { background: rgba(255,255,255,0.03); } +.dark .cs-conversation--active { background: rgba(59,130,246,0.12); } +.dark .cs-conversation__name { color: #e7e9ee; } +.dark .cs-conversation__info, +.dark .cs-message__sent-time { color: #a9b2c3; } + +.dark .cs-conversation-header { + background: #0e131a; + border-bottom: 1px solid #1f2733; + color: #e7e9ee; +} +.dark .cs-conversation-header__user-name { color: #e7e9ee; } +.dark .cs-conversation-header__info { color: #a9b2c3; } + +.dark .cs-message-list, +.dark .cs-message-list__scroll-wrapper { + background: #0b0f14; +} + +.dark .cs-message--incoming .cs-message__content { + background: #0f1620; color: #e7e9ee; border: 1px solid transparent; +} +.dark .cs-message--outgoing .cs-message__content { + background: #1b6fff; color: #ffffff; border: 1px solid transparent; +} + +.dark .cs-message-input { + background: #0e131a; border-top: 1px solid #1f2733; +} +.dark .cs-message-input__content-editor-wrapper, +.dark .cs-message-input__content-editor-container, +.dark .cs-message-input__content-editor { + background: #151b24; color: #e7e9ee; +} +.dark .cs-message-input__content-editor[data-placeholder]:empty:before { + color: #7f8aa3; +} +.dark .cs-message-input__tools, +.dark .cs-button { color: #7fb2ff; } + +.dark .cs-message-list::-webkit-scrollbar, +.dark .cs-sidebar::-webkit-scrollbar { width: 10px; height: 10px; } +.dark .cs-message-list::-webkit-scrollbar-thumb, +.dark .cs-sidebar::-webkit-scrollbar-thumb { background: #1a2230; border-radius: 8px; } + +.dark .cs-conversation-header__content, +.dark .cs-conversation-header__avatar, +.dark .cs-conversation-header__actions, +.dark .cs-conversation-header__back { + background: transparent !important; +} + +.dark .cs-conversation-header .cs-conversation-header__content .cs-conversation-header__user-name { + color: #e7e9ee !important; + background: transparent !important; +} +.dark .cs-conversation-header .cs-conversation-header__content .cs-conversation-header__info { + color: #a9b2c3 !important; + background: transparent !important; +} + +.dark .cs-conversation-header { + background: #0e131a !important; + border-bottom: 1px solid #1f2733; +} +.dark .cs-conversation-header > * { + background: transparent !important; +} + +.dark .cs-conversation__name { + color: #f0f3fa !important; + font-weight: 600; +} + +.dark .cs-conversation__info { + color: #b8c3d6 !important; +} + +.dark .cs-message--outgoing .cs-message__content, +.dark .cs-message-group--outgoing .cs-message-group__messages .cs-message .cs-message__content { + background: #1b6fff !important; + color: #fff !important; + border: 1px solid transparent; +} + +.dark .cs-message--incoming .cs-message__content, +.dark .cs-message-group--incoming .cs-message-group__messages .cs-message .cs-message__content { + background: #0f1620 !important; + color: #e7e9ee !important; + border: 1px solid transparent; +} diff --git a/apps/messaging/src/utils/polyfills.ts b/apps/messaging/src/utils/polyfills.ts new file mode 100644 index 000000000..ec7d49112 --- /dev/null +++ b/apps/messaging/src/utils/polyfills.ts @@ -0,0 +1,3 @@ +import { Buffer } from 'buffer'; + +(window as any).Buffer = (window as any).Buffer || Buffer; diff --git a/apps/messaging/src/utils/sessionPassphrase.ts b/apps/messaging/src/utils/sessionPassphrase.ts new file mode 100644 index 000000000..b5ccad6bb --- /dev/null +++ b/apps/messaging/src/utils/sessionPassphrase.ts @@ -0,0 +1,13 @@ +let sessionPassphrase: string | null = null; + +export function getSessionPassphrase(): string { + return sessionPassphrase ?? ""; +} + +export function setSessionPassphrase(passphrase: string) { + sessionPassphrase = passphrase; +} + +export function clearSessionPassphrase() { + sessionPassphrase = ""; +} diff --git a/apps/messaging/src/utils/utils.ts b/apps/messaging/src/utils/utils.ts new file mode 100644 index 000000000..af2957922 --- /dev/null +++ b/apps/messaging/src/utils/utils.ts @@ -0,0 +1,195 @@ +import {BarcodeScanner} from "@capacitor-mlkit/barcode-scanning"; +import { toSvg } from "jdenticon"; + +export function avatarDataUrl(seed: string, size = 64) { + const svg = toSvg(seed || "anonymous", size); + return "data:image/svg+xml;utf8," + encodeURIComponent(svg); +} + +export function formatTime(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + return ""; + } + const today = new Date(); + const sameDay = d.toDateString() === today.toDateString(); + return sameDay + ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : d.toLocaleDateString(); +} + +export function extractDid(input: string): string | null { + if (!input) { + return null; + } + + const didRegex = /did:[a-z0-9]+:[^\s&#?]+/i; + + const direct = input.match(didRegex); + if (direct) { + return direct[0]; + } + + try { + const url = new URL(input); + + if (url.protocol === 'mdip:') { + const host = (url.host || '').toLowerCase(); + + if (host === 'auth') { + const challenge = url.searchParams.get('challenge'); + if (challenge?.startsWith('did:')) { + return challenge; + } + } + + if (host === 'accept') { + const credential = url.searchParams.get('credential'); + if (credential?.startsWith('did:')) { + return credential; + } + } + } + + if (url.protocol === 'https:' || url.protocol === 'http:') { + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 2 && parts[0].toLowerCase() === 'attest') { + const cand = decodeURIComponent(parts[1]); + if (cand.startsWith('did:')) { + return cand; + } + } + + const challenge = url.searchParams.get('challenge'); + if (challenge?.startsWith('did:')) { + return challenge; + } + + const credential = url.searchParams.get('credential'); + if (credential?.startsWith('did:')) { + return credential; + } + } + + const fallback = input.match(didRegex)?.[0]; + if (fallback) { + return fallback; + } + } catch {} + + return null; +} + +async function ensureGoogleModuleReady(timeoutMs = 8000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await BarcodeScanner.isGoogleBarcodeScannerModuleAvailable()) { + return true; + } + await new Promise(r => setTimeout(r, 400)); + } + return false; +} + +export async function scanQrCode() { + try { + const perm = await BarcodeScanner.requestPermissions(); + if (perm.camera !== 'granted') { + return null; + } + + const ready = await ensureGoogleModuleReady(); + if (!ready) { + return null; + } + + const { barcodes } = await BarcodeScanner.scan(); + + let did: string | null = null; + for (const b of barcodes) { + const candidate = extractDid(b.rawValue); + if (candidate) { + did = candidate; + break; + } + } + + if (!did) { + return null; + } + + return did; + } catch {} + return null; +} + +export function truncateMiddle(str: string, max = 34) { + if (str.length <= max) { + return str; + } + const half = Math.floor((max - 3) / 2); + return `${str.slice(0, half)}...${str.slice(-half)}`; +} + +export function convertNamesToDIDs(names: string[], nameList: Record) { + let converted: string[] = []; + for (let did of names) { + converted.push(nameList[did] ?? did); + } + return converted; +} + +export function arraysMatchMembers(arr1: string[], arr2: string[]) { + if (arr1.length !== arr2.length) { + return false; + } + const sorted1 = [...arr1].sort(); + const sorted2 = [...arr2].sort(); + return sorted1.every((val, idx) => val === sorted2[idx]); +} + +export type ChatPayload = { + type?: string; + message?: string; + groupId?: string; + groupName?: string; + [key: string]: unknown; +}; + +export function parseChatPayload(body: string): ChatPayload | null { + if (typeof body !== "string") { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return null; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const payload = parsed as Record; + + if ("type" in payload && typeof payload.type !== "string") { + return null; + } + if ("message" in payload && typeof payload.message !== "string") { + return null; + } + if ("groupId" in payload && typeof payload.groupId !== "string") { + return null; + } + if ("groupName" in payload && typeof payload.groupName !== "string") { + return null; + } + + return payload as ChatPayload; +} + +export function stringifyChatPayload(payload: ChatPayload): string { + return JSON.stringify(payload); +} diff --git a/apps/messaging/tsconfig.json b/apps/messaging/tsconfig.json new file mode 100644 index 000000000..aed38d086 --- /dev/null +++ b/apps/messaging/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "allowJs": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "types": ["node", "vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/messaging/vite.config.ts b/apps/messaging/vite.config.ts new file mode 100644 index 000000000..96d852d48 --- /dev/null +++ b/apps/messaging/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; + +export default defineConfig({ + base: './', + plugins: [react()], + resolve: { + alias: { + "@mdip/cipher/web": path.resolve(__dirname, "../../packages/cipher/dist/esm/cipher-web.js"), + "@mdip/common/errors": path.resolve(__dirname, "../../packages/common/dist/esm/errors.js"), + "@mdip/gatekeeper/client": path.resolve(__dirname, "../../packages/gatekeeper/dist/esm/gatekeeper-client.js"), + "@mdip/gatekeeper/types": path.resolve(__dirname, "../../packages/gatekeeper/dist/types/types.d.js"), + "@mdip/keymaster/wallet/web": path.resolve(__dirname, "../../packages/keymaster/dist/esm/db/web.js"), + "@mdip/keymaster/wallet/web-enc": path.resolve(__dirname, "../../packages/keymaster/dist/esm/db/web-enc.js"), + "@mdip/keymaster/wallet/cache": path.resolve(__dirname, "../../packages/keymaster/dist/esm/db/cache.js"), + "@mdip/keymaster/wallet/typeGuards": path.resolve(__dirname, "../../packages/keymaster/dist/esm/db/typeGuards.js"), + "@mdip/keymaster/types": path.resolve(__dirname, "../../packages/keymaster/dist/types/types.d.js"), + "@mdip/keymaster/search": path.resolve(__dirname, "../../packages/keymaster/dist/esm/search-client.js"), + "@mdip/keymaster": path.resolve(__dirname, "../../packages/keymaster/dist/esm/keymaster.js"), + buffer: 'buffer', + } + }, + optimizeDeps: { + include: ['buffer'], + }, + build: { + sourcemap: true, + outDir: 'dist', + chunkSizeWarningLimit: 2000, + rollupOptions: { + input: { + index: path.resolve(__dirname, 'index.html') + } + } + } +}); diff --git a/package-lock.json b/package-lock.json index 8878cf0ef..827589a90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22606,9 +22606,9 @@ } }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", "peer": true, "engines": {