npub.email/src/routes/compose/+page.svelte

282 lines
No EOL
7 KiB
Svelte

<script lang="ts">
import RecipientChip from '../../components/RecipientChip.svelte';
import { onMount } from 'svelte';
import { ndk, pageIcon, pageTitle } from '$lib/stores.svelte';
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);
let recipients = $state([]);
let replyTo = $state('');
let toField = $state('');
let subject = $state('');
let content = $state('');
let stamp = $state('');
let tokenInfo = $state<TokenInfoWithMailSubscriptionDuration>();
let tokenInfoError = $state('');
$effect(() => {
if (isSending) $pageTitle = 'Sending...';
else $pageTitle = 'New Letter';
});
$effect(() => {
try {
tokenInfo = new TokenInfoWithMailSubscriptionDuration(stamp);
tokenInfoError = '';
} catch (e) {
tokenInfoError = (e as Error).message;
}
});
function isValidNpub(npub: string): boolean {
return npub.startsWith('npub');
}
function isPossibleNip05(nip05: string): boolean {
let parts = nip05.split('@');
if (parts.length !== 2) return false;
let domain = parts[1];
return domain.includes('.');
}
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);
if (!user)
return alert('Invalid recipient (nip05 does not match any npub)');
}
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 = [];
const alreadyCheckedPublicKeys: string[] = [];
const realRecipients: NDKUser[] = [];
for (let r of recipients) {
let recipient;
if (isPossibleNip05(r))
recipient = await $ndk.getUserFromNip05(r);
else
recipient = $ndk.getUser({
npub: r
});
if (!recipient) return alert(`Invalid recipient: ${r}`);
if (alreadyCheckedPublicKeys.includes(recipient.pubkey)) continue;
alreadyCheckedPublicKeys.push(recipient.pubkey);
notesToSend.push(await createSealedLetter(
$ndk.activeUser!,
recipient,
subject,
content,
replyTo,
newStamp
));
realRecipients.push(recipient);
}
notesToSend.push(await createCarbonCopyLetter(
$ndk.activeUser!,
realRecipients,
subject,
content
));
await Promise.all(notesToSend.map(n => n.publish()));
letterSent = Date.now();
} finally {
isSending = false;
}
}
onMount(() => {
$pageTitle = 'New Letter';
$pageIcon = 'proicons:compose';
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('replyTo'))
replyTo = urlParams.get('replyTo');
if (urlParams.has('to'))
recipients.push(urlParams.get('to'));
if (urlParams.has('subject'))
subject = urlParams.get('subject');
if (urlParams.has('content'))
content = urlParams.get('content');
});
</script>
{#if letterSent}
<Notification title="Letter Sent" duration={5000}>
Letter sent to {recipients.join(', ')}.
</Notification>
{/if}
{#if !isSending}
<div class="compose-container">
<div class="compose-fields">
<div class="field">
<label>To:</label>
<div class="to">
{#each recipients as r}
<RecipientChip recipient={r}
onRemove={() => recipients = recipients.filter((recipient) => recipient !== r)} />
{/each}
<input
bind:value={toField}
onkeyup={recepientInput}
placeholder="Enter npub or nip05..."
type="text"
/>
</div>
</div>
<div class="field">
<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 && stamp.length > 0}
<p class="stamp-count">
<Icon icon="fxemoji:stampedenvelope" /> {tokenInfo.amount} sats
</p>
{/if}
{/if}
</div>
<div class="compose-editor">
<textarea bind:value={content} class="editor-content"></textarea>
</div>
<div class="compose-actions">
<button class="send-button" onclick={send}>Send</button>
</div>
</div>
{/if}
<style>
.compose-container {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: var(--spacing-md);
}
.compose-fields {
display: grid;
gap: var(--spacing-sm);
& .field {
position: relative;
display: grid;
grid-template-columns: 80px 1fr;
place-items: end;
align-items: center;
gap: var(--spacing-sm);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
padding: var(--spacing-md);
& .to {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
align-items: center;
width: 100%;
}
& label {
font-weight: bolder;
color: var(--text-secondary);
}
& input {
width: 100%;
flex-basis: 100%;
}
}
}
.compose-editor {
position: relative;
background: var(--editor-bg);
border-radius: 12px;
border: 1px solid var(--input-border);
overflow: hidden;
transition: all 0.3s ease;
&:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--input-focus);
}
& .editor-content {
background: transparent;
resize: none;
border: 0;
width: 100%;
padding: var(--spacing-md);
min-height: 300px;
}
}
.compose-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
@layer components {
input {
flex-grow: 1;
}
.send-button {
background: var(--accent);
color: white;
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
&:hover {
background: var(--accent-light);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
}
}
</style>