update ui for consistent in light and dark mode

This commit is contained in:
reya 2023-10-24 21:15:59 +07:00
parent 854a47f266
commit 507628bcaa
19 changed files with 788 additions and 442 deletions

View File

@ -19,11 +19,10 @@
}, },
"dependencies": { "dependencies": {
"@evilmartians/harmony": "^1.1.0", "@evilmartians/harmony": "^1.1.0",
"@formkit/auto-animate": "^0.8.0",
"@getalby/sdk": "^2.5.0", "@getalby/sdk": "^2.5.0",
"@nostr-dev-kit/ndk": "^2.0.3", "@nostr-dev-kit/ndk": "^2.0.3",
"@nostr-dev-kit/ndk-cache-dexie": "^2.0.3", "@nostr-dev-kit/ndk-cache-dexie": "^2.0.3",
"@nostr-fetch/adapter-ndk": "^0.12.2", "@nostr-fetch/adapter-ndk": "^0.13.0",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",
@ -33,7 +32,7 @@
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-toolbar": "^1.0.4", "@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^4.36.1", "@tanstack/react-query": "4.36.1",
"@tauri-apps/api": "2.0.0-alpha.8", "@tauri-apps/api": "2.0.0-alpha.8",
"@tauri-apps/cli": "2.0.0-alpha.15", "@tauri-apps/cli": "2.0.0-alpha.15",
"@tauri-apps/plugin-app": "2.0.0-alpha.1", "@tauri-apps/plugin-app": "2.0.0-alpha.1",
@ -81,7 +80,6 @@
"react-markdown": "^8.0.7", "react-markdown": "^8.0.7",
"react-router-dom": "^6.17.0", "react-router-dom": "^6.17.0",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"react-xarrows": "^2.0.2",
"reactflow": "^11.9.4", "reactflow": "^11.9.4",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sonner": "^1.0.3", "sonner": "^1.0.3",

View File

@ -8,9 +8,6 @@ dependencies:
'@evilmartians/harmony': '@evilmartians/harmony':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0 version: 1.1.0
'@formkit/auto-animate':
specifier: ^0.8.0
version: 0.8.0
'@getalby/sdk': '@getalby/sdk':
specifier: ^2.5.0 specifier: ^2.5.0
version: 2.5.0 version: 2.5.0
@ -21,8 +18,8 @@ dependencies:
specifier: ^2.0.3 specifier: ^2.0.3
version: 2.0.3(typescript@5.2.2) version: 2.0.3(typescript@5.2.2)
'@nostr-fetch/adapter-ndk': '@nostr-fetch/adapter-ndk':
specifier: ^0.12.2 specifier: ^0.13.0
version: 0.12.2(@nostr-dev-kit/ndk@2.0.3)(nostr-fetch@0.13.0) version: 0.13.0(@nostr-dev-kit/ndk@2.0.3)(nostr-fetch@0.13.0)
'@radix-ui/react-alert-dialog': '@radix-ui/react-alert-dialog':
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0) version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0)
@ -51,7 +48,7 @@ dependencies:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0) version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^4.36.1 specifier: 4.36.1
version: 4.36.1(react-dom@18.2.0)(react@18.2.0) version: 4.36.1(react-dom@18.2.0)(react@18.2.0)
'@tauri-apps/api': '@tauri-apps/api':
specifier: 2.0.0-alpha.8 specifier: 2.0.0-alpha.8
@ -194,9 +191,6 @@ dependencies:
react-string-replace: react-string-replace:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
react-xarrows:
specifier: ^2.0.2
version: 2.0.2(react@18.2.0)
reactflow: reactflow:
specifier: ^11.9.4 specifier: ^11.9.4
version: 11.9.4(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0) version: 11.9.4(@types/react@18.2.29)(react-dom@18.2.0)(react@18.2.0)
@ -865,10 +859,6 @@ packages:
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
dev: false dev: false
/@formkit/auto-animate@0.8.0:
resolution: {integrity: sha512-G8f7489ka0mWyi+1IEZT+xgIwcpWtRMmE2x+IrVoQ+KM1cP6VDj/TbujZjwxdb0P8w8b16/qBfViRmydbYHwMw==}
dev: false
/@getalby/sdk@2.5.0: /@getalby/sdk@2.5.0:
resolution: {integrity: sha512-MRLgI6WxCCLgrar+qDqm/UhKs+V6yXzNm4y1bJRAuN72nkKT+TjTJHCmk9GjTngR3FrOfLbeMsPwBxCmbvfrLQ==} resolution: {integrity: sha512-MRLgI6WxCCLgrar+qDqm/UhKs+V6yXzNm4y1bJRAuN72nkKT+TjTJHCmk9GjTngR3FrOfLbeMsPwBxCmbvfrLQ==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -1003,24 +993,17 @@ packages:
- typescript - typescript
dev: false dev: false
/@nostr-fetch/adapter-ndk@0.12.2(@nostr-dev-kit/ndk@2.0.3)(nostr-fetch@0.13.0): /@nostr-fetch/adapter-ndk@0.13.0(@nostr-dev-kit/ndk@2.0.3)(nostr-fetch@0.13.0):
resolution: {integrity: sha512-+7EVuxS5DDZvNo6qbfFp7xRHwIyjyi36hYkiQFDjbQ4gX5LKo9RIPB1P+1XGkOSDFshypTbovZCaFunscJ/zhQ==} resolution: {integrity: sha512-pTpAmwdaDUymdaVbYDoBV0A+LvOj6VIQqhrcfsWlr2+O4vbOy5DLqE3189xFZhkW0I+eT9m18H9YxbHUZS85hw==}
peerDependencies: peerDependencies:
'@nostr-dev-kit/ndk': ^0.7.5 '@nostr-dev-kit/ndk': ^0.8.4
nostr-fetch: ^0.12.2 nostr-fetch: ^0.13.0
dependencies: dependencies:
'@nostr-dev-kit/ndk': 2.0.3(typescript@5.2.2) '@nostr-dev-kit/ndk': 2.0.3(typescript@5.2.2)
'@nostr-fetch/kernel': 0.12.2 '@nostr-fetch/kernel': 0.13.0
nostr-fetch: 0.13.0 nostr-fetch: 0.13.0
dev: false dev: false
/@nostr-fetch/kernel@0.12.2:
resolution: {integrity: sha512-ja7StOV33NmdtAMGfQIS0/R0dAkLRm3QxN6u/YAQdp5mXER4BYxiQKxUS/dCoTCSX986MH2zp9Fm0f76u4VaNQ==}
dependencies:
'@noble/curves': 1.2.0
'@noble/hashes': 1.3.2
dev: false
/@nostr-fetch/kernel@0.13.0: /@nostr-fetch/kernel@0.13.0:
resolution: {integrity: sha512-KoF6pCezZvGP9ZQo23Ib4UDwJRd08xqVeVTQ44Z/b+gxBBrZCR0lnwOPS6KYBvdOfduynQEryNt5if5ZLxpRlg==} resolution: {integrity: sha512-KoF6pCezZvGP9ZQo23Ib4UDwJRd08xqVeVTQ44Z/b+gxBBrZCR0lnwOPS6KYBvdOfduynQEryNt5if5ZLxpRlg==}
dependencies: dependencies:
@ -4832,6 +4815,7 @@ packages:
/lodash@4.17.21: /lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: true
/log-update@5.0.1: /log-update@5.0.1:
resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==}
@ -6094,17 +6078,6 @@ packages:
tslib: 2.6.2 tslib: 2.6.2
dev: false dev: false
/react-xarrows@2.0.2(react@18.2.0):
resolution: {integrity: sha512-tDlAqaxHNmy0vegW/6NdhoWyXJq1LANX/WUAlHyzoHe9BwFVnJPPDghmDjYeVr7XWFmBrVTUrHsrW7GKYI6HtQ==}
peerDependencies:
react: '>=16.8.0'
dependencies:
'@types/prop-types': 15.7.9
lodash: 4.17.21
prop-types: 15.8.1
react: 18.2.0
dev: false
/react@18.2.0: /react@18.2.0:
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}

