+
{
kClassName="fill-[#ffffff]"
/>
-
- Why do I need a Kaspa wallet?
+
+ Why do I need a Seed Phrase?
@@ -90,11 +90,11 @@ export const TrustMessage: FC = () => {
openWhy ? "mt-2 max-h-40 opacity-100" : "max-h-0 opacity-0"
}`}
>
-
- Kasia is messaging app that protects your privacy. Your Kaspa wallet
- acts as your secure login – no email, phone number, or personal
- details needed. Messages are encrypted and sequenced on the Kaspa
- blockDAG, making them completely private and decentralized.
+
+ Kasia is a messaging app that protects your privacy. Your seed
+ phrase is your secure login—no email, phone number, or personal
+ details required. It proves ownership of your identity and allows
+ you to recover access if your device is lost or replaced.
Kasia is community built by volunteers.
diff --git a/src/components/MessagesPane/Broadcasts/BroadcastDisplay.tsx b/src/components/MessagesPane/Broadcasts/BroadcastDisplay.tsx
index 89c79af9..21b06047 100644
--- a/src/components/MessagesPane/Broadcasts/BroadcastDisplay.tsx
+++ b/src/components/MessagesPane/Broadcasts/BroadcastDisplay.tsx
@@ -114,6 +114,7 @@ export const BroadcastDisplay: FC = ({
{renderTimestamp()}
diff --git a/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx b/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx
index 117620a0..27c18e8b 100644
--- a/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx
+++ b/src/components/MessagesPane/Broadcasts/BroadcastMessagesList.tsx
@@ -1,120 +1,147 @@
-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";
+export const BLOCKED_PLACEHOLDER = "Blocked Contact";
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_PLACEHOLDER,
+ 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/MessagesPane/Composing/Broadcast/BroadcastComposer.tsx b/src/components/MessagesPane/Composing/Broadcast/BroadcastComposer.tsx
index b2040bba..d4748ff6 100644
--- a/src/components/MessagesPane/Composing/Broadcast/BroadcastComposer.tsx
+++ b/src/components/MessagesPane/Composing/Broadcast/BroadcastComposer.tsx
@@ -11,6 +11,11 @@ import { MessageInput } from "../Utilities/MessageInput";
import { FeeDisplay } from "../Utilities/FeeDisplay";
import { useFeeEstimate } from "../../../../hooks/MessageComposer/useFeeEstimate";
import { useIsMobile } from "../../../../hooks/useIsMobile";
+import {
+ useFeatureFlagsStore,
+ FeatureFlags,
+} from "../../../../store/featureflag.store";
+import { MARKDOWN_PREFIX } from "../../../../config/constants";
export const BroadcastComposer = ({ channelName }: { channelName: string }) => {
const setDraft = useComposerStore((s: ComposerState) => s.setDraft);
@@ -26,6 +31,9 @@ export const BroadcastComposer = ({ channelName }: { channelName: string }) => {
const messageInputRef = useRef(null);
const isMobile = useIsMobile();
+ const { flags } = useFeatureFlagsStore();
+ const markdownEnabled = flags[FeatureFlags.MARKDOWN];
+
// readines check
const canBroadcast = !!channelName && !!sendBroadcast;
@@ -71,7 +79,13 @@ export const BroadcastComposer = ({ channelName }: { channelName: string }) => {
{
const cameraInputRef = useRef(null);
const isMobile = useIsMobile();
+ const { flags } = useFeatureFlagsStore();
+ const cameraEnabled = flags[FeatureFlags.ENABLED_CAMERA];
+ const markdownEnabled = flags[FeatureFlags.MARKDOWN];
+
const feeState = useFeeEstimate({
toSelf: true,
recipient,
@@ -64,10 +71,6 @@ export const DirectComposer = ({ recipient }: { recipient?: string }) => {
(conversation.status === "active" ||
(conversation.status === "pending" && conversation.initiatedByMe));
- // Check if camera feature is enabled
- const { flags } = useFeatureFlagsStore();
- const cameraEnabled = flags[FeatureFlags.ENABLED_CAMERA];
-
const guardReady = () => {
if (!canCompose) {
toast.error("Accept or send handshake to chat");
@@ -194,7 +197,7 @@ export const DirectComposer = ({ recipient }: { recipient?: string }) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
-
+
diff --git a/src/components/MessagesPane/Utilities/Content/MarkdownRenderer.tsx b/src/components/MessagesPane/Utilities/Content/MarkdownRenderer.tsx
new file mode 100644
index 00000000..1a0474af
--- /dev/null
+++ b/src/components/MessagesPane/Utilities/Content/MarkdownRenderer.tsx
@@ -0,0 +1,94 @@
+import React from "react";
+import ReactMarkdown from "react-markdown";
+import remarkBreaks from "remark-breaks";
+// NOTE: In its current vanilla form, our use of react-markdown is safe from XSS attack
+// If adding more plugins or raw HTML support, add rehype-sanitize or find a way to sanitize properly
+
+interface MarkdownRendererProps {
+ content: string;
+ enableMdLinks?: boolean;
+ enableMdImages?: boolean;
+}
+
+const isSafeHref = (href?: string) => {
+ if (!href) return false;
+ const normalized = href.trim().toLowerCase();
+ return (
+ normalized.startsWith("http://") ||
+ normalized.startsWith("https://") ||
+ normalized.startsWith("mailto:") ||
+ normalized.startsWith("www.")
+ );
+};
+
+const normalizeHref = (href?: string) => {
+ if (!href) return undefined;
+ const trimmed = href.trim();
+ return trimmed.toLowerCase().startsWith("www.")
+ ? `https://${trimmed}`
+ : trimmed;
+};
+
+export const MarkdownRenderer: React.FC = ({
+ content,
+ enableMdLinks = false,
+ enableMdImages = false,
+}) => (
+
+);
diff --git a/src/components/MessagesPane/Utilities/Content/MessageContent.tsx b/src/components/MessagesPane/Utilities/Content/MessageContent.tsx
index 4a09c285..f1657a01 100644
--- a/src/components/MessagesPane/Utilities/Content/MessageContent.tsx
+++ b/src/components/MessagesPane/Utilities/Content/MessageContent.tsx
@@ -1,22 +1,35 @@
import { FC, useState, useRef, useEffect } from "react";
import clsx from "clsx";
import { parseMessageForDisplay } from "../../../../utils/message-format";
+import { MARKDOWN_PREFIX } from "../../../../config/constants";
+import { MarkdownRenderer } from "./MarkdownRenderer";
+import { useFeatureFlagsStore } from "../../../../store/featureflag.store";
type MessageContentProps = {
content: string;
isDecrypting: boolean;
+ isBroadcast?: boolean;
isOutgoing?: boolean;
};
export const MessageContent: FC = ({
content,
isDecrypting,
+ isBroadcast = false,
isOutgoing = true,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [shouldCollapse, setShouldCollapse] = useState(false);
const contentRef = useRef(null);
+ const imageLinksEnabled = useFeatureFlagsStore(
+ (state) => state.flags.broadcast_image_links
+ );
+
+ const clickableLinksEnabled = useFeatureFlagsStore(
+ (state) => state.flags.broadcast_links
+ );
+
// check if content should be collapsed based on line count, length or some combo
useEffect(() => {
if (typeof content === "string" && !isDecrypting) {
@@ -39,9 +52,26 @@ export const MessageContent: FC = ({
);
}
- // render plain text with newlines as
and \\n as literal text
+ // render content (markdown or plain text)
if (typeof content === "string") {
- const parsedContent = parseMessageForDisplay(content);
+ // check if content starts with markdown flag and process content
+ const isMarkdown = content.startsWith(MARKDOWN_PREFIX);
+ const displayContent = isMarkdown
+ ? content.slice(MARKDOWN_PREFIX.length)
+ : content;
+
+ // preserve multiple consecutive newlines by replacing them with non-breaking spaces
+ const formattedContent = displayContent.replace(/\n{2,}/g, "\n\u00A0\n");
+
+ const renderedContent = isMarkdown ? (
+
+ ) : (
+ parseMessageForDisplay(displayContent)
+ );
return (
@@ -61,7 +91,7 @@ export const MessageContent: FC = ({
: "none",
}}
>
- {parsedContent}
+ {renderedContent}
{shouldCollapse && (
diff --git a/src/components/MessagesPane/Utilities/Utils/BubbleClassGenerator.tsx b/src/components/MessagesPane/Utilities/Utils/BubbleClassGenerator.tsx
index 058af4c8..bd28f716 100644
--- a/src/components/MessagesPane/Utilities/Utils/BubbleClassGenerator.tsx
+++ b/src/components/MessagesPane/Utilities/Utils/BubbleClassGenerator.tsx
@@ -1,4 +1,5 @@
import clsx from "clsx";
+import { TransactionStatus } from "../../../../types/all";
type GroupPosition = "single" | "top" | "middle" | "bottom";
type BubbleClassOptions = {
@@ -6,7 +7,7 @@ type BubbleClassOptions = {
groupPosition: GroupPosition;
className?: string;
noBubble?: boolean;
- status?: "pending" | "confirmed" | "failed";
+ status?: TransactionStatus;
customBorderColor?: string;
};
diff --git a/src/components/Modals/BroadcastParticipantInfo.tsx b/src/components/Modals/BroadcastParticipantInfo.tsx
deleted file mode 100644
index 2d30ba6d..00000000
--- a/src/components/Modals/BroadcastParticipantInfo.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { FC } from "react";
-import { AvatarHash } from "../icons/AvatarHash";
-import { CopyableValueWithQR } from "./CopyableValueWithQR";
-import clsx from "clsx";
-
-type BroadcastParticipantInfoProps = {
- address: string;
- nickname?: string;
- onClose: () => void;
-};
-
-export const BroadcastParticipantInfo: FC = ({
- address,
- nickname,
-}) => {
- return (
- e.stopPropagation()}>
-
- {/* Avatar and basic info */}
-
-
-
- {nickname && (
-
- {nickname.slice(0, 2).toUpperCase()}
-
- )}
-
-
-
- {nickname || "No nickname"}
-
-
- Broadcast Participant
-
-
-
-
- {/* Address section */}
-
-
-
- );
-};
diff --git a/src/components/Modals/BroadcastParticipantInfoModal.tsx b/src/components/Modals/BroadcastParticipantInfoModal.tsx
new file mode 100644
index 00000000..43a3765e
--- /dev/null
+++ b/src/components/Modals/BroadcastParticipantInfoModal.tsx
@@ -0,0 +1,104 @@
+import { FC } from "react";
+import { AvatarHash } from "../icons/AvatarHash";
+import { CopyableValueWithQR } from "./CopyableValueWithQR";
+import clsx from "clsx";
+import { useBlocklistStore } from "../../store/blocklist.store";
+import { useUiStore } from "../../store/ui.store";
+import { BlockUnblockButton } from "../Common/BlockUnblockButton";
+import { toast } from "../../utils/toast-helper";
+
+type BroadcastParticipantInfoModalProps = {
+ address: string;
+ nickname?: string;
+};
+
+export const BroadcastParticipantInfoModal: FC<
+ BroadcastParticipantInfoModalProps
+> = ({ address, nickname }) => {
+ const blocklistStore = useBlocklistStore();
+ const uiStore = useUiStore();
+ const isBlocked = blocklistStore.blockedAddresses.has(address);
+
+ const handleBlockWithConfirmation = () => {
+ // set up confirmation modal
+ uiStore.setConfirmationConfig({
+ title: "Block Participant",
+ message: `This will block ${nickname || "this participant"} and delete any existing direct messages with them.`,
+ confirmText: "Block",
+ cancelText: "Cancel",
+ onConfirm: async () => {
+ try {
+ await blocklistStore.blockAddressAndDeleteData(address);
+ toast.success("Participant blocked");
+ } catch (error) {
+ console.error("Error blocking participant:", error);
+ toast.error("Failed to block participant");
+ }
+ },
+ });
+
+ // open confirmation modal
+ uiStore.openModal("confirm");
+ };
+
+ return (
+ e.stopPropagation()}>
+
+ {/* Avatar and basic info */}
+
+
+
+ {nickname && (
+
+ {nickname.slice(0, 2).toUpperCase()}
+
+ )}
+
+
+
+
+ {nickname || "No nickname"}
+
+ {/* actions menu */}
+
+
+
+ Broadcast Participant
+
+
+
+
+ {/* Address section */}
+
+
+ {/* block status */}
+ {isBlocked && (
+
+
+ This participant is blocked
+
+
+ )}
+
+
+ );
+};
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 b807c326..c569795d 100644
--- a/src/components/Modals/ContactInfoModal.tsx
+++ b/src/components/Modals/ContactInfoModal.tsx
@@ -2,84 +2,106 @@ import { FC } from "react";
import { OneOnOneConversation } from "../../types/all";
import { AvatarHash } from "../icons/AvatarHash";
import clsx from "clsx";
+import { useBlocklistStore } from "../../store/blocklist.store";
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 isBlocked = blocklistStore.blockedAddresses.has(
+ oooc.contact.kaspaAddress
+ );
+
+ 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"}
+
+
+
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/DeleteWalletModal.tsx b/src/components/Modals/DeleteWalletModal.tsx
index deaacd44..440f7142 100644
--- a/src/components/Modals/DeleteWalletModal.tsx
+++ b/src/components/Modals/DeleteWalletModal.tsx
@@ -46,11 +46,11 @@ export const DeleteWalletModal: FC
= ({
- Delete Wallet
+ Delete Account
- Are you sure you want to delete the wallet{" "}
+ Are you sure you want to delete the accounts{" "}
"{wallet.name}"
@@ -58,8 +58,8 @@ export const DeleteWalletModal: FC = ({
- This action cannot be undone. All wallet data will be permanently
- removed.
+ This action cannot be undone. All account data and funds will be
+ permanently removed.
diff --git a/src/components/Modals/ImagePresenter.tsx b/src/components/Modals/ImagePresenter.tsx
index 020b5bae..acd9b5e0 100644
--- a/src/components/Modals/ImagePresenter.tsx
+++ b/src/components/Modals/ImagePresenter.tsx
@@ -1,8 +1,14 @@
import { useUiStore } from "../../store/ui.store";
import { useEffect } from "react";
+import { Download, X } from "lucide-react";
+import { downloadImage } from "../../utils/file-download";
-// in the modal show an image that is enlarged
-export const ImagePresenter = () => {
+interface ImagePresenterProps {
+ onClose: () => void;
+}
+
+// Image presenter modal - handles its own modal behavior
+export const ImagePresenter = ({ onClose }: ImagePresenterProps) => {
const { imagePresenterImage, setImagePresenterImage } = useUiStore();
// clear image data when component unmounts (modal closes)
@@ -12,17 +18,57 @@ export const ImagePresenter = () => {
};
}, [setImagePresenterImage]);
+ // escape key handling
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [onClose]);
+
if (!imagePresenterImage) {
return null;
}
+ const handleDownload = () => {
+ downloadImage(imagePresenterImage);
+ };
+
+ const handleClose = () => {
+ onClose();
+ };
+
return (
-
-

+
+
e.stopPropagation()}>
+
+

+
+
+
+
);
};
diff --git a/src/components/Modals/LockedSettingsModal.tsx b/src/components/Modals/LockedSettingsModal.tsx
index c177c0c0..4b71b949 100644
--- a/src/components/Modals/LockedSettingsModal.tsx
+++ b/src/components/Modals/LockedSettingsModal.tsx
@@ -1,17 +1,31 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { NetworkSelector } from "../NetworkSelector";
import { useNetworkStore } from "../../store/network.store";
-import { NetworkType } from "../../types/all";
+import { NetworkType, ConnectionMode } from "../../types/all";
import { Button } from "../Common/Button";
import { useUiStore } from "../../store/ui.store";
import { ThemeToggle } from "../Common/ThemeToggle";
-import { Shield } from "lucide-react";
+import { Shield, ArrowLeft, Coffee, Check, RefreshCcw } from "lucide-react";
import { devMode } from "../../config/dev-mode";
import { deleteDB } from "idb";
import { useDBStore } from "../../store/db.store";
import { useOrchestrator } from "../../hooks/useOrchestrator";
import { AppVersion } from "../App/AppVersion";
import { Donations } from "../Common/Donations";
+import { toast } from "../../utils/toast-helper";
+import { checkIndexerHealth } from "../../utils/indexer-validation";
+import { client as indexerClient } from "../../service/indexer/generated/client.gen";
+import {
+ isIndexerDisabled,
+ setIndexerDisabled,
+ getIndexerUrl,
+ setIndexerUrl,
+ getIndexerConnectionMode,
+ setIndexerConnectionMode,
+ getNodeConnectionMode,
+ setNodeConnectionMode,
+ getEffectiveIndexerUrl,
+} from "../../utils/indexer-settings";
export const LockedSettingsModal: React.FC = () => {
const networkStore = useNetworkStore();
@@ -34,6 +48,42 @@ export const LockedSettingsModal: React.FC = () => {
const [connectionError, setConnectionError] = useState
();
+ const [indexerDisabled, setIndexerDisabledState] =
+ useState(isIndexerDisabled());
+
+ // ui collapsable states
+ const [showNodeSettings, setShowNodeSettings] = useState(false);
+ const [showIndexerSettings, setShowIndexerSettings] = useState(false);
+ const [showDevSettings, setShowDevSettings] = useState(false);
+
+ // connection modes
+ const [nodeConnectionMode, setNodeConnectionModeState] =
+ useState(getNodeConnectionMode());
+ const [indexerConnectionMode, setIndexerConnectionModeState] =
+ useState(getIndexerConnectionMode());
+
+ // wrapper functions that update both state and localStorage
+ const updateNodeConnectionMode = (mode: ConnectionMode) => {
+ setNodeConnectionModeState(mode);
+ setNodeConnectionMode(mode);
+ };
+
+ const updateIndexerConnectionMode = (mode: ConnectionMode) => {
+ setIndexerConnectionModeState(mode);
+ setIndexerConnectionMode(mode);
+ };
+
+ const [indexerUrl, setIndexerUrlState] = useState(getIndexerUrl() || "");
+ const [testIndexerStatus, setTestIndexerStatus] = useState<
+ "idle" | "testing" | "success" | "error"
+ >("idle");
+
+ const [isSavingIndexer, setIsSavingIndexer] = useState(false);
+
+ useEffect(() => {
+ setTestIndexerStatus("idle");
+ }, [indexerConnectionMode]);
+
const deleteIndexDB = async () => {
if (dbStore.db) {
await deleteDB(dbStore.db.name);
@@ -42,25 +92,85 @@ export const LockedSettingsModal: React.FC = () => {
await dbStore.initDB();
};
- const devInfo = () => {
- if (!devMode) {
- return null;
+ const handleIndexerDisabledChange = (disabled: boolean) => {
+ setIndexerDisabled(disabled);
+ setIndexerDisabledState(disabled);
+
+ // when disabling indexer, automatically switch to AUTO mode
+ if (disabled) {
+ updateIndexerConnectionMode("auto");
+ }
+ };
+
+ const handleIndexerUrlSave = async () => {
+ // its gonna contain something, so just basic validation for now
+ if (!indexerUrl || indexerUrl.length <= 5) {
+ toast.error("Not valid url");
+ return;
+ }
+
+ setIsSavingIndexer(true);
+ try {
+ const status = await checkIndexerHealth(indexerUrl);
+ if (status === "success") {
+ setIndexerUrl(indexerUrl || null);
+ setIndexerConnectionMode("manual");
+
+ // Reconfigure indexer client immediately with new settings
+ const network = selectedNetwork === "mainnet" ? "mainnet" : "testnet";
+ indexerClient.setConfig({
+ baseUrl: getEffectiveIndexerUrl(network),
+ });
+
+ setShowIndexerSettings(false);
+ toast.success("Indexer URL saved successfully");
+ } else {
+ toast.error("Indexer validation failed");
+ }
+ } catch (error) {
+ toast.error(
+ `Failed to validate indexer URL: ${error instanceof Error ? error.message : String(error)}`
+ );
+ } finally {
+ setIsSavingIndexer(false);
}
+ };
+
+ const handleIndexerUrlCancel = () => {
+ setIndexerUrlState(getIndexerUrl() || "");
+ setIndexerConnectionModeState(getIndexerConnectionMode());
+ setShowIndexerSettings(false);
+ };
- return (
-
-
-
- Dev mode enabled
-
-
-
-
-
-
- );
+ const testIndexer = async () => {
+ setTestIndexerStatus("testing");
+
+ try {
+ const urlToTest =
+ indexerConnectionMode === "auto"
+ ? import.meta.env.VITE_INDEXER_MAINNET_URL ||
+ import.meta.env.VITE_INDEXER_TESTNET_URL
+ : indexerUrl;
+
+ if (!urlToTest) {
+ setTestIndexerStatus("error");
+ return;
+ }
+
+ const status = await checkIndexerHealth(urlToTest);
+ if (status === "success") {
+ setTestIndexerStatus("success");
+ toast.success("Indexer is active and processing blocks");
+ } else if (status === "reachable") {
+ setTestIndexerStatus("success");
+ toast.warning("Indexer is reachable but not processing blocks");
+ } else {
+ setTestIndexerStatus("error");
+ toast.error("Indexer validation failed");
+ }
+ } catch {
+ setTestIndexerStatus("error");
+ }
};
const connectWithErrorWrapper = async (
@@ -97,69 +207,349 @@ export const LockedSettingsModal: React.FC = () => {
return;
}
- await connectWithErrorWrapper(
- selectedNetwork,
- nodeUrl === "" ? null : nodeUrl
- );
+ const urlToUse =
+ nodeConnectionMode === "auto" ? null : nodeUrl === "" ? null : nodeUrl;
+
+ await connectWithErrorWrapper(selectedNetwork, urlToUse);
};
return (
-
-
-
-
-
-
-
-
-
-
- Network Settings
-
-
-
-
-
setNodeUrl(e.target.value)}
- className="w-full flex-grow rounded-3xl border border-[var(--border-color)] bg-[var(--input-bg)] p-2 text-[var(--text-primary)]"
- placeholder="wss://your-own-node-url.com"
- />
-
-
-
-
-
- {connectionError && (
-
{connectionError}
- )}
- {connectionSuccess && (
-
- Successfully connected to the node!
-
- )}
+
+ {/* Main settings list - shown when no section is expanded */}
+ {!showNodeSettings && !showIndexerSettings && !showDevSettings ? (
+ <>
+
+
+
+
+ Settings
+
+
+
+ {/* Node Settings Button */}
+
+
+ {/* Indexer Settings Button */}
+
+
+ {/* Dev Settings Button - only when dev mode is enabled */}
+ {devMode && (
+
+ )}
+
+
+
+ >
+ ) : showNodeSettings ? (
+ /* Node Settings Expanded Section */
+ <>
+
+
+
Node Settings
+
-
+
+
+
- {devInfo()}
+
+
+
+
+
+
+
+
+
+ {nodeConnectionMode === "manual" && (
+ <>
+
+
setNodeUrl(e.target.value)}
+ className="w-full flex-grow rounded-3xl border border-[var(--border-color)] bg-[var(--input-bg)] p-2 text-[var(--text-primary)]"
+ placeholder="wss://your-own-node-url.com"
+ />
+ >
+ )}
+
+ {nodeConnectionMode === "manual" && (
+
+
+
+
+ )}
+
+
+ {connectionError && (
+
{connectionError}
+ )}
+ {connectionSuccess && (
+
+ Successfully connected to the node!
+
+ )}
+ >
+ ) : showIndexerSettings ? (
+ /* Indexer Settings Expanded Section */
+ <>
+
+
+
Indexer Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Test Indexer Connection Button */}
+ {!indexerDisabled && (
+
+
+
+ )}
+
+ {indexerConnectionMode === "manual" && (
+ <>
+
+
{
+ setIndexerUrlState(e.target.value);
+ setTestIndexerStatus("idle");
+ }}
+ className="w-full flex-grow rounded-3xl border border-[var(--border-color)] bg-[var(--input-bg)] p-2 text-[var(--text-primary)]"
+ placeholder="https://your-indexer-url.com"
+ />
+ >
+ )}
+
+ {indexerConnectionMode === "manual" && (
+
+
+
+
+ )}
+
+ >
+ ) : showDevSettings ? (
+ <>
+
+
+
Dev Settings
+
+
+
+
+
+ Dev mode enabled
+
+
+
+
+
+
+ >
+ ) : null}
+
+ {/* show donations at the home settings */}
+ {!showNodeSettings && !showIndexerSettings && !showDevSettings && (
+
+ )}
);
};
diff --git a/src/components/Modals/MessageBackup.tsx b/src/components/Modals/MessageBackup.tsx
index f888cd74..7c4a5dc3 100644
--- a/src/components/Modals/MessageBackup.tsx
+++ b/src/components/Modals/MessageBackup.tsx
@@ -3,14 +3,16 @@ import { useWalletStore } from "../../store/wallet.store";
import { useMessagingStore } from "../../store/messaging.store";
import { Button } from "../Common/Button";
import clsx from "clsx";
+import { toast } from "../../utils/toast-helper";
export const MessageBackup: React.FC = () => {
const walletStore = useWalletStore();
const messageStore = useMessagingStore();
const onExportMessages = useCallback(async () => {
+ toast.info("Starting Message Export...");
if (!walletStore.unlockedWallet?.password) {
- alert("Please unlock your wallet first");
+ toast.error("Wallet not unlocked");
return;
}
@@ -42,15 +44,17 @@ export const MessageBackup: React.FC = () => {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
- alert("Messages exported successfully!");
+ toast.success("Messages exported successfully!");
} catch (error) {
- console.error("Error exporting messages:", error);
- alert("Failed to export messages");
+ toast.error(
+ error instanceof Error ? error.message : "Failed to export messages"
+ );
}
}, [messageStore, walletStore.unlockedWallet]);
const onImportMessages = useCallback(
async (event: React.ChangeEvent
) => {
+ toast.info("Starting Message Import...");
const file = event.target.files?.[0];
if (!file) return;
@@ -64,10 +68,9 @@ export const MessageBackup: React.FC = () => {
walletStore.unlockedWallet,
walletStore.unlockedWallet.password
);
- alert("Messages imported successfully!");
+ toast.success("Messages imported successfully!");
} catch (error: unknown) {
- console.error("Error importing messages:", error);
- alert(
+ toast.error(
error instanceof Error ? error.message : "Failed to import messages"
);
}
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
+
+
- return (
- <>
-
Confirm Handshake
+ {/* Confirmation Section */}
+
+
Confirm Handshake
Recipient:
@@ -422,196 +619,7 @@ export const NewChatForm: React.FC = ({ onClose }) => {
Back
- >
- );
- }
-
- return (
- <>
-
Start New Conversation
-
+
>
);
};
diff --git a/src/components/Modals/SettingsModal.tsx b/src/components/Modals/SettingsModal.tsx
index 312e7f7d..c6d1ad38 100644
--- a/src/components/Modals/SettingsModal.tsx
+++ b/src/components/Modals/SettingsModal.tsx
@@ -43,6 +43,8 @@ import { PasswordField } from "../Common/PasswordField";
import { HoldToDelete } from "../Common/HoldToDelete";
import { AppVersion } from "../App/AppVersion";
import { toast } from "../../utils/toast-helper";
+import { Donations } from "../Common/Donations";
+import { BlockList } from "./SubSettings/BlockList";
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
@@ -94,7 +96,13 @@ export const SettingsModal: React.FC
= ({
{ id: "security", label: "Security", 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
? [
@@ -129,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);
@@ -237,18 +248,18 @@ export const SettingsModal: React.FC = ({
const handleNameChange = async () => {
if (!selectedWalletId) {
- setNameChangeError("No wallet selected");
+ setNameChangeError("No account selected");
return;
}
// Basic validation (real-time validation handles most cases)
if (!newWalletName.trim()) {
- setNameChangeError("Please enter a wallet name");
+ setNameChangeError("Please enter an account name");
return;
}
if (newWalletName.trim().length < 2) {
- setNameChangeError("Wallet name must be at least 2 characters long");
+ setNameChangeError("Account name must be at least 2 characters long");
return;
}
@@ -271,7 +282,7 @@ export const SettingsModal: React.FC = ({
}, 2000);
} catch (error) {
setNameChangeError(
- error instanceof Error ? error.message : "Failed to change wallet name"
+ error instanceof Error ? error.message : "Failed to change account name"
);
} finally {
setIsChangingName(false);
@@ -345,7 +356,7 @@ export const SettingsModal: React.FC = ({
const currentWallet = wallets.find((w) => w.id === selectedWalletId);
if (currentWallet && newWalletName.trim() === currentWallet.name) {
- setNameChangeError("This is already your current wallet name");
+ setNameChangeError("This is already your current account name");
return;
}
@@ -357,7 +368,7 @@ export const SettingsModal: React.FC = ({
);
if (nameExists) {
- setNameChangeError("A wallet with this name already exists");
+ setNameChangeError("An account with this name already exists");
return;
}
@@ -365,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);
@@ -394,6 +394,7 @@ export const SettingsModal: React.FC = ({
"h-[600px] w-full max-w-3xl": !isMobile,
})}
>
+
= ({
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,
}
)}
@@ -472,13 +473,13 @@ export const SettingsModal: React.FC
= ({
Your Wallet:
-
)}
- {/* Change Wallet Name */}
+ {/* Change Account Name */}
@@ -504,7 +505,7 @@ export const SettingsModal: React.FC = ({
Import / Export Messages
-
+
Backup or restore your message history
@@ -522,7 +523,7 @@ export const SettingsModal: React.FC = ({
Delete All Messages
-
+
Permanently remove all conversations and data
@@ -534,12 +535,12 @@ export const SettingsModal: React.FC = ({
- Change Wallet Name
+ Change Account Name
@@ -547,29 +548,29 @@ export const SettingsModal: React.FC = ({
{nameChangeSuccess ? (
- Wallet name changed successfully!
+ Account name changed successfully!
- Your wallet name has been updated.
+ Your account name has been updated.
) : (