- implement stamps.

- fix #2
This commit is contained in:
Danny Morabito 2024-11-28 22:17:47 +01:00
parent a983fe669b
commit 0021db1c58
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
14 changed files with 256 additions and 39 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -15,19 +15,21 @@
"@iconify/svelte": "^4.0.2",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/kit": "^2.8.4",
"@sveltejs/kit": "^2.8.5",
"@sveltejs/vite-plugin-svelte": "^4.0.2",
"prettier": "^3.4.1",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.2.9",
"svelte": "^5.2.10",
"svelte-check": "^4.1.0",
"typescript": "^5.7.2",
"vite": "^5.4.11"
},
"dependencies": {
"@arx/utils": "git+ssh://git@git.arx-ccn.com:222/Arx/ts-utils#v0.0.4",
"@arx/utils": "git+ssh://git@git.arx-ccn.com:222/Arx/ts-utils",
"@gandlaf21/bc-ur": "^1.1.12",
"@nostr-dev-kit/ndk": "^2.10.7",
"@nostr-dev-kit/ndk-cache-dexie": "^2.5.8",
"@nostr-dev-kit/ndk-svelte": "^2.3.2"
"@nostr-dev-kit/ndk-svelte": "^2.3.2",
"qrcode": "^1.5.4"
}
}

View file

