parent
a983fe669b
commit
0021db1c58
14 changed files with 256 additions and 39 deletions
|
@ -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">
|
||||
|
|
41
src/components/AnimatedQRCode.svelte
Normal file
41
src/components/AnimatedQRCode.svelte
Normal 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>
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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';
|
||||
|
|
480
src/lib/base.css
Normal file
480
src/lib/base.css
Normal file
|
@ -0,0 +1,480 @@
|
|||
@layer reset {
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img, picture, svg, video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@property --gradient-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
: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);
|
||||
--depth-shadow: 0 8px 32px -8px rgb(0 0 0 / 0.3);
|
||||
--noise-filter: url("data:image/svg+xml,%3Csvg width='1000' height='1000' viewBox='0 0 1000 1000' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch' seed='2' result='noise'/%3E%3CfeColorMatrix type='saturate' values='0' in='noise' result='desaturatedNoise'/%3E%3CfeBlend in='SourceGraphic' in2='desaturatedNoise' mode='overlay' result='blend'/%3E%3CfeComposite operator='in' in2='SourceGraphic'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E#noiseFilter");
|
||||
--gradient-shine: linear-gradient(
|
||||
135deg,
|
||||
transparent 25%,
|
||||
rgb(255 255 255 / 0.1) 50%,
|
||||
transparent 75%
|
||||
);
|
||||
--base-hue: 200;
|
||||
--accent: oklch(67% 0.2 var(--base-hue));
|
||||
--accent-light: oklch(75% 0.2 var(--base-hue));
|
||||
--text-primary: oklch(100% 0 0);
|
||||
--text-secondary: oklch(100% 0 0 / 0.7);
|
||||
--blur-strength: 16px;
|
||||
--spacing-xs: clamp(0.25rem, 0.5vw, 0.5rem);
|
||||
--spacing-sm: clamp(0.5rem, 1vw, 1rem);
|
||||
--spacing-md: clamp(1rem, 2vw, 2rem);
|
||||
--spacing-lg: clamp(2rem, 4vw, 4rem);
|
||||
|
||||
|
||||
--editor-bg: oklch(10% 0.02 var(--base-hue) / 0.6);
|
||||
--toolbar-bg: oklch(15% 0.02 var(--base-hue) / 0.8);
|
||||
--input-border: oklch(100% 0.02 var(--base-hue) / 0.15);
|
||||
--input-focus: oklch(60% 0.2 var(--base-hue) / 0.3);
|
||||
--chip-bg: oklch(60% 0.2 var(--base-hue) / 0.15);
|
||||
|
||||
--npub-color: oklch(70% 0.2 calc(var(--base-hue) + 60));
|
||||
--nip05-color: oklch(85% 0.3 calc(var(--base-hue) - 30));
|
||||
--email-color: oklch(70% 0.2 calc(var(--base-hue) - 60));
|
||||
--verified-color: oklch(70% 0.2 calc(var(--base-hue) + 180));
|
||||
--chip-success: oklch(60% 0.2 calc(var(--base-hue) + 180) / 0.5);
|
||||
--chip-warning: oklch(60% 0.2 calc(var(--base-hue) + 60) / 0.5);
|
||||
--chip-error: oklch(60% 0.2 calc(var(--base-hue) + 30) / 0.5);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-in;
|
||||
position: fixed;
|
||||
inset: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--blur-strength));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: var(--spacing-md);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
box-shadow: var(--depth-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 0 30px var(--glass-glow);
|
||||
width: 50vmin;
|
||||
height: 50vmin;
|
||||
justify-content: center;
|
||||
|
||||
}
|
||||
|
||||
.htmx-request .loading, .htmx-request.loading {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer layout {
|
||||
body {
|
||||
background: linear-gradient(
|
||||
var(--gradient-angle),
|
||||
oklch(10% 0.2 calc(var(--base-hue) - 3)),
|
||||
oklch(12.5% 0.2 calc(var(--base-hue) - 2)),
|
||||
oklch(15% 0.2 calc(var(--base-hue) - 1)),
|
||||
oklch(17.5% 0.2 var(--base-hue)),
|
||||
oklch(20% 0.2 calc(var(--base-hue) + 1)),
|
||||
oklch(25% 0.2 calc(var(--base-hue) + 2)),
|
||||
oklch(30% 0.2 calc(var(--base-hue) + 3))
|
||||
),
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgb(99 102 241 / 0.15),
|
||||
transparent 50%
|
||||
);
|
||||
background-blend-mode: normal, overlay;
|
||||
font-family: 'Inter', system-ui;
|
||||
color: var(--text-primary);
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: rotate-gradient 20s linear infinite;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: var(--noise-filter);
|
||||
opacity: 0.05;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 100dvh;
|
||||
padding: var(--spacing-md);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
#header {
|
||||
position: sticky;
|
||||
top: var(--spacing-md);
|
||||
backdrop-filter: blur(var(--blur-strength)) saturate(180%);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: var(--spacing-md);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
box-shadow: var(--depth-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 0 30px var(--glass-glow);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--gradient-shine);
|
||||
background-size: 200% 200%;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
animation: shine 3s linear infinite;
|
||||
}
|
||||
|
||||
& .title {
|
||||
position: relative;
|
||||
text-shadow: 0 0 20px var(--glass-glow);
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -4px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#content {
|
||||
height: 100%;
|
||||
backdrop-filter: blur(var(--blur-strength));
|
||||
background: var(--glass-bg);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--depth-shadow);
|
||||
}
|
||||
|
||||
#page-title {
|
||||
width: fit-content;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
padding-left: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
h3, h4 {
|
||||
width: fit-content;
|
||||
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
button, .button {
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: linear-gradient(45deg, var(--accent), transparent);
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translatey(1px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
transform: translatey(-2px);
|
||||
box-shadow: 0 0 15px var(--glass-glow);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input[type="text"], input[type="password"] {
|
||||
background: var(--editor-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
color: var(--text-primary);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--input-focus);
|
||||
box-shadow: 0 0 0 2px var(--input-focus),
|
||||
0 0 15px var(--glass-glow);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.notification {
|
||||
isolation: isolate;
|
||||
contain: none;
|
||||
position: fixed;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--blur-strength));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-md);
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: flex-start;
|
||||
box-shadow: var(--depth-shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 0 30px var(--glass-glow);
|
||||
transform: translateX(120%);
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 1000;
|
||||
|
||||
&.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--gradient-shine);
|
||||
background-size: 200% 200%;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 1;
|
||||
animation: shine 3s linear infinite;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.glass {
|
||||
backdrop-filter: blur(var(--blur-strength));
|
||||
background: var(--glass-bg);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--depth-shadow);
|
||||
}
|
||||
|
||||
p.error {
|
||||
color: oklch(60% 50% 35);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
text-shadow: -1.5px -1.5px 0 oklch(15% 10% 35),
|
||||
1.5px -1.5px 0 oklch(15% 10% 35),
|
||||
-1.5px 1.5px 0 oklch(15% 10% 35),
|
||||
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 {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
<h3>Create Folder</h3>
|
||||
<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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue