wip: clean up & refactor

This commit is contained in:
Ren Amamiya 2023-08-16 20:52:09 +07:00
parent c05bb54976
commit ab61bfb2cd
79 changed files with 183 additions and 2618 deletions

View File

@ -20,14 +20,12 @@
"@ctrl/magnet-link": "^3.1.2",
"@headlessui/react": "^1.7.16",
"@nostr-dev-kit/ndk": "^0.8.17",
"@nostr-fetch/adapter-ndk": "^0.12.2",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.32.6",
"@tanstack/react-query-devtools": "^4.32.6",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "2.0.0-alpha.6",
"@tauri-apps/cli": "2.0.0-alpha.11",
@ -61,7 +59,6 @@
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1",
"nostr-fetch": "^0.12.2",
"nostr-tools": "^1.14.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",

View File

@ -10,9 +10,6 @@ dependencies:
'@nostr-dev-kit/ndk':
specifier: ^0.8.17
version: 0.8.17(typescript@5.1.6)
'@nostr-fetch/adapter-ndk':
specifier: ^0.12.2
version: 0.12.2(@nostr-dev-kit/ndk@0.8.17)(nostr-fetch@0.12.2)
'@radix-ui/react-alert-dialog':
specifier: ^1.0.4
version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.20)(react-dom@18.2.0)(react@18.2.0)
@ -31,9 +28,6 @@ dependencies:
'@tanstack/react-query':
specifier: ^4.32.6
version: 4.32.6(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query-devtools':
specifier: ^4.32.6
version: 4.32.6(@tanstack/react-query@4.32.6)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-virtual':
specifier: 3.0.0-beta.54
version: 3.0.0-beta.54(react@18.2.0)
@ -133,9 +127,6 @@ dependencies:
lru-cache:
specifier: ^10.0.1
version: 10.0.1
nostr-fetch:
specifier: ^0.12.2
version: 0.12.2
nostr-tools:
specifier: ^1.14.0
version: 1.14.0
@ -1003,24 +994,6 @@ packages:
- typescript
dev: false
/@nostr-fetch/adapter-ndk@0.12.2(@nostr-dev-kit/ndk@0.8.17)(nostr-fetch@0.12.2):
resolution: {integrity: sha512-+7EVuxS5DDZvNo6qbfFp7xRHwIyjyi36hYkiQFDjbQ4gX5LKo9RIPB1P+1XGkOSDFshypTbovZCaFunscJ/zhQ==}
peerDependencies:
'@nostr-dev-kit/ndk': ^0.7.5
nostr-fetch: ^0.12.2
dependencies:
'@nostr-dev-kit/ndk': 0.8.17(typescript@5.1.6)
'@nostr-fetch/kernel': 0.12.2
nostr-fetch: 0.12.2
dev: false
/@nostr-fetch/kernel@0.12.2:
resolution: {integrity: sha512-ja7StOV33NmdtAMGfQIS0/R0dAkLRm3QxN6u/YAQdp5mXER4BYxiQKxUS/dCoTCSX986MH2zp9Fm0f76u4VaNQ==}
dependencies:
'@noble/curves': 1.1.0
'@noble/hashes': 1.3.1
dev: false
/@popperjs/core@2.11.8:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
@ -1733,32 +1706,10 @@ packages:
tailwindcss: 3.3.3
dev: true
/@tanstack/match-sorter-utils@8.8.4:
resolution: {integrity: sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==}
engines: {node: '>=12'}
dependencies:
remove-accents: 0.4.2
dev: false
/@tanstack/query-core@4.32.6:
resolution: {integrity: sha512-YVB+mVWENQwPyv+40qO7flMgKZ0uI41Ph7qXC2Zf1ft5AIGfnXnMZyifB2ghhZ27u+5wm5mlzO4Y6lwwadzxCA==}
dev: false
/@tanstack/react-query-devtools@4.32.6(@tanstack/react-query@4.32.6)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Gd9pBkm2sbeze9P5Yp8R7y0rZVUdoIOhduomDjz138WdJuVbRS4Y8p6gX2uMJFsUFVe7jA6fX/D6NfQ9o5OS/A==}
peerDependencies:
'@tanstack/react-query': ^4.32.6
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@tanstack/match-sorter-utils': 8.8.4
'@tanstack/react-query': 4.32.6(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
superjson: 1.13.1
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/@tanstack/react-query@4.32.6(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-AITu/IKJJJXsHHeXNBy5bclu12t08usMCY0vFC2dh9SP/w6JAk5U9GwfjOIPj3p+ATADZvxQPe8UiCtMLNeQbg==}
peerDependencies:
@ -2998,13 +2949,6 @@ packages:
engines: {node: '>=12'}
dev: false
/copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
dependencies:
is-what: 4.1.15
dev: false
/create-esm-loader@0.2.3:
resolution: {integrity: sha512-cllzD6IU/mzXBs5OdQVWL3+ne5Elpu3Wdm7h5OldMbGXk76yr9XzHlQXWJ4zfs0ZAibe26rkbs4KvMAJm7fIZA==}
engines: {node: '>=14.x'}
@ -4280,11 +4224,6 @@ packages:
dependencies:
call-bind: 1.0.2
/is-what@4.1.15:
resolution: {integrity: sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==}
engines: {node: '>=12.13'}
dev: false
/isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@ -5124,12 +5063,6 @@ packages:
engines: {node: '>=14.16'}
dev: false
/nostr-fetch@0.12.2:
resolution: {integrity: sha512-0WH0LlaPcIvG5gOIwrGtzRwHpaZ+JQxH0XG7EjQcKpviePVmVKWK7UAGuzuWJj/V0iSqnDGOLSQ+HSEBjGVCEQ==}
dependencies:
'@nostr-fetch/kernel': 0.12.2
dev: false
/nostr-tools@1.14.0:
resolution: {integrity: sha512-hwq2i1z5/DneXRE5Zu/TzQuKzVLcB+gOdfT9CeoiScvNw/2dWRGJvyTXIdF92d7NQ7nMcEwqVJPDytLpEpiiKw==}
dependencies:
@ -6016,10 +5949,6 @@ packages:
unified: 10.1.2
dev: false
/remove-accents@0.4.2:
resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==}
dev: false
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -6354,13 +6283,6 @@ packages:
time-span: 5.1.0
dev: false
/superjson@1.13.1:
resolution: {integrity: sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg==}
engines: {node: '>=10'}
dependencies:
copy-anything: 3.0.5
dev: false
/supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}

View File