View File

@ -5,7 +5,7 @@
{ {
"width": 1080, "width": 1080,
"height": 800, "height": 800,
"minWidth": 1080, "minWidth": 560,
"minHeight": 800, "minHeight": 800,
"resizable": true, "resizable": true,
"title": "Lume", "title": "Lume",

View File

@ -5,7 +5,7 @@
{ {
"width": 1080, "width": 1080,
"height": 800, "height": 800,
"minWidth": 1080, "minWidth": 560,
"minHeight": 800, "minHeight": 800,
"resizable": true, "resizable": true,
"title": "Lume", "title": "Lume",

View File

@ -5,7 +5,7 @@
{ {
"width": 1080, "width": 1080,
"height": 800, "height": 800,
"minWidth": 1080, "minWidth": 560,
"minHeight": 800, "minHeight": 800,
"resizable": true, "resizable": true,
"title": "Lume", "title": "Lume",

View File

@ -52,6 +52,8 @@ export function CreateAccountScreen() {
name: data.name, name: data.name,
display_name: data.name, display_name: data.name,
bio: data.about, bio: data.about,
picture: picture,
avatar: picture,
}; };
const userPrivkey = generatePrivateKey(); const userPrivkey = generatePrivateKey();
@ -105,7 +107,7 @@ export function CreateAccountScreen() {
if (filePath) { if (filePath) {
await writeTextFile( await writeTextFile(
filePath, filePath,
`Generated by Lume (lume.nu)\nPublic key: ${keys.npub}\nPrivate key: ${keys.nsec}` `Nostr account, generated by Lume (lume.nu)\nPublic key: ${keys.npub}\nPrivate key: ${keys.nsec}`
); );
setDownloaded(true); setDownloaded(true);

View File

@ -1,150 +0,0 @@
import { webln } from '@getalby/sdk';
import * as Dialog from '@radix-ui/react-dialog';
import { message } from '@tauri-apps/plugin-dialog';
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import {
AlbyIcon,
ArrowRightCircleIcon,
CancelIcon,
CheckCircleIcon,
LoaderIcon,
} from '@shared/icons';
export function NWCAlby() {
const { db } = useStorage();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const initAlby = async () => {
try {
setIsloading(true);
const provider = webln.NostrWebLNProvider.withNewSecret();
const walletConnectURL = provider.getNostrWalletConnectUrl(true);
// get auth url
const authURL = provider.getAuthorizationUrl({ name: 'Lume' });
// open auth window
/*
const webview = new WebviewWindow('alby', {
title: 'Connect Alby',
url: authURL.href,
center: true,
width: 400,
height: 650,
});
webview.listen('tauri://close-requested', async () => {
await db.secureSave('nwc', walletConnectURL);
setIsConnected(true);
setIsloading(false);
});
*/
} catch (e) {
setIsloading(false);
await message(e.toString(), { title: 'Connect Alby', type: 'error' });
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-neutral-200">
<AlbyIcon className="h-8 w-8" />
</div>
<div>
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">Alby</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Require alby account
</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-lg bg-neutral-300 px-3 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
>
Connect
</button>
</Dialog.Trigger>
</div>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-neutral-400 dark:bg-neutral-600">
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-white/10 bg-white/5 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Alby integration (Beta)
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />
</Dialog.Close>
</div>
</div>
</div>
<div className="flex flex-col gap-3 px-5 py-5">
<div className="relative flex h-40 items-center justify-center gap-4">
<div className="inline-flex h-16 w-16 items-end justify-center rounded-lg bg-black pb-2">
<img src="/lume.png" className="w-1/3" alt="Lume Logo" />
</div>
<div className="w-20 border border-dashed border-white/5" />
<div className="inline-flex h-16 w-16 items-center justify-center rounded-lg bg-white">
<AlbyIcon className="h-8 w-8" />
</div>
{isConnected ? (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<CheckCircleIcon className="h-5 w-5 text-green-500" />
</div>
) : null}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
When you click &quot;Connect&quot;, a new window will open and you need
to click the &quot;Connect Wallet&quot; button to grant Lume permission
to integrate with your Alby account.
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
All information will be encrypted and stored on the local machine.
</p>
</div>
<button
type="button"
onClick={() => initAlby()}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : isConnected ? (
<>
<span className="w-5" />
<span>Connected</span>
<CheckCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -0,0 +1,66 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
export function NWCForm({ setWalletConnectURL }) {
const { db } = useStorage();
const [uri, setUri] = useState('');
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
setLoading(true);
if (!uri.startsWith('nostr+walletconnect:')) {
toast.error(
'Connect URI is required and must start with format nostr+walletconnect:, please check again'
);
setLoading(false);
return;
}
const uriObj = new URL(uri);
const params = new URLSearchParams(uriObj.search);
if (params.has('relay') && params.has('secret')) {
await db.secureSave(`${db.account.pubkey}-nwc`, uri);
setWalletConnectURL(uri);
setLoading(false);
} else {
setLoading(false);
toast.error('Connect URI is not valid, please check again');
return;
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="flex flex-col gap-1.5">
<textarea
name="walletConnectURL"
value={uri}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={false}
onChange={(e) => setUri(e.target.value)}
placeholder="nostr+walletconnect://"
className="h-40 w-full resize-none rounded-lg bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
</div>
<button
type="button"
onClick={submit}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Connect'}
</button>
</div>
);
}

View File

@ -1,165 +0,0 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CancelIcon, LoaderIcon, WorldIcon } from '@shared/icons';
type FormValues = {
uri: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.uri ? values : {},
errors: !values.uri
? {
uri: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function NWCOther() {
const { db } = useStorage();
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const onSubmit = async (data: { [x: string]: string }) => {
try {
if (!data.uri.startsWith('nostr+walletconnect:')) {
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
return;
}
setIsloading(true);
const uriObj = new URL(data.uri);
const params = new URLSearchParams(uriObj.search);
if (params.has('relay') && params.has('secret')) {
await db.secureSave('nwc', data.uri);
setIsloading(false);
setIsOpen(false);
}
} catch (e) {
setIsloading(false);
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between pt-4">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-700">
<WorldIcon className="h-5 w-5" />
</div>
<div>
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
URI String
</h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Using format nostr+walletconnect:
</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-lg bg-neutral-300 px-3 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
>
Connect
</button>
</Dialog.Trigger>
</div>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-white dark:bg-black" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-neutral-400 dark:bg-neutral-600">
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-white/10 bg-white/5 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Nostr Wallet Connect
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />
</Dialog.Close>
</div>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex flex-col gap-3 px-5 py-5"
>
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
className="text-sm font-semibold uppercase tracking-wider text-neutral-600 dark:text-neutral-400"
>
Connect URI
</label>
<input
{...register('uri', { required: true })}
placeholder="nostr+walletconnect:"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
/>
<span className="text-sm text-red-400">
{errors.uri && <p>{errors.uri.message}</p>}
</span>
</div>
<div className="flex flex-col gap-1 text-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-blue-500 px-6 font-medium leading-none text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
All information will be encrypted and stored on the local machine.
</span>
</div>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NWCAlby } from '@app/nwc/components/alby'; import { NWCForm } from '@app/nwc/components/form';
import { NWCOther } from '@app/nwc/components/other';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@ -18,10 +17,9 @@ export function NWCScreen() {
useEffect(() => { useEffect(() => {
async function getNWC() { async function getNWC() {
const nwc = await db.secureLoad('nwc'); const nwc = await db.secureLoad(`${db.account.pubkey}-nwc`);
if (nwc) setWalletConnectURL(nwc); if (nwc) setWalletConnectURL(nwc);
} }
getNWC(); getNWC();
}, []); }, []);
@ -29,24 +27,19 @@ export function NWCScreen() {
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col gap-5"> <div className="flex w-full flex-col gap-5">
<div className="text-center"> <div className="text-center">
<h3 className="text-2xl font-bold leading-tight"> <h3 className="text-2xl font-bold leading-tight">Nostr Wallet Connect</h3>
Nostr Wallet Connect (Beta)
</h3>
<p className="leading-tight text-neutral-600 dark:text-neutral-400"> <p className="leading-tight text-neutral-600 dark:text-neutral-400">
Sending tips easily via Bitcoin Lightning. Sending zap easily via Bitcoin Lightning.
</p> </p>
</div> </div>
<div className="mx-auto max-w-lg"> <div className="mx-auto max-w-lg">
{!walletConnectURL ? ( {!walletConnectURL ? (
<div className="flex w-full flex-col gap-4 divide-y divide-neutral-200 rounded-xl bg-neutral-100 p-3 dark:divide-neutral-800 dark:bg-neutral-900"> <NWCForm setWalletConnectURL={setWalletConnectURL} />
<NWCAlby />
<NWCOther />
</div>
) : ( ) : (
<div className="flex w-full flex-col rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900"> <div className="flex w-full flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="mb-1 inline-flex items-center gap-1.5 text-sm text-teal-500"> <div className="flex items-center justify-center gap-1.5 text-sm text-teal-500">
<CheckCircleIcon className="h-4 w-4" /> <CheckCircleIcon className="h-4 w-4" />
<p>You&apos;re using nostr wallet connect</p> <div>You&apos;re using nostr wallet connect</div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<textarea <textarea
@ -57,7 +50,7 @@ export function NWCScreen() {
<button <button
type="button" type="button"
onClick={() => remove()} onClick={() => remove()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100" className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
> >
Remove connection Remove connection
</button> </button>
@ -76,7 +69,7 @@ export function NWCScreen() {
<p className="text-sm text-neutral-600 dark:text-neutral-400"> <p className="text-sm text-neutral-600 dark:text-neutral-400">
To learn more about the details have a look at{' '} To learn more about the details have a look at{' '}
<a <a
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md" href="https://github.com/nostr-protocol/nips/blob/master/47.md"
target="_blank" target="_blank"
className="text-blue-500" className="text-blue-500"
rel="noreferrer" rel="noreferrer"
@ -87,13 +80,10 @@ export function NWCScreen() {
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100"> <h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
About tipping About zapping
</h5> </h5>
<p className="text-sm text-neutral-600 dark:text-neutral-400"> <p className="text-sm text-neutral-600 dark:text-neutral-400">
Also known as Zap in other Nostr client. Lume doesn&apos;t take any commission or platform fees when you zap
</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Lume doesn&apos;t take any commission or platform fees when you tip
someone. someone.
</p> </p>
<p className="text-sm text-neutral-600 dark:text-neutral-400"> <p className="text-sm text-neutral-600 dark:text-neutral-400">

View File

@ -1,18 +1,22 @@
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { fetch } from '@tauri-apps/plugin-http'; import { fetch } from '@tauri-apps/plugin-http';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader'; import {
import { BannerUploader } from '@shared/bannerUploader'; CancelIcon,
import { CancelIcon, CheckCircleIcon, LoaderIcon, UnverifiedIcon } from '@shared/icons'; CheckCircleIcon,
import { Image } from '@shared/image'; LoaderIcon,
PlusIcon,
import { useNostr } from '@utils/hooks/useNostr'; UnverifiedIcon,
} from '@shared/icons';
interface NIP05 { interface NIP05 {
names: { names: {
@ -25,12 +29,12 @@ export function EditProfileModal() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih'); const [picture, setPicture] = useState('');
const [banner, setBanner] = useState(null); const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: false, text: '' }); const [nip05, setNIP05] = useState({ verified: false, text: '' });
const { db } = useStorage(); const { db } = useStorage();
const { publish } = useNostr(); const { ndk } = useNDK();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -75,11 +79,105 @@ export function EditProfileModal() {
return false; return false;
}; };
const onSubmit = async (data: NDKUserProfile) => { const uploadAvatar = async () => {
try {
// start loading // start loading
setLoading(true); setLoading(true);
let event: NDKEvent; const selected = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append('fileToUpload', blob);
data.append('submit', 'Upload Image');
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setPicture(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const uploadBanner = async () => {
try {
// start loading
setLoading(true);
const selected = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (!selected) {
setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append('fileToUpload', blob);
data.append('submit', 'Upload Image');
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setBanner(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
const onSubmit = async (data: NDKUserProfile) => {
// start loading
setLoading(true);
const content = { const content = {
...data, ...data,
@ -89,14 +187,14 @@ export function EditProfileModal() {
image: data.picture, image: data.picture,
}; };
const event = new NDKEvent(ndk);
event.kind = NDKKind.Metadata;
event.tags = [];
if (data.nip05) { if (data.nip05) {
const nip05IsVerified = await verifyNIP05(data.nip05); const nip05IsVerified = await verifyNIP05(data.nip05);
if (nip05IsVerified) { if (nip05IsVerified) {
event = await publish({ event.content = JSON.stringify({ ...content, nip05: data.nip05 });
content: JSON.stringify({ ...content, nip05: data.nip05 }),
kind: 0,
tags: [],
});
} else { } else {
setNIP05((prev) => ({ ...prev, verified: false })); setNIP05((prev) => ({ ...prev, verified: false }));
setError('nip05', { setError('nip05', {
@ -105,14 +203,12 @@ export function EditProfileModal() {
}); });
} }
} else { } else {
event = await publish({ event.content = JSON.stringify(content);
content: JSON.stringify(content),
kind: 0,
tags: [],
});
} }
if (event.id) { const publishedRelays = await event.publish();
if (publishedRelays) {
// invalid cache // invalid cache
queryClient.invalidateQueries(['user', db.account.pubkey]); queryClient.invalidateQueries(['user', db.account.pubkey]);
// reset form // reset form
@ -144,7 +240,7 @@ export function EditProfileModal() {
</button> </button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-white dark:bg-black" /> <Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-neutral-100 dark:bg-neutral-900"> <div className="relative h-min w-full max-w-xl rounded-xl bg-neutral-100 dark:bg-neutral-900">
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-neutral-200 px-5 py-5 dark:border-neutral-800"> <div className="h-min w-full shrink-0 rounded-t-xl border-b border-neutral-200 px-5 py-5 dark:border-neutral-800">
@ -173,18 +269,30 @@ export function EditProfileModal() {
<div className="h-full w-full bg-black dark:bg-white" /> <div className="h-full w-full bg-black dark:bg-white" />
)} )}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform"> <div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} /> <button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/50"
>
<PlusIcon className="h-5 w-5" />
</button>
</div> </div>
</div> </div>
<div className="mb-5 px-4"> <div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14"> <div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-neutral-900">
<Image <img
src={picture} src={picture}
alt="user's avatar" alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-neutral-900" className="h-14 w-14 rounded-xl object-cover"
/> />
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform"> <div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} /> <button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50"
>
<PlusIcon className="h-5 w-5" />
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -50,15 +50,15 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
return ( return (
<> <>
<div className="h-56 w-full"> <div className="h-56 w-full overflow-hidden rounded-tl-lg">
{user.banner ? ( {user.banner ? (
<img <img
src={user.banner} src={user.banner}
alt="user banner" alt="user banner"
className="h-full w-full object-cover" className="h-full w-full rounded-tl-lg object-cover"
/> />
) : ( ) : (
<div className="h-full w-full bg-neutral-100 dark:bg-neutral-900" /> <div className="h-full w-full rounded-tl-lg bg-neutral-100 dark:bg-neutral-900" />
)} )}
</div> </div>
<div className="-mt-7 flex w-full flex-col items-center px-5"> <div className="-mt-7 flex w-full flex-col items-center px-5">

455
src/libs/ndk/cache.ts Normal file
View File

@ -0,0 +1,455 @@
import { NDKEvent, NDKRelay, profileFromEvent } from '@nostr-dev-kit/ndk';
import type {
Hexpubkey,
NDKCacheAdapter,
NDKFilter,
NDKSubscription,
NDKUserProfile,
} from '@nostr-dev-kit/ndk';
import _debug from 'debug';
import { matchFilter } from 'nostr-tools';
import { LRUCache } from 'typescript-lru-cache';
import { createDatabase, db } from './db';
export { db } from './db';
interface NDKCacheAdapterDexieOptions {
/**
* The name of the database to use
*/
dbName?: string;
/**
* Debug instance to use for logging
*/
debug?: debug.IDebugger;
/**
* The number of seconds to store events in Dexie (IndexedDB) before they expire
* Defaults to 3600 seconds (1 hour)
*/
expirationTime?: number;
/**
* Number of profiles to keep in an LRU cache
*/
profileCacheSize?: number | 'disabled';
}
export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
public debug: debug.Debugger;
private expirationTime;
readonly locking;
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
private dirtyProfiles: Set<Hexpubkey> = new Set();
constructor(opts: NDKCacheAdapterDexieOptions = {}) {
createDatabase(opts.dbName || 'ndk');
this.debug = opts.debug || _debug('ndk:dexie-adapter');
this.locking = true;
this.expirationTime = opts.expirationTime || 3600;
if (opts.profileCacheSize !== 'disabled') {
this.profiles = new LRUCache({
maxSize: opts.profileCacheSize || 100000,
});
setInterval(() => {
this.dumpProfiles();
}, 1000 * 10);
}
}
public async query(subscription: NDKSubscription): Promise<void> {
Promise.allSettled(
subscription.filters.map((filter) => this.processFilter(filter, subscription))
);
}
public async fetchProfile(pubkey: Hexpubkey) {
if (!this.profiles) return null;
let profile = this.profiles.get(pubkey);
if (!profile) {
const user = await db.users.get({ pubkey });
if (user) {
profile = user.profile;
this.profiles.set(pubkey, profile);
}
}
return profile;
}
public saveProfile(pubkey: Hexpubkey, profile: NDKUserProfile) {
if (!this.profiles) return;
this.profiles.set(pubkey, profile);
this.dirtyProfiles.add(pubkey);
}
private async processFilter(
filter: NDKFilter,
subscription: NDKSubscription
): Promise<void> {
const _filter = { ...filter };
delete _filter.limit;
const filterKeys = Object.keys(_filter || {}).sort();
try {
(await this.byKindAndAuthor(filterKeys, filter, subscription)) ||
(await this.byAuthors(filterKeys, filter, subscription)) ||
(await this.byKinds(filterKeys, filter, subscription)) ||
(await this.byIdsQuery(filterKeys, filter, subscription)) ||
(await this.byNip33Query(filterKeys, filter, subscription)) ||
(await this.byTagsAndOptionallyKinds(filterKeys, filter, subscription));
} catch (error) {
console.error(error);
}
}
public async setEvent(
event: NDKEvent,
_filter: NDKFilter,
relay?: NDKRelay
): Promise<void> {
if (event.kind === 0) {
if (!this.profiles) return;
const profile: NDKUserProfile = profileFromEvent(event);
this.profiles.set(event.pubkey, profile);
} else {
let addEvent = true;
if (event.isParamReplaceable()) {
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
const existingEvent = await db.events.where({ id: replaceableId }).first();
if (
existingEvent &&
event.created_at &&
existingEvent.createdAt > event.created_at
) {
addEvent = false;
}
}
if (addEvent) {
db.events.put({
id: event.tagId(),
pubkey: event.pubkey,
content: event.content,
kind: event.kind!,
createdAt: event.created_at!,
relay: relay?.url,
event: JSON.stringify(event.rawEvent()),
});
// Don't cache contact lists as tags since it's expensive
// and there is no use case for it
if (event.kind !== 3) {
event.tags.forEach((tag) => {
if (tag[0].length !== 1) return;
db.eventTags.put({
id: `${event.id}:${tag[0]}:${tag[1]}`,
eventId: event.id,
tag: tag[0],
value: tag[1],
tagValue: tag[0] + tag[1],
});
});
}
}
}
}
/**
* Searches by authors
*/
private async byAuthors(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['authors'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (hasAllKeys && filter.authors) {
for (const pubkey of filter.authors) {
const events = await db.events.where({ pubkey }).toArray();
for (const event of events) {
let rawEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
return foundEvents;
}
/**
* Searches by kinds
*/
private async byKinds(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['kinds'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (hasAllKeys && filter.kinds) {
for (const kind of filter.kinds) {
const events = await db.events.where({ kind }).toArray();
for (const event of events) {
let rawEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
return foundEvents;
}
/**
* Searches by ids
*/
private async byIdsQuery(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['ids'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
if (hasAllKeys && filter.ids) {
for (const id of filter.ids) {
const event = await db.events.where({ id }).first();
if (!event) continue;
let rawEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
}
return true;
}
return false;
}
/**
* Searches by NIP-33
*/
private async byNip33Query(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['#d', 'authors', 'kinds'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
if (hasAllKeys && filter.kinds && filter.authors) {
for (const kind of filter.kinds) {
const replaceableKind = kind >= 30000 && kind < 40000;
if (!replaceableKind) continue;
for (const author of filter.authors) {
for (const dTag of filter['#d']) {
const replaceableId = `${kind}:${author}:${dTag}`;
const event = await db.events.where({ id: replaceableId }).first();
if (!event) continue;
let rawEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
}
}
}
return true;
}
return false;
}
/**
* Searches by kind & author
*/
private async byKindAndAuthor(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
const f = ['authors', 'kinds'];
const hasAllKeys =
filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
let foundEvents = false;
if (!hasAllKeys) return false;
if (filter.kinds && filter.authors) {
for (const kind of filter.kinds) {
for (const author of filter.authors) {
const events = await db.events.where({ kind, pubkey: author }).toArray();
for (const event of events) {
let rawEvent;
try {
rawEvent = JSON.parse(event.event);
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
subscription.eventReceived(ndkEvent, relay, true);
foundEvents = true;
}
}
}
}
return foundEvents;
}
/**
* Searches by tags and optionally filters by tags
*/
private async byTagsAndOptionallyKinds(
filterKeys: string[],
filter: NDKFilter,
subscription: NDKSubscription
): Promise<boolean> {
for (const filterKey of filterKeys) {
const isKind = filterKey === 'kinds';
const isTag = filterKey.startsWith('#') && filterKey.length === 2;
if (!isKind && !isTag) return false;
}
const events = await this.filterByTag(filterKeys, filter);
const kinds = filter.kinds as number[];
for (const event of events) {
if (!kinds?.includes(event.kind!)) continue;
subscription.eventReceived(event, undefined, true);
}
return false;
}
private async filterByTag(
filterKeys: string[],
filter: NDKFilter
): Promise<NDKEvent[]> {
const retEvents: NDKEvent[] = [];
for (const filterKey of filterKeys) {
if (filterKey.length !== 2) continue;
const tag = filterKey.slice(1);
// const values = filter[filterKey] as string[];
const values: string[] = [];
for (const [key, value] of Object.entries(filter)) {
if (key === filterKey) values.push(value as string);
}
for (const value of values) {
const eventTags = await db.eventTags.where({ tagValue: tag + value }).toArray();
if (!eventTags.length) continue;
const eventIds = eventTags.map((t) => t.eventId);
const events = await db.events.where('id').anyOf(eventIds).toArray();
for (const event of events) {
let rawEvent;
try {
rawEvent = JSON.parse(event.event);
// Make sure all passed filters match the event
if (!matchFilter(filter, rawEvent)) continue;
} catch (e) {
console.log('failed to parse event', e);
continue;
}
const ndkEvent = new NDKEvent(undefined, rawEvent);
const relay = event.relay ? new NDKRelay(event.relay) : undefined;
ndkEvent.relay = relay;
retEvents.push(ndkEvent);
}
}
}
return retEvents;
}
private async dumpProfiles(): Promise<void> {
const profiles = [];
if (!this.profiles) return;
for (const pubkey of this.dirtyProfiles) {
const profile = this.profiles.get(pubkey);
if (!profile) continue;
profiles.push({
pubkey,
profile,
createdAt: Date.now(),
});
}
if (profiles.length) {
await db.users.bulkPut(profiles);
}
this.dirtyProfiles.clear();
}
}

View File

@ -55,7 +55,7 @@ export function ActiveAccount() {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="aspect-square h-auto w-full animate-pulse rounded-lg bg-white/10" /> <div className="aspect-square h-auto w-full animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
); );
} }

View File

@ -1,31 +1,69 @@
import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons'; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function AvatarUploader({ export function AvatarUploader({
setPicture, setPicture,
}: { }: {
setPicture: Dispatch<SetStateAction<string>>; setPicture: Dispatch<SetStateAction<string>>;
}) { }) {
const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadAvatar = async () => { const uploadAvatar = async () => {
try {
// start loading
setLoading(true); setLoading(true);
const image = await upload(null);
if (image.url) { const selected = await open({
setPicture(image.url); multiple: false,
} filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (!selected) {
setLoading(false); setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append('fileToUpload', blob);
data.append('submit', 'Upload Image');
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setPicture(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
}; };
return ( return (
<button <button
type="button" type="button"
onClick={() => uploadAvatar()} onClick={() => uploadAvatar()}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-100 px-1.5 py-1 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800" className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-100 px-1.5 py-1 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
> >
{loading ? ( {loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" /> <LoaderIcon className="h-4 w-4 animate-spin" />

View File

@ -1,24 +1,62 @@
import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons'; import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function BannerUploader({ export function BannerUploader({
setBanner, setBanner,
}: { }: {
setBanner: Dispatch<SetStateAction<string>>; setBanner: Dispatch<SetStateAction<string>>;
}) { }) {
const { upload } = useNostr();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadBanner = async () => { const uploadBanner = async () => {
try {
// start loading
setLoading(true); setLoading(true);
const image = await upload(null);
if (image.url) { const selected = await open({
setBanner(image.url); multiple: false,
} filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (!selected) {
setLoading(false); setLoading(false);
return;
}
const file = await readBinaryFile(selected.path);
const blob = new Blob([file]);
const data = new FormData();
data.append('fileToUpload', blob);
data.append('submit', 'Upload Image');
const res = await fetch('https://nostr.build/api/v2/upload/files', {
method: 'POST',
body: data,
});
if (res.ok) {
const json = await res.json();
const content = json.data[0];
setBanner(content.url);
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
}; };
return ( return (

View File

@ -67,7 +67,7 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
</Tooltip.Portal> </Tooltip.Portal>
</Tooltip.Root> </Tooltip.Root>
<AlertDialog.Portal> <AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-white/20" /> <AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center"> <AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-md rounded-xl bg-white dark:bg-black"> <div className="relative h-min w-full max-w-md rounded-xl bg-white dark:bg-black">
<div className="flex flex-col gap-2 border-b border-neutral-100 px-5 py-6 dark:border-neutral-900"> <div className="flex flex-col gap-2 border-b border-neutral-100 px-5 py-6 dark:border-neutral-900">

View File

@ -1,7 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { memo } from 'react'; import { memo } from 'react';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@ -68,13 +67,6 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
</h5> </h5>
</div> </div>
<div className="mt-1.5"> <div className="mt-1.5">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view it via
njump.me.{' '}
<Link to={noteLink} className="text-blue-500">
Learn more
</Link>
</div>
<LinkPreview urls={[noteLink]} /> <LinkPreview urls={[noteLink]} />
</div> </div>
</div> </div>
@ -87,10 +79,10 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
onKeyDown={(e) => openThread(e, id)} onKeyDown={(e) => openThread(e, id)}
role="button" role="button"
tabIndex={0} tabIndex={0}
className="mt-3 cursor-default rounded-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800" className="mt-3 cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800"
> >
<User pubkey={data.pubkey} time={data.created_at} variant="mention" /> <User pubkey={data.pubkey} time={data.created_at} variant="mention" />
<div className="mt-1.5">{renderKind(data)}</div> <div className="mt-1">{renderKind(data)}</div>
</div> </div>
); );
}); });

View File

@ -57,6 +57,7 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
<hr className="my-4 h-px w-full border-none bg-neutral-100" /> <hr className="my-4 h-px w-full border-none bg-neutral-100" />
<NoteReplyForm id={params.content} /> <NoteReplyForm id={params.content} />
<ReplyList id={params.content} /> <ReplyList id={params.content} />
<div className="h-10" />
</div> </div>
</WidgetWrapper> </WidgetWrapper>
); );