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, {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
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