folder management + cleanup

This commit is contained in:
Danny Morabito 2024-12-04 17:08:17 +01:00
parent e959809a0e
commit a97ed1746d
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
3 changed files with 273 additions and 147 deletions

View file

@ -1,14 +1,14 @@
import NDK, { import NDK, {
NDKEvent, NDKEvent,
type NDKEventId, type NDKEventId,
NDKKind, NDKKind,
NDKPrivateKeySigner, NDKPrivateKeySigner,
type NDKUser type NDKUser
} from '@nostr-dev-kit/ndk'; } from '@nostr-dev-kit/ndk';
import { import {
dateFormat as dateFormatStore, dateFormat as dateFormatStore,
ndk as ndkStore, ndk as ndkStore,
timeFormat as timeFormatStore timeFormat as timeFormatStore
} from './stores.svelte'; } from './stores.svelte';
import { generateSecretKey } from 'nostr-tools'; import { generateSecretKey } from 'nostr-tools';
import { Letter } from '$lib/letter'; import { Letter } from '$lib/letter';
@ -23,195 +23,202 @@ dateFormatStore.subscribe((d: string) => (dateFormat = d));
timeFormatStore.subscribe((t: string) => (timeFormat = t)); timeFormatStore.subscribe((t: string) => (timeFormat = t));
async function waitForNDK() { async function waitForNDK() {
if (ndk) return; if (ndk) return;
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForNDK(); await waitForNDK();
} }
export function randomTimeUpTo2DaysInThePast() { export function randomTimeUpTo2DaysInThePast() {
const now = Date.now(); const now = Date.now();
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000 - 3600 * 1000; // 1 hour buffer in case of clock skew const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000 - 3600 * 1000; // 1 hour buffer in case of clock skew
return Math.floor((Math.floor(Math.random() * (now - twoDaysAgo)) + twoDaysAgo) / 1000); return Math.floor((Math.floor(Math.random() * (now - twoDaysAgo)) + twoDaysAgo) / 1000);
} }
export async function decryptSealedMessage(message: NDKEvent): Promise<NDKEvent> { export async function decryptSealedMessage(message: NDKEvent): Promise<NDKEvent> {
await waitForNDK(); await waitForNDK();
const sealedMessage = JSON.parse(await ndk.signer!.nip44Decrypt(message.author, message.content)); const sealedMessage = JSON.parse(await ndk.signer!.nip44Decrypt(message.author, message.content));
const author = ndk.getUser({ pubkey: sealedMessage.pubkey }); const author = ndk.getUser({ pubkey: sealedMessage.pubkey });
const msg = JSON.parse(await ndk.signer!.nip44Decrypt(author, sealedMessage.content)); const msg = JSON.parse(await ndk.signer!.nip44Decrypt(author, sealedMessage.content));
const event = new NDKEvent(ndk, msg); const event = new NDKEvent(ndk, msg);
if (event.pubkey === '') event.pubkey = author.pubkey; if (event.pubkey === '') event.pubkey = author.pubkey;
return event; return event;
} }
export async function decryptSealedMessageIntoReadableType( export async function decryptSealedMessageIntoReadableType(
encryptedMessage: NDKEvent encryptedMessage: NDKEvent
): Promise<Letter | undefined> { ): Promise<Letter | undefined> {
await waitForNDK(); await waitForNDK();
let rawDecrypted = await decryptSealedMessage(encryptedMessage); let rawDecrypted = await decryptSealedMessage(encryptedMessage);
switch (rawDecrypted.kind) { switch (rawDecrypted.kind) {
case NDKKind.Article: case NDKKind.Article:
return getLetterFromDecryptedMessage(rawDecrypted, encryptedMessage); return getLetterFromDecryptedMessage(rawDecrypted, encryptedMessage);
} }
} }
export async function moveMessageToFolder(id: NDKEventId, folder: NDKEventId | string) { export async function moveMessageToFolder(id: NDKEventId, folder: NDKEventId | string) {
if (folder === 'sent') throw new Error('Cannot move message to sent folder'); if (folder === 'sent') throw new Error('Cannot move message to sent folder');
await waitForNDK(); await waitForNDK();
const user = await ndk.signer!.user(); const user = await ndk.signer!.user();
const rawMessage = new NDKEvent(); const rawMessage = new NDKEvent();
rawMessage.author = user; rawMessage.author = user;
rawMessage.created_at = Math.ceil(Date.now() / 1000); rawMessage.created_at = Math.ceil(Date.now() / 1000);
rawMessage.kind = NDKKind.Label; rawMessage.kind = NDKKind.Label;
rawMessage.content = ''; rawMessage.content = '';
rawMessage.tags.push(['label-type', 'letter-to-folder-mapping']); rawMessage.tags.push(['label-type', 'letter-to-folder-mapping']);
rawMessage.tags.push(['message', id]); rawMessage.tags.push(['message', id]);
rawMessage.tags.push(['folder', folder]); rawMessage.tags.push(['folder', folder]);
return encryptEventForRecipient(rawMessage, user); return encryptEventForRecipient(rawMessage, user);
} }
export async function createFolder(name: string, icon: string) { export async function createFolder(name: string, icon: string) {
await waitForNDK(); await waitForNDK();
const allFolders = await FolderLabel.getAll(ndk); const allFolders = await FolderLabel.getAll(ndk);
const newFolder = { const newFolder = FolderLabel.fromJSON({
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: name, name: name,
icon: icon icon: icon
}; });
allFolders.push(newFolder); allFolders.push(newFolder);
await FolderLabel.save(ndk, allFolders); await FolderLabel.save(ndk, allFolders);
return newFolder; return newFolder;
}
export async function deleteFolder(id: string) {
await waitForNDK();
const allFolders = await FolderLabel.getAll(ndk);
allFolders.splice(allFolders.findIndex(f => f.id === id), 1);
await FolderLabel.save(ndk, allFolders);
} }
export async function createSealedLetter( export async function createSealedLetter(
from: NDKUser, from: NDKUser,
to: NDKUser, to: NDKUser,
subject: string, subject: string,
content: string, content: string,
replyTo?: string, replyTo?: string,
stamp?: string stamp?: string
) { ) {
await waitForNDK(); await waitForNDK();
const rawMessage = new NDKEvent(); const rawMessage = new NDKEvent();
rawMessage.author = from; rawMessage.author = from;
rawMessage.created_at = Math.ceil(Date.now() / 1000); rawMessage.created_at = Math.ceil(Date.now() / 1000);
rawMessage.kind = NDKKind.Article; rawMessage.kind = NDKKind.Article;
rawMessage.content = content; rawMessage.content = content;
rawMessage.tags.push(['subject', subject]); rawMessage.tags.push(['subject', subject]);
if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']); if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']);
if (stamp) { if (stamp) {
const encryptedStamp = await ndk.signer!.nip44Encrypt(to, stamp); const encryptedStamp = await ndk.signer!.nip44Encrypt(to, stamp);
rawMessage.tags.push(['stamp', encryptedStamp]); rawMessage.tags.push(['stamp', encryptedStamp]);
} }
rawMessage.tags.push(['p', to.pubkey]); rawMessage.tags.push(['p', to.pubkey]);
return encryptEventForRecipient(rawMessage, to); return encryptEventForRecipient(rawMessage, to);
} }
export async function createCarbonCopyLetter( export async function createCarbonCopyLetter(
sender: NDKUser, sender: NDKUser,
recipients: NDKUser[], recipients: NDKUser[],
subject: string, subject: string,
content: string, content: string,
replyTo?: string replyTo?: string
) { ) {
await waitForNDK(); await waitForNDK();
const rawMessage = new NDKEvent(); const rawMessage = new NDKEvent();
rawMessage.author = sender; rawMessage.author = sender;
rawMessage.created_at = Math.ceil(Date.now() / 1000); rawMessage.created_at = Math.ceil(Date.now() / 1000);
rawMessage.kind = NDKKind.Article; rawMessage.kind = NDKKind.Article;
rawMessage.content = content; rawMessage.content = content;
rawMessage.tags.push(['subject', subject]); rawMessage.tags.push(['subject', subject]);
if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']); if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']);
for (const recipient of recipients) rawMessage.tags.push(['p', recipient.pubkey]); for (const recipient of recipients) rawMessage.tags.push(['p', recipient.pubkey]);
return encryptEventForRecipient(rawMessage, sender); return encryptEventForRecipient(rawMessage, sender);
} }
export async function encryptEventForRecipient( export async function encryptEventForRecipient(
event: NDKEvent, event: NDKEvent,
recipient: NDKUser recipient: NDKUser
): Promise<NDKEvent> { ): Promise<NDKEvent> {
await waitForNDK(); await waitForNDK();
let randomKey = generateSecretKey(); let randomKey = generateSecretKey();
const randomKeySinger = new NDKPrivateKeySigner(randomKey); const randomKeySinger = new NDKPrivateKeySigner(randomKey);
const seal = new NDKEvent(); const seal = new NDKEvent();
seal.pubkey = recipient.pubkey; seal.pubkey = recipient.pubkey;
seal.kind = 13; seal.kind = 13;
seal.content = await ndk.signer!.nip44Encrypt(recipient, JSON.stringify(event)); seal.content = await ndk.signer!.nip44Encrypt(recipient, JSON.stringify(event));
seal.created_at = randomTimeUpTo2DaysInThePast(); seal.created_at = randomTimeUpTo2DaysInThePast();
await seal.sign(ndk.signer); await seal.sign(ndk.signer);
const giftWrap = new NDKEvent(); const giftWrap = new NDKEvent();
giftWrap.kind = 1059; giftWrap.kind = 1059;
giftWrap.created_at = randomTimeUpTo2DaysInThePast(); giftWrap.created_at = randomTimeUpTo2DaysInThePast();
giftWrap.content = await randomKeySinger.nip44Encrypt(recipient, JSON.stringify(seal)); giftWrap.content = await randomKeySinger.nip44Encrypt(recipient, JSON.stringify(seal));
giftWrap.tags.push(['p', recipient.pubkey]); giftWrap.tags.push(['p', recipient.pubkey]);
await giftWrap.sign(randomKeySinger); await giftWrap.sign(randomKeySinger);
giftWrap.ndk = ndk; giftWrap.ndk = ndk;
return giftWrap; return giftWrap;
} }
export function isValidNip05(nip05: string): boolean { export function isValidNip05(nip05: string): boolean {
let parts = nip05.split('@'); let parts = nip05.split('@');
if (parts.length !== 2) return false; if (parts.length !== 2) return false;
let domain = parts[1]; let domain = parts[1];
return domain.includes('.'); return domain.includes('.');
} }
let letterCache: { let letterCache: {
[id: string]: Letter; [id: string]: Letter;
} = $state({}); } = $state({});
export async function getLetterFromDecryptedMessage( export async function getLetterFromDecryptedMessage(
msg: NDKEvent, msg: NDKEvent,
encryptedMessage: NDKEvent encryptedMessage: NDKEvent
): Promise<Letter | undefined> { ): Promise<Letter | undefined> {
if (letterCache[encryptedMessage.id]) return letterCache[encryptedMessage.id]; if (letterCache[encryptedMessage.id]) return letterCache[encryptedMessage.id];
await waitForNDK(); await waitForNDK();
if (msg.kind != NDKKind.Article) return; if (msg.kind != NDKKind.Article) return;
letterCache[encryptedMessage.id] = await Letter.fromDecryptedMessage(msg, encryptedMessage, ndk); letterCache[encryptedMessage.id] = await Letter.fromDecryptedMessage(msg, encryptedMessage, ndk);
return letterCache[encryptedMessage.id]; return letterCache[encryptedMessage.id];
} }
export function getReadableDate(date: Date): string { export function getReadableDate(date: Date): string {
const map = { const map = {
y: date.getFullYear(), y: date.getFullYear(),
m: String(date.getMonth() + 1).padStart(2, '0'), m: String(date.getMonth() + 1).padStart(2, '0'),
d: String(date.getDate()).padStart(2, '0') d: String(date.getDate()).padStart(2, '0')
}; };
return dateFormat.replace(/[ymd]/g, (char) => map[char]); return dateFormat.replace(/[ymd]/g, (char) => map[char]);
} }
export function getReadableTime(date: Date) { export function getReadableTime(date: Date) {
let hours = date.getHours(); let hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0');
const use12Hour = timeFormat.includes('a'); const use12Hour = timeFormat.includes('a');
let period = ''; let period = '';
if (use12Hour) { if (use12Hour) {
period = hours >= 12 ? 'PM' : 'AM'; period = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12; hours = hours % 12;
hours = hours ? hours : 12; hours = hours ? hours : 12;
} }
const map = { const map = {
h: String(hours).padStart(2, '0'), h: String(hours).padStart(2, '0'),
m: minutes, m: minutes,
s: seconds, s: seconds,
a: period a: period
}; };
return timeFormat.replace(/[hmsa]/g, (char) => map[char]); return timeFormat.replace(/[hmsa]/g, (char) => map[char]);
} }
export function appendToBody(node: HTMLElement) { export function appendToBody(node: HTMLElement) {
document.body.appendChild(node); document.body.appendChild(node);
return { return {
destroy() { destroy() {
if (node.parentNode) { if (node.parentNode) {
node.parentNode.removeChild(node); node.parentNode.removeChild(node);
} }
} }
}; };
} }

