wip: refactor

This commit is contained in:
Ren Amamiya 2023-08-17 15:11:40 +07:00
parent ab61bfb2cd
commit 414dd50a5c
33 changed files with 958 additions and 648 deletions

View File

@ -20,6 +20,7 @@
"@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",
@ -44,21 +45,21 @@
"@tauri-apps/plugin-stronghold": "github:tauri-apps/tauri-plugin-stronghold#v2",
"@tauri-apps/plugin-upload": "github:tauri-apps/tauri-plugin-upload#v2",
"@tauri-apps/plugin-window": "github:tauri-apps/tauri-plugin-window#v2",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-mention": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"@tiptap/extension-image": "^2.1.1",
"@tiptap/extension-mention": "^2.1.1",
"@tiptap/extension-placeholder": "^2.1.1",
"@tiptap/pm": "^2.1.1",
"@tiptap/react": "^2.1.1",
"@tiptap/starter-kit": "^2.1.1",
"@tiptap/suggestion": "^2.1.1",
"@void-cat/api": "^1.0.7",
"dayjs": "^1.11.9",
"destr": "^2.0.1",
"get-urls": "^12.1.0",
"html-to-text": "^9.0.5",
"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",
@ -68,7 +69,6 @@
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-router-dom": "^6.15.0",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.5.0",
"remark-gfm": "^3.0.1",
"tippy.js": "^6.3.7",
@ -93,7 +93,7 @@
"eslint": "^8.47.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^14.0.0",
@ -105,7 +105,6 @@
"tailwindcss": "^3.3.3",
"typescript": "^5.1.6",
"vite": "^4.4.9",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
}
}

File diff suppressed because it is too large Load Diff

20
src-tauri/Cargo.lock generated
View File

