Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/ai/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ The **currency** selector does **not** appear in the user menu — it is always

---

## Personal notes

Signed-in users have a private list of free-text **personal notes**, reached from a **Notes** item in the user menu placed directly **after Contacts**. The item opens a Notes modal that lists the user's notes newest-first and supports add, edit, and delete.

A note is a free-standing memo — it is **not** attached to any transaction, address, token, or network. Each note is body text plus created/updated timestamps; there is no title, rich text, attachments, tags, or folders.

- **End-to-end encrypted via vetKeys.** Notes are encrypted in the browser before they leave the device and decrypted in the browser on read, so the canister and the node providers only ever store and see **ciphertext**. A per-user symmetric key is derived via vetKD (one key per principal) and cached as a non-extractable `CryptoKey` in IndexedDB, so it is derived once per device. One user cannot read or write another user's notes.
- **Lazy loading.** Nothing loads at wallet startup. The notes (and the per-user key) are fetched, derived, and decrypted on the **first** open of the Notes modal; the decrypted notes are cached for the session, so re-opening is instant.
- **Limits.** A note holds up to **2,000 characters** (counted in Unicode code points, so any language / script / emoji is supported), enforced client-side; the backend independently rejects oversized ciphertext. Empty or whitespace-only notes cannot be saved. A user may keep up to **1,000 notes**; at the cap, creating a new note is refused (and the UI disables "Add note") while editing and deleting existing notes still work — no note is ever evicted.
- **Timestamps** are stored as UTC and displayed in the user's local timezone: a never-edited note reads "Created …", an edited note "Updated …" (and rises to the top, since the list sorts by last update).
- **Safe rendering.** Note text is always rendered as plain text (auto-escaped, never as HTML), with line breaks handled by CSS and bidi/control characters neutralized on display, so a note cannot execute scripts or reorder surrounding UI.
- **Delete is immediate, with no confirmation dialog, but reversible**: deleting a note surfaces a pinned, auto-dismissing "Note deleted" snackbar with an **Undo** action that restores the note exactly (same id and timestamps, returning to its original position). A single note that fails to decrypt shows an inline error with a Retry action without affecting the others.

The editor step deliberately has **no (X) and ignores backdrop clicks** — only Cancel or Save exits it — so unsaved text cannot be lost to an accidental dismissal; the list and empty states close normally via X, Close, or the backdrop.

---

## WalletConnect

OISY connects to external dApps over WalletConnect (Reown WalletKit). When a dApp proposes a session, OISY advertises one namespace per chain family for which the signed-in user has a loaded address, so a single connection can span Ethereum, Solana, and Bitcoin at once.
Expand Down
12 changes: 12 additions & 0 deletions src/frontend/src/lib/components/core/Menu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import IconEye from '$lib/components/icons/lucide/IconEye.svelte';
import IconEyeOff from '$lib/components/icons/lucide/IconEyeOff.svelte';
import IconMaximize from '$lib/components/icons/lucide/IconMaximize.svelte';
import IconNotebook from '$lib/components/icons/lucide/IconNotebook.svelte';
import IconShare from '$lib/components/icons/lucide/IconShare.svelte';
import IconUsersRound from '$lib/components/icons/lucide/IconUsersRound.svelte';
import LicenseAgreementLink from '$lib/components/license-agreement/LicenseAgreementLink.svelte';
Expand All @@ -37,6 +38,7 @@
NAVIGATION_MENU_VIP_BUTTON,
NAVIGATION_MENU_REFERRAL_BUTTON,
NAVIGATION_MENU_ADDRESS_BOOK_BUTTON,
NAVIGATION_MENU_NOTES_BUTTON,
NAVIGATION_MENU_GOLD_BUTTON,
NAVIGATION_MENU_SCANNER_BUTTON,
NAVIGATION_MENU_PAY_BUTTON,
Expand Down Expand Up @@ -91,6 +93,7 @@
);