View file

@ -15,6 +15,7 @@
import Select from '../../components/Select.svelte'; import Select from '../../components/Select.svelte';
import SettingsLine from './SettingsLine.svelte'; import SettingsLine from './SettingsLine.svelte';
import SubscriptionSettings from './SubscriptionSettings.svelte'; import SubscriptionSettings from './SubscriptionSettings.svelte';
import FolderManagement from './FolderManagement.svelte';
onMount(async () => { onMount(async () => {
$pageTitle = 'Settings'; $pageTitle = 'Settings';
@ -30,6 +31,10 @@
<SubscriptionSettings /> <SubscriptionSettings />
</SettingsLine> </SettingsLine>
<SettingsLine title="Folder Management">
<FolderManagement />
</SettingsLine>
<SettingsLine title="Sorting and Grouping"> <SettingsLine title="Sorting and Grouping">
<Checkbox bind:checked={$groupByStamps} label="Group by stamps" /> <Checkbox bind:checked={$groupByStamps} label="Group by stamps" />
<Select <Select

View file

@ -0,0 +1,114 @@
<script lang="ts">
import { FolderLabel } from '$lib/folderLabel';
import { ndk } from '$lib/stores.svelte';
import { createFolder, deleteFolder } from '$lib/utils.svelte';
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import IconButton from '../../components/IconButton.svelte';
let loading = $state(true);
let folders = $state<FolderLabel[]>([]);
let newFolderName = $state('');
async function loadFolders() {
loading = true;
folders = await FolderLabel.getAll($ndk);
loading = false;
}
async function handleDelete(folderId: string) {
loading = true;
await deleteFolder(folderId);
await loadFolders();
}
async function handleAdd() {
if (!newFolderName.trim()) return;
loading = true;
await createFolder(newFolderName, '');
newFolderName = '';
await loadFolders();
}
onMount(loadFolders);
</script>
{#if loading}
<div class="loading">
<Icon icon="eos-icons:loading" width="5em" />
</div>
{:else}
<table>
<tbody>
{#each folders as folder}
<tr>
<td class="icon-cell">
<Icon icon={folder.icon} width="1.5em" />
</td>
<td class="name-cell">{folder.name}</td>
<td class="action-cell">
<button onclick={() => handleDelete(folder.id)}>
<Icon icon="icon-park-twotone:delete" width="1.5em" />
</button>
</td>
</tr>
{/each}
<tr class="add-folder">
<td class="input-container">
<input type="text" bind:value={newFolderName} placeholder="New folder name" />
</td>
<td class="button-cell">
<IconButton icon="material-symbols:add" text="Add" on:click={handleAdd} />
</td>
</tr>
</tbody>
</table>
{/if}
<style>
.loading {
display: flex;
justify-content: center;
align-items: center;
}
table {
width: 100%;
}
tr {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
}
.icon-cell {
display: flex;
width: 1.5em;
height: 1.5em;
}
.name-cell {
flex: 1;
}
.action-cell {
display: flex;
justify-content: flex-end;
}
.add-folder {
margin-top: 1rem;
}
.input-container {
flex: 1;
}
.button-cell {
display: flex;
justify-content: flex-end;
padding-left: 1rem;
}
</style>