@ -2912,9 +2912,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5381209b232fae437a2dd8054c9fe4953fb7a4b05e6e437f64aacdd01f97859"
checksum = "a7de0cd2fdb9ef32781658513eab9080a22b3a9775955019daaf1f54bd37753a"
dependencies = [
"cocoa 0.25.0",
"crossbeam-channel",
@ -5589,9 +5589,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.31.0"
version = "1.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40de3a2ba249dcb097e01be5e67a5ff53cf250397715a071a81543e8a832a920"
checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9"
dependencies = [
"backtrace",
"bytes",
@ -5745,9 +5745,9 @@ dependencies = [
[[package]]
name = "tray-icon"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "672bc5a19944bfd51e5a874e9fcb7f129b76e5dd448fe295fa25411f3f800637"
checksum = "6b164327e17101c78ba3dfdf879b977027ef1bd7855668ac30063de21fc02447"
dependencies = [
"cocoa 0.25.0",
"core-graphics 0.23.1",
@ -6492,9 +6492,9 @@ checksum = "d419259aba16b663966e29e6d7c6ecfa0bb8425818bb96f6f1f3c3eb71a6e7b9"
[[package]]
name = "winnow"
version = "0.5.11"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e461589e194280efaa97236b73623445efa195aa633fd7004f39805707a9d53"
checksum = "83817bbecf72c73bad717ee86820ebf286203d2e04c3951f3cd538869c897364"
dependencies = [
"memchr",
]
@ -6520,9 +6520,9 @@ dependencies = [
[[package]]
name = "wry"
version = "0.31.0"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6289018fa3cbc051c13f4ae1a102d80c3f35a527456c75567eb2cad6989020"
checksum = "07bf838a5430184dfe0b1f568af7998a455c0df75a1df300a3894e0f181e7408"
dependencies = [
"base64 0.21.2",
"block",

View File

@ -16,7 +16,10 @@ tauri-build = { version = "2.0.0-alpha.8", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.0.0-alpha.11", features = ["macos-private-api"] }
tauri = { version = "2.0.0-alpha.11", features = [
"protocol-asset",
"macos-private-api",
] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }

View File

@ -1,9 +1,12 @@
-- Add migration script here
CREATE TABLE
events (
id INTEGER NOT NULL PRIMARY KEY,
cache_key TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
event_kind INTEGER NOT NULL DEFAULT 1,
event TEXT NOT NULL
id TEXT NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL,
event TEXT NOT NULL,
author TEXT NOT NULL,
root_id TEXT,
reply_id TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);

View File

@ -0,0 +1,3 @@
-- Add migration script here
ALTER TABLE accounts
ADD COLUMN last_login_at NUMBER NOT NULL DEFAULT 0;

View File

@ -129,6 +129,12 @@ fn main() {
sql: include_str!("../migrations/20230816090508_clean_up_tables.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230817014932,
description: "add last login to account",
sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),

View File

@ -80,7 +80,18 @@
}
},
"security": {
"csp": "upgrade-insecure-requests"
"csp": {
"connect-src": "ipc: https://ipc.localhost",
"content-security-policy": "upgrade-insecure-requests"
},
"freezePrototype": false,
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["$APPCONFIG/*.db", "$RESOURCE/**"],
"deny": ["$APPCONFIG/*.stronghold"]
}
}
},
"windows": [
{

View File

@ -1,4 +1,3 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BaseDirectory, writeTextFile } from '@tauri-apps/plugin-fs';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react';
@ -16,7 +15,6 @@ import { useStronghold } from '@stores/stronghold';
export function CreateStep1Screen() {
const { db } = useStorage();
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
@ -52,33 +50,16 @@ export function CreateStep1Screen() {
setDownloaded(true);
};
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[][];
is_active: number;
}) => {
return db.createAccount(data.npub, data.pubkey);
},
onSuccess: (data) => {
queryClient.setQueryData(['account'], data);
},
});
const submit = () => {
setLoading(true);
// update state
setPrivkey(privkey);
setTempPrivkey(privkey); // only use if user close app and reopen it
setPubkey(pubkey);
account.mutate({
npub,
pubkey,
follows: null,
is_active: 1,
});
// save to database
db.createAccount(npub, pubkey);
// redirect to next step
navigate('/auth/create/step-2', { replace: true });

View File

@ -1,4 +1,3 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
@ -17,7 +16,6 @@ import { useNostr } from '@utils/hooks/useNostr';
export function CreateStep3Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
@ -47,8 +45,6 @@ export function CreateStep3Screen() {
tags: [],
});
queryClient.invalidateQueries(['account']);
if (event) {
navigate('/auth/onboarding', { replace: true });
}

View File

@ -1,4 +1,3 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
@ -31,9 +30,6 @@ const resolver: Resolver<FormValues> = async (values) => {
};
export function ImportStep1Screen() {
const { db } = useStorage();
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setTempPubkey = useOnboarding((state) => state.setTempPrivkey);
@ -42,20 +38,7 @@ export function ImportStep1Screen() {
const [loading, setLoading] = useState(false);
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[];
is_active: number | boolean;
}) => {
return db.createAccount(data.npub, data.pubkey);
},
onSuccess: (data) => {
queryClient.setQueryData(['account'], data);
},
});
const { db } = useStorage();
const {
register,
setError,
@ -81,12 +64,7 @@ export function ImportStep1Screen() {
setPubkey(pubkey);
// add account to local database
account.mutate({
npub,
pubkey,
follows: null,
is_active: 1,
});
db.createAccount(npub, pubkey);
// redirect to step 2
navigate('/auth/import/step-2', { replace: true });

View File

@ -1,4 +1,3 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
@ -13,7 +12,6 @@ import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
export function ImportStep3Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
@ -30,10 +28,6 @@ export function ImportStep3Screen() {
const data = await fetchUserData();
if (data.status === 'ok') {
// update last login
await db.updateLastLogin(Math.floor(Date.now() / 1000));
queryClient.invalidateQueries(['account']);
navigate('/auth/onboarding/step-2', { replace: true });
} else {
console.log('error: ', data.message);

View File

@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
@ -14,12 +14,11 @@ import { useNostr } from '@utils/hooks/useNostr';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardStep1Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const { publish, fetchUserData, prefetchEvents } = useNostr();
const { db } = useStorage();
const { publish, fetchUserData } = useNostr();
const { status, data } = useQuery(['trending-profiles'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
@ -45,14 +44,13 @@ export function OnboardStep1Screen() {
const tags = arrayToNIP02([...follows, db.account.pubkey]);
const event = await publish({ content: '', kind: 3, tags: tags });
await db.updateAccount('follows', follows);
// prefetch notes with current follows
const data = await fetchUserData(follows);
// prefetch data
const user = await fetchUserData(follows);
const data = await prefetchEvents();
// redirect to next step
if (event && data.status === 'ok') {
queryClient.invalidateQueries(['account']);
if (event && user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
setLoading(false);

View File

@ -1,92 +1,112 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { LumeEvent, Widget } from '@utils/types';
import { DBEvent, Widget } from '@utils/types';
export function FeedBlock({ params }: { params: Widget }) {
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
export function FeedWidget({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
queryFn: async () => {
return { data: [], nextCursor: 0 };
queryFn: async ({ pageParam = 0 }) => {
const authors = JSON.parse(params.content);
return await db.getAllEventsByAuthors(authors, 20, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
const dbEvents = useMemo(
() => (data ? data.pages.flatMap((d: { data: DBEvent[] }) => d.data) : []),
[data]
);
const parentRef = useRef<HTMLDivElement>();
const virtualizer = useVirtualizer({
count: hasNextPage ? dbEvents.length : dbEvents.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
estimateSize: () => 650,
overscan: 4,
});
const items = virtualizer.getVirtualItems();
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) {
return;
}
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
// render event match event kind
const renderItem = useCallback(
(index: string | number) => {
const note: LumeEvent = notes[index];
if (!note) return;
switch (note.kind) {
const dbEvent: DBEvent = dbEvents[index];
if (!dbEvent) return;
const event: NDKEvent = JSON.parse(dbEvent.event as string);
switch (event.kind) {
case 1: {
const root = note.tags.find((el) => el[3] === 'root')?.[1];
const reply = note.tags.find((el) => el[3] === 'reply')?.[1];
if (root || reply) {
if (dbEvent.root_id || dbEvent.reply_id) {
return (
<div key={note.id} data-index={index} ref={rowVirtualizer.measureElement}>
<NoteThread event={note} root={root} reply={reply} />
<div
key={(dbEvent.root_id || dbEvent.reply_id) + dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteThread
event={event}
root={dbEvent.root_id}
reply={dbEvent.reply_id}
/>
</div>
);
} else {
return (
<div key={note.id} data-index={index} ref={rowVirtualizer.measureElement}>
<NoteKind_1 event={note} skipMetadata={false} />
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
}
}
case 6:
return (
<div key={note.id} data-index={index} ref={rowVirtualizer.measureElement}>
<Repost key={note.id} event={note} />
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={dbEvent.id} event={event} />
</div>
);
case 1063:
return (
<div key={note.id} data-index={index} ref={rowVirtualizer.measureElement}>
<NoteKind_1063 key={note.id} event={note} />
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKind_1063 key={dbEvent.id} event={event} />
</div>
);
default:
return (
<div key={note.id} data-index={index} ref={rowVirtualizer.measureElement}>
<NoteKindUnsupport key={note.id} event={note} />
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={dbEvent.id} event={event} />
</div>
);
}
},
[notes]
[dbEvents]
);
return (
@ -99,32 +119,31 @@ export function FeedBlock({ params }: { params: Widget }) {
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
) : items.length === 0 ? (
<div className="px-3 py-1.5">
<div className="bbg-white/10 rounded-xl px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
Not found any posts from last 48 hours
Not found any postrs from last 48 hours
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${totalSize}px`,
position: 'relative',
width: '100%',
height: `${virtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
transform: `translateY(${items[0].start}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
{items.map((item) => renderItem(item.index))}
</div>
</div>
)}
@ -135,6 +154,33 @@ export function FeedBlock({ params }: { params: Widget }) {
</div>
</div>
)}
<div className="px-3 py-1.5">
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{isFetchingNextPage ? (
<>
<span className="w-5" />
<span>Loading...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : hasNextPage ? (
<>
<span className="w-5" />
<span>Load more</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Nothing more to load</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</div>
);

View File

@ -1,35 +1,74 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { NoteKind_1, NoteSkeleton, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { TitleBar } from '@shared/titleBar';
import { nHoursAgo } from '@utils/date';
import { LumeEvent, Widget } from '@utils/types';
import { Widget } from '@utils/types';
export function HashtagBlock({ params }: { params: Widget }) {
export function HashtagWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(['hashtag', params.content], async () => {
const { status, data } = useQuery(['hashtag-widget', params.content], async () => {
const events = await ndk.fetchEvents({
kinds: [1],
'#t': [params.content],
since: nHoursAgo(24),
});
return [...events] as unknown as LumeEvent[];
return [...events] as unknown as NDKEvent[];
});
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
estimateSize: () => 650,
overscan: 4,
});
const items = virtualizer.getVirtualItems();
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
// render event match event kind
const renderItem = useCallback(
(index: string | number) => {
const event: NDKEvent = data[index];
if (!event) return;
switch (event.kind) {
case 1:
return (
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
case 6:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={event.id} event={event} />
</div>
);
default:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={event.id} event={event} />
</div>
);
}
},
[data]
);
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
@ -41,40 +80,31 @@ export function HashtagBlock({ params }: { params: Widget }) {
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
) : items.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm font-medium text-white">
No new posts about this hashtag in 24 hours ago
No new postrs about this hashtag in 24 hours ago
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${totalSize}px`,
position: 'relative',
width: '100%',
height: `${virtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
transform: `translateY(${items[0].start}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={data[virtualRow.index]} />
</div>
))}
{items.map((item) => renderItem(item.index))}
</div>
</div>
)}

View File

@ -1,4 +1,4 @@
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useEffect, useMemo, useRef } from 'react';
@ -6,85 +6,75 @@ import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { useNostr } from '@utils/hooks/useNostr';
import { LumeEvent } from '@utils/types';
import { DBEvent } from '@utils/types';
export function NetworkBlock() {
export function NetworkWidget() {
const { sub } = useNostr();
const { db } = useStorage();
const { sub, fetchNotes } = useNostr();
const { status, data, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['network-widget'],
queryFn: async ({ pageParam = 24 }) => {
return { data: [], nextCursor: 0 };
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['network-widget'],
queryFn: async ({ pageParam = 0 }) => {
return await db.getAllEvents(20, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const parentRef = useRef();
const notes = useMemo(
() => (data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : []),
const dbEvents = useMemo(
() => (data ? data.pages.flatMap((d: { data: DBEvent[] }) => d.data) : []),
[data]
);
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
const parentRef = useRef<HTMLDivElement>();
const virtualizer = useVirtualizer({
count: hasNextPage ? dbEvents.length : dbEvents.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
estimateSize: () => 650,
overscan: 4,
});
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 items = virtualizer.getVirtualItems();
// render event match event kind
const renderItem = useCallback(
(index: string | number) => {
const note: LumeEvent = notes[index];
if (!note) return;
switch (note.kind) {
const dbEvent: DBEvent = dbEvents[index];
if (!dbEvent) return;
const event: NDKEvent = JSON.parse(dbEvent.event as string);
switch (event.kind) {
case 1: {
let root: string;
let reply: string;
if (note.tags?.[0]?.[0] === 'e' && !note.tags?.[0]?.[3]) {
root = note.tags[0][1];
} else {
root = note.tags.find((el) => el[3] === 'root')?.[1];
reply = note.tags.find((el) => el[3] === 'reply')?.[1];
}
if (root || reply) {
if (dbEvent.root_id || dbEvent.reply_id) {
return (
<div
key={(root || reply) + note.id + index}
key={(dbEvent.root_id || dbEvent.reply_id) + dbEvent.id + index}
data-index={index}
ref={rowVirtualizer.measureElement}
ref={virtualizer.measureElement}
>
<NoteThread event={note} root={root} reply={reply} />
<NoteThread
event={event}
root={dbEvent.root_id}
reply={dbEvent.reply_id}
/>
</div>
);
} else {
return (
<div
key={note.id + index}
key={dbEvent.id + index}
data-index={index}
ref={rowVirtualizer.measureElement}
ref={virtualizer.measureElement}
>
<NoteKind_1 event={note} skipMetadata={false} />
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
}
@ -92,38 +82,52 @@ export function NetworkBlock() {
case 6:
return (
<div
key={note.id + index}
key={dbEvent.id + index}
data-index={index}
ref={rowVirtualizer.measureElement}
ref={virtualizer.measureElement}
>
<Repost key={note.id} event={note} />
<Repost key={dbEvent.id} event={event} />
</div>
);
case 1063:
return (
<div
key={note.id + index}
key={dbEvent.id + index}
data-index={index}
ref={rowVirtualizer.measureElement}
ref={virtualizer.measureElement}
>
<NoteKind_1063 key={note.id} event={note} />
<NoteKind_1063 key={dbEvent.id} event={event} />
</div>
);
default:
return (
<div
key={note.id + index}
key={dbEvent.id + index}
data-index={index}
ref={rowVirtualizer.measureElement}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={note.id} event={note} />
<NoteKindUnsupport key={dbEvent.id} event={event} />
</div>
);
}
},
[notes]
[dbEvents]
);
// subscribe for new event
// sub will be managed by lru-cache
useEffect(() => {
if (db.account && db.account.network) {
const filter: NDKFilter = {
kinds: [1, 6],
authors: db.account.network,
since: Math.floor(Date.now() / 1000),
};
sub(filter, (event) => console.log('[network] event received: ', event.content));
}
}, []);
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar title="Network" />
@ -134,7 +138,7 @@ export function NetworkBlock() {
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
) : dbEvents.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
@ -154,30 +158,56 @@ export function NetworkBlock() {
</div>
) : (
<div
className="relative w-full"
style={{
height: `${totalSize}px`,
position: 'relative',
width: '100%',
height: `${virtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
transform: `translateY(${items[0].start}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
{items.map((item) => renderItem(item.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="mb-20 px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
<div className="px-3 py-1.5">
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{isFetchingNextPage ? (
<>
<span className="w-5" />
<span>Loading...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : hasNextPage ? (
<>
<span className="w-5" />
<span>Load more</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Nothing more to load</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</div>
);

View File

@ -1,37 +1,79 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { NoteKind_1, NoteSkeleton, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { TitleBar } from '@shared/titleBar';
import { UserProfile } from '@shared/userProfile';
import { nHoursAgo } from '@utils/date';
import { LumeEvent, Widget } from '@utils/types';
export function UserBlock({ params }: { params: Widget }) {
const parentRef = useRef<HTMLDivElement>(null);
import { DBEvent, Widget } from '@utils/types';
export function UserWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(['user-feed', params.content], async () => {
const events = await ndk.fetchEvents({
kinds: [1],
authors: [params.content],
since: nHoursAgo(48),
});
return [...events] as unknown as LumeEvent[];
});
const { status, data } = useQuery(
['user-widget', params.content],
async () => {
const events = await ndk.fetchEvents({
kinds: [1],
authors: [params.content],
since: nHoursAgo(24),
});
return [...events] as unknown as DBEvent[];
},
{ refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false }
);
const rowVirtualizer = useVirtualizer({
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
overscan: 2,
estimateSize: () => 650,
overscan: 4,
});
const items = virtualizer.getVirtualItems();
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
// render event match event kind
const renderItem = useCallback(
(index: string | number) => {
const event: NDKEvent = data[index];
if (!event) return;
switch (event.kind) {
case 1:
return (
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
case 6:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={event.id} event={event} />
</div>
);
default:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={event.id} event={event} />
</div>
);
}
},
[data]
);
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
@ -49,41 +91,31 @@ export function UserBlock({ params }: { params: Widget }) {
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
) : items.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
No new posts from this user in 48 hours ago
No new postr from user in 24 hours ago
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
width: '100%',
height: `${virtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
transform: `translateY(${items[0].start}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={data[virtualRow.index]} />
</div>
))}
<div className="h-20" />
{items.map((item) => renderItem(item.index))}
</div>
</div>
)}

View File

@ -3,11 +3,11 @@ import { useCallback, useEffect } from 'react';
import { FeedModal } from '@app/space/components/modals/feed';
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 { NetworkBlock } from '@app/space/components/widgets/network';
import { FeedWidget } from '@app/space/components/widgets/feed';
import { HashtagWidget } from '@app/space/components/widgets/hashtag';
import { NetworkWidget } from '@app/space/components/widgets/network';
import { ThreadBlock } from '@app/space/components/widgets/thread';
import { UserBlock } from '@app/space/components/widgets/user';
import { UserWidget } from '@app/space/components/widgets/user';
import { useStorage } from '@libs/storage/provider';
@ -29,15 +29,15 @@ export function SpaceScreen() {
(widget: Widget) => {
switch (widget.kind) {
case 1:
return <FeedBlock key={widget.id} params={widget} />;
return <FeedWidget key={widget.id} params={widget} />;
case 2:
return <ThreadBlock key={widget.id} params={widget} />;
case 3:
return <HashtagBlock key={widget.id} params={widget} />;
return <HashtagWidget key={widget.id} params={widget} />;
case 5:
return <UserBlock key={widget.id} params={widget} />;
return <UserWidget key={widget.id} params={widget} />;
case 9999:
return <NetworkBlock key={widget.id} />;
return <NetworkWidget key={widget.id} />;
default:
break;
}
@ -52,7 +52,7 @@ 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">
{!widgets ? (
<div className="flex w-[350px] shrink-0 flex-col">
<div className="flex w-[400px] shrink-0 flex-col">
<div className="flex w-full flex-1 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-white/10" />
</div>
@ -60,14 +60,14 @@ export function SpaceScreen() {
) : (
widgets.map((widget) => renderItem(widget))
)}
<div className="flex w-[350px] shrink-0 flex-col">
<div className="flex w-[250px] shrink-0 flex-col">
<div className="inline-flex h-full w-full flex-col items-center justify-center gap-1">
<FeedModal />
<ImageModal />
<HashtagModal />
</div>
</div>
<div className="w-[250px] shrink-0" />
<div className="w-[150px] shrink-0" />
</div>
);
}

View File

@ -11,7 +11,7 @@ import { useNostr } from '@utils/hooks/useNostr';
export function SplashScreen() {
const { db } = useStorage();
const { ndk, relayUrls } = useNDK();
const { fetchUserData } = useNostr();
const { fetchUserData, prefetchEvents } = useNostr();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
@ -27,9 +27,10 @@ export function SplashScreen() {
try {
const user = await fetchUserData();
if (user.status === 'ok') {
const now = Math.floor(Date.now() / 1000);
await db.updateLastLogin(now);
const data = await prefetchEvents();
if (user.status === 'ok' && data.status === 'ok') {
await db.updateLastLogin();
await invoke('close_splashscreen');
} else {
setIsLoading(false);

View File

@ -1,14 +1,13 @@
import { NDKCacheAdapter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
import { LumeStorage } from '@libs/storage/instance';
import { Store } from '@tauri-apps/plugin-store';
export default class TauriAdapter implements NDKCacheAdapter {
public store: LumeStorage;
public store: Store;
readonly locking: boolean;
constructor(db: LumeStorage) {
this.store = db;
constructor() {
this.store = new Store('.ndk_cache.dat');
this.locking = true;
}
@ -21,34 +20,15 @@ export default class TauriAdapter implements NDKCacheAdapter {
for (const author of filter.authors) {
for (const kind of filter.kinds) {
const key = `${author}:${kind}`;
promises.concat(this.store.getALlEventByKey(key));
promises.push(this.store.get(key));
}
}
const results = await Promise.all(promises);
for (const result of results) {
if (result && result.event) {
console.log('cache hit: ', result.event);
const ndkEvent = new NDKEvent(
subscription.ndk,
JSON.parse(result.event as string)
);
subscription.eventReceived(ndkEvent, undefined, true);
}
}
}
if (filter.ids) {
for (const id of filter.ids) {
const cacheEvent = await this.store.getEventByID(id);
if (cacheEvent) {
console.log('cache hit: ', id);
const ndkEvent = new NDKEvent(
subscription.ndk,
JSON.parse(cacheEvent.event as string)
);
if (result) {
const ndkEvent = new NDKEvent(subscription.ndk, JSON.parse(result as string));
subscription.eventReceived(ndkEvent, undefined, true);
}
}
@ -60,9 +40,13 @@ export default class TauriAdapter implements NDKCacheAdapter {
const key = `${nostrEvent.pubkey}:${nostrEvent.kind}`;
return new Promise((resolve) => {
Promise.all([
this.store.createEvent(key, event.id, event.kind, JSON.stringify(nostrEvent)),
]).then(() => resolve());
Promise.all([this.store.set(key, JSON.stringify(nostrEvent))]).then(() =>
resolve()
);
});
}
public async saveCache(): Promise<void> {
return await this.store.save();
}
}

View File

@ -1,6 +1,6 @@
// inspire by: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import TauriAdapter from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider';
@ -13,6 +13,8 @@ export const NDKInstance = () => {
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]);
// TODO: fully support NIP-11
async function verifyRelays(relays: string[]) {
const verifiedRelays: string[] = [];
@ -64,7 +66,6 @@ export const NDKInstance = () => {
explicitRelayUrls = await verifyRelays(FULL_RELAYS);
}
const cacheAdapter = new TauriAdapter(db);
console.log('ndk cache adapter: ', cacheAdapter);
const instance = new NDK({ explicitRelayUrls, cacheAdapter });
@ -80,6 +81,10 @@ export const NDKInstance = () => {
useEffect(() => {
if (!ndk) initNDK();
return () => {
cacheAdapter.saveCache();
};
}, []);
return {

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, LumeEvent, Relays, Widget } from '@utils/types';
import { Account, DBEvent, Relays, Widget } from '@utils/types';
export class LumeStorage {
public db: Database;
@ -93,6 +93,13 @@ export class LumeStorage {
}
}
public async updateLastLogin() {
return await this.db.execute(
'UPDATE accounts SET last_login_at = $1 WHERE id = $2;',
[Math.floor(Date.now() / 1000), this.account.id]
);
}
public async getWidgets() {
const result: Array<Widget> = await this.db.select(
`SELECT * FROM widgets WHERE account_id = "${this.account.id}" ORDER BY created_at DESC;`
@ -122,35 +129,99 @@ export class LumeStorage {
}
public async createEvent(
cacheKey: string,
event_id: string,
event_kind: number,
event: string
id: string,
event: string,
author: string,
root_id: string,
reply_id: string,
created_at: number
) {
return await this.db.execute(
'INSERT OR IGNORE INTO events (cache_key, event_id, event_kind, event) VALUES ($1, $2, $3, $4);',
[cacheKey, event_id, event_kind, event]
'INSERT OR IGNORE INTO events (id, account_id, event, author, root_id, reply_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7);',
[id, this.account.id, event, author, root_id, reply_id, created_at]
);
}
public async getALlEventByKey(cacheKey: string) {
const events: LumeEvent[] = await this.db.select(
'SELECT * FROM events WHERE cache_key = $1 ORDER BY id DESC;',
[cacheKey]
);
if (events.length < 1) return null;
return events;
}
public async getEventByID(id: string) {
const event = await this.db.select(
'SELECT * FROM events WHERE event_id = $1 ORDER BY id DESC LIMIT 1;',
const results: DBEvent[] = await this.db.select(
'SELECT * FROM events WHERE id = $1 ORDER BY id DESC LIMIT 1;',
[id]
)?.[0];
);
if (!event) return null;
return event;
if (results.length < 1) return null;
return results[0];
}
public async countTotalEvents() {
const result: Array<{ total: string }> = await this.db.select(
'SELECT COUNT(*) AS "total" FROM events;'
);
return parseInt(result[0].total);
}
public async getAllEvents(limit: number, offset: number) {
const totalEvents = await this.countTotalEvents();
const nextCursor = offset + limit;
const events: { data: DBEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: DBEvent[] = await this.db.select(
'SELECT * FROM events ORDER BY created_at DESC LIMIT $1 OFFSET $2;',
[limit, offset]
);
if (query && query.length > 0) {
events['data'] = query;
events['nextCursor'] =
Math.round(totalEvents / nextCursor) > 1 ? nextCursor : undefined;
return events;
}
return {
data: [],
nextCursor: 0,
};
}
public async getAllEventsByAuthors(authors: string[], limit: number, offset: number) {
const totalEvents = await this.countTotalEvents();
const nextCursor = offset + limit;
const authorsArr = `'${authors.join("','")}'`;
const events: { data: DBEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: DBEvent[] = await this.db.select(
'SELECT * FROM events WHERE author IN ($1) ORDER BY created_at DESC LIMIT $2 OFFSET $3;',
[authorsArr, limit, offset]
);
if (query && query.length > 0) {
events['data'] = query;
events['nextCursor'] =
Math.round(totalEvents / nextCursor) > 1 ? nextCursor : undefined;
return events;
}
return {
data: [],
nextCursor: 0,
};
}
public async isEventsEmpty() {
const results: DBEvent[] = await this.db.select(
'SELECT * FROM events ORDER BY id DESC LIMIT 1;'
);
return results.length < 1;
}
public async getExplicitRelayUrls() {
@ -175,13 +246,6 @@ export class LumeStorage {
return await this.db.execute(`DELETE FROM relays WHERE relay = "${relay}";`);
}
public async updateLastLogin(time: number) {
return await this.db.execute(
'UPDATE settings SET value = $1 WHERE key = "last_login";',
[time]
);
}
public async removePrivkey() {
return await this.db.execute(
`UPDATE accounts SET privkey = "privkey is stored in secure storage" WHERE id = "${this.account.id}";`

View File

@ -1,16 +1,16 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useMemo } from 'react';
import { NoteActions, NoteContent, NoteMetadata } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function NoteKind_1({
event,
skipMetadata = false,
}: {
event: LumeEvent;
event: NDKEvent;
skipMetadata?: boolean;
}) {
const content = useMemo(() => parser(event), [event.id]);

View File

@ -1,14 +1,14 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Image } from '@shared/image';
import { NoteActions, NoteMetadata } from '@shared/notes';
import { User } from '@shared/user';
import { LumeEvent } from '@utils/types';
function isImage(url: string) {
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
}
export function NoteKind_1063({ event }: { event: LumeEvent }) {
export function NoteKind_1063({ event }: { event: NDKEvent }) {
const url = event.tags[0][1];
return (

View File

@ -1,3 +1,5 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import {
NoteActions,
NoteContent,
@ -8,25 +10,32 @@ import {
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
import { getRepostID } from '@utils/transform';
import { LumeEvent } from '@utils/types';
export function Repost({ event }: { event: LumeEvent }) {
const repostID = getRepostID(event.tags);
export function Repost({ event }: { event: NDKEvent }) {
const repostID = event.tags.find((el) => el[0] === 'e')?.[1];
const { status, data } = useEvent(repostID, event.content as unknown as string);
if (status === 'loading') {
return (
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
);
}
if (status === 'error') {
return (
<div className="flex items-center justify-center overflow-hidden rounded-xl bg-white/10 px-3 py-3">
<p className="text-white/50">Failed to fetch event: {repostID}</p>
<div className="h-min w-full px-3 py-1.5">
<div className="flex flex-col gap-1 overflow-hidden rounded-xl bg-white/10 px-3 py-3">
<p className="select-text break-all text-white/50">
Failed to get repostr with ID
</p>
<div className="break-all rounded-lg bg-white/10 px-2 py-2">
<p className="text-white">{repostID}</p>
</div>
</div>
</div>
);
}

View File

@ -1,17 +1,17 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useMemo } from 'react';
import { NoteActions, NoteContent, NoteMetadata, SubNote } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function NoteThread({
event,
root,
reply,
}: {
event: LumeEvent;
event: NDKEvent;
root: string;
reply: string;
}) {

View File

@ -1,9 +1,9 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { NoteActions, NoteMetadata } from '@shared/notes';
import { User } from '@shared/user';
import { LumeEvent } from '@utils/types';
export function NoteKindUnsupport({ event }: { event: LumeEvent }) {
export function NoteKindUnsupport({ event }: { event: NDKEvent }) {
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3">

View File

@ -2,6 +2,8 @@ import { memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useStorage } from '@libs/storage/provider';
import { MentionUser, NoteSkeleton } from '@shared/notes';
import { User } from '@shared/user';
@ -11,13 +13,15 @@ import { useWidgets } from '@stores/widgets';
import { useEvent } from '@utils/hooks/useEvent';
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const { db } = useStorage();
const { status, data } = useEvent(id);
const setWidget = useWidgets((state) => state.setWidget);
const openThread = (event, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
setWidget({ kind: widgetKinds.thread, title: 'Thread', content: thread });
setWidget(db, { kind: widgetKinds.thread, title: 'Thread', content: thread });
} else {
event.stopPropagation();
}
@ -26,7 +30,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
if (!id) {
return (
<div className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3">
<p className="break-all">Failed to fetch event: {id}</p>
<p className="break-all">Failed to get event with id: {id}</p>
</div>
);
}
@ -64,14 +68,14 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
},
}}
>
{data?.content?.original?.length > 160
? data.content.original.substring(0, 160) + '...'
: data.content.original}
{data?.content.length > 160
? data.content.substring(0, 160) + '...'
: data.content}
</ReactMarkdown>
</div>
</>
) : (
<p className="break-all">Failed to fetch event: {id}</p>
<p className="break-all">Failed to get event with id: {id}</p>
)}
</div>
);

View File

@ -1,25 +1,29 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function useEvent(id: string, embed?: string) {
const { ndk } = useNDK();
const { status, data, error, isFetching } = useQuery(
['note', id],
const { status, data, error } = useQuery(
['event', id],
async () => {
if (embed) {
const event: LumeEvent = JSON.parse(embed);
if (event.kind === 1) embed['content'] = parser(event);
return embed as unknown as LumeEvent;
} else {
const event = (await ndk.fetchEvent(id)) as LumeEvent;
if (!event) throw new Error('event not found');
if (event.kind === 1) event['content'] = parser(event) as unknown as string;
return event as LumeEvent;
const event: NDKEvent = JSON.parse(embed);
// @ts-expect-error, #TODO: convert NDKEvent to ExNDKEvent
if (event.kind === 1) event.content = parser(event);
return event as unknown as NDKEvent;
}
const event = (await ndk.fetchEvent(id)) as NDKEvent;
if (!event) throw new Error('event not found');
// @ts-expect-error, #TODO: convert NDKEvent to ExNDKEvent
if (event.kind === 1) event.content = parser(event);
return event as NDKEvent;
},
{
staleTime: Infinity,
@ -29,5 +33,5 @@ export function useEvent(id: string, embed?: string) {
}
);
return { status, data, error, isFetching };
return { status, data, error };
}

View File

@ -6,7 +6,9 @@ import {
NDKSubscription,
NDKUser,
} from '@nostr-dev-kit/ndk';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { LRUCache } from 'lru-cache';
import { NostrFetcher } from 'nostr-fetch';
import { nip19 } from 'nostr-tools';
import { useMemo } from 'react';
@ -16,17 +18,9 @@ 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();
const { ndk, relayUrls } = useNDK();
const { db } = useStorage();
const privkey = useStronghold((state) => state.privkey);
@ -81,30 +75,65 @@ export function useNostr() {
await db.updateAccount('follows', [...follows]);
await db.updateAccount('network', [...new Set([...follows, ...network])]);
return { status: 'ok' };
// clear lru caches
lruNetwork.clear();
return { status: 'ok', message: 'User data fetched' };
} catch (e) {
return { status: 'failed', message: e };
}
};
const fetchNotes = async (since: number): Promise<NotesResponse> => {
const prefetchEvents = async () => {
try {
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),
});
// setup nostr-fetch
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
const dbEventsEmpty = await db.isEventsEmpty();
const sorted = [...events].sort(
(a, b) => b.created_at - a.created_at
) as unknown as LumeEvent[];
let since: number;
if (dbEventsEmpty) {
since = nHoursAgo(24);
} else {
since = db.account.last_login_at ?? nHoursAgo(24);
}
return { status: 'ok', data: sorted, nextCursor: since * 2 };
console.log("prefetching events with user's network: ", db.account.network.length);
console.log('prefetching events since: ', since);
const events = fetcher.allEventsIterator(
relayUrls,
{
kinds: [1, 6],
authors: db.account.network,
},
{ since: since }
);
// save all events to database
for await (const event of events) {
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
db.createEvent(
event.id,
JSON.stringify(event),
event.pubkey,
root,
reply,
event.created_at
);
}
return { status: 'ok', data: [], message: 'prefetch completed' };
} catch (e) {
console.error('failed get notes, error: ', e);
console.error('prefetch events failed, error: ', e);
return { status: 'failed', data: [], message: e };
}
};
@ -150,5 +179,5 @@ export function useNostr() {
return res;
};
return { sub, fetchUserData, fetchNotes, publish, createZap };
return { sub, fetchUserData, prefetchEvents, publish, createZap };
}

View File

@ -1,10 +1,11 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import getUrls from 'get-urls';
import { Event, parseReferences } from 'nostr-tools';
import ReactPlayer from 'react-player';
import { LumeEvent, RichContent } from '@utils/types';
import { RichContent } from '@utils/types';
export function parser(event: LumeEvent) {
export function parser(event: NDKEvent) {
if (event.kind !== 1) return;
const references = parseReferences(event as unknown as Event);

View File

@ -1,5 +1,4 @@
import { NDKTag } from '@nostr-dev-kit/ndk';
import { destr } from 'destr';
// convert array to NIP-02 tag list
export function arrayToNIP02(arr: string[]) {
@ -12,8 +11,7 @@ export function arrayToNIP02(arr: string[]) {
}
// get repost id from event tags
export function getRepostID(arr: NDKTag[]) {
const tags = destr(arr) as string[];
export function getRepostID(tags: NDKTag[]) {
let quoteID = null;
if (tags.length > 0) {

12
src/utils/types.d.ts vendored
View File

@ -8,8 +8,15 @@ export interface RichContent {
links: string[];
}
export interface LumeEvent extends NDKEvent {
richContent: RichContent;
export interface DBEvent {
id: string;
account_id: number;
event: string | NDKEvent;
author: string;
root_id: string;
reply_id: string;
created_at: number;
richContent?: RichContent;
}
export interface Account extends NDKUserProfile {
@ -20,6 +27,7 @@ export interface Account extends NDKUserProfile {
network: null | string[];
is_active: number;
privkey?: string; // deprecated
last_login_at: number;
}
export interface Profile extends NDKUserProfile {