const addressModalId = Symbol();
const notesModalId = Symbol();
const referralModalId = Symbol();
const universalScannerModalId = Symbol();
const payDialogModalId = Symbol();
Expand Down Expand Up @@ -158,6 +161,15 @@
{$i18n.navigation.text.address_book}
</ButtonMenu>

<ButtonMenu
ariaLabel={$i18n.navigation.alt.notes}
onclick={() => modalStore.openNotes(notesModalId)}
testId={NAVIGATION_MENU_NOTES_BUTTON}
>
<IconNotebook size="20" />
{$i18n.navigation.text.notes}
</ButtonMenu>

<ButtonMenu
ariaLabel={$isPrivacyMode
? $i18n.navigation.alt.show_balances
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/src/lib/components/core/Modals.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import AddressBookModal from '$lib/components/address-book/AddressBookModal.svelte';
import DappModalDetails from '$lib/components/dapps/DappModalDetails.svelte';
import NftImageConsentModal from '$lib/components/nfts/NftImageConsentModal.svelte';
import NotesModal from '$lib/components/notes/NotesModal.svelte';
import PayDialog from '$lib/components/pay/PayDialog.svelte';
import ReceiveAddressModal from '$lib/components/receive/ReceiveAddressModal.svelte';
import ReceiveAddresses from '$lib/components/receive/ReceiveAddresses.svelte';
Expand All @@ -26,6 +27,7 @@
modalSettingsState,
modalReferralCode,
modalAddressBook,
modalNotes,
modalVipQrCodeData,
modalIcHideTokenData,
modalHideTokenData,
Expand Down Expand Up @@ -66,6 +68,8 @@
<ReferralCodeModal />
{:else if $modalAddressBook}
<AddressBookModal />
{:else if $modalNotes}
<NotesModal />
{:else if $modalNftImageConsent && nonNullish($modalNftImageConsentData)}
<NftImageConsentModal collection={$modalNftImageConsentData} />
{:else if $modalNftFullscreenDisplayOpen && nonNullish($modalNftFullscreenDisplayData?.imageUrl)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!-- source: ISC Lucide - please visit https://lucide.dev/license -->
<script lang="ts">
interface Props {
size?: string;
}

let { size = '24' }: Props = $props();
</script>

<svg
fill="none"
height={size}
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><rect
height="20"
rx="2"
width="16"
x="4"
y="2"
/><path d="M9.5 8h5" /><path d="M9.5 12H16" /><path d="M9.5 16H14" /></svg
>
33 changes: 33 additions & 0 deletions src/frontend/src/lib/components/notes/EmptyNotes.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script lang="ts">
import IconNotebook from '$lib/components/icons/lucide/IconNotebook.svelte';
import NotesPrivacyInfoBox from '$lib/components/notes/NotesPrivacyInfoBox.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { NOTES_ADD_BUTTON } from '$lib/constants/test-ids.constants';
import { i18n } from '$lib/stores/i18n.store';

interface Props {
onAddNote: () => void;
disabled?: boolean;
}

let { onAddNote, disabled = false }: Props = $props();
</script>

<div class="flex flex-col items-center gap-8 text-center">
<div
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-subtle-10 text-tertiary"
>
<IconNotebook size="28" />
</div>

<div class="flex flex-col gap-3">
<h1>{$i18n.notes.text.empty_title}</h1>
<span class="text-tertiary">{$i18n.notes.text.empty_subtitle}</span>
</div>

<Button {disabled} onclick={onAddNote} testId={NOTES_ADD_BUTTON}>
{$i18n.notes.text.empty_add}
</Button>

<NotesPrivacyInfoBox />
</div>
66 changes: 66 additions & 0 deletions src/frontend/src/lib/components/notes/InputPersonalNote.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<script lang="ts">
import { notEmptyString } from '@dfinity/utils';
import { slide } from 'svelte/transition';
import { MAX_PERSONAL_NOTE_LENGTH } from '$lib/constants/app.constants';
import { NOTES_INPUT } from '$lib/constants/test-ids.constants';
import { SLIDE_DURATION } from '$lib/constants/transition.constants';
import { i18n } from '$lib/stores/i18n.store';
import { replacePlaceholders } from '$lib/utils/i18n.utils';
import { personalNoteLength } from '$lib/utils/personal-note.utils';

interface Props {
value: string;
isValid: boolean;
disabled?: boolean;
}

let { value = $bindable(), isValid = $bindable(), disabled = false }: Props = $props();

let textarea = $state<HTMLTextAreaElement | undefined>();

// Trim before measuring and validating (the trimmed value is what gets stored);
// the cap is counted in Unicode code points, not UTF-16 units, so emoji / CJK /
// astral characters count as the user sees them.
const trimmed = $derived(value.trim());
const isTooLong = $derived(personalNoteLength(trimmed) > MAX_PERSONAL_NOTE_LENGTH);

$effect(() => {
isValid = notEmptyString(trimmed) && !isTooLong;
});

// Auto-focus on open so the user can type immediately: add opens empty with the
// caret ready; edit places the caret at the end. preventScroll keeps focusing
// from yanking the surrounding modal/list.
$effect(() => {
const element = textarea;
if (element === undefined) {
return;
}
const end = element.value.length;
element.setSelectionRange(end, end);
element.focus({ preventScroll: true });
});
</script>

<div style="--input-font-size: var(--text-base)" class="w-full">
<label
class="flex w-full flex-col gap-2 rounded-lg bg-brand-subtle-10 p-4 text-sm md:p-6 md:text-base md:font-bold"
>
{$i18n.notes.text.note_label}
<textarea
bind:this={textarea}
class="min-h-32 w-full resize-none rounded-md bg-primary p-3 text-base font-normal text-primary outline-none placeholder:text-tertiary"
data-tid={NOTES_INPUT}
{disabled}
placeholder={$i18n.notes.text.placeholder}
rows="6"
bind:value></textarea>
{#if isTooLong}
<p class="text-error-primary" transition:slide={SLIDE_DURATION}>
{replacePlaceholders($i18n.notes.text.too_long, {
$maxCharacters: `${MAX_PERSONAL_NOTE_LENGTH}`
})}
</p>
{/if}
</label>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script lang="ts">
import { isNullish, nonNullish } from '@dfinity/utils';
import { fly } from 'svelte/transition';
import { NOTES_DELETED_SNACKBAR, NOTES_UNDO_BUTTON } from '$lib/constants/test-ids.constants';
import { authIdentity } from '$lib/derived/auth.derived';
import { restorePersonalNote } from '$lib/services/personal-notes.services';
import { i18n } from '$lib/stores/i18n.store';
import { personalNotesUndoStore } from '$lib/stores/personal-notes.store';
import { toastsError } from '$lib/stores/toasts.store';

// The undo window: the snackbar auto-dismisses after a few seconds, matching the
// app's toast behaviour. Restarts whenever a newer delete replaces the note.
const UNDO_TIMEOUT_MS = 5000;

$effect(() => {
if (isNullish($personalNotesUndoStore)) {
return;
}
const timer = setTimeout(() => personalNotesUndoStore.set(undefined), UNDO_TIMEOUT_MS);
return () => clearTimeout(timer);
});

const undo = async () => {
const note = $personalNotesUndoStore;
personalNotesUndoStore.set(undefined);

if (isNullish(note) || isNullish($authIdentity)) {
return;
}

try {
await restorePersonalNote({ identity: $authIdentity, note });
} catch (err: unknown) {
toastsError({ msg: { text: $i18n.notes.error.restore }, err });
}
};
</script>

{#if nonNullish($personalNotesUndoStore)}
<div class="pointer-events-none fixed inset-x-0 bottom-4 z-[1100] flex justify-center px-4">
<div
class="pointer-events-auto flex items-center gap-4 rounded-lg bg-primary-inverted px-4 py-3 text-sm text-primary-inverted shadow-lg"
data-tid={NOTES_DELETED_SNACKBAR}
transition:fly={{ y: 20, duration: 150 }}
>
<span>{$i18n.notes.text.deleted}</span>
<button
class="font-bold text-brand-primary-alt hover:underline"
data-tid={NOTES_UNDO_BUTTON}
onclick={undo}
type="button"
>
{$i18n.notes.text.undo}
</button>
</div>
</div>
{/if}
115 changes: 115 additions & 0 deletions src/frontend/src/lib/components/notes/NoteListItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script lang="ts">
import IconNotebook from '$lib/components/icons/lucide/IconNotebook.svelte';
import IconPencil from '$lib/components/icons/lucide/IconPencil.svelte';
import IconTrash from '$lib/components/icons/lucide/IconTrash.svelte';
import Button from '$lib/components/ui/Button.svelte';
import {
NOTES_DELETE_BUTTON,
NOTES_EDIT_BUTTON,
NOTES_LIST_ITEM,
NOTES_RETRY_DECRYPT_BUTTON
} from '$lib/constants/test-ids.constants';
import { currentLanguage } from '$lib/derived/i18n.derived';
import { i18n } from '$lib/stores/i18n.store';
import {
isPersonalNoteDecryptionFailure,
type PersonalNoteEntryUi
} from '$lib/types/personal-note';
import { replacePlaceholders } from '$lib/utils/i18n.utils';
import { formatPersonalNoteTimestamp, personalNotePreview } from '$lib/utils/personal-note.utils';

interface Props {
note: PersonalNoteEntryUi;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRetry: () => void;
}

let { note, onEdit, onDelete, onRetry }: Props = $props();

const failed = $derived(isPersonalNoteDecryptionFailure(note));

// Never-edited notes read "Created …"; edited notes "Updated …" (and sort to the
// top). All times render in the user's local timezone.
const timestamp = $derived.by(() => {
if (isPersonalNoteDecryptionFailure(note)) {
return '';
}
const edited = note.updated_at_ns !== note.created_at_ns;
return replacePlaceholders(edited ? $i18n.notes.text.updated : $i18n.notes.text.created, {
$date: formatPersonalNoteTimestamp({
ns: edited ? note.updated_at_ns : note.created_at_ns,
language: $currentLanguage
})
});
});

// Plain-text preview: bidi-neutralized, whitespace collapsed to a single line,
// then clamped to 2 lines via CSS (escaping still applies — Svelte auto-escapes).
const preview = $derived(
isPersonalNoteDecryptionFailure(note) ? '' : personalNotePreview(note.note)
);
</script>

<li
class="group flex items-center gap-3 border-b border-brand-subtle-10 py-3 last-of-type:border-b-0"
data-tid={NOTES_LIST_ITEM}
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-subtle-10 text-tertiary"
>
<IconNotebook size="20" />
</div>

{#if failed}
<div class="flex min-w-0 flex-1 flex-col">
<span class="text-error-primary">{$i18n.notes.text.decryption_failed}</span>
</div>
<Button
ariaLabel={$i18n.core.text.retry}
colorStyle="secondary-light"
onclick={onRetry}
paddingSmall
testId={NOTES_RETRY_DECRYPT_BUTTON}
>
{$i18n.core.text.retry}
</Button>
{:else}
<button
class="flex min-w-0 flex-1 flex-col gap-1 text-left"
onclick={() => onEdit(note.id)}
type="button"
>
<span
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere;"
class="text-primary"
>
{preview}
</span>
<span class="text-xs text-tertiary">{timestamp}</span>
</button>

<div
class="flex shrink-0 items-center gap-1 opacity-100 transition-opacity md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
>
<button
class="p-2 text-tertiary hover:text-primary"
aria-label={$i18n.notes.alt.edit}
data-tid={NOTES_EDIT_BUTTON}
onclick={() => onEdit(note.id)}
type="button"
>
<IconPencil size="20" />
</button>
<button
class="p-2 text-tertiary hover:text-error-primary"
aria-label={$i18n.notes.alt.delete}
data-tid={NOTES_DELETE_BUTTON}
onclick={() => onDelete(note.id)}
type="button"
>
<IconTrash />
</button>
</div>
{/if}
</li>
Loading
Loading