@ -4,7 +4,6 @@
<meta charset="utf-8" />
<link href="%sveltekit.assets%/favicon.png" rel="icon" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link href="%sveltekit.assets%/base.css" rel="stylesheet" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { UR, UREncoder } from '@gandlaf21/bc-ur';
import { onDestroy, onMount } from 'svelte';
import { Buffer } from 'buffer';
import * as qr from 'qrcode';
let { value } = $props();
const ur = UR.fromBuffer(Buffer.from(value));
const encoder = new UREncoder(ur, 200, 0);
let displayedData = $state('');
let timeout = $state(0);
async function nextSegment() {
let nextPart = encoder.nextPart();
displayedData = await qr.toDataURL(nextPart);
timeout = setTimeout(nextSegment, 1000 / 15);
}
onMount(() => {
nextSegment();
});
onDestroy(() => {
if (timeout)
clearTimeout(timeout);
});
</script>
{#if displayedData}
<img src={displayedData} alt="QR Code" />
{/if}
<style>
img {
place-self: center;
}
</style>

View file

@ -4,7 +4,8 @@
let {
open = $bindable<boolean>(false),
children,
onClose = $bindable(() => console.log('Closed'))
onClose = $bindable(() => {
})
} = $props();
let dialog: HTMLDialogElement;
@ -15,7 +16,7 @@
<dialog
bind:this={dialog}
class="glass"
onclose={() => onClose()}
onclose={() => { open = false; onClose(); }}
use:appendToBody
>
<div class="dialog-content">
@ -62,6 +63,31 @@
width: 100%;
padding: var(--spacing-md);
> :global(.title) {
position: fixed;
display: flex;
width: calc(100% + 2 * var(--spacing-md));
margin: calc(var(--spacing-md) * -1);
padding: var(--spacing-md);
background: color-mix(in oklab, black 50%, var(--accent));
z-index: 99999;
> :global(.close-button) {
position: absolute;
top: var(--spacing-md);
right: calc(
var(--spacing-md) * 2 + 1.5rem
);
}
:global(& + *) {
margin-top: calc(
1.5rem +
var(--spacing-md) * 3
);
}
}
> :global(*:not(:last-child)) {
margin-bottom: var(--spacing-sm);
}

View file

@ -119,7 +119,7 @@
</div>
{#if letter.stamps}
<div class="stamps-count">
<Icon icon='icon-park-twotone:stamp' />
<Icon icon="emojione-v1:stamped-envelope" />
{letter.stamps} sats
</div>
{/if}
@ -148,7 +148,6 @@
<Dialog
bind:open={moveFolderDialogOpen}
onClose={() => moveFolderDialogOpen = false}
>
<h3>Move Letters to Folder</h3>
<p class="text-secondary">Select a folder to move the selected letters to:</p>
@ -172,7 +171,7 @@
</div>
{#if letter.stamps}
<div class="stamps-count">
<Icon icon='icon-park-twotone:stamp' />
<Icon icon="emojione-v1:stamped-envelope" />
{letter.stamps} sats
</div>
{/if}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import '$lib/base.css';
export let content: string;
export let position: 'top' | 'bottom' | 'left' | 'right' = 'top';

View file

@ -44,6 +44,8 @@
:root {
--glass-bg: color-mix(in srgb, white 8%, transparent);
--glass-bg-dark: color-mix(in srgb, black 8%, transparent);
--glass-bg-darker: color-mix(in srgb, black 50%, transparent);
--glass-border: color-mix(in srgb, white 12%, transparent);
--glass-shadow: rgb(0 0 0 / 0.1);
--glass-glow: oklch(67% 0.2 var(--base-hue) / 0.15);
@ -409,6 +411,62 @@
1.5px 1.5px 0 oklch(15% 10% 35);
letter-spacing: 1px;
}
.stamp-count {
display: flex;
place-content: center;
align-items: center;
gap: var(--spacing-sm);
font-size: 2em;
&.button {
cursor: pointer;
background: var(--glass-bg);
padding: var(--spacing-sm);
border: 1px solid var(--glass-border);
border-radius: 8px;
text-decoration: none;
&:hover {
box-shadow: 0 0 15px var(--glass-glow);
}
}
}
hr {
border: none;
height: 2px;
margin: var(--spacing-md) 0;
position: relative;
overflow: visible;
background: linear-gradient(
90deg,
transparent,
var(--accent),
var(--accent-light),
var(--accent),
transparent
);
inset: -1px;
filter: blur(2px);
box-shadow: 0 0 15px var(--glass-glow);
&::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
background: var(--gradient-shine);
background-size: 200% 200%;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover::after {
opacity: 1;
animation: shine 3s linear infinite;
}
}
}
@keyframes shine {

View file

@ -1,5 +1,6 @@
import NDK, { NDKEvent, type NDKEventId, NDKKind, type NDKUser } from '@nostr-dev-kit/ndk';
import { isValidNip05 } from '$lib/utils.svelte';
import { TokenInfoWithMailSubscriptionDuration } from '@arx/utils';
export class Letter {
id: NDKEventId;
@ -8,7 +9,7 @@ export class Letter {
content: string;
date: Date;
recipients: Set<NDKUser>;
stamps: number;
stamp?: TokenInfoWithMailSubscriptionDuration;
emailAddress?: string;
private constructor() {
@ -18,7 +19,6 @@ export class Letter {
this.content = '';
this.date = new Date();
this.recipients = new Set<NDKUser>();
this.stamps = 0;
}
static async fromDecryptedMessage(
@ -35,6 +35,15 @@ export class Letter {
letter.content = decryptedMessage.content;
letter.date = new Date(decryptedMessage.created_at! * 1000);
letter.emailAddress = decryptedMessage.tags.find((t) => t[0] === 'email:from')?.[1];
let stampToken = decryptedMessage.tags.find((t) => t[0] === 'stamp')?.[1];
if (stampToken) {
try {
stampToken = await ndk.signer!.nip44Decrypt(letter.from, stampToken);
letter.stamp = new TokenInfoWithMailSubscriptionDuration(stampToken);
} catch (error) {
console.error('Error decrypting stamp token:', error);
}
}
for (const tag of decryptedMessage.tags) {
if (tag[0] === 'p') {

View file

@ -4,7 +4,7 @@ import { NDKNip07Signer, NDKUser } from '@nostr-dev-kit/ndk';
import NDKSvelte from '@nostr-dev-kit/ndk-svelte';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'arxmail-cache' });
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'npub-email-cache' });
const nip07signer = new NDKNip07Signer();
@ -16,9 +16,9 @@ export const _ndk = new NDKSvelte({
'wss://offchain.pub',
'wss://relay.snort.social'
],
autoConnectUserRelays: false,
autoConnectUserRelays: true,
relayAuthDefaultPolicy: async (r) => true,
enableOutboxModel: false,
enableOutboxModel: true,
// signer: nip07signer,
cacheAdapter: dexieAdapter
});

View file

@ -120,7 +120,8 @@ export async function createSealedLetter(
to: NDKUser,
subject: string,
content: string,
replyTo?: string
replyTo?: string,
stamp?: string
) {
await waitForNDK();
const rawMessage = new NDKEvent();
@ -130,6 +131,10 @@ export async function createSealedLetter(
rawMessage.content = content;
rawMessage.tags.push(['subject', subject]);
if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']);
if (stamp) {
const encryptedStamp = await ndk.signer!.nip44Encrypt(to, stamp);
rawMessage.tags.push(['stamp', encryptedStamp]);
}
rawMessage.tags.push(['p', to.pubkey]);
return encryptEventForRecipient(rawMessage, to);
}

View file

@ -25,14 +25,16 @@
let folders = $state([
{ id: 'inbox', name: 'Inbox', icon: 'solar:inbox-bold-duotone' },
{ id: 'sent', name: 'Sent', icon: 'fa:send' }
{ id: 'sent', name: 'Sent', icon: 'fa:send' },
{ id: 'trash', name: 'Trash', icon: 'eos-icons:trash' }
]);
async function decryptMessages(messages) {
const currentMessages = messages;
const decryptedFolders = [
{ id: 'inbox', name: 'Inbox', icon: 'solar:inbox-bold-duotone' },
{ id: 'sent', name: 'Sent', icon: 'fa:send' }
{ id: 'sent', name: 'Sent', icon: 'fa:send' },
{ id: 'trash', name: 'Trash', icon: 'formkit:trash' }
];
const allLetters = [];
let allLettersInFolders: NDKEventId[] = [];
@ -104,7 +106,9 @@
bind:open={isAddingFolder}
onClose={newFolderDialogClosed}
>
<div class="title">
<h3>Create Folder</h3>
</div>
<p class="text-secondary">Please enter a name for the new folder:</p>
<form onsubmit={addNewFolderPressed}>
@ -142,6 +146,10 @@
<div class="folder-view">
{#if letters.length === 0}
<Icon icon="eos-icons:loading" width="5em" />
<br />
<p>
If you have letters, they will appear here, please wait...
</p>
{:else}
<MailboxFolderItems foldersList={folders} folder={folders.find(f => f.id === activeFolder)} {letters} />
{/if}

View file

@ -5,6 +5,8 @@
import Notification from '../../components/Notification.svelte';
import { createCarbonCopyLetter, createSealedLetter } from '$lib/utils.svelte';
import type { NDKUser } from '@nostr-dev-kit/ndk';
import { TokenInfoWithMailSubscriptionDuration } from '@arx/utils';
import Icon from '@iconify/svelte';
let isSending = $state(false);
let letterSent = $state(0);
@ -13,6 +15,18 @@
let toField = $state('');
let subject = $state('');
let content = $state('');
let stamp = $state('');
let tokenInfo = $state<TokenInfoWithMailSubscriptionDuration>();
let tokenInfoError = $state('');
$effect(() => {
try {
tokenInfo = new TokenInfoWithMailSubscriptionDuration(stamp);
tokenInfoError = '';
} catch (e) {
tokenInfoError = (e as Error).message;
}
});
function isValidNpub(npub: string): boolean {
return npub.startsWith('npub');
@ -25,8 +39,12 @@
return domain.includes('.');
}
async function addRecipient(e: KeyboardEvent) {
function recepientInput(e: KeyboardEvent) {
if (e.key !== 'Enter') return;
return addRecipient();
}
async function addRecipient() {
if (!isValidNpub(toField) && !isPossibleNip05(toField)) return alert('Invalid recipient');
if (isPossibleNip05(toField)) {
const user = await $ndk.getUserFromNip05(toField);
@ -36,13 +54,23 @@
if (recipients.includes(toField)) toField = '';
recipients = [...recipients, toField];
toField = '';
return true;
}
async function send() {
if (!confirm('Are you sure you want to send this message?')) return;
if (toField.length != 0 && !await addRecipient())
return;
if (recipients.length === 0) return alert('Please add at least one recipient');
if (subject.length === 0) return alert('Please add a subject');
if (content.length === 0) return alert('Please add a message');
let newStamp;
if (stamp) {
if (tokenInfoError) return alert(tokenInfoError);
if (!tokenInfo) return alert('Please add a valid stamp');
newStamp = await tokenInfo.receive();
stamp = '';
}
isSending = true;
try {
const notesToSend = [];
@ -64,7 +92,8 @@
recipient,
subject,
content,
replyTo
replyTo,
newStamp
));
realRecipients.push(recipient);
}
@ -124,7 +153,7 @@
<input
bind:value={toField}
class="input"
onkeyup={addRecipient}
onkeyup={recepientInput}
placeholder="Enter npub or nip05..."
type="text"
/>
@ -135,6 +164,23 @@
<label>Subject:</label>
<input bind:value={subject} class="input" placeholder="Enter subject..." type="text">
</div>
{#if recipients.length < 2}
<div class="field">
<label>Stamp:</label>
<input bind:value={stamp} class="input" placeholder="Enter stamp..." type="text" />
</div>
{#if tokenInfoError && stamp.length > 0}
<p class="error">
{tokenInfoError}
</p>
{:else if tokenInfo}
<p class="stamp-count">
<Icon icon="emojione-v1:stamped-envelope" /> {tokenInfo.amount} sats
</p>
{/if}
{/if}
</div>
<div class="compose-editor">

View file

@ -6,6 +6,8 @@
import { decryptSealedMessageIntoReadableType, getReadableDate, getReadableTime } from '$lib/utils.svelte';
import { goto } from '$app/navigation';
import { Letter } from '$lib/letter';
import Dialog from '../../../components/Dialog.svelte';
import AnimatedQRCode from '../../../components/AnimatedQRCode.svelte';
const { data } = $props();
const { id } = data;
@ -13,6 +15,7 @@
let error = $state('');
let loading = $state(true);
let letter = $state<Letter>();
let stampDialogOpen = $state(false);
onMount(async () => {
let letterEvent = await $ndk.fetchEvent(id);
@ -81,13 +84,13 @@
</div>
<div class="actions">
{#if letter.stamps}
<div class="stamps-badge">
<Icon icon='icon-park-twotone:stamp' />
<span>{letter.stamps} sats</span>
{#if letter.stamp}
<div onclick={() => stampDialogOpen = true} class="stamp-count button">
<Icon icon="emojione-v1:stamped-envelope" />
<span>{letter.stamp.amount} sats</span>
</div>
{/if}
<button class="reply-btn" on:click={handleReply}>
<button class="reply-btn" onclick={handleReply}>
<Icon icon="mdi:reply" />
<span>Reply</span>
</button>
@ -109,6 +112,36 @@
</div>
{/if}
<Dialog bind:open={stampDialogOpen}>
{#if letter && letter.stamp}
<div class="title">
<h3>Bitcoin Stamp</h3>
<button class="close-button" onclick={() => stampDialogOpen = false}>Close</button>
</div>
<p>This letter contains bitcoin!</p>
<p>Scan or copy this token to claim {letter.stamp.amount} sats in your Cashu wallet.</p>
<p>Warning: Each token can only be claimed once.</p>
<hr />
<p>⚠️ Warning: Cashu is custodial - don't store large amounts.</p>
<p>⚡ Move significant amounts to your own self-custodial Lightning wallet.</p>
<hr />
<div class="stamp-count">
<Icon icon="emojione-v1:stamped-envelope" />
<span>{letter.stamp.amount} sats</span>
</div>
<AnimatedQRCode value={letter.stamp.tokenString} />
<input
type="text"
value={letter.stamp.tokenString}
readonly
onclick={() => {navigator.clipboard.writeText(letter.stamp.tokenString); alert('Copied!') }}
/>
{/if}
</Dialog>
<style>
.loading-state,
.error-state {
@ -196,22 +229,12 @@
}
.actions {
margin-top: var(--spacing-sm);
display: flex;
align-items: center;
flex-direction: column;
gap: var(--spacing-sm);
}
.stamps-badge {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
color: var(--text-primary);
}
.reply-btn {
display: inline-flex;
align-items: center;