From 10a36d5a65fcecc291d426b5d546a5b7596a8a0b Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:12:50 +0300 Subject: [PATCH 01/12] wip: block lists --- .../Broadcasts/BroadcastMessagesList.tsx | 226 ++++++++++-------- .../Modals/BroadcastParticipantInfo.tsx | 27 ++- src/components/Modals/ContactInfoModal.tsx | 192 ++++++++++----- src/components/Modals/SettingsModal.tsx | 120 ++++++++++ .../SideBarPane/Directs/ContactList.tsx | 14 +- src/hooks/useOrchestrator.ts | 13 + src/service/block-processor-service.ts | 14 ++ src/service/conversation-manager-service.ts | 10 + src/service/storage-encryption.ts | 1 + src/store/blocklist.store.ts | 163 +++++++++++++ src/store/messaging.store.ts | 40 +++- .../repository/blocked-address.repository.ts | 201 ++++++++++++++++ src/store/repository/db.ts | 86 ++++++- 13 files changed, 924 insertions(+), 183 deletions(-) create mode 100644 src/store/blocklist.store.ts create mode 100644 src/store/repository/blocked-address.repository.ts diff --git a/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx b/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx index 117620a0..9028c6af 100644 --- a/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx +++ b/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx @@ -1,120 +1,146 @@ -import { FC, memo } from "react"; +import { FC } from "react"; import { BroadcastMessage } from "../../../store/broadcast.store"; import { BroadcastDisplay } from "./BroadcastDisplay"; import { DateSeparator } from "../../DateSeparator"; import { isToday } from "../../../utils/message-date-format"; +import { useBlocklistStore } from "../../../store/blocklist.store"; interface BroadcastMessagesListProps { messages: BroadcastMessage[]; walletAddress: string; } -export const BroadcastMessagesList: FC = memo( - ({ messages, walletAddress }) => { - if (!messages.length) { - return ( -
- No messages in this channel yet... -
- ); - } +export const BroadcastMessagesList: FC = ({ + messages, + walletAddress, +}) => { + const blockedAddresses = useBlocklistStore((state) => state.blockedAddresses); + const broadcastBlockedDisplayMode = useBlocklistStore( + (state) => state.broadcastBlockedDisplayMode + ); - // compute last index of outgoing and messages from each sender - const lastMessageIndices = new Map(); - messages.forEach((message, idx) => { - lastMessageIndices.set(message.senderAddress, idx); - }); + // filter or map blocked messages based on display mode + const processedMessages = messages + .map((message) => { + const isSenderBlocked = blockedAddresses.has(message.senderAddress); + if (isSenderBlocked && broadcastBlockedDisplayMode === "hide") { + return null; // hide completely + } + if (isSenderBlocked && broadcastBlockedDisplayMode === "placeholder") { + return { + ...message, + content: "Blocked Contact", + isBlockedPlaceholder: true, + }; + } + return { ...message, isBlockedPlaceholder: false }; + }) + .filter(Boolean) as (BroadcastMessage & { + isBlockedPlaceholder: boolean; + })[]; - const firstTodayIdx = messages.findIndex((message) => - isToday(message.timestamp) + if (!processedMessages.length) { + return ( +
+ No messages in this channel yet... +
); + } - return ( - <> - {messages.map((message, idx) => { - const isFirst = idx === 0; - const isOutgoing = message.senderAddress === walletAddress; - const showTimestamp = - idx === (lastMessageIndices.get(message.senderAddress) || -1); - const previousMessage = messages[idx - 1]; - const nextMessage = messages[idx + 1]; - const dateObj = message.timestamp; + // compute last index of outgoing and messages from each sender + const lastMessageIndices = new Map(); + processedMessages.forEach((message, idx) => { + lastMessageIndices.set(message.senderAddress, idx); + }); + + const firstTodayIdx = processedMessages.findIndex((message) => + isToday(message.timestamp) + ); - // is this the first message of today? - const isFirstToday = idx === firstTodayIdx && isToday(dateObj); + return ( + <> + {processedMessages.map((message, idx) => { + const isFirst = idx === 0; + const isOutgoing = message.senderAddress === walletAddress; + const showTimestamp = + idx === (lastMessageIndices.get(message.senderAddress) || -1); + const previousMessage = processedMessages[idx - 1]; + const nextMessage = processedMessages[idx + 1]; + const dateObj = message.timestamp; - // show date time stamp if: - // - this is the first message of today - // - or, this is not the first message and there's a 5min+ gap - // - or, this is the first message - const showSeparator = - isFirstToday || - (idx > 0 && - previousMessage && - message.timestamp.getTime() - - previousMessage.timestamp.getTime() > - 5 * 60 * 1000) || - (idx === 0 && !isToday(dateObj)); + // is this the first message of today? + const isFirstToday = idx === firstTodayIdx && isToday(dateObj); - // if there's a separator, treat as new group - const isPrevSameSender = - !showSeparator && + // show date time stamp if: + // - this is the first message of today + // - or, this is not the first message and there's a 5min+ gap + // - or, this is the first message + const showSeparator = + isFirstToday || + (idx > 0 && previousMessage && - previousMessage.senderAddress === message.senderAddress; - const isNextSameSender = - nextMessage && - // if the next message has a separator, it's not same group - !( - (idx + 1 === firstTodayIdx && isToday(nextMessage.timestamp)) || - (idx + 1 > 0 && - messages[idx + 1 - 1] && - nextMessage.timestamp.getTime() - - messages[idx + 1 - 1].timestamp.getTime() > - 30 * 60 * 1000) || - (idx + 1 === 0 && !isToday(nextMessage.timestamp)) - ) && - nextMessage.senderAddress === message.senderAddress; + message.timestamp.getTime() - previousMessage.timestamp.getTime() > + 5 * 60 * 1000) || + (idx === 0 && !isToday(dateObj)); - const isSingleInGroup = !isPrevSameSender && !isNextSameSender; - const isTopOfGroup = !isPrevSameSender && isNextSameSender; - const isBottomOfGroup = isPrevSameSender && !isNextSameSender; + // if there's a separator, treat as new group + const isPrevSameSender = + !showSeparator && + previousMessage && + previousMessage.senderAddress === message.senderAddress; + const isNextSameSender = + nextMessage && + // if the next message has a separator, it's not same group + !( + (idx + 1 === firstTodayIdx && isToday(nextMessage.timestamp)) || + (idx + 1 > 0 && + processedMessages[idx + 1 - 1] && + nextMessage.timestamp.getTime() - + processedMessages[idx + 1 - 1].timestamp.getTime() > + 30 * 60 * 1000) || + (idx + 1 === 0 && !isToday(nextMessage.timestamp)) + ) && + nextMessage.senderAddress === message.senderAddress; - return ( -
- {showSeparator && - (isFirstToday ? ( -
- Today -
- {dateObj.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} -
- ) : ( - - ))} - -
- ); - })} - - ); - } -); + const isSingleInGroup = !isPrevSameSender && !isNextSameSender; + const isTopOfGroup = !isPrevSameSender && isNextSameSender; + const isBottomOfGroup = isPrevSameSender && !isNextSameSender; + + return ( +
+ {showSeparator && + (isFirstToday ? ( +
+ Today +
+ {dateObj.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+ ) : ( + + ))} + +
+ ); + })} + + ); +}; diff --git a/src/components/Modals/BroadcastParticipantInfo.tsx b/src/components/Modals/BroadcastParticipantInfo.tsx index 2d30ba6d..6b9b6b04 100644 --- a/src/components/Modals/BroadcastParticipantInfo.tsx +++ b/src/components/Modals/BroadcastParticipantInfo.tsx @@ -2,6 +2,8 @@ import { FC } from "react"; import { AvatarHash } from "../icons/AvatarHash"; import { CopyableValueWithQR } from "./CopyableValueWithQR"; import clsx from "clsx"; +import { useBlocklistStore } from "../../store/blocklist.store"; +import { BlockUnblockButton } from "../Common/BlockUnblockButton"; type BroadcastParticipantInfoProps = { address: string; @@ -12,7 +14,11 @@ type BroadcastParticipantInfoProps = { export const BroadcastParticipantInfo: FC = ({ address, nickname, + onClose, }) => { + const blocklistStore = useBlocklistStore(); + const isBlocked = blocklistStore.blockedAddresses.has(address); + return (
e.stopPropagation()}>
@@ -42,8 +48,16 @@ export const BroadcastParticipantInfo: FC = ({ )}
-
- {nickname || "No nickname"} +
+
+ {nickname || "No nickname"} +
+ {/* actions menu */} +
Broadcast Participant @@ -53,6 +67,15 @@ export const BroadcastParticipantInfo: FC = ({ {/* Address section */} + + {/* block status */} + {isBlocked && ( +
+
+ This participant is blocked +
+
+ )}
); diff --git a/src/components/Modals/ContactInfoModal.tsx b/src/components/Modals/ContactInfoModal.tsx index b807c326..67966c7f 100644 --- a/src/components/Modals/ContactInfoModal.tsx +++ b/src/components/Modals/ContactInfoModal.tsx @@ -2,84 +2,154 @@ import { FC } from "react"; import { OneOnOneConversation } from "../../types/all"; import { AvatarHash } from "../icons/AvatarHash"; import clsx from "clsx"; +import { useBlocklistStore } from "../../store/blocklist.store"; +import { useDBStore } from "../../store/db.store"; +import { useMessagingStore } from "../../store/messaging.store"; +import { toast } from "../../utils/toast-helper"; +import { BlockUnblockButton } from "../Common/BlockUnblockButton"; type ContactInfoModalProps = { oooc: OneOnOneConversation; onClose: () => void; }; -export const ContactInfoModal: FC = ({ oooc }) => ( -
e.stopPropagation()}> -
-
-
- - {oooc.contact.name?.trim()?.slice(0, 2)?.toUpperCase() && ( - - {oooc.contact.name.trim().slice(0, 2).toUpperCase()} - - )} -
-
-
- {oooc.contact.name || "No nickname"} -
-
Contact
-
-
- {/* Indented content below avatar/nickname/contact */} -
- {" "} - {/* pl-14 aligns with avatar+gap */} -
-
- Address +export const ContactInfoModal: FC = ({ + oooc, + onClose, +}) => { + const blocklistStore = useBlocklistStore(); + const repositories = useDBStore((s) => s.repositories); + const messagingStore = useMessagingStore(); + + const isBlocked = blocklistStore.blockedAddresses.has( + oooc.contact.kaspaAddress + ); + + const handleBlockWithConfirmation = async () => { + // show confirmation dialog for blocking + const confirmed = window.confirm( + "This will block and delete ALL messages with this contact" + ); + + if (!confirmed) return; + + try { + // block the contact + await blocklistStore.blockAddress(oooc.contact.kaspaAddress); + + // delete all messages, conversation, and contact from database + await repositories.deleteAllDataForContact(oooc.contact.id, { + deleteConversation: true, + deleteContact: true, + }); + + // remove the conversation from in-memory store + useMessagingStore.setState((state) => ({ + oneOnOneConversations: state.oneOnOneConversations.filter( + (conversation) => conversation.contact.id !== oooc.contact.id + ), + })); + + // close modal and navigate away + messagingStore.setOpenedRecipient(null); + onClose(); + + toast.success("Contact blocked and all conversations deleted"); + } catch (error) { + console.error("Error blocking contact:", error); + toast.error("Failed to block contact"); + } + }; + + return ( +
e.stopPropagation()}> +
+
+
+ + {oooc.contact.name?.trim()?.slice(0, 2)?.toUpperCase() && ( + + {oooc.contact.name.trim().slice(0, 2).toUpperCase()} + + )}
-
- {oooc.contact.kaspaAddress} +
+
+
+ {oooc.contact.name || "No nickname"} +
+ {/* block/unblock button */} + +
+
Contact
- {oooc.contact.name && ( + {/* Indented content below avatar/nickname/contact */} +
+ {" "} + {/* pl-14 aligns with avatar+gap */}
- Nickname + Address
- {oooc.contact.name} + {oooc.contact.kaspaAddress}
- )} -
-
- Messages -
-
- {oooc.events.length || 0} messages -
-
-
-
- Last Activity + {oooc.contact.name && ( +
+
+ Nickname +
+
+ {oooc.contact.name} +
+
+ )} +
+
+ Messages +
+
+ {oooc.events.length || 0} messages +
-
- {oooc.conversation.lastActivityAt.toLocaleString()} +
+
+ Last Activity +
+
+ {oooc.conversation.lastActivityAt.toLocaleString()} +
+ {/* block status */} + {isBlocked && ( +
+
+ This contact is blocked +
+
+ )}
-
-); + ); +}; diff --git a/src/components/Modals/SettingsModal.tsx b/src/components/Modals/SettingsModal.tsx index 4312b81f..2ec32cfb 100644 --- a/src/components/Modals/SettingsModal.tsx +++ b/src/components/Modals/SettingsModal.tsx @@ -33,6 +33,8 @@ import { Palette, RectangleEllipsis, Coffee, + Ban, + X, } from "lucide-react"; import { toHex, PROTOCOL } from "../../config/protocol"; import { devMode } from "../../config/dev-mode"; @@ -44,6 +46,7 @@ import { HoldToDelete } from "../Common/HoldToDelete"; import { AppVersion } from "../App/AppVersion"; import { toast } from "../../utils/toast-helper"; import { Donations } from "../Common/Donations"; +import { useBlocklistStore } from "../../store/blocklist.store"; interface SettingsModalProps { isOpen: boolean; onClose: () => void; @@ -87,12 +90,14 @@ export const SettingsModal: React.FC = ({ const initRepositories = useDBStore((s) => s.initRepositories); const setSession = useSessionState((s) => s.setSession); const { flags, flips, setFlag } = useFeatureFlagsStore(); + const blocklistStore = useBlocklistStore(); const tabs = [ { id: "account", label: "Account", icon: User }, { id: "theme", label: "Theme", icon: Monitor }, { id: "network", label: "Network", icon: Network }, { id: "security", label: "Security", icon: Shield }, + { id: "blocklist", label: "Blocklist", icon: Shield }, // only show if there are >0 flips ...(Object.keys(flips).length > 0 ? [{ id: "extras", label: "Extra", icon: RectangleEllipsis }] @@ -953,6 +958,121 @@ export const SettingsModal: React.FC = ({ )}
)} + {activeTab === "blocklist" && ( +
+

Blocklist

+ +
+ {/* broadcast display mode toggle */} +
+
+ Broadcast Display Mode +
+
+ Choose how to display messages from blocked participants + in broadcasts +
+
+ + +
+
+ + {/* blocked addresses list */} +
+
+ Blocked Addresses ( + {blocklistStore.blockedAddressList.length}) +
+ {blocklistStore.blockedAddressList.length === 0 ? ( +
+ No blocked addresses +
+ ) : ( +
+ {blocklistStore.blockedAddressList.map((blocked) => ( +
+
+
+ {blocked.kaspaAddress} +
+ {blocked.reason && ( +
+ {blocked.reason} +
+ )} +
+ +
+ ))} +
+ )} +
+
+
+ )} {activeTab === "extras" && (

Extra

diff --git a/src/components/SideBarPane/Directs/ContactList.tsx b/src/components/SideBarPane/Directs/ContactList.tsx index e0aeb171..3a956744 100644 --- a/src/components/SideBarPane/Directs/ContactList.tsx +++ b/src/components/SideBarPane/Directs/ContactList.tsx @@ -20,17 +20,17 @@ export const ContactList: FC = ({ setMobileView, isMobile, }) => { - const messageStore = useMessagingStore(); - - const contacts = messageStore.oneOnOneConversations.map( - (oooc) => oooc.contact + const oneOnOneConversations = useMessagingStore( + (state) => state.oneOnOneConversations ); + + const contacts = oneOnOneConversations.map((oooc) => oooc.contact); // order contacts by last activity (most recent first) const orderedContacts = contacts.sort((a, b) => { - const conversationA = messageStore.oneOnOneConversations.find( + const conversationA = oneOnOneConversations.find( (oooc) => oooc.contact.id === a.id ); - const conversationB = messageStore.oneOnOneConversations.find( + const conversationB = oneOnOneConversations.find( (oooc) => oooc.contact.id === b.id ); @@ -69,7 +69,7 @@ export const ContactList: FC = ({ }); // then, add contacts from messages that match content - messageStore.oneOnOneConversations.forEach((oneOnOneConversation) => { + oneOnOneConversations.forEach((oneOnOneConversation) => { oneOnOneConversation.events.forEach((event) => { if (event.content.includes(q)) { // only add if not already present diff --git a/src/hooks/useOrchestrator.ts b/src/hooks/useOrchestrator.ts index 885927f3..76d6957c 100644 --- a/src/hooks/useOrchestrator.ts +++ b/src/hooks/useOrchestrator.ts @@ -9,6 +9,7 @@ import { UnlockedWallet } from "../types/wallet.type"; import { useLiveStore } from "../store/live.store"; import { useBroadcastStore } from "../store/broadcast.store"; import { useFeatureFlagsStore, FeatureFlags } from "../store/featureflag.store"; +import { useBlocklistStore } from "../store/blocklist.store"; export type ConnectOpts = { networkType?: NetworkType; @@ -143,6 +144,18 @@ export const useOrchestrator = () => { ); } + // load blocked addresses + useBlocklistStore + .getState() + .loadBlockedAddresses() + .then(() => console.log("Blocked addresses loaded")) + .catch((error) => + console.error( + "Failed to load blocked addresses during initialization:", + error + ) + ); + if (networkStore.rpc) { liveStore.start(networkStore.rpc, receivedAddressString); } diff --git a/src/service/block-processor-service.ts b/src/service/block-processor-service.ts index 5d86ade7..0c29d44c 100644 --- a/src/service/block-processor-service.ts +++ b/src/service/block-processor-service.ts @@ -17,6 +17,7 @@ import EventEmitter from "eventemitter3"; import { devMode } from "../config/dev-mode"; import { useBroadcastStore } from "../store/broadcast.store"; import { getTransactionId } from "../types/transactions"; +import { useBlocklistStore } from "../store/blocklist.store"; export type RawResolvedKasiaTransaction = { id: string; @@ -143,6 +144,19 @@ export class BlockProcessorService extends EventEmitter<{ const messageType = parsed.type; const targetAlias = parsed.alias; + // check if sender is blocked - reject direct messages, allow broadcasts to be filtered by UI + const blocklistStore = useBlocklistStore.getState(); + const isSenderBlocked = blocklistStore.isBlocked(resolvedSenderAddress); + + if (isSenderBlocked && messageType !== PROTOCOL.headers.BROADCAST.type) { + if (devMode) { + console.log( + `Block Processor - Rejecting message from blocked address: ${resolvedSenderAddress}` + ); + } + return; // don't process messages from blocked addresses + } + const isCommForUs = messageType === PROTOCOL.headers.COMM.type && targetAlias && diff --git a/src/service/conversation-manager-service.ts b/src/service/conversation-manager-service.ts index c1a173ac..e22f85da 100644 --- a/src/service/conversation-manager-service.ts +++ b/src/service/conversation-manager-service.ts @@ -14,6 +14,7 @@ import { } from "../store/repository/conversation.repository"; import { Contact } from "../store/repository/contact.repository"; import { Handshake } from "../store/repository/handshake.repository"; +import { useBlocklistStore } from "../store/blocklist.store"; export class ConversationManagerService { private static readonly STORAGE_KEY_PREFIX = "encrypted_conversations"; @@ -168,6 +169,15 @@ export class ConversationManagerService { payload: HandshakePayload ): Promise { try { + // check if sender is blocked before processing handshake + const blocklistStore = useBlocklistStore.getState(); + if (blocklistStore.isBlocked(senderAddress)) { + console.log( + `Conversation Manager - Rejecting handshake from blocked address: ${senderAddress}` + ); + return; // don't process handshakes from blocked addresses + } + // STEP 1 – look up strictly by sender address only const existingConversationAndContactByAddress = this.getConversationWithContactByAddress(senderAddress); diff --git a/src/service/storage-encryption.ts b/src/service/storage-encryption.ts index 401e60b2..f6a363d9 100644 --- a/src/service/storage-encryption.ts +++ b/src/service/storage-encryption.ts @@ -38,6 +38,7 @@ export async function reEncryptMessagesForWallet( repositories.messageRepository.reEncrypt(newPassword), repositories.paymentRepository.reEncrypt(newPassword), repositories.broadcastChannelRepository.reEncrypt(newPassword), + repositories.blockedAddressRepository.reEncrypt(newPassword), ]); console.log(`Successfully reEncrypted messages for wallet ${walletId}`); diff --git a/src/store/blocklist.store.ts b/src/store/blocklist.store.ts new file mode 100644 index 00000000..e4814fe6 --- /dev/null +++ b/src/store/blocklist.store.ts @@ -0,0 +1,163 @@ +import { create } from "zustand"; +import { useDBStore } from "./db.store"; +import { BlockedAddress } from "./repository/blocked-address.repository"; +import { v4 } from "uuid"; + +interface BlocklistState { + blockedAddresses: Set; + blockedAddressList: BlockedAddress[]; + isLoaded: boolean; + + loadBlockedAddresses: () => Promise; + blockAddress: (address: string, reason?: string) => Promise; + unblockAddress: (address: string) => Promise; + isBlocked: (address: string) => boolean; + reset: () => void; + + // broadcast display settings + broadcastBlockedDisplayMode: "hide" | "placeholder"; + setBroadcastBlockedDisplayMode: (mode: "hide" | "placeholder") => void; +} + +const BROADCAST_DISPLAY_MODE_KEY = "kasia_broadcast_blocked_display_mode"; + +// get initial display mode from localStorage +const getInitialDisplayMode = (): "hide" | "placeholder" => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem(BROADCAST_DISPLAY_MODE_KEY); + if (saved === "hide" || saved === "placeholder") { + return saved; + } + } + return "placeholder"; // default to placeholder +}; + +export const useBlocklistStore = create((set, get) => ({ + blockedAddresses: new Set(), + blockedAddressList: [], + isLoaded: false, + + loadBlockedAddresses: async () => { + try { + const repositories = useDBStore.getState().repositories; + if (!repositories) { + console.warn( + "Repositories not initialized, cannot load blocked addresses" + ); + return; + } + + const blockedAddresses = + await repositories.blockedAddressRepository.getBlockedAddresses(); + + const addressSet = new Set( + blockedAddresses.map((addr) => addr.kaspaAddress) + ); + + set({ + blockedAddresses: addressSet, + blockedAddressList: blockedAddresses, + isLoaded: true, + }); + } catch (error) { + console.error("Failed to load blocked addresses:", error); + set({ + blockedAddresses: new Set(), + blockedAddressList: [], + isLoaded: true, + }); + } + }, + + blockAddress: async (address: string, reason?: string) => { + try { + const repositories = useDBStore.getState().repositories; + if (!repositories) { + throw new Error("Repositories not initialized"); + } + + // check if already blocked + if (get().blockedAddresses.has(address)) { + console.log(`Address ${address} is already blocked`); + return; + } + + const newBlockedAddress: Omit = { + id: v4(), + kaspaAddress: address, + timestamp: new Date(), + reason, + }; + + await repositories.blockedAddressRepository.blockAddress( + newBlockedAddress + ); + + // update in-memory state + set((state) => { + const newSet = new Set(state.blockedAddresses); + newSet.add(address); + return { + blockedAddresses: newSet, + blockedAddressList: [ + ...state.blockedAddressList, + { ...newBlockedAddress, tenantId: repositories.tenantId }, + ], + }; + }); + + console.log(`Blocked address: ${address}`); + } catch (error) { + console.error("Failed to block address:", error); + throw error; + } + }, + + unblockAddress: async (address: string) => { + try { + const repositories = useDBStore.getState().repositories; + if (!repositories) { + throw new Error("Repositories not initialized"); + } + + await repositories.blockedAddressRepository.unblockAddress(address); + + // update in-memory state + set((state) => { + const newSet = new Set(state.blockedAddresses); + newSet.delete(address); + return { + blockedAddresses: newSet, + blockedAddressList: state.blockedAddressList.filter( + (addr) => addr.kaspaAddress !== address + ), + }; + }); + + console.log(`Unblocked address: ${address}`); + } catch (error) { + console.error("Failed to unblock address:", error); + throw error; + } + }, + + isBlocked: (address: string) => { + return get().blockedAddresses.has(address); + }, + + reset: () => { + set({ + blockedAddresses: new Set(), + blockedAddressList: [], + isLoaded: false, + }); + }, + + // broadcast display settings + broadcastBlockedDisplayMode: getInitialDisplayMode(), + + setBroadcastBlockedDisplayMode: (mode: "hide" | "placeholder") => { + set({ broadcastBlockedDisplayMode: mode }); + localStorage.setItem(BROADCAST_DISPLAY_MODE_KEY, mode); + }, +})); diff --git a/src/store/messaging.store.ts b/src/store/messaging.store.ts index 8b014df8..86525822 100644 --- a/src/store/messaging.store.ts +++ b/src/store/messaging.store.ts @@ -52,6 +52,7 @@ import { importData, } from "../service/import-export-service"; import { useNetworkStore } from "./network.store"; +import { useBlocklistStore } from "./blocklist.store"; import { historicalLoader_loadSendAndReceivedHandshake } from "../utils/historical-loader"; interface MessagingState { @@ -448,6 +449,7 @@ export const useMessagingStore = create((set, g) => { }, hydrateOneonOneConversations: async () => { const repositories = useDBStore.getState().repositories; + const blocklistStore = useBlocklistStore.getState(); const conversationWithContacts = g().conversationManager?.getAllConversationsWithContact(); @@ -456,18 +458,25 @@ export const useMessagingStore = create((set, g) => { return; } - const oneOnOneConversationPromises = conversationWithContacts.map( - async ({ - contact, - conversation, - }): Promise => { - const events = await repositories.getKasiaEventsByConversationId( - conversation.id - ); - - return { conversation, contact, events }; - } + // filter out blocked contacts before hydrating + const unBlockedConversationWithContacts = conversationWithContacts.filter( + ({ contact }) => + !blocklistStore.blockedAddresses.has(contact.kaspaAddress) ); + + const oneOnOneConversationPromises = + unBlockedConversationWithContacts.map( + async ({ + contact, + conversation, + }): Promise => { + const events = await repositories.getKasiaEventsByConversationId( + conversation.id + ); + + return { conversation, contact, events }; + } + ); const oneOnOneConversations = await Promise.all( oneOnOneConversationPromises ); @@ -526,6 +535,15 @@ export const useMessagingStore = create((set, g) => { ? transaction.recipientAddress : transaction.senderAddress; + // check if participant is blocked - skip processing + const blocklistStore = useBlocklistStore.getState(); + if (blocklistStore.blockedAddresses.has(participantAddress)) { + console.log( + `Skipping transaction from blocked address: ${participantAddress}` + ); + continue; + } + if ( await repositories.doesKasiaEventExistsById( `${unlockedWallet.id}_${transaction.transactionId}` diff --git a/src/store/repository/blocked-address.repository.ts b/src/store/repository/blocked-address.repository.ts new file mode 100644 index 00000000..9d97fffc --- /dev/null +++ b/src/store/repository/blocked-address.repository.ts @@ -0,0 +1,201 @@ +import { encryptXChaCha20Poly1305, decryptXChaCha20Poly1305 } from "kaspa-wasm"; +import { KasiaDB, DBNotFoundException } from "./db"; + +export type DbBlockedAddress = { + /** + * `uuidv4()` + */ + id: string; + /** + * tenant is the selected wallet + */ + tenantId: string; + timestamp: Date; + /** + * encrypted data shaped as `json(BlockedAddressBag)` + */ + encryptedData: string; +}; + +export type BlockedAddressBag = { + kaspaAddress: string; + reason?: string; // optional reason for blocking +}; + +export type BlockedAddress = BlockedAddressBag & + Omit; + +export class BlockedAddressRepository { + constructor( + readonly db: KasiaDB, + readonly tenantId: string, + readonly walletPassword: string + ) {} + + async getBlockedAddress(id: string): Promise { + const result = await this.db.get("blockedAddresses", id); + + if (!result) { + throw new DBNotFoundException(); + } + + return this._dbBlockedAddressToBlockedAddress(result); + } + + async getBlockedAddressByKaspaAddress( + kaspaAddress: string + ): Promise { + const result = await this.getBlockedAddresses().then((addresses) => { + return addresses.find((addr) => addr.kaspaAddress === kaspaAddress); + }); + + if (!result) { + throw new DBNotFoundException(); + } + + return result; + } + + async getBlockedAddresses(): Promise { + return this.db + .getAllFromIndex("blockedAddresses", "by-tenant-id", this.tenantId) + .then((dbBlockedAddresses) => { + return dbBlockedAddresses.map((dbBlockedAddress) => { + return this._dbBlockedAddressToBlockedAddress(dbBlockedAddress); + }); + }); + } + + async isAddressBlocked(kaspaAddress: string): Promise { + try { + await this.getBlockedAddressByKaspaAddress(kaspaAddress); + return true; + } catch (error) { + if (error instanceof DBNotFoundException) { + return false; + } + throw error; + } + } + + async blockAddress( + blockedAddress: Omit + ): Promise { + // check if already blocked + const isBlocked = await this.isAddressBlocked(blockedAddress.kaspaAddress); + if (isBlocked) { + throw new Error("Address is already blocked"); + } + + return this.db.put( + "blockedAddresses", + this._blockedAddressToDbBlockedAddress({ + ...blockedAddress, + tenantId: this.tenantId, + }) + ); + } + + async unblockAddress(kaspaAddress: string): Promise { + const blockedAddress = + await this.getBlockedAddressByKaspaAddress(kaspaAddress); + await this.db.delete("blockedAddresses", blockedAddress.id); + } + + async saveBulk( + blockedAddresses: Omit[] + ): Promise { + const tx = this.db.transaction("blockedAddresses", "readwrite"); + const store = tx.objectStore("blockedAddresses"); + + for (const blockedAddress of blockedAddresses) { + // check if address already exists + const existing = await store.get(blockedAddress.id); + if (!existing) { + await store.put( + this._blockedAddressToDbBlockedAddress({ + ...blockedAddress, + tenantId: this.tenantId, + }) + ); + } + } + + await tx.done; + } + + async reEncrypt(newPassword: string): Promise { + const transaction = this.db.transaction("blockedAddresses", "readwrite"); + const store = transaction.objectStore("blockedAddresses"); + const index = store.index("by-tenant-id"); + const cursor = await index.openCursor(IDBKeyRange.only(this.tenantId)); + if (!cursor) { + return; + } + + do { + const dbBlockedAddress = cursor.value; + // decrypt with old password + const decryptedData = decryptXChaCha20Poly1305( + dbBlockedAddress.encryptedData, + this.walletPassword + ); + // re-encrypt with new password + const reEncryptedData = encryptXChaCha20Poly1305( + decryptedData, + newPassword + ); + // update in database + await cursor.update({ + ...dbBlockedAddress, + encryptedData: reEncryptedData, + }); + } while (await cursor.continue()); + } + + async deleteTenant(tenantId: string): Promise { + const keys = await this.db.getAllKeysFromIndex( + "blockedAddresses", + "by-tenant-id", + tenantId + ); + + await Promise.all(keys.map((k) => this.db.delete("blockedAddresses", k))); + } + + private _blockedAddressToDbBlockedAddress( + blockedAddress: BlockedAddress + ): DbBlockedAddress { + return { + id: blockedAddress.id, + encryptedData: encryptXChaCha20Poly1305( + JSON.stringify({ + kaspaAddress: blockedAddress.kaspaAddress, + reason: blockedAddress.reason, + } satisfies BlockedAddressBag), + this.walletPassword + ), + timestamp: blockedAddress.timestamp, + tenantId: this.tenantId, + }; + } + + private _dbBlockedAddressToBlockedAddress( + dbBlockedAddress: DbBlockedAddress + ): BlockedAddress { + const blockedAddressBag = JSON.parse( + decryptXChaCha20Poly1305( + dbBlockedAddress.encryptedData, + this.walletPassword + ) + ) as BlockedAddressBag; + + return { + tenantId: dbBlockedAddress.tenantId, + id: dbBlockedAddress.id, + timestamp: dbBlockedAddress.timestamp, + kaspaAddress: blockedAddressBag.kaspaAddress, + reason: blockedAddressBag.reason, + }; + } +} diff --git a/src/store/repository/db.ts b/src/store/repository/db.ts index 6033a479..2112a530 100644 --- a/src/store/repository/db.ts +++ b/src/store/repository/db.ts @@ -22,8 +22,12 @@ import { BroadcastChannelRepository, } from "./broadcast-channel.repository"; import { MetaRespository } from "./meta.repository"; +import { + DbBlockedAddress, + BlockedAddressRepository, +} from "./blocked-address.repository"; -const CURRENT_DB_VERSION = 3; +const CURRENT_DB_VERSION = 4; export class DBNotFoundException extends Error { constructor() { @@ -111,6 +115,14 @@ export interface KasiaDBSchema extends DBSchema { "by-tenant-id": string; }; }; + blockedAddresses: { + key: string; + value: DbBlockedAddress; + indexes: { + "by-tenant-id": string; + "by-tenant-id-timestamp": [string, Date]; + }; + }; } export type KasiaDB = IDBPDatabase; @@ -260,9 +272,22 @@ export const openDatabase = async (): Promise => { } if (oldVersion <= 3) { + // blocked addresses + const blockedAddressesStore = db.createObjectStore("blockedAddresses", { + keyPath: "id", + }); + blockedAddressesStore.createIndex("by-tenant-id", "tenantId"); + blockedAddressesStore.createIndex("by-tenant-id-timestamp", [ + "tenantId", + "timestamp", + ]); + + console.log("Database schema upgraded to v4 - blocked addresses"); + } + + if (oldVersion <= 4) { // HERE next migration, first increase CURRENT_DB_VERSION then implement with oldVersion <= CURRENT_DB_VERSION - 1 // add more if branching for each next version - // BROADCAST CHANNELS } }, }); @@ -277,6 +302,7 @@ export class Repositories { public readonly handshakeRepository: HandshakeRepository; public readonly savedHandshakeRepository: SavedHandhshakeRepository; public readonly broadcastChannelRepository: BroadcastChannelRepository; + public readonly blockedAddressRepository: BlockedAddressRepository; public readonly metadataRepository: MetaRespository; constructor( @@ -328,6 +354,12 @@ export class Repositories { walletPassword ); + this.blockedAddressRepository = new BlockedAddressRepository( + db, + tenantId, + walletPassword + ); + this.metadataRepository = new MetaRespository(tenantId); } @@ -361,4 +393,54 @@ export class Repositories { ]); return !!message || !!payment || !!handshake; } + + /** + * delete all messages, payments, handshakes and optionally conversation and contact for a given contact + */ + async deleteAllDataForContact( + contactId: string, + options?: { deleteConversation?: boolean; deleteContact?: boolean } + ): Promise { + try { + // get the conversation for this contact + const conversation = await this.conversationRepository + .getConversationByContactId(contactId) + .catch(() => null); + + if (conversation) { + // delete all messages, payments, and handshakes for this conversation + const [messages, payments, handshakes] = await Promise.all([ + this.messageRepository.getMessagesByConversationId(conversation.id), + this.paymentRepository.getPaymentsByConversationId(conversation.id), + this.handshakeRepository.getHanshakesByConversationId( + conversation.id + ), + ]); + + // delete all events + await Promise.all([ + ...messages.map((m) => this.messageRepository.deleteMessage(m.id)), + ...payments.map((p) => this.paymentRepository.deletePayment(p.id)), + ...handshakes.map((h) => + this.handshakeRepository.deleteHandshake(h.id) + ), + ]); + + // delete conversation if requested + if (options?.deleteConversation) { + await this.conversationRepository.deleteConversation(conversation.id); + } + } + + // delete contact if requested + if (options?.deleteContact) { + await this.contactRepository.deleteContact(contactId); + } + + console.log(`Deleted all data for contact ${contactId}`); + } catch (error) { + console.error("Error deleting contact data:", error); + throw error; + } + } } From 4ddfd7d37e77d092df81423c093df08c23a7a187 Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:24:29 +0300 Subject: [PATCH 02/12] refactor: tidy settings menu --- src/components/Common/BlockUnblockButton.tsx | 96 ++++++ .../Broadcasts/BroadcastMessagesList.tsx | 3 +- src/components/Modals/SettingsModal.tsx | 159 +++------- .../Modals/SubSettings/BlockList.tsx | 282 ++++++++++++++++++ .../repository/blocked-address.repository.ts | 2 +- 5 files changed, 419 insertions(+), 123 deletions(-) create mode 100644 src/components/Common/BlockUnblockButton.tsx create mode 100644 src/components/Modals/SubSettings/BlockList.tsx diff --git a/src/components/Common/BlockUnblockButton.tsx b/src/components/Common/BlockUnblockButton.tsx new file mode 100644 index 00000000..8ce6e6fe --- /dev/null +++ b/src/components/Common/BlockUnblockButton.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { Ban } from "lucide-react"; +import { + Popover, + PopoverButton, + PopoverPanel, + Transition, +} from "@headlessui/react"; +import { MoreHorizontal } from "lucide-react"; +import { useBlocklistStore } from "../../store/blocklist.store"; +import { toast } from "../../utils/toast-helper"; + +interface BlockUnblockButtonProps { + address: string; + onBlock?: () => void; + onUnblock?: () => void; + className?: string; +} + +export const BlockUnblockButton: React.FC = ({ + address, + onBlock, + onUnblock, + className = "ml-auto pt-2", +}) => { + const [isBlocking, setIsBlocking] = useState(false); + const blocklistStore = useBlocklistStore(); + const isBlocked = blocklistStore.blockedAddresses.has(address); + + const handleBlock = async () => { + try { + setIsBlocking(true); + if (isBlocked) { + await blocklistStore.unblockAddress(address); + toast.success("Unblocked"); + onUnblock?.(); + } else { + await blocklistStore.blockAddress(address); + toast.success("Blocked"); + onBlock?.(); + } + } catch (error) { + console.error("Error blocking/unblocking:", error); + toast.error("Failed to update block status"); + } finally { + setIsBlocking(false); + } + }; + + return ( +
+ + {({ close }) => ( + <> + + + + + +
+ +
+
+
+ + )} +
+
+ ); +}; diff --git a/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx b/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx index 9028c6af..27c18e8b 100644 --- a/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx +++ b/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx @@ -5,6 +5,7 @@ import { DateSeparator } from "../../DateSeparator"; import { isToday } from "../../../utils/message-date-format"; import { useBlocklistStore } from "../../../store/blocklist.store"; +export const BLOCKED_PLACEHOLDER = "Blocked Contact"; interface BroadcastMessagesListProps { messages: BroadcastMessage[]; walletAddress: string; @@ -29,7 +30,7 @@ export const BroadcastMessagesList: FC = ({ if (isSenderBlocked && broadcastBlockedDisplayMode === "placeholder") { return { ...message, - content: "Blocked Contact", + content: BLOCKED_PLACEHOLDER, isBlockedPlaceholder: true, }; } diff --git a/src/components/Modals/SettingsModal.tsx b/src/components/Modals/SettingsModal.tsx index 2ec32cfb..1d2f0e8e 100644 --- a/src/components/Modals/SettingsModal.tsx +++ b/src/components/Modals/SettingsModal.tsx @@ -33,8 +33,6 @@ import { Palette, RectangleEllipsis, Coffee, - Ban, - X, } from "lucide-react"; import { toHex, PROTOCOL } from "../../config/protocol"; import { devMode } from "../../config/dev-mode"; @@ -46,7 +44,7 @@ import { HoldToDelete } from "../Common/HoldToDelete"; import { AppVersion } from "../App/AppVersion"; import { toast } from "../../utils/toast-helper"; import { Donations } from "../Common/Donations"; -import { useBlocklistStore } from "../../store/blocklist.store"; +import { BlockList } from "./SubSettings/BlockList"; interface SettingsModalProps { isOpen: boolean; onClose: () => void; @@ -90,17 +88,21 @@ export const SettingsModal: React.FC = ({ const initRepositories = useDBStore((s) => s.initRepositories); const setSession = useSessionState((s) => s.setSession); const { flags, flips, setFlag } = useFeatureFlagsStore(); - const blocklistStore = useBlocklistStore(); const tabs = [ { id: "account", label: "Account", icon: User }, { id: "theme", label: "Theme", icon: Monitor }, { id: "network", label: "Network", icon: Network }, { id: "security", label: "Security", icon: Shield }, - { id: "blocklist", label: "Blocklist", icon: Shield }, // only show if there are >0 flips ...(Object.keys(flips).length > 0 - ? [{ id: "extras", label: "Extra", icon: RectangleEllipsis }] + ? [ + { + id: "extras", + label: isMobile ? "Feat." : "Features", + icon: RectangleEllipsis, + }, + ] : []), ...(devMode ? [ @@ -135,6 +137,9 @@ export const SettingsModal: React.FC = ({ // Delete all messages state const [showDeleteAll, setShowDeleteAll] = useState(false); + // Blocklist state + const [showBlocklist, setShowBlocklist] = useState(false); + // Custom theme state const [showCustomTheme, setShowCustomTheme] = useState(false); @@ -813,7 +818,7 @@ export const SettingsModal: React.FC = ({ )} {activeTab === "security" && (
- {!showPasswordChange ? ( + {!showPasswordChange && !showBlocklist ? ( <>

Security

@@ -855,9 +860,23 @@ export const SettingsModal: React.FC = ({
+ + {/* Blocklist */} +
- ) : ( + ) : showPasswordChange ? ( <>
- )} -
- )} - {activeTab === "blocklist" && ( -
-

Blocklist

- -
- {/* broadcast display mode toggle */} -
-
- Broadcast Display Mode -
-
- Choose how to display messages from blocked participants - in broadcasts -
-
- + ) : showBlocklist ? ( + <> +
+

Blocklist

-
- - {/* blocked addresses list */} -
-
- Blocked Addresses ( - {blocklistStore.blockedAddressList.length}) -
- {blocklistStore.blockedAddressList.length === 0 ? ( -
- No blocked addresses -
- ) : ( -
- {blocklistStore.blockedAddressList.map((blocked) => ( -
-
-
- {blocked.kaspaAddress} -
- {blocked.reason && ( -
- {blocked.reason} -
- )} -
- -
- ))} -
- )} -
-
+ + + ) : null}
)} {activeTab === "extras" && (
-

Extra

+

Features

{/* Warning */} diff --git a/src/components/Modals/SubSettings/BlockList.tsx b/src/components/Modals/SubSettings/BlockList.tsx new file mode 100644 index 00000000..aaa8e32e --- /dev/null +++ b/src/components/Modals/SubSettings/BlockList.tsx @@ -0,0 +1,282 @@ +import React, { useState } from "react"; +import { useBlocklistStore } from "../../../store/blocklist.store"; +import { X, Plus, ArrowLeft, ChevronDown, ChevronUp } from "lucide-react"; +import clsx from "clsx"; +import { toast } from "../../../utils/toast-helper"; +import { BLOCKED_PLACEHOLDER } from "../../../components/MessagesPane/Broadcasts/BroadcastMessagesList"; +import { Button } from "../../Common/Button"; + +export const BlockList: React.FC = () => { + const blocklistStore = useBlocklistStore(); + const [showAddForm, setShowAddForm] = useState(false); + const [newAddress, setNewAddress] = useState(""); + const [newReason, setNewReason] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isBroadcastExpanded, setIsBroadcastExpanded] = useState(false); + + const handleAddAddress = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!newAddress.trim()) { + toast.error("Please enter an address"); + return; + } + + // Basic Kaspa address validation (starts with kaspa:) + if ( + !newAddress.trim().startsWith("kaspa:") && + !newAddress.trim().startsWith("kaspatest:") + ) { + toast.error("Please enter a valid Kaspa address"); + return; + } + + setIsSubmitting(true); + + try { + await blocklistStore.blockAddress( + newAddress.trim(), + newReason.trim() || undefined + ); + toast.success("Address blocked successfully"); + setNewAddress(""); + setNewReason(""); + setShowAddForm(false); + } catch (error) { + console.error("Error blocking address:", error); + toast.error("Failed to block address"); + } finally { + setIsSubmitting(false); + } + }; + + const handleCancel = () => { + setNewAddress(""); + setNewReason(""); + setShowAddForm(false); + }; + + return ( +
+ {/* Content wrapper with sliding animation */} +
+ {/* broadcast display mode toggle - collapsible */} +
+ {/* Collapsible Header */} + + + {/* Collapsible Content */} +
+
+ Choose how to display messages from blocked participants in + broadcasts +
+
+ + +
+
+
+ + {/* blocked addresses list */} +
+
+
+ Blocked Addresses ({blocklistStore.blockedAddressList.length}) +
+ +
+ + {blocklistStore.blockedAddressList.length === 0 ? ( +
+ No blocked addresses +
+ ) : ( +
+ {blocklistStore.blockedAddressList.map((blocked) => ( +
+
+
+ {blocked.kaspaAddress} +
+ {blocked.reason && ( +
+ {blocked.reason} +
+ )} +
+ +
+ ))} +
+ )} +
+
+ + {/* Add Address Form - slides in to take over the whole component */} +
+
+
+

Add Blocked Address

+ +
+
+
+ + setNewAddress(e.target.value)} + className="border-primary-border bg-primary-bg text-primary focus:ring-kas-secondary/80 w-full rounded-lg border p-3 text-base focus:ring-2 focus:outline-none" + placeholder="kaspa:..." + disabled={isSubmitting} + required + /> +
+ +
+ + setNewReason(e.target.value)} + className="border-primary-border bg-primary-bg text-primary focus:ring-kas-secondary/80 w-full rounded-lg border p-3 text-base focus:ring-2 focus:outline-none" + placeholder="Why are you blocking this address?" + disabled={isSubmitting} + maxLength={100} + /> +
+ +
+ + +
+
+
+
+
+ ); +}; diff --git a/src/store/repository/blocked-address.repository.ts b/src/store/repository/blocked-address.repository.ts index 9d97fffc..3ba8ef64 100644 --- a/src/store/repository/blocked-address.repository.ts +++ b/src/store/repository/blocked-address.repository.ts @@ -19,7 +19,7 @@ export type DbBlockedAddress = { export type BlockedAddressBag = { kaspaAddress: string; - reason?: string; // optional reason for blocking + reason?: string; }; export type BlockedAddress = BlockedAddressBag & From 8dc4e0dfa7de05741dcc218b4fc1c20a0b3376bf Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:25:55 +0300 Subject: [PATCH 03/12] feat: further bcast --- src/components/Common/BlockUnblockButton.tsx | 10 +- src/components/Layout/ModalHost.tsx | 4 - .../Modals/BroadcastParticipantInfo.tsx | 8 +- src/components/Modals/SettingsModal.tsx | 42 +-- .../Modals/SubSettings/BlockList.tsx | 354 +++++------------- src/store/messaging.store.ts | 9 + 6 files changed, 139 insertions(+), 288 deletions(-) diff --git a/src/components/Common/BlockUnblockButton.tsx b/src/components/Common/BlockUnblockButton.tsx index 8ce6e6fe..2cdb1a3d 100644 --- a/src/components/Common/BlockUnblockButton.tsx +++ b/src/components/Common/BlockUnblockButton.tsx @@ -35,9 +35,13 @@ export const BlockUnblockButton: React.FC = ({ toast.success("Unblocked"); onUnblock?.(); } else { - await blocklistStore.blockAddress(address); - toast.success("Blocked"); - onBlock?.(); + if (onBlock) { + onBlock(); + } else { + // Default behavior: block directly + await blocklistStore.blockAddress(address); + toast.success("Blocked"); + } } } catch (error) { console.error("Error blocking/unblocking:", error); diff --git a/src/components/Layout/ModalHost.tsx b/src/components/Layout/ModalHost.tsx index 22bcfad7..36170098 100644 --- a/src/components/Layout/ModalHost.tsx +++ b/src/components/Layout/ModalHost.tsx @@ -140,10 +140,6 @@ export const ModalHost = () => { { - closeModal("broadcast-participant-info"); - setSelectedParticipant(null); - }} /> )} diff --git a/src/components/Modals/BroadcastParticipantInfo.tsx b/src/components/Modals/BroadcastParticipantInfo.tsx index 6b9b6b04..25a2db42 100644 --- a/src/components/Modals/BroadcastParticipantInfo.tsx +++ b/src/components/Modals/BroadcastParticipantInfo.tsx @@ -8,13 +8,11 @@ import { BlockUnblockButton } from "../Common/BlockUnblockButton"; type BroadcastParticipantInfoProps = { address: string; nickname?: string; - onClose: () => void; }; export const BroadcastParticipantInfo: FC = ({ address, nickname, - onClose, }) => { const blocklistStore = useBlocklistStore(); const isBlocked = blocklistStore.blockedAddresses.has(address); @@ -53,11 +51,7 @@ export const BroadcastParticipantInfo: FC = ({ {nickname || "No nickname"}
{/* actions menu */} - +
Broadcast Participant diff --git a/src/components/Modals/SettingsModal.tsx b/src/components/Modals/SettingsModal.tsx index 1d2f0e8e..308e41a2 100644 --- a/src/components/Modals/SettingsModal.tsx +++ b/src/components/Modals/SettingsModal.tsx @@ -442,9 +442,9 @@ export const SettingsModal: React.FC = ({ isMobile && activeTab === tab.id, "text-primary bg-primary-bg border-kas-secondary rounded-lg border": !isMobile && activeTab === tab.id, - "text-muted-foreground hover:text-primary border-b-2 border-transparent": + "hover:text-primary border-b-2 border-transparent": isMobile && activeTab !== tab.id, - "text-muted-foreground hover:text-primary border border-transparent": + "hover:text-primary border border-transparent": !isMobile && activeTab !== tab.id, } )} @@ -484,7 +484,7 @@ export const SettingsModal: React.FC = ({
Your Wallet:
-
+
{unlockedWallet.name}
@@ -500,7 +500,7 @@ export const SettingsModal: React.FC = ({
Change Wallet Name
-
+
Update your wallet's display name
@@ -516,7 +516,7 @@ export const SettingsModal: React.FC = ({
Import / Export Messages
-
+
Backup or restore your message history
@@ -534,7 +534,7 @@ export const SettingsModal: React.FC = ({
Delete All Messages
-
+
Permanently remove all conversations and data
@@ -546,7 +546,7 @@ export const SettingsModal: React.FC = ({
@@ -561,7 +561,7 @@ export const SettingsModal: React.FC = ({
Wallet name changed successfully!
-
+
Your wallet name has been updated.
@@ -627,7 +627,7 @@ export const SettingsModal: React.FC = ({
@@ -642,7 +642,7 @@ export const SettingsModal: React.FC = ({
@@ -661,7 +661,7 @@ export const SettingsModal: React.FC = ({
Confirm Deletion
-
+
Click and hold the delete button below to confirm you want to permanently delete all messages.
@@ -745,7 +745,7 @@ export const SettingsModal: React.FC = ({
@@ -791,7 +791,7 @@ export const SettingsModal: React.FC = ({
Current Network
-
+
= ({ : "(Disconnected)"}
{networkStore.nodeUrl && ( -
+
{networkStore.nodeUrl}
@@ -838,7 +838,7 @@ export const SettingsModal: React.FC = ({
Change Password
-
+
Update the password used to unlock your wallet
@@ -855,7 +855,7 @@ export const SettingsModal: React.FC = ({
Seed Phrase
-
+
View Your Wallets Seed Phrase
@@ -869,7 +869,7 @@ export const SettingsModal: React.FC = ({
Blocklist
-
+
Manage blocked addresses and privacy settings
@@ -881,7 +881,7 @@ export const SettingsModal: React.FC = ({
@@ -894,7 +894,7 @@ export const SettingsModal: React.FC = ({
Password changed successfully!
-
+
Your wallet password has been updated.
@@ -979,7 +979,7 @@ export const SettingsModal: React.FC = ({
@@ -1016,7 +1016,7 @@ export const SettingsModal: React.FC = ({
{item.label}
-
+
{item.desc}
diff --git a/src/components/Modals/SubSettings/BlockList.tsx b/src/components/Modals/SubSettings/BlockList.tsx index aaa8e32e..49491a86 100644 --- a/src/components/Modals/SubSettings/BlockList.tsx +++ b/src/components/Modals/SubSettings/BlockList.tsx @@ -1,281 +1,129 @@ import React, { useState } from "react"; import { useBlocklistStore } from "../../../store/blocklist.store"; -import { X, Plus, ArrowLeft, ChevronDown, ChevronUp } from "lucide-react"; +import { X, ChevronDown, ChevronUp } from "lucide-react"; import clsx from "clsx"; import { toast } from "../../../utils/toast-helper"; import { BLOCKED_PLACEHOLDER } from "../../../components/MessagesPane/Broadcasts/BroadcastMessagesList"; -import { Button } from "../../Common/Button"; export const BlockList: React.FC = () => { const blocklistStore = useBlocklistStore(); - const [showAddForm, setShowAddForm] = useState(false); - const [newAddress, setNewAddress] = useState(""); - const [newReason, setNewReason] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); const [isBroadcastExpanded, setIsBroadcastExpanded] = useState(false); - const handleAddAddress = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!newAddress.trim()) { - toast.error("Please enter an address"); - return; - } - - // Basic Kaspa address validation (starts with kaspa:) - if ( - !newAddress.trim().startsWith("kaspa:") && - !newAddress.trim().startsWith("kaspatest:") - ) { - toast.error("Please enter a valid Kaspa address"); - return; - } - - setIsSubmitting(true); - - try { - await blocklistStore.blockAddress( - newAddress.trim(), - newReason.trim() || undefined - ); - toast.success("Address blocked successfully"); - setNewAddress(""); - setNewReason(""); - setShowAddForm(false); - } catch (error) { - console.error("Error blocking address:", error); - toast.error("Failed to block address"); - } finally { - setIsSubmitting(false); - } - }; - - const handleCancel = () => { - setNewAddress(""); - setNewReason(""); - setShowAddForm(false); - }; - return (
- {/* Content wrapper with sliding animation */} -
- {/* broadcast display mode toggle - collapsible */} -
+ {/* Collapsible Header */} + +

+ Broadcast Display Mode +

+ {isBroadcastExpanded ? ( + + ) : ( + + )} + - {/* Collapsible Content */} -
-
- Choose how to display messages from blocked participants in - broadcasts -
-
- - -
+ {/* Collapsible Content */} +
+
+ Choose how to display messages from blocked participants in + broadcasts
-
- - {/* blocked addresses list */} -
-
-
- Blocked Addresses ({blocklistStore.blockedAddressList.length}) -
+
-
- - {blocklistStore.blockedAddressList.length === 0 ? ( -
- No blocked addresses -
- ) : ( -
- {blocklistStore.blockedAddressList.map((blocked) => ( -
-
-
- {blocked.kaspaAddress} -
- {blocked.reason && ( -
- {blocked.reason} -
- )} -
- -
- ))} -
- )} -
-
- - {/* Add Address Form - slides in to take over the whole component */} -
-
-
-

Add Blocked Address

-
-
- - setNewAddress(e.target.value)} - className="border-primary-border bg-primary-bg text-primary focus:ring-kas-secondary/80 w-full rounded-lg border p-3 text-base focus:ring-2 focus:outline-none" - placeholder="kaspa:..." - disabled={isSubmitting} - required - /> -
+
+
-
- - setNewReason(e.target.value)} - className="border-primary-border bg-primary-bg text-primary focus:ring-kas-secondary/80 w-full rounded-lg border p-3 text-base focus:ring-2 focus:outline-none" - placeholder="Why are you blocking this address?" - disabled={isSubmitting} - maxLength={100} - /> -
+ {/* blocked addresses list */} +
+
+
+ Blocked Addresses ({blocklistStore.blockedAddressList.length}) +
+
-
- -
+ ) : ( +
+ {blocklistStore.blockedAddressList.map((blocked) => ( +
- {isSubmitting ? "Blocking..." : "Block Address"} - -
- -
+
+
+ {blocked.kaspaAddress} +
+ {blocked.reason && ( +
+ {blocked.reason} +
+ )} +
+ +
+ ))} +
+ )}
); diff --git a/src/store/messaging.store.ts b/src/store/messaging.store.ts index 86525822..089b54f1 100644 --- a/src/store/messaging.store.ts +++ b/src/store/messaging.store.ts @@ -390,6 +390,15 @@ export const useMessagingStore = create((set, g) => { metadata ); + // Skip processing historical data for blocked addresses + // const blocklistStore = useBlocklistStore.getState(); + // if (blocklistStore.blockedAddresses.has(senderAddress)) { + // console.log( + // `Skipping historical handshake processing for blocked address: ${senderAddress}` + // ); + // return; + // } + console.log( "Loading Strategy - handshake history reconciliation loaded" ); From 050b67ccd5ce5f6f6c4cf6d12dc83696ef43e0ac Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:40:21 +0300 Subject: [PATCH 04/12] chore: color, component name --- src/components/Layout/ModalHost.tsx | 4 ++-- ...ntInfo.tsx => BroadcastParticipantInfoModal.tsx} | 13 ++++++------- src/components/Modals/ContactInfoModal.tsx | 4 ++-- src/components/Modals/SettingsModal.tsx | 4 ++-- 4 files changed, 12 insertions(+), 13 deletions(-) rename src/components/Modals/{BroadcastParticipantInfo.tsx => BroadcastParticipantInfoModal.tsx} (89%) diff --git a/src/components/Layout/ModalHost.tsx b/src/components/Layout/ModalHost.tsx index 36170098..781ef357 100644 --- a/src/components/Layout/ModalHost.tsx +++ b/src/components/Layout/ModalHost.tsx @@ -11,7 +11,7 @@ import { ContactInfoModal } from "../Modals/ContactInfoModal"; import { NewChatForm } from "../Modals/NewChatForm"; import { LoaderCircle } from "lucide-react"; import { ImagePresenter } from "../Modals/ImagePresenter"; -import { BroadcastParticipantInfo } from "../Modals/BroadcastParticipantInfo"; +import { BroadcastParticipantInfoModal } from "../Modals/BroadcastParticipantInfoModal"; import { QrScannerModal } from "../Modals/QrScannerModal"; import { OffChainHandshakeModal } from "../Modals/OffChainHandshakeModal"; import { DeleteWalletModal } from "../Modals/DeleteWalletModal"; @@ -137,7 +137,7 @@ export const ModalHost = () => { setSelectedParticipant(null); }} > - diff --git a/src/components/Modals/BroadcastParticipantInfo.tsx b/src/components/Modals/BroadcastParticipantInfoModal.tsx similarity index 89% rename from src/components/Modals/BroadcastParticipantInfo.tsx rename to src/components/Modals/BroadcastParticipantInfoModal.tsx index 25a2db42..5909f34a 100644 --- a/src/components/Modals/BroadcastParticipantInfo.tsx +++ b/src/components/Modals/BroadcastParticipantInfoModal.tsx @@ -5,15 +5,14 @@ import clsx from "clsx"; import { useBlocklistStore } from "../../store/blocklist.store"; import { BlockUnblockButton } from "../Common/BlockUnblockButton"; -type BroadcastParticipantInfoProps = { +type BroadcastParticipantInfoModalProps = { address: string; nickname?: string; }; -export const BroadcastParticipantInfo: FC = ({ - address, - nickname, -}) => { +export const BroadcastParticipantInfoModal: FC< + BroadcastParticipantInfoModalProps +> = ({ address, nickname }) => { const blocklistStore = useBlocklistStore(); const isBlocked = blocklistStore.blockedAddresses.has(address); @@ -64,8 +63,8 @@ export const BroadcastParticipantInfo: FC = ({ {/* block status */} {isBlocked && ( -
-
+
+
This participant is blocked
diff --git a/src/components/Modals/ContactInfoModal.tsx b/src/components/Modals/ContactInfoModal.tsx index 67966c7f..3ffde4aa 100644 --- a/src/components/Modals/ContactInfoModal.tsx +++ b/src/components/Modals/ContactInfoModal.tsx @@ -142,8 +142,8 @@ export const ContactInfoModal: FC = ({
{/* block status */} {isBlocked && ( -
-
+
+
This contact is blocked
diff --git a/src/components/Modals/SettingsModal.tsx b/src/components/Modals/SettingsModal.tsx index 308e41a2..acfc5ea5 100644 --- a/src/components/Modals/SettingsModal.tsx +++ b/src/components/Modals/SettingsModal.tsx @@ -796,8 +796,8 @@ export const SettingsModal: React.FC = ({ className={clsx( "h-2 w-2 rounded-full", networkStore.isConnected - ? "bg-green-500" - : "bg-red-500" + ? "bg-[var(--accent-green)]" + : "bg-[var(--accent-red)]" )} /> {networkStore.network}{" "} From 45e4231ba42b1055c47c2087bf42fdac5e89ee64 Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:09:18 +0300 Subject: [PATCH 05/12] fix: misc - tidy up var names - some styling - load block list before handshakes - really make sure we dont get handshakes for blocked messages --- .../Modals/SubSettings/BlockList.tsx | 4 +- src/hooks/useOrchestrator.ts | 26 +++++++------ src/service/conversation-manager-service.ts | 37 +++++++++++++++++-- src/store/blocklist.store.ts | 4 +- .../repository/blocked-address.repository.ts | 4 +- 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/components/Modals/SubSettings/BlockList.tsx b/src/components/Modals/SubSettings/BlockList.tsx index 49491a86..5987a2b3 100644 --- a/src/components/Modals/SubSettings/BlockList.tsx +++ b/src/components/Modals/SubSettings/BlockList.tsx @@ -46,7 +46,7 @@ export const BlockList: React.FC = () => { blocklistStore.setBroadcastBlockedDisplayMode("placeholder") } className={clsx( - "flex w-full cursor-pointer items-center gap-2 rounded-lg border p-3 transition-all", + "flex w-full cursor-pointer items-center gap-2 rounded-lg border p-3 transition-all active:rounded-4xl", blocklistStore.broadcastBlockedDisplayMode === "placeholder" ? "bg-kas-secondary/10 border-kas-secondary" : "bg-primary-bg border-primary-border hover:bg-primary-bg/50" @@ -62,7 +62,7 @@ export const BlockList: React.FC = () => { blocklistStore.setBroadcastBlockedDisplayMode("hide") } className={clsx( - "flex w-full cursor-pointer items-center gap-2 rounded-lg border p-3 transition-all", + "flex w-full cursor-pointer items-center gap-2 rounded-lg border p-3 transition-all active:rounded-4xl", blocklistStore.broadcastBlockedDisplayMode === "hide" ? "bg-kas-secondary/10 border-kas-secondary" : "bg-primary-bg border-primary-border hover:bg-primary-bg/50" diff --git a/src/hooks/useOrchestrator.ts b/src/hooks/useOrchestrator.ts index 76d6957c..c3ddf3c3 100644 --- a/src/hooks/useOrchestrator.ts +++ b/src/hooks/useOrchestrator.ts @@ -126,6 +126,17 @@ export const useOrchestrator = () => { conversationManager: null, }); + // load blocked addresses BEFORE messaging store to prevent rehydrating blocked contacts + try { + await useBlocklistStore.getState().loadBlockedAddresses(); + console.log("Blocked addresses loaded"); + } catch (error) { + console.error( + "Failed to load blocked addresses during initialization:", + error + ); + } + await messagingStore.load(receivedAddressString); // load broadcast channels async so we can start processing them straight away @@ -144,18 +155,6 @@ export const useOrchestrator = () => { ); } - // load blocked addresses - useBlocklistStore - .getState() - .loadBlockedAddresses() - .then(() => console.log("Blocked addresses loaded")) - .catch((error) => - console.error( - "Failed to load blocked addresses during initialization:", - error - ) - ); - if (networkStore.rpc) { liveStore.start(networkStore.rpc, receivedAddressString); } @@ -198,6 +197,9 @@ export const useOrchestrator = () => { // clear broadcast store to prevent channel/message bleed broadcastStore.reset(); + + // clear blocklist store to prevent address list bleed + useBlocklistStore.getState().reset(); }; return { connect, startSession, onPause, onResume }; diff --git a/src/service/conversation-manager-service.ts b/src/service/conversation-manager-service.ts index e22f85da..027d18e5 100644 --- a/src/service/conversation-manager-service.ts +++ b/src/service/conversation-manager-service.ts @@ -445,6 +445,13 @@ export class ConversationManagerService { recipientAddress: string, initiatedByMe: boolean ): Promise<{ conversation: Conversation; contact: Contact }> { + // prevent creating conversations with blocked addresses + if (this.isAddressBlocked(recipientAddress)) { + throw new Error( + `Cannot create conversation with blocked address: ${recipientAddress}` + ); + } + const contact = await this.repositories.contactRepository .getContactByKaspaAddress(recipientAddress) .catch(async (error) => { @@ -492,6 +499,13 @@ export class ConversationManagerService { payload: SavedHandshakePayload, transactionId: string ): Promise<{ conversation: Conversation; contact: Contact }> { + // check if address is blocked before processing + if (this.isAddressBlocked(payload.recipientAddress)) { + throw new Error( + `Cannot hydrate blocked address: ${payload.recipientAddress}` + ); + } + const contact = await this.repositories.contactRepository .getContactByKaspaAddress(payload.recipientAddress) .catch(async (error) => { @@ -590,13 +604,17 @@ export class ConversationManagerService { } private isValidKaspaAddress(address: string): boolean { - // Check for both mainnet and testnet address formats + // check for both mainnet and testnet address formats return ( (address.startsWith("kaspa:") || address.startsWith("kaspatest:")) && address.length > 10 ); } + private isAddressBlocked(address: string): boolean { + return useBlocklistStore.getState().isBlocked(address); + } + /** * assumption: conversation does not exist yet * -> you should call this method only if you are sure that the conversation does not exist yet @@ -605,6 +623,12 @@ export class ConversationManagerService { payload: HandshakePayload, senderAddress: string ) { + // ignore handshakes from blocked addresses + if (this.isAddressBlocked(senderAddress)) { + console.log(`Ignoring handshake from blocked address: ${senderAddress}`); + return; + } + const isMyNewAliasValid = isAlias(payload.theirAlias); const myAlias = this.generateUniqueAlias(); @@ -788,7 +812,14 @@ export class ConversationManagerService { ourAliasForPartner: string, theirAliasForUs: string ): Promise<{ conversationId: string; contactId: string }> { - // Check if contact already exists - for offline handshakes, we should only create new contacts + // prevent creating offline handshakes with blocked addresses + if (this.isAddressBlocked(partnerAddress)) { + throw new Error( + `Cannot create handshake with blocked address: ${partnerAddress}` + ); + } + + // check if contact already exists - for offline handshakes, we should only create new contacts let contact: Contact; try { await this.repositories.contactRepository.getContactByKaspaAddress( @@ -797,7 +828,7 @@ export class ConversationManagerService { throw new Error(`Cannot create handshake. Contact already exists.`); } catch (error) { if (error instanceof DBNotFoundException) { - // Contact doesn't exist, create a new one + // contact doesn't exist, create a new one const newContact = { id: uuidv4(), kaspaAddress: partnerAddress, diff --git a/src/store/blocklist.store.ts b/src/store/blocklist.store.ts index e4814fe6..4d6b0241 100644 --- a/src/store/blocklist.store.ts +++ b/src/store/blocklist.store.ts @@ -89,7 +89,7 @@ export const useBlocklistStore = create((set, get) => ({ reason, }; - await repositories.blockedAddressRepository.blockAddress( + await repositories.blockedAddressRepository.saveBlockedAddress( newBlockedAddress ); @@ -120,7 +120,7 @@ export const useBlocklistStore = create((set, get) => ({ throw new Error("Repositories not initialized"); } - await repositories.blockedAddressRepository.unblockAddress(address); + await repositories.blockedAddressRepository.deleteBlockedAddress(address); // update in-memory state set((state) => { diff --git a/src/store/repository/blocked-address.repository.ts b/src/store/repository/blocked-address.repository.ts index 3ba8ef64..45d2fd91 100644 --- a/src/store/repository/blocked-address.repository.ts +++ b/src/store/repository/blocked-address.repository.ts @@ -78,7 +78,7 @@ export class BlockedAddressRepository { } } - async blockAddress( + async saveBlockedAddress( blockedAddress: Omit ): Promise { // check if already blocked @@ -96,7 +96,7 @@ export class BlockedAddressRepository { ); } - async unblockAddress(kaspaAddress: string): Promise { + async deleteBlockedAddress(kaspaAddress: string): Promise { const blockedAddress = await this.getBlockedAddressByKaspaAddress(kaspaAddress); await this.db.delete("blockedAddresses", blockedAddress.id); From c42dafff93a95d6f4f3fda1f221e7beeb70f6f35 Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:19:56 +0300 Subject: [PATCH 06/12] feat: add generic confirmation modal --- src/components/Layout/ModalHost.tsx | 9 +++ src/components/Modals/ConfirmationModal.tsx | 69 +++++++++++++++++++ src/components/Modals/ContactInfoModal.tsx | 64 +++++++++-------- .../Modals/SubSettings/BlockList.tsx | 16 +++-- src/store/ui.store.ts | 18 ++++- 5 files changed, 141 insertions(+), 35 deletions(-) create mode 100644 src/components/Modals/ConfirmationModal.tsx diff --git a/src/components/Layout/ModalHost.tsx b/src/components/Layout/ModalHost.tsx index 781ef357..a59f2bc8 100644 --- a/src/components/Layout/ModalHost.tsx +++ b/src/components/Layout/ModalHost.tsx @@ -15,6 +15,7 @@ import { BroadcastParticipantInfoModal } from "../Modals/BroadcastParticipantInf import { QrScannerModal } from "../Modals/QrScannerModal"; import { OffChainHandshakeModal } from "../Modals/OffChainHandshakeModal"; import { DeleteWalletModal } from "../Modals/DeleteWalletModal"; +import { ConfirmationModal } from "../Modals/ConfirmationModal"; import { useBroadcastStore } from "../../store/broadcast.store"; import { KASPA_DONATION_ADDRESS } from "../../config/constants"; @@ -163,6 +164,14 @@ export const ModalHost = () => { onClose={() => closeModal("delete")} /> )} + + {/* Confirmation Modal */} + {modals.confirm && ( + closeModal("confirm")} + /> + )} ); }; diff --git a/src/components/Modals/ConfirmationModal.tsx b/src/components/Modals/ConfirmationModal.tsx new file mode 100644 index 00000000..17ac7f22 --- /dev/null +++ b/src/components/Modals/ConfirmationModal.tsx @@ -0,0 +1,69 @@ +import { FC, useEffect } from "react"; +import { Modal } from "../Common/modal"; +import { Button } from "../Common/Button"; +import { useUiStore } from "../../store/ui.store"; + +interface ConfirmationModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const ConfirmationModal: FC = ({ + isOpen, + onClose, +}) => { + const confirmationConfig = useUiStore((s) => s.confirmationConfig); + const setConfirmationConfig = useUiStore((s) => s.setConfirmationConfig); + + // Clear confirmation config when modal closes + useEffect(() => { + if (!isOpen) { + setConfirmationConfig(null); + } + }, [isOpen, setConfirmationConfig]); + + const handleConfirm = () => { + if (confirmationConfig?.onConfirm) { + confirmationConfig.onConfirm(); + } + setConfirmationConfig(null); + onClose(); + }; + + const handleCancel = () => { + if (confirmationConfig?.onCancel) { + confirmationConfig.onCancel(); + } + setConfirmationConfig(null); + onClose(); + }; + + if (!isOpen || !confirmationConfig) return null; + + return ( + +
+

+ {confirmationConfig.title} +

+ +

+ {confirmationConfig.message} +

+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/Modals/ContactInfoModal.tsx b/src/components/Modals/ContactInfoModal.tsx index 3ffde4aa..72bfe1d2 100644 --- a/src/components/Modals/ContactInfoModal.tsx +++ b/src/components/Modals/ContactInfoModal.tsx @@ -5,6 +5,7 @@ import clsx from "clsx"; import { useBlocklistStore } from "../../store/blocklist.store"; import { useDBStore } from "../../store/db.store"; import { useMessagingStore } from "../../store/messaging.store"; +import { useUiStore } from "../../store/ui.store"; import { toast } from "../../utils/toast-helper"; import { BlockUnblockButton } from "../Common/BlockUnblockButton"; @@ -25,40 +26,47 @@ export const ContactInfoModal: FC = ({ oooc.contact.kaspaAddress ); - const handleBlockWithConfirmation = async () => { - // show confirmation dialog for blocking - const confirmed = window.confirm( - "This will block and delete ALL messages with this contact" - ); + const uiStore = useUiStore(); - if (!confirmed) return; + const handleBlockWithConfirmation = () => { + // set up confirmation modal + uiStore.setConfirmationConfig({ + title: "Block Contact", + message: "This will block and delete ALL messages with this contact", + confirmText: "Block", + cancelText: "Cancel", + onConfirm: async () => { + try { + // block the contact + await blocklistStore.blockAddress(oooc.contact.kaspaAddress); - try { - // block the contact - await blocklistStore.blockAddress(oooc.contact.kaspaAddress); + // delete all messages, conversation, and contact from database + await repositories.deleteAllDataForContact(oooc.contact.id, { + deleteConversation: true, + deleteContact: true, + }); - // delete all messages, conversation, and contact from database - await repositories.deleteAllDataForContact(oooc.contact.id, { - deleteConversation: true, - deleteContact: true, - }); + // remove the conversation from in-memory store + useMessagingStore.setState((state) => ({ + oneOnOneConversations: state.oneOnOneConversations.filter( + (conversation) => conversation.contact.id !== oooc.contact.id + ), + })); - // remove the conversation from in-memory store - useMessagingStore.setState((state) => ({ - oneOnOneConversations: state.oneOnOneConversations.filter( - (conversation) => conversation.contact.id !== oooc.contact.id - ), - })); + // close modal and navigate away + messagingStore.setOpenedRecipient(null); + onClose(); - // close modal and navigate away - messagingStore.setOpenedRecipient(null); - onClose(); + toast.success("Contact blocked and all conversations deleted"); + } catch (error) { + console.error("Error blocking contact:", error); + toast.error("Failed to block contact"); + } + }, + }); - toast.success("Contact blocked and all conversations deleted"); - } catch (error) { - console.error("Error blocking contact:", error); - toast.error("Failed to block contact"); - } + // open confirmation modal + uiStore.openModal("confirm"); }; return ( diff --git a/src/components/Modals/SubSettings/BlockList.tsx b/src/components/Modals/SubSettings/BlockList.tsx index 5987a2b3..4ff63331 100644 --- a/src/components/Modals/SubSettings/BlockList.tsx +++ b/src/components/Modals/SubSettings/BlockList.tsx @@ -18,7 +18,7 @@ export const BlockList: React.FC = () => { onClick={() => setIsBroadcastExpanded(!isBroadcastExpanded)} className="hover:bg-secondary-bg -my-2 flex w-full cursor-pointer items-center justify-between rounded-lg p-2 transition-colors" > -

+

Broadcast Display Mode

{isBroadcastExpanded ? ( @@ -54,7 +54,9 @@ export const BlockList: React.FC = () => { >
Show Placeholder
-
Display "{BLOCKED_PLACEHOLDER}"
+
+ Display "{BLOCKED_PLACEHOLDER}" +
+ )}
diff --git a/src/components/Modals/ContactInfoModal.tsx b/src/components/Modals/ContactInfoModal.tsx index 3ec49b4f..c569795d 100644 --- a/src/components/Modals/ContactInfoModal.tsx +++ b/src/components/Modals/ContactInfoModal.tsx @@ -3,10 +3,6 @@ import { OneOnOneConversation } from "../../types/all"; import { AvatarHash } from "../icons/AvatarHash"; import clsx from "clsx"; import { useBlocklistStore } from "../../store/blocklist.store"; -import { useMessagingStore } from "../../store/messaging.store"; -import { useUiStore } from "../../store/ui.store"; -import { toast } from "../../utils/toast-helper"; -import { BlockUnblockButton } from "../Common/BlockUnblockButton"; type ContactInfoModalProps = { oooc: OneOnOneConversation; @@ -18,43 +14,11 @@ export const ContactInfoModal: FC = ({ onClose, }) => { const blocklistStore = useBlocklistStore(); - const messagingStore = useMessagingStore(); const isBlocked = blocklistStore.blockedAddresses.has( oooc.contact.kaspaAddress ); - const uiStore = useUiStore(); - - const handleBlockWithConfirmation = () => { - // set up confirmation modal - uiStore.setConfirmationConfig({ - title: "Block Contact", - message: "This will block and delete ALL messages with this contact", - confirmText: "Block", - cancelText: "Cancel", - onConfirm: async () => { - try { - await blocklistStore.blockAddressAndDeleteData( - oooc.contact.kaspaAddress - ); - - // close modal and navigate away - messagingStore.setOpenedRecipient(null); - onClose(); - - toast.success("Contact blocked and all conversations deleted"); - } catch (error) { - console.error("Error blocking contact:", error); - toast.error("Failed to block contact"); - } - }, - }); - - // open confirmation modal - uiStore.openModal("confirm"); - }; - return (
e.stopPropagation()}>
@@ -86,12 +50,6 @@ export const ContactInfoModal: FC = ({
{oooc.contact.name || "No nickname"}
- {/* block/unblock button */} -
Contact
From 38b957019bd1dd85182fbe755edffe2831625123 Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Tue, 28 Oct 2025 00:10:37 +0300 Subject: [PATCH 10/12] feat: multiple items - post rebase cleanup - delete blocked-store for tenant ID - add import export for blocked addresses - bring settings modal into modal system --- src/components/Layout/DesktopMenu.tsx | 13 +++--------- src/components/Layout/ModalHost.tsx | 9 +++++++++ src/components/Layout/SlideOutMenu.tsx | 11 +--------- src/components/Modals/SettingsModal.tsx | 13 +----------- src/service/import-export-service.ts | 13 +++++++++++- src/store/messaging.store.ts | 20 ++++++------------- .../repository/blocked-address.repository.ts | 10 ---------- src/store/ui.store.ts | 1 + src/utils/historical-loader.ts | 11 ++++++++++ 9 files changed, 44 insertions(+), 57 deletions(-) diff --git a/src/components/Layout/DesktopMenu.tsx b/src/components/Layout/DesktopMenu.tsx index 079e7f58..0238046b 100644 --- a/src/components/Layout/DesktopMenu.tsx +++ b/src/components/Layout/DesktopMenu.tsx @@ -1,7 +1,6 @@ -import { FC, useState } from "react"; +import { FC } from "react"; import clsx from "clsx"; import { PanelLeftOpen, Settings, ArrowLeft, User, Wallet } from "lucide-react"; -import { SettingsModal } from "../Modals/SettingsModal"; import { useUiStore } from "../../store/ui.store"; import { useWalletStore } from "../../store/wallet.store"; @@ -19,7 +18,6 @@ export const DesktopMenu: FC = ({ }) => { const openModal = useUiStore((s) => s.openModal); const lockWallet = useWalletStore((s) => s.lock); - const [showSettingsModal, setShowSettingsModal] = useState(false); return (
@@ -59,7 +57,7 @@ export const DesktopMenu: FC = ({ {/* settings */}
- - setShowSettingsModal(false)} - />
); }; diff --git a/src/components/Layout/ModalHost.tsx b/src/components/Layout/ModalHost.tsx index a59f2bc8..878101f1 100644 --- a/src/components/Layout/ModalHost.tsx +++ b/src/components/Layout/ModalHost.tsx @@ -7,6 +7,7 @@ import { Wallet } from "../Modals/Wallet"; import { WalletSeedRetreiveDisplay } from "../Modals/WalletSeedRetreiveDisplay"; import { WalletWithdrawal } from "../Modals/WalletWithdrawal"; import { LockedSettingsModal } from "../Modals/LockedSettingsModal"; +import { SettingsModal } from "../Modals/SettingsModal"; import { ContactInfoModal } from "../Modals/ContactInfoModal"; import { NewChatForm } from "../Modals/NewChatForm"; import { LoaderCircle } from "lucide-react"; @@ -98,6 +99,14 @@ export const ModalHost = () => { )} + {/* Unlocked Settings Modal */} + {modals["settings-unlocked"] && ( + closeModal("settings-unlocked")} + /> + )} + {/* Contact Info Modal */} {modals["contact-info-modal"] && oneOnOneConversation && ( = ({ const setSettingsOpen = useUiStore((s) => s.setSettingsOpen); const { openModal } = useUiStore(); - const [showSettingsModal, setShowSettingsModal] = useState(false); const [mounted, setMounted] = useState(false); useEffect(() => { @@ -137,7 +134,7 @@ export const SlideOutMenu: FC = ({ {/* Sign Out Section */}
- - {/* Settings Modal */} - setShowSettingsModal(false)} - /> ); }; diff --git a/src/components/Modals/SettingsModal.tsx b/src/components/Modals/SettingsModal.tsx index acfc5ea5..6893c22b 100644 --- a/src/components/Modals/SettingsModal.tsx +++ b/src/components/Modals/SettingsModal.tsx @@ -376,18 +376,7 @@ export const SettingsModal: React.FC = ({ setNameChangeError(""); }, [newWalletName, wallets, selectedWalletId, showNameChange]); - useEffect(() => { - if (isOpen) { - document.body.classList.add("settings-modal-open"); - } else { - document.body.classList.remove("settings-modal-open"); - } - return () => { - document.body.classList.remove("settings-modal-open"); - }; - }, [isOpen]); - - // Reset custom theme state when switching away from custom theme + // reset custom theme state when switching away from custom theme useEffect(() => { if (theme !== "custom") { setShowCustomTheme(false); diff --git a/src/service/import-export-service.ts b/src/service/import-export-service.ts index c6b77b89..0eb7b8b4 100644 --- a/src/service/import-export-service.ts +++ b/src/service/import-export-service.ts @@ -6,6 +6,7 @@ import type { Handshake } from "../store/repository/handshake.repository"; import type { Payment } from "../store/repository/payment.repository"; import type { DecryptionTrial } from "../store/repository/decryption-trial.repository"; import type { SavedHandshake } from "../store/repository/saved-handshake.repository"; +import type { BlockedAddress } from "../store/repository/blocked-address.repository"; /** * Normalize string dates to Date objects in the backup data @@ -44,6 +45,9 @@ const normalizeDates = (backup: BackupV2): BackupV2 => { savedHandshakes: backup.savedHandshakes.map((sh) => normalizeDateField(sh, "createdAt") ), + blockedAddresses: backup.blockedAddresses.map((blockedAddress) => + normalizeDateField(blockedAddress, "timestamp") + ), }; }; @@ -61,10 +65,11 @@ export type BackupV2 = { payments: Payment[]; decryptionTrials: DecryptionTrial[]; savedHandshakes: SavedHandshake[]; + blockedAddresses: BlockedAddress[]; }; /** - * Export contacts, conversations, messages, handshakes, payments and self handshakes, + * Export contacts, conversations, messages, handshakes, payments, self handshakes, and blocked addresses, */ export const exportData = async ( repositories: Repositories @@ -77,6 +82,7 @@ export const exportData = async ( payments, decryptionTrials, savedHandshakes, + blockedAddresses, ] = await Promise.all([ repositories.messageRepository.getMessages(), repositories.contactRepository.getContacts(), @@ -85,6 +91,7 @@ export const exportData = async ( repositories.paymentRepository.getPayments(), repositories.decryptionTrialRepository.getDecryptionTrials(), repositories.savedHandshakeRepository.getAllSavedHandshakes(), + repositories.blockedAddressRepository.getBlockedAddresses(), ]); return { @@ -96,6 +103,7 @@ export const exportData = async ( payments, decryptionTrials, savedHandshakes, + blockedAddresses, }; }; @@ -122,6 +130,9 @@ export const importData = async ( repositories.savedHandshakeRepository.saveBulk( normalizedBackup.savedHandshakes ), + repositories.blockedAddressRepository.saveBulk( + normalizedBackup.blockedAddresses + ), ]); } catch (error) { // If any operation fails, the transaction will be aborted diff --git a/src/store/messaging.store.ts b/src/store/messaging.store.ts index 089b54f1..0049d40c 100644 --- a/src/store/messaging.store.ts +++ b/src/store/messaging.store.ts @@ -37,11 +37,7 @@ import { Payment } from "./repository/payment.repository"; import { Message } from "./repository/message.repository"; import { Handshake } from "./repository/handshake.repository"; import { HistoricalSyncer } from "../service/historical-syncer"; -import { - ContextualMessageResponse, - HandshakeResponse, - SelfStashResponse, -} from "../service/indexer/generated"; +import { ContextualMessageResponse } from "../service/indexer/generated"; import { tryParseBase64AsHexToHex } from "../utils/payload-encoding"; import { hexToString } from "../utils/format"; import { RawResolvedKasiaTransaction } from "../service/block-processor-service"; @@ -390,15 +386,6 @@ export const useMessagingStore = create((set, g) => { metadata ); - // Skip processing historical data for blocked addresses - // const blocklistStore = useBlocklistStore.getState(); - // if (blocklistStore.blockedAddresses.has(senderAddress)) { - // console.log( - // `Skipping historical handshake processing for blocked address: ${senderAddress}` - // ); - // return; - // } - console.log( "Loading Strategy - handshake history reconciliation loaded" ); @@ -981,6 +968,7 @@ export const useMessagingStore = create((set, g) => { repositories.handshakeRepository.deleteTenant(walletTenant), repositories.messageRepository.deleteTenant(walletTenant), repositories.savedHandshakeRepository.deleteTenant(walletTenant), + repositories.blockedAddressRepository.deleteTenant(walletTenant), ]); // 3. Reset metadata @@ -993,6 +981,8 @@ export const useMessagingStore = create((set, g) => { isCreatingNewChat: false, }); + useBlocklistStore.getState().reset(); + // 5. Clear and reinitialize conversation manager const manager = g().conversationManager; if (manager) { @@ -1046,6 +1036,8 @@ export const useMessagingStore = create((set, g) => { await g()?.conversationManager?.loadConversations(); await g().hydrateOneonOneConversations(); + + await useBlocklistStore.getState().loadBlockedAddresses(); }, conversationManager: null, initiateHandshake: async ( diff --git a/src/store/repository/blocked-address.repository.ts b/src/store/repository/blocked-address.repository.ts index 45d2fd91..5f37805c 100644 --- a/src/store/repository/blocked-address.repository.ts +++ b/src/store/repository/blocked-address.repository.ts @@ -32,16 +32,6 @@ export class BlockedAddressRepository { readonly walletPassword: string ) {} - async getBlockedAddress(id: string): Promise { - const result = await this.db.get("blockedAddresses", id); - - if (!result) { - throw new DBNotFoundException(); - } - - return this._dbBlockedAddressToBlockedAddress(result); - } - async getBlockedAddressByKaspaAddress( kaspaAddress: string ): Promise { diff --git a/src/store/ui.store.ts b/src/store/ui.store.ts index 9355e670..dc909c5c 100644 --- a/src/store/ui.store.ts +++ b/src/store/ui.store.ts @@ -15,6 +15,7 @@ export type ModalType = | "delete" | "seed" | "settings" + | "settings-unlocked" | "contact-info-modal" | "image" | "new-chat" diff --git a/src/utils/historical-loader.ts b/src/utils/historical-loader.ts index 53bde0cc..f87eba0d 100644 --- a/src/utils/historical-loader.ts +++ b/src/utils/historical-loader.ts @@ -14,6 +14,7 @@ import { import { MetadataV1 } from "../store/repository/meta.repository"; import { ConversationManagerService } from "../service/conversation-manager-service"; import { Handshake } from "../store/repository/handshake.repository"; +import { useBlocklistStore } from "../store/blocklist.store"; export const historicalLoader_loadSendAndReceivedHandshake = async ( repositories: Repositories, @@ -126,6 +127,15 @@ export const historicalLoader_loadSendAndReceivedHandshake = async ( continue; } + // if handshake is in blocked list, ignore + const blocklistStore = useBlocklistStore.getState(); + if (blocklistStore.blockedAddresses.has(handshake.sender)) { + console.log( + `Skipping historical handshake processing for blocked address: ${handshake.sender}` + ); + continue; + } + try { const encryptedMessage = new EncryptedMessage(handshake.message_payload); @@ -142,6 +152,7 @@ export const historicalLoader_loadSendAndReceivedHandshake = async ( // only consider the last saved handshake for our recipient // since we loop by last first, we can skip this iteration if already exist const handshakes = lastReceivedHSBySenderAddress.get(handshake.sender); + if (!handshakes) { lastReceivedHSBySenderAddress.set(handshake.sender, { handshake, From 7091f70294ca4cf37cba1addde38e20e35aeea63 Mon Sep 17 00:00:00 2001 From: HocusLocust <182385655+HocusLocusTee@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:22:17 +0300 Subject: [PATCH 11/12] style: transition new chat instead of present new modal --- src/components/Modals/NewChatForm.tsx | 426 +++++++++++++------------- 1 file changed, 217 insertions(+), 209 deletions(-) diff --git a/src/components/Modals/NewChatForm.tsx b/src/components/Modals/NewChatForm.tsx index a516b874..3bb38383 100644 --- a/src/components/Modals/NewChatForm.tsx +++ b/src/components/Modals/NewChatForm.tsx @@ -347,27 +347,224 @@ export const NewChatForm: React.FC = ({ onClose }) => { } }; - if (showConfirmation) { - let recipientDisplay; - if ( - detectedRecipientInputValueFormat === "kns" && - resolvedRecipientAddress - ) { - recipientDisplay = ( -
- {recipientInputValue} -
- -
+ // helper function for smooth transitions + const blockVisible = (hidden: boolean) => + clsx( + "transition-all duration-300 ease-in-out", + hidden + ? "pointer-events-none max-h-0 overflow-hidden opacity-0" + : "pointer-events-auto max-h-screen opacity-100" + ); + + let recipientDisplay; + if (detectedRecipientInputValueFormat === "kns" && resolvedRecipientAddress) { + recipientDisplay = ( +
+ {recipientInputValue} +
+
- ); - } else { - recipientDisplay = ; - } +
+ ); + } else { + recipientDisplay = ; + } + + return ( + <> + {/* Form Section */} +
+

Start New Conversation

+
+
+ +
+