@ -2,7 +2,7 @@
CREATE TABLE
events (
id INTEGER NOT NULL PRIMARY KEY,
cache_key TEXT NOT NULL UNIQUE,
cache_key TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
event_kind INTEGER NOT NULL DEFAULT 1,
event TEXT NOT NULL

View File

@ -0,0 +1,8 @@
-- Add migration script here
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS chats;
DROP TABLE IF EXISTS metadata;
DROP TABLE IF EXISTS replies;

View File

@ -123,6 +123,12 @@ fn main() {
sql: include_str!("../migrations/20230814083543_add_events_table.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230816090508,
description: "clean up tables",
sql: include_str!("../migrations/20230816090508_clean_up_tables.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
@ -138,7 +144,6 @@ fn main() {
..Default::default()
};
// let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12);
let key = argon2::hash_raw(
password.as_ref(),
b"LUME_NEED_RUST_DEVELOPER_HELP_MAKE_SALT_RANDOM",

View File

@ -42,9 +42,13 @@ export function CreateStep1Screen() {
};
const download = async () => {
await writeTextFile('lume-keys.txt', `Public key: ${npub}\nPrivate key: ${nsec}`, {
dir: BaseDirectory.Download,
});
await writeTextFile(
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
{
dir: BaseDirectory.Download,
}
);
setDownloaded(true);
};

View File

@ -5,7 +5,7 @@ import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
const data = [
@ -52,7 +52,7 @@ export function OnboardStep2Screen() {
setLoading(true);
for (const tag of tags) {
await db.createWidget(BLOCK_KINDS.hashtag, tag, tag.replace('#', ''));
await db.createWidget(widgetKinds.hashtag, tag, tag.replace('#', ''));
}
navigate('/auth/onboarding/step-3', { replace: true });

View File

@ -11,8 +11,6 @@ import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
type FormValues = {
password: string;
privkey: string;

View File

@ -60,18 +60,19 @@ export function UnlockScreen() {
// redirect to home
navigate('/', { replace: true });
} catch (e) {
setLoading(false);
setError('password', {
type: 'custom',
message: e,
});
}
} else {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3',
});
}
setLoading(false);
};
return (
@ -118,7 +119,7 @@ export function UnlockScreen() {
<>
<span className="w-5" />
<span>Decryting...</span>
<LoaderIcon className="h-5 w-5" />
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>

View File

@ -1,10 +1,12 @@
import { LogicalSize, appWindow } from '@tauri-apps/plugin-window';
import { LogicalSize, getCurrent } from '@tauri-apps/plugin-window';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() {
const appWindow = getCurrent();
async function setWindow() {
await appWindow.setSize(new LogicalSize(400, 500));
await appWindow.setResizable(false);

View File

@ -1,58 +0,0 @@
import { Popover, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { MutedItem } from '@app/channel/components/mutedItem';
import { MuteIcon } from '@shared/icons';
export function ChannelBlackList({ blacklist }: { blacklist: any }) {
return (
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${
open ? 'bg-zinc-800 hover:bg-zinc-700' : 'bg-zinc-900 hover:bg-zinc-800'
}`}
>
<MuteIcon
width={16}
height={16}
className="text-white/50 group-hover:text-white"
/>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
<div className="shadow-popover flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
<div className="flex flex-col gap-0.5">
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
Your muted list
</h3>
<p className="text-base leading-tight text-white/50">
Currently, unmute only affect locally, when you move to new client,
muted list will loaded again
</p>
</div>
</div>
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
{blacklist.map((item: any) => (
<MutedItem key={item.id} data={item} />
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
}

View File

@ -1,269 +0,0 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useContext, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { createChannel } from '@libs/storage';
import { AvatarUploader } from '@shared/avatarUploader';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelCreateModal() {
const { ndk } = useNDK();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const addChannel = useMutation({
mutationFn: (event: any) => {
return createChannel(
event.id,
event.pubkey,
event.name,
event.picture,
event.about,
event.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
try {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 40;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// insert to database
addChannel.mutate({
...event,
name: data.name,
picture: data.picture,
about: data.about,
});
// reset form
reset();
setTimeout(() => {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/channel/${event.id}`);
}, 1000);
} catch (e) {
console.log('error: ', e);
}
};
useEffect(() => {
setValue('picture', image);
}, [setValue, image]);
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<PlusIcon width={12} height={12} className="text-white/50" />
</div>
<div>
<h5 className="font-medium text-white/50">Create channel</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-white"
>
Create channel
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-white/50">
Channels are freedom square, everyone can speech freely, no one can
stop you or deceive what to speech
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex h-full w-full flex-col gap-4"
>
<input
type={'hidden'}
{...register('picture')}
value={image}
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-white/50 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-white/50"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium uppercase tracking-wider text-white/50">
Picture
</span>
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="channel picture"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader setPicture={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Channel name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
spellCheck={false}
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Description
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
/>
</div>
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
<div className="flex flex-col gap-1">
<span className="font-semibold leading-none text-white">
Encrypted
</span>
<p className="w-4/5 text-sm leading-none text-white/50">
All messages are encrypted and only invited members can view and
send message
</p>
</div>
<div>
<button
type="button"
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
) : (
'Create channel →'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@ -1,34 +0,0 @@
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
export function ChannelsListItem({ data }: { data: any }) {
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? 'bg-zinc-900/50 text-white' : ''
)
}
>
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
<span className="text-xs text-white">#</span>
</div>
<div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center">
{data.new_messages && (
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
}

View File

@ -1,52 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { ChannelCreateModal } from '@app/channel/components/createModal';
import { ChannelsListItem } from '@app/channel/components/item';
import { getChannels } from '@libs/storage';
export function ChannelsList() {
const {
status,
data: channels,
isFetching,
} = useQuery(
['channels'],
async () => {
return await getChannels();
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
return (
<div className="flex flex-col">
{status === 'loading' ? (
<>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
</>
) : (
channels.map((item: { event_id: string }) => (
<ChannelsListItem key={item.event_id} data={item} />
))
)}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
<div className="h-3.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
<ChannelCreateModal />
</div>
);
}

View File

@ -1,24 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function Member({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<>
{isError || isLoading ? (
<div className="h-7 w-7 animate-pulse rounded bg-zinc-800" />
) : (
<Image
className="inline-block h-7 w-7 rounded"
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
/>
)}
</>
);
}

View File

@ -1,28 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Member } from '@app/channel/components/member';
import { getChannelUsers } from '@libs/storage';
export function ChannelMembers({ id }: { id: string }) {
const { status, data, isFetching } = useQuery(['channel-members', id], async () => {
return await getChannelUsers(id);
});
return (
<div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members
</h5>
<div className="mt-3 flex w-full flex-wrap gap-1.5">
{status === 'loading' || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} />
))
)}
</div>
</div>
);
}

View File

@ -1,115 +0,0 @@
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useContext, useState } from 'react';
import { UserReply } from '@app/channel/components/messages/userReply';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelMessageForm({ channelID }: { channelID: string }) {
const { ndk } = useNDK();
const [value, setValue] = useState('');
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const { account } = useAccount();
const submit = () => {
let tags: string[][];
if (replyTo.id !== null) {
tags = [
['e', channelID, '', 'root'],
['e', replyTo.id, '', 'reply'],
['p', replyTo.pubkey, ''],
];
} else {
tags = [['e', channelID, '', 'root']];
}
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 42;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
// reset state
setValue('');
};
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const stopReply = () => {
closeReply();
};
return (
<div className={`relative w-full ${replyTo.id ? 'h-36' : 'h-24'}`}>
{replyTo.id && (
<div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserReply pubkey={replyTo.pubkey} />
<div className="-mt-5 pl-[38px]">
<div className="text-base text-white">{replyTo.content}</div>
</div>
</div>
<button
type="button"
onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<CancelIcon width={12} height={12} className="text-white" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
} w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-white/50`}
/>
<div className="absolute bottom-0 right-2 h-11">
<div className="flex h-full items-center justify-end gap-3 text-white/50">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
}

View File

@ -1,132 +0,0 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, HideIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageHideButton({ id }: { id: string }) {
const { ndk } = useNDK();
const hide = useChannelMessages((state: any) => state.hideMessage);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const hideMessage = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 43;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['e', id]];
// publish event
event.publish();
// update state
hide(id);
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<HideIcon width={16} height={16} className="text-zinc-200" />
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-white/50">
This message will be hidden from your feed.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-white/50 hover:bg-zinc-800 hover:text-white"
>
Cancel
</button>
<button
type="button"
onClick={() => hideMessage()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@ -1,56 +0,0 @@
import { MessageHideButton } from '@app/channel/components/messages/hideButton';
import { MessageMuteButton } from '@app/channel/components/messages/muteButton';
import { MessageReplyButton } from '@app/channel/components/messages/replyButton';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function ChannelMessageItem({ data }: { data: LumeEvent }) {
const content = parser(data);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
) : (
<></>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
}

View File

@ -1,132 +0,0 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useContext, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, MuteIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageMuteButton({ pubkey }: { pubkey: string }) {
const { ndk } = useNDK();
const mute = useChannelMessages((state: any) => state.muteUser);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const muteUser = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 44;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['p', pubkey]];
// publish event
event.publish();
// update state
mute(pubkey);
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<MuteIcon width={16} height={16} className="text-zinc-200" />
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</Dialog.Title>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-white/50">
You will no longer see messages from this user.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-white/50 hover:bg-zinc-800 hover:text-white"
>
Cancel
</button>
<button
type="button"
onClick={() => muteUser()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@ -1,29 +0,0 @@
import { ReplyMessageIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
export function MessageReplyButton({
id,
pubkey,
content,
}: {
id: string;
pubkey: string;
content: string;
}) {
const openReply = useChannelMessages((state: any) => state.openReply);
const createReply = () => {
openReply(id, pubkey, content);
};
return (
<button
type="button"
onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button>
);
}

View File

@ -1,40 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function ChannelMessageUserMute({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="flex items-center gap-3">
{isError || isLoading ? (
<>
<div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-center justify-between">
<div className="flex items-baseline gap-2 text-base">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-center justify-between">
<span className="leading-none text-zinc-300">
You has been muted this user
</span>
</div>
</>
)}
</div>
);
}

View File

@ -1,35 +0,0 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function UserReply({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-2">
{isError || isLoading ? (
<>
<div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-white/50" />
</>
) : (
<>
<div className="relative h-9 w-9 shrink overflow-hidden rounded">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-9 w-9 rounded object-cover"
/>
</div>
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-white/50">
Replying to {user?.name || shortenKey(pubkey)}
</span>
</>
)}
</div>
);
}

View File

@ -1,44 +0,0 @@
import { nip19 } from 'nostr-tools';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
import { CopyIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
export function ChannelMetadata({ id }: { id: string }) {
const metadata = useChannelProfile(id);
const noteID = id ? nip19.noteEncode(id) : null;
const copyNoteID = async () => {
const { writeText } = await import('@tauri-apps/plugin-clipboard-manager');
if (noteID) {
await writeText(noteID);
}
};
return (
<div className="flex flex-col gap-2">
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={metadata?.picture}
fallback={DEFAULT_AVATAR}
alt={id}
className="h-11 w-11 rounded-md bg-zinc-900 object-contain"
/>
</div>
<div className="flex flex-col gap-2">
<div className="inline-flex items-center gap-1">
<h5 className="text-lg font-semibold leading-none">{metadata?.name}</h5>
<button type="button" onClick={() => copyNoteID()}>
<CopyIcon width={14} height={14} className="text-white/50" />
</button>
</div>
<p className="leading-tight text-white/50">
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
</p>
</div>
</div>
);
}

View File

@ -1,85 +0,0 @@
import { useState } from 'react';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function MutedItem({ data }: { data: any }) {
const { user, isError, isLoading } = useProfile(data.content);
const [status, setStatus] = useState(data.status);
const unmute = async () => {
const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 0);
if (res) {
setStatus(0);
}
};
const mute = async () => {
const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 1);
if (res) {
setStatus(1);
}
};
return (
<div className="flex items-center justify-between">
{isError || isLoading ? (
<>
<div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
<div className="h-3 w-16 animate-pulse bg-zinc-800" />
<div className="h-2 w-10 animate-pulse bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.content}
className="h-9 w-9 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
<span className="truncate text-base font-medium leading-none text-white">
{user?.displayName || user?.name || 'Pleb'}
</span>
<span className="text-base leading-none text-white/50">
{shortenKey(data.content)}
</span>
</div>
</div>
<div>
{status === 1 ? (
<button
type="button"
onClick={() => unmute()}
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-white/50 hover:bg-zinc-800 hover:text-fuchsia-500"
>
Unmute
</button>
) : (
<button
type="button"
onClick={() => mute()}
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-white/50 hover:bg-zinc-800 hover:text-fuchsia-500"
>
Mute
</button>
)}
</div>
</>
)}
</div>
);
}

View File

@ -1,36 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { getChannel, updateChannelMetadata } from '@libs/storage';
export function useChannelProfile(id: string) {
const { ndk } = useNDK();
const { data } = useQuery(['channel-metadata', id], async () => {
return await getChannel(id);
});
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [41],
},
{
closeOnEose: true,
}
);
sub.addListener('event', (event: { content: string }) => {
// update in local database
updateChannelMetadata(id, event.content);
});
return () => {
sub.stop();
};
}, []);
return data;
}

View File

@ -1,149 +0,0 @@
import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ChannelMembers } from '@app/channel/components/members';
import { ChannelMessageForm } from '@app/channel/components/messages/form';
import { ChannelMetadata } from '@app/channel/components/metadata';
import { useNDK } from '@libs/ndk/provider';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix, getHourAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
import { ChannelMessageItem } from './components/messages/item';
const now = new Date();
const Header = (
<div className="relative py-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white/50 shadow-sm ring-1 ring-inset ring-zinc-800">
{getHourAgo(24, now).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</div>
</div>
);
const Empty = (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</h3>
<p className="text-base leading-none text-white/50">
Be the first to share a message in this channel.
</p>
</div>
);
export function ChannelScreen() {
const { ndk } = useNDK();
const virtuosoRef = useRef(null);
const { id } = useParams();
const [messages, fetchMessages, addMessage, clearMessages] = useChannelMessages(
(state: any) => [state.messages, state.fetch, state.add, state.clear]
);
useLayoutEffect(() => {
fetchMessages(id);
}, [fetchMessages]);
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false }
);
sub.addListener('event', (event: LumeEvent) => {
addMessage(id, event);
});
return () => {
clearMessages();
sub.stop();
};
}, []);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={messages[index]} />;
},
[messages]
);
const computeItemKey = useCallback(
(index: string | number) => {
return messages[index].event_id;
},
[messages]
);
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
>
<h3 className="font-semibold text-white">Public Channel</h3>
</div>
<div className="h-full w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-full w-full flex-1">
{!messages ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChannelMessageForm channelID={id} />
</div>
</div>
</div>
</div>
<div className="col-span-1 flex flex-col">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
/>
<div className="flex flex-col gap-3 p-3">
<ChannelMetadata id={id} />
<ChannelMembers id={id} />
</div>
</div>
</div>
);
}

View File

@ -6,17 +6,14 @@ import { NewMessageModal } from '@app/chats/components/modal';
import { ChatsListSelfItem } from '@app/chats/components/self';
import { UnknownsModal } from '@app/chats/components/unknowns';
import { useNDK } from '@libs/ndk/provider';
import { getChats } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { Chats } from '@utils/types';
export function ChatsList() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data: chats } = useQuery(['chats'], async () => {
return await getChats();
return { follows: [], unknowns: [] };
});
const renderItem = useCallback(

View File

@ -1,5 +1,5 @@
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
@ -9,22 +9,18 @@ import { ChatMessageItem } from '@app/chats/components/messages/item';
import { ChatSidebar } from '@app/chats/components/sidebar';
import { useNDK } from '@libs/ndk/provider';
import { createChat, getChatMessages } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { useStronghold } from '@stores/stronghold';
import { Chats } from '@utils/types';
export function ChatScreen() {
const queryClient = useQueryClient();
const virtuosoRef = useRef(null);
const { ndk } = useNDK();
const { db } = useStorage();
const { pubkey } = useParams();
const { status, data } = useQuery(['chat', pubkey], async () => {
return await getChatMessages(db.account.pubkey, pubkey);
return [];
});
const userPrivkey = useStronghold((state) => state.privkey);
@ -49,22 +45,6 @@ export function ChatScreen() {
[data]
);
const chat = useMutation({
mutationFn: (data: Chats) => {
return createChat(
data.id,
data.receiver_pubkey,
data.sender_pubkey,
data.content,
data.tags,
data.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chat', pubkey] });
},
});
useEffect(() => {
const sub: NDKSubscription = ndk.subscribe(
{
@ -79,14 +59,7 @@ export function ChatScreen() {
);
sub.addListener('event', (event) => {
chat.mutate({
id: event.id,
receiver_pubkey: pubkey,
sender_pubkey: event.pubkey,
content: event.content,
tags: event.tags,
created_at: event.created_at,
});
console.log(event);
});
return () => {

View File

@ -1,16 +1,15 @@
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
export function AccountSettingsScreen() {
const { status, account } = useAccount();
const [type, setType] = useState('password');
const privkey = useStronghold((state) => state.privkey);
const { db } = useStorage();
const showPrivateKey = () => {
if (type === 'password') {
@ -35,7 +34,7 @@ export function AccountSettingsScreen() {
</label>
<input
readOnly
value={account.pubkey}
value={db.account.pubkey}
className="relative w-2/3 rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
/>
</div>
@ -45,7 +44,7 @@ export function AccountSettingsScreen() {
</label>
<input
readOnly
value={account.npub}
value={db.account.npub}
className="relative w-2/3 rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
/>
</div>

View File

@ -3,26 +3,24 @@ import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { getSetting, updateSetting } from '@libs/storage';
export function AutoStartSetting() {
const [enabled, setEnabled] = useState(false);
const toggle = async () => {
if (!enabled) {
await enable();
await updateSetting('auto_start', 1);
// await updateSetting('auto_start', 1);
console.log(`registered for autostart? ${await isEnabled()}`);
} else {
await disable();
await updateSetting('auto_start', 0);
// await updateSetting('auto_start', 0);
}
setEnabled(!enabled);
};
useEffect(() => {
async function getAppSetting() {
const setting = await getSetting('auto_start');
const setting = '0';
if (parseInt(setting) === 0) {
setEnabled(false);
} else {

View File

@ -1,17 +1,12 @@
import { useState } from 'react';
import { getSetting, updateSetting } from '@libs/storage';
import { CheckCircleIcon } from '@shared/icons';
const setting = await getSetting('cache_time');
const cacheTime = setting;
export function CacheTimeSetting() {
const [time, setTime] = useState(cacheTime);
const [time, setTime] = useState('0');
const update = async () => {
await updateSetting('cache_time', time);
// await updateSetting('cache_time', time);
};
return (

View File

@ -10,7 +10,7 @@ import { useStorage } from '@libs/storage/provider';
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR, widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
export function FeedModal() {
@ -40,7 +40,7 @@ export function FeedModal() {
// update state
setWidget(db, {
kind: BLOCK_KINDS.feed,
kind: widgetKinds.feed,
title: data.title,
content: JSON.stringify(selected),
});

View File

@ -6,7 +6,7 @@ import { useStorage } from '@libs/storage/provider';
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
export function HashtagModal() {
@ -28,7 +28,7 @@ export function HashtagModal() {
// update state
setWidget(db, {
kind: BLOCK_KINDS.hashtag,
kind: widgetKinds.hashtag,
title: data.hashtag,
content: data.hashtag.replace('#', ''),
});

View File

@ -7,7 +7,7 @@ import { useStorage } from '@libs/storage/provider';
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
import { DEFAULT_AVATAR, widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { useImageUploader } from '@utils/hooks/useUploader';
@ -40,7 +40,7 @@ export function ImageModal() {
setLoading(true);
// mutate
setWidget(db, { kind: BLOCK_KINDS.image, title: data.title, content: data.content });
setWidget(db, { kind: widgetKinds.image, title: data.title, content: data.content });
setLoading(false);
// reset form

View File

@ -2,8 +2,6 @@ import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useEffect, useRef } from 'react';
import { getNotesByAuthors } from '@libs/storage';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
@ -11,14 +9,12 @@ import { TitleBar } from '@shared/titleBar';
import { LumeEvent, Widget } from '@utils/types';
const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: Widget }) {
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors(params.content, ITEM_PER_PAGE, pageParam);
queryFn: async () => {
return { data: [], nextCursor: 0 };
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});

View File

@ -1,36 +0,0 @@
import { CancelIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { Widget } from '@utils/types';
export function ImageBlock({ params }: { params: Widget }) {
const remove = useWidgets((state) => state.removeWidget);
return (
<div className="flex h-full w-[400px] shrink-0 flex-col justify-between">
<div className="relative h-full w-full flex-1 overflow-hidden p-3">
<div className="absolute left-0 top-3 h-16 w-full px-3">
<div className="flex h-16 items-center justify-between overflow-hidden rounded-t-xl px-5">
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
<button
type="button"
onClick={() => remove(params.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
>
<CancelIcon width={16} height={16} className="text-white" />
</button>
</div>
</div>
<Image
src={params.content}
fallback={DEFAULT_AVATAR}
alt={params.title}
className="h-full w-full rounded-xl object-cover"
/>
</div>
</div>
);
}

View File

@ -1,9 +1,11 @@
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { NostrEvent } from 'nostr-fetch';
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
@ -13,11 +15,12 @@ import { useNostr } from '@utils/hooks/useNostr';
import { LumeEvent } from '@utils/types';
export function NetworkBlock() {
const { fetchNotes } = useNostr();
const { db } = useStorage();
const { sub, fetchNotes } = useNostr();
const { status, data, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['network-widget'],
queryFn: async ({ pageParam = 24 }) => {
return await fetchNotes(pageParam);
return { data: [], nextCursor: 0 };
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
refetchOnWindowFocus: false,
@ -26,8 +29,7 @@ export function NetworkBlock() {
const parentRef = useRef();
const notes = useMemo(
// @ts-expect-error, todo
() => (data ? data.pages.flatMap((d: { data: NostrEvent[] }) => d.data) : []),
() => (data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : []),
[data]
);
@ -37,10 +39,20 @@ export function NetworkBlock() {
estimateSize: () => 500,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
useEffect(() => {
const since = Math.floor(Date.now() / 1000);
const filter: NDKFilter = {
kinds: [1, 6],
authors: db.account.network,
since: since,
};
sub(filter, (event) => console.log('[network] event received: ', event));
}, []);
const renderItem = useCallback(
(index: string | number) => {
const note: LumeEvent = notes[index];

View File

@ -1,48 +0,0 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { createReplyNote } from '@libs/storage';
export function useLiveThread(id: string) {
const queryClient = useQueryClient();
const now = useRef(Math.floor(Date.now() / 1000));
const { ndk } = useNDK();
const thread = useMutation({
mutationFn: (data: NDKEvent) => {
return createReplyNote(
id,
data.id,
data.pubkey,
data.kind,
data.tags,
data.content,
data.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['replies', id] });
},
});
useEffect(() => {
const filter: NDKFilter = {
kinds: [1],
'#e': [id],
since: now.current,
};
const sub = ndk.subscribe(filter, { closeOnEose: false });
sub.addListener('event', (event: NDKEvent) => {
thread.mutate(event);
});
return () => {
sub.stop();
};
}, []);
}

View File

@ -1,45 +0,0 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useEffect, useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { createNote } from '@libs/storage';
import { useAccount } from '@utils/hooks/useAccount';
export function useNewsfeed() {
const sub = useRef(null);
const now = useRef(Math.floor(Date.now() / 1000));
const { ndk } = useNDK();
const { status, account } = useAccount();
useEffect(() => {
if (status === 'success' && account) {
const filter: NDKFilter = {
kinds: [1, 6],
authors: account.follows,
since: now.current,
};
sub.current = ndk.subscribe(filter, { closeOnEose: false });
sub.current.addListener('event', (event: NDKEvent) => {
// add to db
createNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at
);
});
}
return () => {
if (sub.current) {
sub.current.stop();
}
};
}, [status]);
}

View File

@ -5,7 +5,6 @@ import { HashtagModal } from '@app/space/components/modals/hashtag';
import { ImageModal } from '@app/space/components/modals/image';
import { FeedBlock } from '@app/space/components/widgets/feed';
import { HashtagBlock } from '@app/space/components/widgets/hashtag';
import { ImageBlock } from '@app/space/components/widgets/image';
import { NetworkBlock } from '@app/space/components/widgets/network';
import { ThreadBlock } from '@app/space/components/widgets/thread';
import { UserBlock } from '@app/space/components/widgets/user';
@ -19,18 +18,16 @@ import { useWidgets } from '@stores/widgets';
import { Widget } from '@utils/types';
export function SpaceScreen() {
const { db } = useStorage();
const [widgets, fetchWidgets] = useWidgets((state) => [
state.widgets,
state.fetchWidgets,
]);
const { db } = useStorage();
const renderItem = useCallback(
(widget: Widget) => {
switch (widget.kind) {
case 0:
return <ImageBlock key={widget.id} params={widget} />;
case 1:
return <FeedBlock key={widget.id} params={widget} />;
case 2:
@ -39,6 +36,8 @@ export function SpaceScreen() {
return <HashtagBlock key={widget.id} params={widget} />;
case 5:
return <UserBlock key={widget.id} params={widget} />;
case 9999:
return <NetworkBlock key={widget.id} />;
default:
break;
}
@ -52,7 +51,6 @@ export function SpaceScreen() {
return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
<NetworkBlock />
{!widgets ? (
<div className="flex w-[350px] shrink-0 flex-col">
<div className="flex w-full flex-1 items-center justify-center p-3">

View File

@ -11,14 +11,15 @@ interface Response {
}
export function TrendingNotes() {
const { status, data, error } = useQuery(
const { status, data } = useQuery(
['trending-notes'],
async () => {
const res = await fetch('https://api.nostr.band/v0/trending/notes');
if (!res.ok) {
throw new Error('Error');
throw new Error('failed to fecht trending notes');
}
const json: Response = await res.json();
if (!json.notes) return null;
return json.notes;
},
{
@ -29,19 +30,18 @@ export function TrendingNotes() {
}
);
console.log('notes: ', data);
return (
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
<TitleBar title="Trending Posts" />
<div className="h-full">
{error && <p>Failed to fetch</p>}
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : status === 'error' ? (
<p>Failed to fetch</p>
) : (
<div className="relative flex w-full flex-col">
{data.map((item) => (

View File

@ -10,7 +10,7 @@ interface Response {
}
export function TrendingProfiles() {
const { status, data, error } = useQuery(
const { status, data } = useQuery(
['trending-profiles'],
async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
@ -18,6 +18,7 @@ export function TrendingProfiles() {
throw new Error('Error');
}
const json: Response = await res.json();
if (!json.profiles) return null;
return json.profiles;
},
{
@ -28,22 +29,21 @@ export function TrendingProfiles() {
}
);
console.log('profiles: ', data);
return (
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
<TitleBar title="Trending Profiles" />
<div className="h-full">
{error && <p>Failed to fetch</p>}
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : status === 'error' ? (
<p>Failed to fetch</p>
) : (
<div className="relative flex w-full flex-col gap-3 px-3 pt-1.5">
{data?.map((item) => (
{data.map((item) => (
<Profile key={item.pubkey} data={item} />
))}
</div>

View File

@ -21,15 +21,15 @@ export default class TauriAdapter implements NDKCacheAdapter {
for (const author of filter.authors) {
for (const kind of filter.kinds) {
const key = `${author}:${kind}`;
promises.push(this.store.getEventByKey(key));
promises.concat(this.store.getALlEventByKey(key));
}
}
const results = await Promise.all(promises);
for (const result of results) {
if (result) {
console.log('cache hit: ', result);
if (result && result.event) {
console.log('cache hit: ', result.event);
const ndkEvent = new NDKEvent(
subscription.ndk,
JSON.parse(result.event as string)

View File

@ -56,6 +56,7 @@ export const NDKInstance = () => {
async function initNDK() {
let explicitRelayUrls: string[];
const explicitRelayUrlsFromDB = await db.getExplicitRelayUrls();
console.log('relays in db: ', explicitRelayUrlsFromDB);
if (explicitRelayUrlsFromDB) {
explicitRelayUrls = await verifyRelays(explicitRelayUrlsFromDB);

View File

@ -1,435 +0,0 @@
import Database from '@tauri-apps/plugin-sql';
import { destr } from 'destr';
import { parser } from '@utils/parser';
import { getParentID } from '@utils/transform';
import {
Account,
Chats,
LumeEvent,
Profile,
Relays,
Settings,
Widget,
} from '@utils/types';
let db: null | Database = null;
// connect database (sqlite)
// path: tauri::api::path::BaseDirectory::App
export async function connect(): Promise<Database> {
if (db) {
return db;
}
try {
db = await Database.load('sqlite:lume.db');
} catch (e) {
throw new Error('Failed to connect to database, error: ', e);
}
return db;
}
// get active account
export async function getActiveAccount() {
const db = await connect();
const result: Array<Account> = await db.select(
'SELECT * FROM accounts WHERE is_active = 1;'
);
if (result.length > 0) {
return result[0];
} else {
return null;
}
}
// create account
export async function createAccount(npub: string, pubkey: string, follows?: string[][]) {
const db = await connect();
const res = await db.execute(
'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, follows, is_active) VALUES (?, ?, ?, ?, ?);',
[npub, pubkey, 'privkey is stored in secure storage', follows || '', 1]
);
if (res) {
await createWidget(
0,
'Have fun together!',
'https://void.cat/d/N5KUHEQCVg7SywXUPiJ7yq.jpg'
);
}
const getAccount = await getActiveAccount();
return getAccount;
}
// update account
export async function updateAccount(column: string, value: string | string[]) {
const db = await connect();
const account = await getActiveAccount();
return await db.execute(`UPDATE accounts SET ${column} = ? WHERE id = ?;`, [
value,
account.id,
]);
}
// count total notes
export async function countTotalNotes() {
const db = await connect();
const result: Array<{ total: string }> = await db.select(
'SELECT COUNT(*) AS "total" FROM notes WHERE kind IN (1, 6);'
);
return parseInt(result[0].total);
}
// get all notes
export async function getNotes(limit: number, offset: number) {
const db = await connect();
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const notes: { data: LumeEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: LumeEvent[] = await db.select(
`SELECT * FROM notes WHERE kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
);
query.forEach(
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
);
notes['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
return notes;
}
// get all notes by authors
export async function getNotesByAuthors(authors: string, limit: number, offset: number) {
const db = await connect();
const totalNotes = await countTotalNotes();
const nextCursor = offset + limit;
const array = JSON.parse(authors);
const finalArray = `'${array.join("','")}'`;
const notes: { data: LumeEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: LumeEvent[] = await db.select(
`SELECT * FROM notes WHERE pubkey IN (${finalArray}) AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`
);
query.forEach(
(el) => (el.tags = typeof el.tags === 'string' ? destr(el.tags) : el.tags)
);
notes['data'] = query;
notes['nextCursor'] = Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
return notes;
}
// get note by id
export async function getNoteByID(event_id: string) {
const db = await connect();
const result: LumeEvent[] = await db.select(
`SELECT * FROM notes WHERE event_id = "${event_id}";`
);
if (result[0]) {
if (result[0].kind === 1) result[0]['content'] = parser(result[0]);
return result[0];
} else {
return null;
}
}
// create note
export async function createNote(
event_id: string,
pubkey: string,
kind: number,
tags: string[][],
content: string,
created_at: number
) {
const db = await connect();
const account = await getActiveAccount();
const parentID = getParentID(tags, event_id);
return await db.execute(
'INSERT OR IGNORE INTO notes (event_id, account_id, pubkey, kind, tags, content, created_at, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?);',
[event_id, account.id, pubkey, kind, tags, content, created_at, parentID]
);
}
// get note replies
export async function getReplies(parent_id: string) {
const db = await connect();
const result: Array<LumeEvent> = await db.select(
`SELECT * FROM replies WHERE parent_id = "${parent_id}" ORDER BY created_at DESC;`
);
return result;
}
// create reply note
export async function createReplyNote(
parent_id: string,
event_id: string,
pubkey: string,
kind: number,
tags: string[][],
content: string,
created_at: number
) {
const db = await connect();
return await db.execute(
'INSERT OR IGNORE INTO replies (event_id, parent_id, pubkey, kind, tags, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);',
[event_id, parent_id, pubkey, kind, tags, content, created_at]
);
}
// get all chats by pubkey
export async function getChats() {
const db = await connect();
const account = await getActiveAccount();
const chats: { follows: Array<Chats> | null; unknowns: Array<Chats> | null } = {
follows: [],
unknowns: [],
};
let result: Array<Chats> = await db.select(
`SELECT DISTINCT sender_pubkey FROM chats WHERE receiver_pubkey = "${account.pubkey}" ORDER BY created_at DESC;`
);
result = result.map((v) => ({ ...v, new_messages: 0 }));
result = result.sort((a, b) => a.new_messages - b.new_messages);
chats.follows = result.filter((el) => {
return account.follows.some((i) => {
return i === el.sender_pubkey;
});
});
chats.unknowns = result.filter(
(el) => !chats.follows.includes(el) && el.sender_pubkey !== account.pubkey
);
return chats;
}
// get chat messages
export async function getChatMessages(receiver_pubkey: string, sender_pubkey: string) {
const db = await connect();
let receiver = [];
const sender: Array<Chats> = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`
);
if (receiver_pubkey !== sender_pubkey) {
receiver = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${receiver_pubkey}" AND receiver_pubkey = "${sender_pubkey}";`
);
}
const result = [...sender, ...receiver].sort(
(x: { created_at: number }, y: { created_at: number }) => x.created_at - y.created_at
);
return result;
}
// create chat
export async function createChat(
event_id: string,
receiver_pubkey: string,
sender_pubkey: string,
content: string,
tags: string[][],
created_at: number
) {
const db = await connect();
await db.execute(
'INSERT OR IGNORE INTO chats (event_id, receiver_pubkey, sender_pubkey, content, tags, created_at) VALUES (?, ?, ?, ?, ?, ?);',
[event_id, receiver_pubkey, sender_pubkey, content, tags, created_at]
);
return sender_pubkey;
}
// get setting
export async function getSetting(key: string) {
const db = await connect();
const result: Array<Settings> = await db.select(
`SELECT value FROM settings WHERE key = "${key}";`
);
return result[0]?.value;
}
// update setting
export async function updateSetting(key: string, value: string | number) {
const db = await connect();
return await db.execute(`UPDATE settings SET value = "${value}" WHERE key = "${key}";`);
}
// get last login
export async function getLastLogin() {
const db = await connect();
const result: Array<Settings> = await db.select(
`SELECT value FROM settings WHERE key = "last_login";`
);
if (result[0]) {
return parseInt(result[0].value);
} else {
return 0;
}
}
// update last login
export async function updateLastLogin(value: number) {
const db = await connect();
return await db.execute(
`UPDATE settings SET value = ${value} WHERE key = "last_login";`
);
}
// get all widgets
export async function getWidgets() {
const db = await connect();
const account = await getActiveAccount();
const result: Array<Widget> = await db.select(
`SELECT * FROM widgets WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
);
return result;
}
// create block
export async function createWidget(
kind: number,
title: string,
content: string | string[]
) {
const db = await connect();
const activeAccount = await getActiveAccount();
const insert = await db.execute(
'INSERT OR IGNORE INTO widgets (account_id, kind, title, content) VALUES (?, ?, ?, ?);',
[activeAccount.id, kind, title, content]
);
if (insert) {
const record: Widget = await db.select(
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
);
return record[0];
} else {
return null;
}
}
// remove block
export async function removeWidget(id: string) {
const db = await connect();
return await db.execute(`DELETE FROM widgets WHERE id = "${id}";`);
}
// logout
export async function removeAll() {
const db = await connect();
await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`);
await db.execute('DELETE FROM replies;');
await db.execute('DELETE FROM notes;');
await db.execute('DELETE FROM widgets;');
await db.execute('DELETE FROM chats;');
await db.execute('DELETE FROM accounts;');
return true;
}
// create metadata
export async function createMetadata(id: string, pubkey: string, content: string) {
const db = await connect();
const now = Math.floor(Date.now() / 1000);
return await db.execute(
'INSERT OR REPLACE INTO metadata (id, pubkey, content, created_at) VALUES (?, ?, ?, ?);',
[id, pubkey, content, now]
);
}
export async function getAllMetadata() {
const db = await connect();
const result: LumeEvent[] = await db.select(`SELECT * FROM metadata;`);
const users: Profile[] = result.map((el) => {
const profile: Profile = destr(el.content);
return {
pubkey: el.pubkey,
ident: profile.name || profile.display_name || profile.username || 'anon',
picture:
profile.picture ||
profile.image ||
'https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih.jpg',
};
});
return users;
}
// get user metadata
export async function getUserMetadata(pubkey: string) {
const db = await connect();
const result = await db.select(`SELECT * FROM metadata WHERE pubkey = "${pubkey}";`);
if (result[0]) {
return { ...result[0], ...JSON.parse(result[0].content) } as Profile;
} else {
return null;
}
}
// delete privkey
export async function removePrivkey() {
const db = await connect();
const activeAccount = await getActiveAccount();
return await db.execute(
`UPDATE accounts SET privkey = "privkey is stored in secure storage" WHERE id = "${activeAccount.id}";`
);
}
// get relays
export async function getRelays() {
const db = await connect();
const activeAccount = await getActiveAccount();
return (await db.select(
`SELECT * FROM relays WHERE account_id = "${activeAccount.id}";`
)) as Relays[];
}
// get relays
export async function getExplicitRelayUrls() {
const db = await connect();
const activeAccount = await getActiveAccount();
if (!activeAccount) return null;
const result: Relays[] = await db.select(
`SELECT * FROM relays WHERE account_id = "${activeAccount.id}";`
);
if (result.length > 0) return result.map((el) => el.relay);
return null;
}
// create relay
export async function createRelay(relay: string, purpose?: string) {
const db = await connect();
const activeAccount = await getActiveAccount();
return await db.execute(
'INSERT OR IGNORE INTO relays (account_id, relay, purpose) VALUES (?, ?, ?);',
[activeAccount.id, relay, purpose || '']
);
}
// remove relay
export async function removeRelay(relay: string) {
const db = await connect();
return await db.execute(`DELETE FROM relays WHERE relay = "${relay}";`);
}

View File

@ -2,7 +2,7 @@ import { BaseDirectory, removeFile } from '@tauri-apps/plugin-fs';
import Database from '@tauri-apps/plugin-sql';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { Account, Relays, Widget } from '@utils/types';
import { Account, LumeEvent, Relays, Widget } from '@utils/types';
export class LumeStorage {
public db: Database;
@ -133,16 +133,14 @@ export class LumeStorage {
);
}
public async getEventByKey(cacheKey: string) {
const event = await this.db.select(
'SELECT * FROM events WHERE cache_key = $1 ORDER BY id DESC LIMIT 1;',
public async getALlEventByKey(cacheKey: string) {
const events: LumeEvent[] = await this.db.select(
'SELECT * FROM events WHERE cache_key = $1 ORDER BY id DESC;',
[cacheKey]
)?.[0];
if (!event) {
// console.error('failed to get event by cache_key: ', cacheKey);
return null;
}
return event;
);
if (events.length < 1) return null;
return events;
}
public async getEventByID(id: string) {
@ -150,20 +148,20 @@ export class LumeStorage {
'SELECT * FROM events WHERE event_id = $1 ORDER BY id DESC LIMIT 1;',
[id]
)?.[0];
if (!event) {
// console.error('failed to get event by id: ', id);
return null;
}
if (!event) return null;
return event;
}
public async getExplicitRelayUrls() {
if (!this.account) return null;
const result: Relays[] = await this.db.select(
`SELECT * FROM relays WHERE account_id = "${this.account.id}";`
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
);
if (result.length > 0) return result.map((el) => el.relay);
return null;
if (result.length < 1) return null;
return result.map((el) => el.relay);
}
public async createRelay(relay: string, purpose?: string) {

View File

@ -1,92 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { produce } from 'immer';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { createChat, getLastLogin } from '@libs/storage';
import { Image } from '@shared/image';
import { NetworkStatusIndicator } from '@shared/networkStatusIndicator';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification';
const lastLogin = await getLastLogin();
export function ActiveAccount({ data }: { data: { pubkey: string; npub: string } }) {
const queryClient = useQueryClient();
const { ndk } = useNDK();
const { status, user } = useProfile(data.pubkey);
const chat = useMutation({
mutationFn: (data: any) => {
return createChat(
data.id,
data.receiver_pubkey,
data.sender_pubkey,
data.content,
data.tags,
data.created_at
);
},
onSuccess: (data: any) => {
const prev = queryClient.getQueryData(['chats']);
const next = produce(prev, (draft: any) => {
const target = draft.findIndex(
(m: { sender_pubkey: string }) => m.sender_pubkey === data
);
if (target !== -1) {
draft[target]['new_messages'] = draft[target]['new_messages'] + 1 || 1;
} else {
draft.push({ sender_pubkey: data, new_messages: 1 });
}
});
queryClient.setQueryData(['chats'], next);
},
});
useEffect(() => {
const since = lastLogin > 0 ? lastLogin : Math.floor(Date.now() / 1000);
const sub = ndk.subscribe(
{
kinds: [4],
'#p': [data.pubkey],
since: since,
},
{
closeOnEose: false,
}
);
sub.addListener('event', (event) => {
switch (event.kind) {
case 4:
// update state
chat.mutate({
id: event.id,
receiver_pubkey: data.pubkey,
sender_pubkey: event.pubkey,
content: event.content,
tags: event.tags,
created_at: event.created_at,
});
// send native notifiation
sendNativeNotification("You've received new message");
break;
default:
break;
}
});
return () => {
sub.stop();
};
}, []);
if (status === 'loading') {
return <div className="h-9 w-9 animate-pulse rounded-md bg-white/50" />;
}

View File

@ -1,13 +1,11 @@
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { getAllMetadata } from '@libs/storage';
import { MentionList } from '@shared/composer';
export const Suggestion = {
items: async ({ query }) => {
const users = await getAllMetadata();
const users = [];
return users
.filter((item) => item.ident.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);

View File

@ -1,5 +1,4 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useHotkeys } from 'react-hotkeys-hook';
import { useStorage } from '@libs/storage/provider';
@ -12,14 +11,11 @@ import {
} from '@shared/icons';
import { useComposer } from '@stores/composer';
import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
export function ComposerModal() {
const { db } = useStorage();
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
useHotkeys(COMPOSE_SHORTCUT, () => toggle(true));
return (
<Dialog.Root open={open} onOpenChange={toggle}>
<Dialog.Trigger asChild>

View File

@ -4,6 +4,8 @@ import { useQueryClient } from '@tanstack/react-query';
import { Fragment, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { CancelIcon, CheckCircleIcon, LoaderIcon, UnverifiedIcon } from '@shared/icons';
@ -11,7 +13,6 @@ import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
export function EditProfileModal() {
@ -23,8 +24,8 @@ export function EditProfileModal() {
const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: false, text: '' });
const { db } = useStorage();
const { publish } = useNostr();
const { account } = useAccount();
const {
register,
handleSubmit,
@ -33,7 +34,7 @@ export function EditProfileModal() {
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res: any = queryClient.getQueryData(['user', account.pubkey]);
const res: any = queryClient.getQueryData(['user', db.account.pubkey]);
if (res.image) {
setPicture(res.image);
}
@ -70,7 +71,7 @@ export function EditProfileModal() {
});
if (!res.ok) return false;
if (res.data.names[username] === account.pubkey) {
if (res.data.names[username] === db.account.pubkey) {
setNIP05((prev) => ({ ...prev, verified: true }));
return true;
} else {
@ -119,7 +120,7 @@ export function EditProfileModal() {
if (event.id) {
setTimeout(() => {
// invalid cache
queryClient.invalidateQueries(['user', account.pubkey]);
queryClient.invalidateQueries(['user', db.account.pubkey]);
// reset form
reset();
// reset state

View File

@ -1,27 +1,15 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { relaunch } from '@tauri-apps/plugin-process';
import { Fragment, useState } from 'react';
import { removeAll } from '@libs/storage';
import { CancelIcon, LogoutIcon } from '@shared/icons';
export function Logout() {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const logout = async () => {
// reset database
await removeAll();
// await removeAll();
// reset react query
queryClient.clear();
// navigate
@ -69,7 +57,6 @@ export function Logout() {
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeModal}
className="inline-flex h-9 items-center justify-center rounded-md px-3 text-sm font-medium text-white/50 hover:bg-white/10"
>
Cancel

View File

@ -1,5 +1,7 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { useStorage } from '@libs/storage/provider';
import { ThreadIcon } from '@shared/icons';
import { MoreActions } from '@shared/notes/actions/more';
import { NoteReaction } from '@shared/notes/actions/reaction';
@ -7,7 +9,7 @@ import { NoteReply } from '@shared/notes/actions/reply';
import { NoteRepost } from '@shared/notes/actions/repost';
import { NoteZap } from '@shared/notes/actions/zap';
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
export function NoteActions({
@ -21,6 +23,7 @@ export function NoteActions({
noOpenThread?: boolean;
root?: string;
}) {
const { db } = useStorage();
const setWidget = useWidgets((state) => state.setWidget);
return (
@ -40,8 +43,8 @@ export function NoteActions({
<button
type="button"
onClick={() =>
setWidget({
kind: BLOCK_KINDS.thread,
setWidget(db, {
kind: widgetKinds.thread,
title: 'Thread',
content: id,
})

View File

@ -1,4 +1,4 @@
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
export function Hashtag({ tag }: { tag: string }) {
@ -9,7 +9,7 @@ export function Hashtag({ tag }: { tag: string }) {
type="button"
onClick={() =>
setWidget({
kind: BLOCK_KINDS.hashtag,
kind: widgetKinds.hashtag,
title: tag,
content: tag.replace('#', ''),
})

View File

@ -24,11 +24,11 @@ export function NoteKind_1({
<div className="w-11 shrink-0" />
<div className="relative z-20 flex-1">
<NoteContent content={content} />
<NoteActions id={event.event_id || event.id} pubkey={event.pubkey} />
<NoteActions id={event.id || event.id} pubkey={event.pubkey} />
</div>
</div>
{!skipMetadata ? (
<NoteMetadata id={event.event_id || event.id} />
<NoteMetadata id={event.id || event.id} />
) : (
<div className="pb-3" />
)}

View File

@ -27,10 +27,10 @@ export function NoteKind_1063({ event }: { event: LumeEvent }) {
className="h-auto w-full rounded-lg object-cover"
/>
)}
<NoteActions id={event.event_id} pubkey={event.pubkey} />
<NoteActions id={event.id} pubkey={event.pubkey} />
</div>
</div>
<NoteMetadata id={event.event_id} />
<NoteMetadata id={event.id} />
</div>
</div>
</div>

View File

@ -31,7 +31,7 @@ export function SubNote({ id, root }: { id: string; root?: string }) {
<div className="w-11 shrink-0" />
<div className="relative z-20 flex-1">
<NoteContent content={data.content} long={data.kind === 30023} />
<NoteActions id={data.event_id} pubkey={data.pubkey} root={root} />
<NoteActions id={data.id} pubkey={data.pubkey} root={root} />
</div>
</div>
</div>

View File

@ -28,10 +28,10 @@ export function NoteThread({
<div className="w-11 shrink-0" />
<div className="relative z-20 flex-1">
<NoteContent content={content} />
<NoteActions id={event.event_id} pubkey={event.pubkey} />
<NoteActions id={event.id} pubkey={event.pubkey} />
</div>
</div>
<NoteMetadata id={event.event_id} />
<NoteMetadata id={event.id} />
</div>
</div>
</div>

View File

@ -25,10 +25,10 @@ export function NoteKindUnsupport({ event }: { event: LumeEvent }) {
<p>{event.content.toString()}</p>
</div>
</div>
<NoteActions id={event.event_id} pubkey={event.pubkey} />
<NoteActions id={event.id} pubkey={event.pubkey} />
</div>
</div>
<NoteMetadata id={event.event_id} />
<NoteMetadata id={event.id} />
</div>
</div>
</div>

View File

@ -5,7 +5,7 @@ import remarkGfm from 'remark-gfm';
import { MentionUser, NoteSkeleton } from '@shared/notes';
import { User } from '@shared/user';
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { useEvent } from '@utils/hooks/useEvent';
@ -17,7 +17,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const openThread = (event, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
setWidget({ kind: BLOCK_KINDS.thread, title: 'Thread', content: thread });
setWidget({ kind: widgetKinds.thread, title: 'Thread', content: thread });
} else {
event.stopPropagation();
}

View File

@ -1,4 +1,4 @@
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { useProfile } from '@utils/hooks/useProfile';
@ -13,7 +13,7 @@ export function MentionUser({ pubkey }: { pubkey: string }) {
type="button"
onClick={() =>
setWidget({
kind: BLOCK_KINDS.user,
kind: widgetKinds.user,
title: user?.nip05 || user?.name || user?.display_name,
content: pubkey,
})

View File

@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query';
import { decode } from 'light-bolt11-decoder';
import { useNDK } from '@libs/ndk/provider';
import { createReplyNote } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { MiniUser } from '@shared/notes/users/mini';
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { compactNumber } from '@utils/number';
@ -16,6 +16,7 @@ import { compactNumber } from '@utils/number';
export function NoteMetadata({ id }: { id: string }) {
const setWidget = useWidgets((state) => state.setWidget);
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery(
['note-metadata', id],
@ -35,15 +36,6 @@ export function NoteMetadata({ id }: { id: string }) {
case 1:
replies += 1;
if (users.length < 3) users.push(event.pubkey);
createReplyNote(
id,
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at
);
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === 'bolt11')[1];
@ -93,7 +85,11 @@ export function NoteMetadata({ id }: { id: string }) {
<button
type="button"
onClick={() =>
setWidget({ kind: BLOCK_KINDS.thread, title: 'Thread', content: id })
setWidget(db, {
kind: widgetKinds.thread,
title: 'Thread',
content: id,
})
}
className="text-white/50"
>

View File

@ -18,11 +18,7 @@ export function Reply({ event, root }: { event: LumeEvent; root?: string }) {
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={content} />
<NoteActions
id={event.event_id || event.id}
pubkey={event.pubkey}
root={root}
/>
<NoteActions id={event.id || event.id} pubkey={event.pubkey} root={root} />
</div>
</div>
<div>

View File

@ -16,7 +16,7 @@ export function SubReply({ event }: { event: LumeEvent }) {
<div className="w-11 shrink-0" />
<div className="flex-1">
<NoteContent content={content} />
<NoteActions id={event.event_id || event.id} pubkey={event.pubkey} />
<NoteActions id={event.id || event.id} pubkey={event.pubkey} />
</div>
</div>
</div>

View File

@ -1,35 +0,0 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
export function Protected({ children }: { children: ReactNode }) {
const privkey = useStronghold((state) => state.privkey);
const { status, account } = useAccount();
if (status === 'loading') {
return (
<div className="flex h-full w-full items-center justify-center bg-black/90">
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
</div>
);
}
if (!account) {
return <Navigate to="/auth/welcome" replace />;
}
if (account && account.privkey.length > 35) {
return <Navigate to="/auth/migrate" replace />;
}
if (account && !privkey) {
return <Navigate to="/auth/unlock" replace />;
}
return children;
}

View File

@ -1,8 +1,11 @@
import { useStorage } from '@libs/storage/provider';
import { CancelIcon } from '@shared/icons';
import { useWidgets } from '@stores/widgets';
export function TitleBar({ id, title }: { id?: string; title: string }) {
const { db } = useStorage();
const remove = useWidgets((state) => state.removeWidget);
return (
@ -15,7 +18,7 @@ export function TitleBar({ id, title }: { id?: string; title: string }) {
{id ? (
<button
type="button"
onClick={() => remove(id)}
onClick={() => remove(db, id)}
className="inline-flex h-6 w-6 shrink translate-y-8 transform items-center justify-center rounded transition-transform duration-150 ease-in-out hover:bg-white/10 group-hover:translate-y-0"
>
<CancelIcon className="h-3 w-3 text-white" />

View File

@ -8,7 +8,7 @@ import { DEFAULT_AVATAR } from '@stores/constants';
import { formatCreatedAt } from '@utils/createdAt';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub, shortenKey } from '@utils/shortenKey';
import { displayNpub } from '@utils/shortenKey';
export function User({
pubkey,
@ -85,7 +85,7 @@ export function User({
{user?.nip05?.toLowerCase() ||
user?.name ||
user?.display_name ||
shortenKey(pubkey)}
displayNpub(pubkey, 16)}
</h5>
<span className="leading-none text-white/50">·</span>
<span className="leading-none text-white/50">{createdAt}</span>

View File

@ -1,92 +0,0 @@
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { createChannelMessage, getChannelMessages, getChannels } from '@libs/storage';
import { LumeEvent } from '@utils/types';
export const useChannels = create(
immer((set) => ({
channels: [],
fetch: async () => {
const response = await getChannels();
set({ channels: response });
},
add: (event) => {
set((state) => {
const target = state.channels.findIndex(
(m: { event_id: string }) => m.event_id === event.id
);
if (target !== -1) {
state.channels[target]['new_messages'] =
state.channels[target]['new_messages'] + 1 || 1;
} else {
state.channels.push({ event_id: event.id, ...event });
}
});
},
clearBubble: (id: string) => {
set((state) => {
const target = state.channels.findIndex(
(m: { event_id: string }) => m.event_id === id
);
state.channels[target]['new_messages'] = 0;
});
},
}))
);
export const useChannelMessages = create(
immer((set) => ({
messages: [],
replyTo: { id: null, pubkey: null, content: null },
fetch: async (id: string) => {
const events = await getChannelMessages(id);
set({ messages: events });
},
add: (id, event: LumeEvent) => {
set((state: any) => {
createChannelMessage(
id,
event.id,
event.pubkey,
event.kind,
event.content,
event.tags,
event.created_at
);
state.messages.push({
event_id: event.id,
channel_id: id,
hide: 0,
mute: 0,
...event,
});
});
},
openReply: (id: string, pubkey: string, content: string) => {
set(() => ({ replyTo: { id, pubkey, content } }));
},
closeReply: () => {
set(() => ({ replyTo: { id: null, pubkey: null, content: null } }));
},
hideMessage: (id: string) => {
set((state) => {
const target = state.messages.findIndex((m) => m.id === id);
state.messages[target]['hide'] = true;
});
},
muteUser: (pubkey: string) => {
set((state) => {
const target = state.messages.findIndex((m) => m.pubkey === pubkey);
state.messages[target]['mute'] = true;
});
},
clear: () => {
set(() => ({
messages: [],
replyTo: { id: null, pubkey: null, content: null },
}));
},
}))
);

View File

@ -1,76 +1,12 @@
export const APP_VERSION = '1.2.0';
export const DEFAULT_AVATAR = 'https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih';
export const OPENGRAPH = {
REGEX_VALID_URL: new RegExp(
'^' +
// protocol identifier
'(?:(?:https?|ftp)://)' +
// user:pass authentication
'(?:\\S+(?::\\S*)?@)?' +
'(?:' +
// IP address exclusion
// private & local networks
'(?!(?:10|127)(?:\\.\\d{1,3}){3})' +
'(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' +
'(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broacast addresses
// (first & last IP address of each class)
'(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' +
'(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' +
'(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' +
'|' +
// host name
'(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' +
// domain name
'(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' +
// TLD identifier
'(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' +
// TLD may end with dot
'\\.?' +
')' +
// port number
'(?::\\d{2,5})?' +
// resource path
'(?:[/?#]\\S*)?' +
'$',
'i'
),
REGEX_LOOPBACK: new RegExp(
'^' +
'(?:(?:10|127)(?:\\.\\d{1,3}){3})' +
'|' +
'(?:(?:169\\.254|192\\.168|192\\.0)(?:\\.\\d{1,3}){2})' +
'|' +
'(?:172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' +
'$',
'i'
),
REGEX_CONTENT_TYPE_IMAGE: new RegExp('image/.*', 'i'),
REGEX_CONTENT_TYPE_AUDIO: new RegExp('audio/.*', 'i'),
REGEX_CONTENT_TYPE_VIDEO: new RegExp('video/.*', 'i'),
REGEX_CONTENT_TYPE_TEXT: new RegExp('text/.*', 'i'),
REGEX_CONTENT_TYPE_APPLICATION: new RegExp('application/.*', 'i'),
};
export const FULL_RELAYS = [
'wss://relayable.org',
'wss://relay.damus.io',
'wss://relay.nostr.band/all',
'wss://nostr.mutinywallet.com',
];
export const BLOCK_KINDS = {
export const widgetKinds = {
image: 0,
feed: 1,
thread: 2,

View File

@ -1,4 +0,0 @@
export const COMPOSE_SHORTCUT = 'meta+n';
export const ADD_IMAGEBLOCK_SHORTCUT = 'meta+i';
export const ADD_FEEDBLOCK_SHORTCUT = 'meta+f';
export const ADD_HASHTAGBLOCK_SHORTCUT = 'meta+t';

View File

@ -18,6 +18,15 @@ export const useWidgets = create<WidgetState>()(
widgets: null,
fetchWidgets: async (db: LumeStorage) => {
const widgets = await db.getWidgets();
// default: add network widget
widgets.unshift({
id: String(widgets.length + 1),
title: 'Network',
content: '',
kind: 9999,
});
set({ widgets: widgets });
},
setWidget: async (db: LumeStorage, { kind, title, content }: Widget) => {

View File

@ -1,25 +1,2 @@
// get X days ago with user provided date
export function getDayAgo(numOfDays, date = new Date()) {
const days = new Date(date.getTime());
days.setDate(date.getDate() - numOfDays);
return days;
}
// get X hours ago with user provided date
export function getHourAgo(numOfHours, date = new Date()) {
const hours = new Date(date.getTime());
hours.setHours(date.getHours() - numOfHours);
return hours;
}
// convert date to unix timestamp
export function dateToUnix(_date?: Date) {
const date = _date || new Date();
return Math.floor(date.getTime() / 1000);
}
export const nHoursAgo = (hrs: number): number =>
Math.floor((Date.now() - hrs * 60 * 60 * 1000) / 1000);

View File

@ -1,31 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
export function useAccount() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data: account } = useQuery(
['account'],
async () => {
const account = await db.getActiveAccount();
console.log('account: ', account);
if (account?.pubkey) {
const user = ndk.getUser({ hexpubkey: account?.pubkey });
await user.fetchProfile();
return { ...account, ...user.profile };
}
return account;
},
{
enabled: !!ndk,
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}
);
return { status, account };
}

View File

@ -16,7 +16,7 @@ export function useEvent(id: string, embed?: string) {
return embed as unknown as LumeEvent;
} else {
const event = (await ndk.fetchEvent(id)) as LumeEvent;
if (!event) return null;
if (!event) throw new Error('event not found');
if (event.kind === 1) event['content'] = parser(event) as unknown as string;
return event as LumeEvent;
}

View File

@ -6,7 +6,6 @@ import {
NDKSubscription,
NDKUser,
} from '@nostr-dev-kit/ndk';
import { destr } from 'destr';
import { LRUCache } from 'lru-cache';
import { nip19 } from 'nostr-tools';
import { useMemo } from 'react';
@ -17,6 +16,14 @@ import { useStorage } from '@libs/storage/provider';
import { useStronghold } from '@stores/stronghold';
import { nHoursAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
interface NotesResponse {
status: string;
data: LumeEvent[];
nextCursor?: number;
message?: string;
}
export function useNostr() {
const { ndk } = useNDK();
@ -37,6 +44,8 @@ export function useNostr() {
callback: (event: NDKEvent) => void,
closeOnEose?: boolean
) => {
if (!ndk) throw new Error('NDK instance not found');
const subEvent = ndk.subscribe(filter, { closeOnEose: closeOnEose ?? true });
subManager.set(JSON.stringify(filter), subEvent);
@ -78,17 +87,22 @@ export function useNostr() {
}
};
const fetchNotes = async (since: number) => {
const fetchNotes = async (since: number): Promise<NotesResponse> => {
try {
if (!ndk) return { status: 'failed', message: 'NDK instance not found' };
if (!ndk) return { status: 'failed', data: [], message: 'NDK instance not found' };
console.log('fetch all events since: ', since);
const events = await ndk.fetchEvents({
kinds: [1],
authors: db.account.network ?? db.account.follows,
since: nHoursAgo(since),
});
return { status: 'ok', data: [...events], nextCursor: since * 2 };
const sorted = [...events].sort(
(a, b) => b.created_at - a.created_at
) as unknown as LumeEvent[];
return { status: 'ok', data: sorted, nextCursor: since * 2 };
} catch (e) {
console.error('failed get notes, error: ', e);
return { status: 'failed', data: [], message: e };
@ -122,14 +136,6 @@ export function useNostr() {
};
const createZap = async (event: NDKEvent, amount: number, message?: string) => {
// @ts-expect-error, LumeEvent to NDKEvent
event.id = event.event_id;
// @ts-expect-error, LumeEvent to NDKEvent
if (typeof event.content !== 'string') event.content = event.content.original;
if (typeof event.tags === 'string') event.tags = destr(event.tags);
if (!privkey) throw new Error('Private key not found');
if (!ndk.signer) {
@ -137,7 +143,7 @@ export function useNostr() {
ndk.signer = signer;
}
// @ts-expect-error, LumeEvent to NDKEvent
// @ts-expect-error, NostrEvent to NDKEvent
const ndkEvent = new NDKEvent(ndk, event);
const res = await ndkEvent.zap(amount, message ?? 'zap from lume');

View File

@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/tauri';
import { Opengraph } from '@utils/types';
export function useOpenGraph(url: string) {
const { status, data, error, isFetching } = useQuery(
const { status, data, error } = useQuery(
['preview', url],
async () => {
const res: Opengraph = await invoke('opengraph', { url });
@ -25,6 +25,5 @@ export function useOpenGraph(url: string) {
status,
data,
error,
isFetching,
};
}

View File

@ -1,38 +0,0 @@
import { BaseDirectory, appConfigDir } from '@tauri-apps/api/path';
import { removeFile } from '@tauri-apps/plugin-fs';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
const dir = await appConfigDir();
export function useSecureStorage() {
async function getClient(stronghold: Stronghold) {
try {
return await stronghold.loadClient('lume');
} catch {
return await stronghold.createClient('lume');
}
}
const save = async (key: string, value: string, password: string) => {
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, password);
const client = await getClient(stronghold);
const store = client.getStore();
await store.insert(key, Array.from(new TextEncoder().encode(value)));
return await stronghold.save();
};
const load = async (key: string, password: string) => {
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, password);
const client = await getClient(stronghold);
const store = client.getStore();
const value = await store.get(key);
const decoded = new TextDecoder().decode(new Uint8Array(value));
return decoded;
};
const reset = async () => {
return await removeFile('lume.stronghold', { dir: BaseDirectory.AppConfig });
};
return { save, load, reset };
}

View File

@ -1,30 +1,5 @@
import { NDKTag } from '@nostr-dev-kit/ndk';
import { destr } from 'destr';
import { nip19 } from 'nostr-tools';
export function truncateContent(str, n) {
return str.length > n ? `${str.slice(0, n - 1)}...` : str;
}
export function setToArray(tags: any) {
const newArray = [];
tags.forEach((item) => {
const hexpubkey = nip19.decode(item.npub).data;
newArray.push(hexpubkey);
});
return newArray;
}
// convert NIP-02 to array of pubkey
export function nip02ToArray(tags: any) {
const arr = [];
tags.forEach((item) => {
arr.push(item[1]);
});
return arr;
}
// convert array to NIP-02 tag list
export function arrayToNIP02(arr: string[]) {
@ -36,52 +11,9 @@ export function arrayToNIP02(arr: string[]) {
return nip02_arr;
}
// convert array object to pure array
export function arrayObjToPureArr(arr: any) {
const pure_arr = [];
arr.forEach((item) => {
pure_arr.push(item.content);
});
return pure_arr;
}
// get parent id from event tags
export function getParentID(arr: string[][], fallback: string) {
const tags = destr(arr);
let parentID = fallback;
if (tags.length > 0) {
if (tags[0][0] === 'e') {
parentID = tags[0][1];
} else {
tags.forEach((tag) => {
if (tag[0] === 'e' && (tag[2] === 'root' || tag[3] === 'root')) {
parentID = tag[1];
}
});
}
}
return parentID;
}
// check id present in event tags
export function isTagsIncludeID(id: string, arr: NDKTag[]) {
const tags = destr(arr);
if (tags.length > 0) {
if (tags[0][1] === id) {
return true;
}
} else {
return false;
}
}
// get parent id from event tags
// get repost id from event tags
export function getRepostID(arr: NDKTag[]) {
const tags = destr(arr);
const tags = destr(arr) as string[];
let quoteID = null;
if (tags.length > 0) {
@ -94,12 +26,3 @@ export function getRepostID(arr: NDKTag[]) {
return quoteID;
}
// sort events by timestamp
export function sortEvents(arr: any) {
arr.sort((a, b) => {
return a.created_at - b.created_at;
});
return arr;
}

View File

@ -1,19 +1,9 @@
import react from '@vitejs/plugin-react-swc';
// import million from 'million/compiler';
import { defineConfig } from 'vite';
import topLevelAwait from 'vite-plugin-top-level-await';
import viteTsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
// million.vite(),
react(),
viteTsconfigPaths(),
topLevelAwait({
promiseExportName: '__tla',
promiseImportName: (i) => `__tla_${i}`,
}),
],
plugins: [react(), viteTsconfigPaths()],
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',