folder management + cleanup
This commit is contained in:
parent
e959809a0e
commit
a97ed1746d
3 changed files with 273 additions and 147 deletions
|
@ -1,14 +1,14 @@
|
|||
import NDK, {
|
||||
NDKEvent,
|
||||
type NDKEventId,
|
||||
NDKKind,
|
||||
NDKPrivateKeySigner,
|
||||
type NDKUser
|
||||
NDKEvent,
|
||||
type NDKEventId,
|
||||
NDKKind,
|
||||
NDKPrivateKeySigner,
|
||||
type NDKUser
|
||||
} from '@nostr-dev-kit/ndk';
|
||||
import {
|
||||
dateFormat as dateFormatStore,
|
||||
ndk as ndkStore,
|
||||
timeFormat as timeFormatStore
|
||||
dateFormat as dateFormatStore,
|
||||
ndk as ndkStore,
|
||||
timeFormat as timeFormatStore
|
||||
} from './stores.svelte';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { Letter } from '$lib/letter';
|
||||
|
@ -23,195 +23,202 @@ dateFormatStore.subscribe((d: string) => (dateFormat = d));
|
|||
timeFormatStore.subscribe((t: string) => (timeFormat = t));
|
||||
|
||||
async function waitForNDK() {
|
||||
if (ndk) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await waitForNDK();
|
||||
if (ndk) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await waitForNDK();
|
||||
}
|
||||
|
||||
export function randomTimeUpTo2DaysInThePast() {
|
||||
const now = Date.now();
|
||||
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);
|
||||
const now = Date.now();
|
||||
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);
|
||||
}
|
||||
|
||||
export async function decryptSealedMessage(message: NDKEvent): Promise<NDKEvent> {
|
||||
await waitForNDK();
|
||||
const sealedMessage = JSON.parse(await ndk.signer!.nip44Decrypt(message.author, message.content));
|
||||
const author = ndk.getUser({ pubkey: sealedMessage.pubkey });
|
||||
const msg = JSON.parse(await ndk.signer!.nip44Decrypt(author, sealedMessage.content));
|
||||
const event = new NDKEvent(ndk, msg);
|
||||
if (event.pubkey === '') event.pubkey = author.pubkey;
|
||||
return event;
|
||||
await waitForNDK();
|
||||
const sealedMessage = JSON.parse(await ndk.signer!.nip44Decrypt(message.author, message.content));
|
||||
const author = ndk.getUser({ pubkey: sealedMessage.pubkey });
|
||||
const msg = JSON.parse(await ndk.signer!.nip44Decrypt(author, sealedMessage.content));
|
||||
const event = new NDKEvent(ndk, msg);
|
||||
if (event.pubkey === '') event.pubkey = author.pubkey;
|
||||
return event;
|
||||
}
|
||||
|
||||
export async function decryptSealedMessageIntoReadableType(
|
||||
encryptedMessage: NDKEvent
|
||||
encryptedMessage: NDKEvent
|
||||
): Promise<Letter | undefined> {
|
||||
await waitForNDK();
|
||||
let rawDecrypted = await decryptSealedMessage(encryptedMessage);
|
||||
switch (rawDecrypted.kind) {
|
||||
case NDKKind.Article:
|
||||
return getLetterFromDecryptedMessage(rawDecrypted, encryptedMessage);
|
||||
}
|
||||
await waitForNDK();
|
||||
let rawDecrypted = await decryptSealedMessage(encryptedMessage);
|
||||
switch (rawDecrypted.kind) {
|
||||
case NDKKind.Article:
|
||||
return getLetterFromDecryptedMessage(rawDecrypted, encryptedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveMessageToFolder(id: NDKEventId, folder: NDKEventId | string) {
|
||||
if (folder === 'sent') throw new Error('Cannot move message to sent folder');
|
||||
await waitForNDK();
|
||||
const user = await ndk.signer!.user();
|
||||
const rawMessage = new NDKEvent();
|
||||
rawMessage.author = user;
|
||||
rawMessage.created_at = Math.ceil(Date.now() / 1000);
|
||||
rawMessage.kind = NDKKind.Label;
|
||||
rawMessage.content = '';
|
||||
rawMessage.tags.push(['label-type', 'letter-to-folder-mapping']);
|
||||
rawMessage.tags.push(['message', id]);
|
||||
rawMessage.tags.push(['folder', folder]);
|
||||
return encryptEventForRecipient(rawMessage, user);
|
||||
if (folder === 'sent') throw new Error('Cannot move message to sent folder');
|
||||
await waitForNDK();
|
||||
const user = await ndk.signer!.user();
|
||||
const rawMessage = new NDKEvent();
|
||||
rawMessage.author = user;
|
||||
rawMessage.created_at = Math.ceil(Date.now() / 1000);
|
||||
rawMessage.kind = NDKKind.Label;
|
||||
rawMessage.content = '';
|
||||
rawMessage.tags.push(['label-type', 'letter-to-folder-mapping']);
|
||||
rawMessage.tags.push(['message', id]);
|
||||
rawMessage.tags.push(['folder', folder]);
|
||||
return encryptEventForRecipient(rawMessage, user);
|
||||
}
|
||||
|
||||
export async function createFolder(name: string, icon: string) {
|
||||
await waitForNDK();
|
||||
const allFolders = await FolderLabel.getAll(ndk);
|
||||
const newFolder = {
|
||||
id: crypto.randomUUID(),
|
||||
name: name,
|
||||
icon: icon
|
||||
};
|
||||
allFolders.push(newFolder);
|
||||
await FolderLabel.save(ndk, allFolders);
|
||||
return newFolder;
|
||||
await waitForNDK();
|
||||
const allFolders = await FolderLabel.getAll(ndk);
|
||||
const newFolder = FolderLabel.fromJSON({
|
||||
id: crypto.randomUUID(),
|
||||
name: name,
|
||||
icon: icon
|
||||
});
|
||||
allFolders.push(newFolder);
|
||||
await FolderLabel.save(ndk, allFolders);
|
||||
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(
|
||||
from: NDKUser,
|
||||
to: NDKUser,
|
||||
subject: string,
|
||||
content: string,
|
||||
replyTo?: string,
|
||||
stamp?: string
|
||||
from: NDKUser,
|
||||
to: NDKUser,
|
||||
subject: string,
|
||||
content: string,
|
||||
replyTo?: string,
|
||||
stamp?: string
|
||||
) {
|
||||
await waitForNDK();
|
||||
const rawMessage = new NDKEvent();
|
||||
rawMessage.author = from;
|
||||
rawMessage.created_at = Math.ceil(Date.now() / 1000);
|
||||
rawMessage.kind = NDKKind.Article;
|
||||
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);
|
||||
await waitForNDK();
|
||||
const rawMessage = new NDKEvent();
|
||||
rawMessage.author = from;
|
||||
rawMessage.created_at = Math.ceil(Date.now() / 1000);
|
||||
rawMessage.kind = NDKKind.Article;
|
||||
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);
|
||||
}
|
||||
|
||||
export async function createCarbonCopyLetter(
|
||||
sender: NDKUser,
|
||||
recipients: NDKUser[],
|
||||
subject: string,
|
||||
content: string,
|
||||
replyTo?: string
|
||||
sender: NDKUser,
|
||||
recipients: NDKUser[],
|
||||
subject: string,
|
||||
content: string,
|
||||
replyTo?: string
|
||||
) {
|
||||
await waitForNDK();
|
||||
const rawMessage = new NDKEvent();
|
||||
rawMessage.author = sender;
|
||||
rawMessage.created_at = Math.ceil(Date.now() / 1000);
|
||||
rawMessage.kind = NDKKind.Article;
|
||||
rawMessage.content = content;
|
||||
rawMessage.tags.push(['subject', subject]);
|
||||
if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']);
|
||||
for (const recipient of recipients) rawMessage.tags.push(['p', recipient.pubkey]);
|
||||
return encryptEventForRecipient(rawMessage, sender);
|
||||
await waitForNDK();
|
||||
const rawMessage = new NDKEvent();
|
||||
rawMessage.author = sender;
|
||||
rawMessage.created_at = Math.ceil(Date.now() / 1000);
|
||||
rawMessage.kind = NDKKind.Article;
|
||||
rawMessage.content = content;
|
||||
rawMessage.tags.push(['subject', subject]);
|
||||
if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']);
|
||||
for (const recipient of recipients) rawMessage.tags.push(['p', recipient.pubkey]);
|
||||
return encryptEventForRecipient(rawMessage, sender);
|
||||
}
|
||||
|
||||
export async function encryptEventForRecipient(
|
||||
event: NDKEvent,
|
||||
recipient: NDKUser
|
||||
event: NDKEvent,
|
||||
recipient: NDKUser
|
||||
): Promise<NDKEvent> {
|
||||
await waitForNDK();
|
||||
let randomKey = generateSecretKey();
|
||||
const randomKeySinger = new NDKPrivateKeySigner(randomKey);
|
||||
const seal = new NDKEvent();
|
||||
seal.pubkey = recipient.pubkey;
|
||||
seal.kind = 13;
|
||||
seal.content = await ndk.signer!.nip44Encrypt(recipient, JSON.stringify(event));
|
||||
seal.created_at = randomTimeUpTo2DaysInThePast();
|
||||
await seal.sign(ndk.signer);
|
||||
const giftWrap = new NDKEvent();
|
||||
giftWrap.kind = 1059;
|
||||
giftWrap.created_at = randomTimeUpTo2DaysInThePast();
|
||||
giftWrap.content = await randomKeySinger.nip44Encrypt(recipient, JSON.stringify(seal));
|
||||
giftWrap.tags.push(['p', recipient.pubkey]);
|
||||
await giftWrap.sign(randomKeySinger);
|
||||
giftWrap.ndk = ndk;
|
||||
return giftWrap;
|
||||
await waitForNDK();
|
||||
let randomKey = generateSecretKey();
|
||||
const randomKeySinger = new NDKPrivateKeySigner(randomKey);
|
||||
const seal = new NDKEvent();
|
||||
seal.pubkey = recipient.pubkey;
|
||||
seal.kind = 13;
|
||||
seal.content = await ndk.signer!.nip44Encrypt(recipient, JSON.stringify(event));
|
||||
seal.created_at = randomTimeUpTo2DaysInThePast();
|
||||
await seal.sign(ndk.signer);
|
||||
const giftWrap = new NDKEvent();
|
||||
giftWrap.kind = 1059;
|
||||
giftWrap.created_at = randomTimeUpTo2DaysInThePast();
|
||||
giftWrap.content = await randomKeySinger.nip44Encrypt(recipient, JSON.stringify(seal));
|
||||
giftWrap.tags.push(['p', recipient.pubkey]);
|
||||
await giftWrap.sign(randomKeySinger);
|
||||
giftWrap.ndk = ndk;
|
||||
return giftWrap;
|
||||
}
|
||||
|
||||
export function isValidNip05(nip05: string): boolean {
|
||||
let parts = nip05.split('@');
|
||||
if (parts.length !== 2) return false;
|
||||
let domain = parts[1];
|
||||
return domain.includes('.');
|
||||
let parts = nip05.split('@');
|
||||
if (parts.length !== 2) return false;
|
||||
let domain = parts[1];
|
||||
return domain.includes('.');
|
||||
}
|
||||
|
||||
let letterCache: {
|
||||
[id: string]: Letter;
|
||||
[id: string]: Letter;
|
||||
} = $state({});
|
||||
|
||||
export async function getLetterFromDecryptedMessage(
|
||||
msg: NDKEvent,
|
||||
encryptedMessage: NDKEvent
|
||||
msg: NDKEvent,
|
||||
encryptedMessage: NDKEvent
|
||||
): Promise<Letter | undefined> {
|
||||
if (letterCache[encryptedMessage.id]) return letterCache[encryptedMessage.id];
|
||||
await waitForNDK();
|
||||
if (msg.kind != NDKKind.Article) return;
|
||||
letterCache[encryptedMessage.id] = await Letter.fromDecryptedMessage(msg, encryptedMessage, ndk);
|
||||
return letterCache[encryptedMessage.id];
|
||||
if (letterCache[encryptedMessage.id]) return letterCache[encryptedMessage.id];
|
||||
await waitForNDK();
|
||||
if (msg.kind != NDKKind.Article) return;
|
||||
letterCache[encryptedMessage.id] = await Letter.fromDecryptedMessage(msg, encryptedMessage, ndk);
|
||||
return letterCache[encryptedMessage.id];
|
||||
}
|
||||
|
||||
export function getReadableDate(date: Date): string {
|
||||
const map = {
|
||||
y: date.getFullYear(),
|
||||
m: String(date.getMonth() + 1).padStart(2, '0'),
|
||||
d: String(date.getDate()).padStart(2, '0')
|
||||
};
|
||||
return dateFormat.replace(/[ymd]/g, (char) => map[char]);
|
||||
const map = {
|
||||
y: date.getFullYear(),
|
||||
m: String(date.getMonth() + 1).padStart(2, '0'),
|
||||
d: String(date.getDate()).padStart(2, '0')
|
||||
};
|
||||
return dateFormat.replace(/[ymd]/g, (char) => map[char]);
|
||||
}
|
||||
|
||||
export function getReadableTime(date: Date) {
|
||||
let hours = date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
let hours = date.getHours();
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
const use12Hour = timeFormat.includes('a');
|
||||
let period = '';
|
||||
const use12Hour = timeFormat.includes('a');
|
||||
let period = '';
|
||||
|
||||
if (use12Hour) {
|
||||
period = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
}
|
||||
if (use12Hour) {
|
||||
period = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12;
|
||||
hours = hours ? hours : 12;
|
||||
}
|
||||
|
||||
const map = {
|
||||
h: String(hours).padStart(2, '0'),
|
||||
m: minutes,
|
||||
s: seconds,
|
||||
a: period
|
||||
};
|
||||
const map = {
|
||||
h: String(hours).padStart(2, '0'),
|
||||
m: minutes,
|
||||
s: seconds,
|
||||
a: period
|
||||
};
|
||||
|
||||
return timeFormat.replace(/[hmsa]/g, (char) => map[char]);
|
||||
return timeFormat.replace(/[hmsa]/g, (char) => map[char]);
|
||||
}
|
||||
|
||||
export function appendToBody(node: HTMLElement) {
|
||||
document.body.appendChild(node);
|
||||
document.body.appendChild(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
destroy() {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
import Select from '../../components/Select.svelte';
|
||||
import SettingsLine from './SettingsLine.svelte';
|
||||
import SubscriptionSettings from './SubscriptionSettings.svelte';
|
||||
import FolderManagement from './FolderManagement.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
$pageTitle = 'Settings';
|
||||
|
@ -30,6 +31,10 @@
|
|||
<SubscriptionSettings />
|
||||
</SettingsLine>
|
||||
|
||||
<SettingsLine title="Folder Management">
|
||||
<FolderManagement />
|
||||
</SettingsLine>
|
||||
|
||||
<SettingsLine title="Sorting and Grouping">
|
||||
<Checkbox bind:checked={$groupByStamps} label="Group by stamps" />
|
||||
<Select
|
||||
|
|
114
src/routes/settings/FolderManagement.svelte
Normal file
114
src/routes/settings/FolderManagement.svelte
Normal 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>
|
Loading…
Reference in a new issue