Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4915b833e7 | ||
|
|
674e5f0339 | ||
|
|
11ed618a7f | ||
|
|
a2e3247432 | ||
|
|
09b3eeda99 | ||
|
|
700f3eb85f | ||
|
|
2f87ed8949 | ||
|
|
cb3c95b133 | ||
|
|
4f4e2f5ccd | ||
|
|
0e6fc65b08 | ||
|
|
876d351358 | ||
|
|
c80414a72d | ||
|
|
7cef6efa6f | ||
|
|
74ff49b8db | ||
|
|
2b50fc438f | ||
|
|
b339e842ca | ||
|
|
1d93f8cf6a | ||
|
|
236131087a | ||
|
|
a66770989b | ||
|
|
9ff74599eb | ||
|
|
c049fa8865 | ||
|
|
41b12746a7 | ||
|
|
50f90ddcc2 | ||
|
|
c9bc7b81dd | ||
|
|
18a9ba3fb0 | ||
|
|
413571ee7f | ||
|
|
17fe3bb1f6 | ||
|
|
0e5adb246f | ||
|
|
296136203a |
+41
-36
@@ -2,7 +2,7 @@
|
||||
"name": "lume",
|
||||
"description": "the communication app",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -18,76 +18,81 @@
|
||||
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@getalby/sdk": "^2.4.0",
|
||||
"@nostr-dev-kit/ndk": "^1.0.0",
|
||||
"@nostr-dev-kit/ndk": "^1.3.1",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "^1.3.0",
|
||||
"@nostr-fetch/adapter-ndk": "^0.12.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@tanstack/react-query": "^4.35.0",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "^1.4.0",
|
||||
"@tiptap/extension-image": "^2.1.8",
|
||||
"@tiptap/extension-mention": "^2.1.8",
|
||||
"@tiptap/extension-placeholder": "^2.1.8",
|
||||
"@tiptap/pm": "^2.1.8",
|
||||
"@tiptap/react": "^2.1.8",
|
||||
"@tiptap/starter-kit": "^2.1.8",
|
||||
"@tiptap/suggestion": "^2.1.8",
|
||||
"dayjs": "^1.11.9",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^4.35.3",
|
||||
"@tauri-apps/api": "^1.5.0",
|
||||
"@tiptap/extension-image": "^2.1.11",
|
||||
"@tiptap/extension-mention": "^2.1.11",
|
||||
"@tiptap/extension-placeholder": "^2.1.11",
|
||||
"@tiptap/pm": "^2.1.11",
|
||||
"@tiptap/react": "^2.1.11",
|
||||
"@tiptap/starter-kit": "^2.1.11",
|
||||
"@tiptap/suggestion": "^2.1.11",
|
||||
"@vidstack/react": "^1.0.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"destr": "^2.0.1",
|
||||
"get-urls": "^12.1.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"lru-cache": "^10.0.1",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nostr-fetch": "^0.13.0",
|
||||
"nostr-tools": "^1.14.2",
|
||||
"nostr-tools": "^1.16.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"re-resizable": "^6.9.11",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.11",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.46.1",
|
||||
"react-hook-form": "^7.46.2",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-player": "^2.13.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-virtuoso": "^4.5.1",
|
||||
"reactflow": "^11.9.2",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql#v1",
|
||||
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
|
||||
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
|
||||
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"virtua": "^0.9.1",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tauri-apps/cli": "^1.4.0",
|
||||
"@tauri-apps/cli": "^1.5.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||
"@types/html-to-text": "^9.0.1",
|
||||
"@types/node": "^20.6.0",
|
||||
"@types/react": "^18.2.21",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/youtube-player": "^5.5.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||
"@typescript-eslint/parser": "^6.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"@types/html-to-text": "^9.0.2",
|
||||
"@types/node": "^20.8.0",
|
||||
"@types/react": "^18.2.23",
|
||||
"@types/react-dom": "^18.2.8",
|
||||
"@types/youtube-player": "^5.5.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.3",
|
||||
"@typescript-eslint/parser": "^6.7.3",
|
||||
"@vitejs/plugin-react-swc": "^3.4.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"clsx": "^2.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.1.2",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint": "^8.50.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^14.0.1",
|
||||
"postcss": "^8.4.29",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"prop-types": "^15.8.1",
|
||||
|
||||
Generated
+1277
-846
File diff suppressed because it is too large
Load Diff
Generated
+236
-304
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lume"
|
||||
version = "1.2.5"
|
||||
version = "1.2.6"
|
||||
description = "the communication app"
|
||||
authors = ["Ren Amamiya"]
|
||||
license = "GPL-3.0"
|
||||
@@ -11,16 +11,16 @@ rust-version = "1.66"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.4", features = [] }
|
||||
tauri-build = { version = "1.4.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.4", features = [
|
||||
tauri = { version = "1.5", features = [
|
||||
"macos-private-api",
|
||||
"window-close",
|
||||
"window-print",
|
||||
"window-create",
|
||||
"macos-private-api",
|
||||
"fs-read-dir",
|
||||
"fs-read-file",
|
||||
"window-start-dragging",
|
||||
@@ -55,6 +55,10 @@ sqlx-cli = { version = "0.7.0", default-features = false, features = [
|
||||
rust-argon2 = "1.0"
|
||||
webpage = { version = "1.6.0", features = ["serde"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos"))'.dependencies]
|
||||
cocoa = "0.25.0"
|
||||
objc = "0.2.7"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
|
||||
+22
-2
@@ -3,15 +3,25 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use tauri::Manager;
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
use webpage::{Webpage, WebpageOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use traffic_light::TrafficLight;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod traffic_light;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct Payload {
|
||||
args: Vec<String>,
|
||||
@@ -102,8 +112,18 @@ fn main() {
|
||||
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
|
||||
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
window.position_traffic_lights(16.0, 25.0);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|e| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let WindowEvent::Resized(..) = e.event() {
|
||||
let window = e.window();
|
||||
window.position_traffic_lights(16.0, 25.0);
|
||||
}
|
||||
})
|
||||
.plugin(
|
||||
tauri_plugin_sql::Builder::default()
|
||||
.add_migrations(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use tauri::{Runtime, Window};
|
||||
|
||||
pub trait TrafficLight {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool);
|
||||
fn position_traffic_lights(&self, x: f64, y: f64);
|
||||
}
|
||||
|
||||
impl<R: Runtime> TrafficLight for Window<R> {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool) {
|
||||
use cocoa::appkit::{NSWindow, NSWindowTitleVisibility};
|
||||
|
||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
unsafe {
|
||||
window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
|
||||
|
||||
if transparent {
|
||||
window.setTitlebarAppearsTransparent_(cocoa::base::YES);
|
||||
} else {
|
||||
window.setTitlebarAppearsTransparent_(cocoa::base::NO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(&self, x: f64, y: f64) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
use cocoa::foundation::NSRect;
|
||||
|
||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
unsafe {
|
||||
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
|
||||
let miniaturize = window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
||||
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
||||
|
||||
let title_bar_container_view = close.superview().superview();
|
||||
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
|
||||
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
|
||||
|
||||
let window_buttons = vec![close, miniaturize, zoom];
|
||||
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
|
||||
|
||||
for (i, button) in window_buttons.into_iter().enumerate() {
|
||||
let mut rect: NSRect = NSView::frame(button);
|
||||
rect.origin.x = x + (i as f64 * space_between);
|
||||
button.setFrameOrigin(rect.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Lume",
|
||||
"version": "1.2.5"
|
||||
"version": "1.2.6"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
@@ -128,6 +128,7 @@
|
||||
"csp": {
|
||||
"content-security-policy": "upgrade-insecure-requests"
|
||||
},
|
||||
"dangerousUseHttpScheme": true,
|
||||
"dangerousRemoteDomainIpcAccess": [
|
||||
{
|
||||
"scheme": "https",
|
||||
@@ -136,7 +137,6 @@
|
||||
"enableTauriAPI": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"macOSPrivateApi": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"tauri": {
|
||||
"windows": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 500,
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"decorations": false,
|
||||
"title": "Lume",
|
||||
"center": true,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"tauri": {
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 500,
|
||||
"decorations": true,
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"decorations": false,
|
||||
"title": "Lume",
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"tauri": {
|
||||
"windows": [
|
||||
{
|
||||
"width": 400,
|
||||
"height": 500,
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"decorations": false,
|
||||
"title": "Lume",
|
||||
"center": true,
|
||||
|
||||
+298
-246
@@ -1,10 +1,15 @@
|
||||
import { message } from '@tauri-apps/api/dialog';
|
||||
import { RouterProvider, createBrowserRouter, redirect } from 'react-router-dom';
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
|
||||
import { ReactFlowProvider } from 'reactflow';
|
||||
|
||||
import { AuthCreateScreen } from '@app/auth/create';
|
||||
import { AuthImportScreen } from '@app/auth/import';
|
||||
import { OnboardingScreen } from '@app/auth/onboarding';
|
||||
import { ErrorScreen } from '@app/error';
|
||||
import { ExploreScreen } from '@app/explore';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { Frame } from '@shared/frame';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
@@ -13,254 +18,300 @@ import { AuthLayout } from '@shared/layouts/auth';
|
||||
import { NoteLayout } from '@shared/layouts/note';
|
||||
import { SettingsLayout } from '@shared/layouts/settings';
|
||||
|
||||
import { checkActiveAccount } from '@utils/checkActiveAccount';
|
||||
|
||||
import './index.css';
|
||||
|
||||
async function Loader() {
|
||||
try {
|
||||
const account = await checkActiveAccount();
|
||||
|
||||
const stronghold = sessionStorage.getItem('stronghold');
|
||||
const privkey = JSON.parse(stronghold).state.privkey || null;
|
||||
|
||||
const onboarding = localStorage.getItem('onboarding');
|
||||
const step = JSON.parse(onboarding).state.step || null;
|
||||
|
||||
if (!account) {
|
||||
return redirect('/auth/welcome');
|
||||
} else {
|
||||
if (step) {
|
||||
return redirect(step);
|
||||
}
|
||||
|
||||
if (!privkey) {
|
||||
return redirect('/auth/unlock');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
await message(e, { title: 'An unexpected error has occurred', type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <AppLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
loader: Loader,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { SpaceScreen } = await import('@app/space');
|
||||
return { Component: SpaceScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users/:pubkey',
|
||||
async lazy() {
|
||||
const { UserScreen } = await import('@app/users');
|
||||
return { Component: UserScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'chats/:pubkey',
|
||||
async lazy() {
|
||||
const { ChatScreen } = await import('@app/chats');
|
||||
return { Component: ChatScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
async lazy() {
|
||||
const { NotificationScreen } = await import('@app/notifications');
|
||||
return { Component: NotificationScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'nwc',
|
||||
async lazy() {
|
||||
const { NWCScreen } = await import('@app/nwc');
|
||||
return { Component: NWCScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/notes',
|
||||
element: <NoteLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: 'text/:id',
|
||||
async lazy() {
|
||||
const { TextNoteScreen } = await import('@app/notes/text');
|
||||
return { Component: TextNoteScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'article/:id',
|
||||
async lazy() {
|
||||
const { ArticleNoteScreen } = await import('@app/notes/article');
|
||||
return { Component: ArticleNoteScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/splashscreen',
|
||||
errorElement: <ErrorScreen />,
|
||||
async lazy() {
|
||||
const { SplashScreen } = await import('@app/splash');
|
||||
return { Component: SplashScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
element: <AuthLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: 'welcome',
|
||||
async lazy() {
|
||||
const { WelcomeScreen } = await import('@app/auth/welcome');
|
||||
return { Component: WelcomeScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'import',
|
||||
element: <AuthImportScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { ImportStep1Screen } = await import('@app/auth/import/step-1');
|
||||
return { Component: ImportStep1Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-2',
|
||||
async lazy() {
|
||||
const { ImportStep2Screen } = await import('@app/auth/import/step-2');
|
||||
return { Component: ImportStep2Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-3',
|
||||
async lazy() {
|
||||
const { ImportStep3Screen } = await import('@app/auth/import/step-3');
|
||||
return { Component: ImportStep3Screen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
element: <AuthCreateScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
|
||||
return { Component: CreateStep1Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-2',
|
||||
async lazy() {
|
||||
const { CreateStep2Screen } = await import('@app/auth/create/step-2');
|
||||
return { Component: CreateStep2Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-3',
|
||||
async lazy() {
|
||||
const { CreateStep3Screen } = await import('@app/auth/create/step-3');
|
||||
return { Component: CreateStep3Screen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'onboarding',
|
||||
element: <OnboardingScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { OnboardStep1Screen } = await import('@app/auth/onboarding/step-1');
|
||||
return { Component: OnboardStep1Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-2',
|
||||
async lazy() {
|
||||
const { OnboardStep2Screen } = await import('@app/auth/onboarding/step-2');
|
||||
return { Component: OnboardStep2Screen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'complete',
|
||||
async lazy() {
|
||||
const { CompleteScreen } = await import('@app/auth/complete');
|
||||
return { Component: CompleteScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'unlock',
|
||||
async lazy() {
|
||||
const { UnlockScreen } = await import('@app/auth/unlock');
|
||||
return { Component: UnlockScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'migrate',
|
||||
async lazy() {
|
||||
const { MigrateScreen } = await import('@app/auth/migrate');
|
||||
return { Component: MigrateScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'reset',
|
||||
async lazy() {
|
||||
const { ResetScreen } = await import('@app/auth/reset');
|
||||
return { Component: ResetScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <SettingsLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { GeneralSettingsScreen } = await import('@app/settings/general');
|
||||
return { Component: GeneralSettingsScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
async lazy() {
|
||||
const { AccountSettingsScreen } = await import('@app/settings/account');
|
||||
return { Component: AccountSettingsScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
const { db } = useStorage();
|
||||
|
||||
const accountLoader = async () => {
|
||||
try {
|
||||
const totalAccount = await db.checkAccount();
|
||||
|
||||
const stronghold = sessionStorage.getItem('stronghold');
|
||||
const privkey = JSON.parse(stronghold).state.privkey || null;
|
||||
|
||||
const onboarding = localStorage.getItem('onboarding');
|
||||
const step = JSON.parse(onboarding).state.step || null;
|
||||
|
||||
if (totalAccount === 0) {
|
||||
return redirect('/auth/welcome');
|
||||
} else {
|
||||
if (step) {
|
||||
return redirect(step);
|
||||
}
|
||||
|
||||
if (!privkey) {
|
||||
return redirect('/auth/unlock');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
await message(e, { title: 'An unexpected error has occurred', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const relayLoader = async ({ params }) => {
|
||||
return defer({
|
||||
relay: fetch(`https://${params.url}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/nostr+json',
|
||||
},
|
||||
}).then((res) => res.data),
|
||||
});
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <AppLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
loader: accountLoader,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { SpaceScreen } = await import('@app/space');
|
||||
return { Component: SpaceScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users/:pubkey',
|
||||
async lazy() {
|
||||
const { UserScreen } = await import('@app/users');
|
||||
return { Component: UserScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'chats/:pubkey',
|
||||
async lazy() {
|
||||
const { ChatScreen } = await import('@app/chats');
|
||||
return { Component: ChatScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
async lazy() {
|
||||
const { NotificationScreen } = await import('@app/notifications');
|
||||
return { Component: NotificationScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'nwc',
|
||||
async lazy() {
|
||||
const { NWCScreen } = await import('@app/nwc');
|
||||
return { Component: NWCScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'explore',
|
||||
element: (
|
||||
<ReactFlowProvider>
|
||||
<ExploreScreen />
|
||||
</ReactFlowProvider>
|
||||
),
|
||||
errorElement: <ErrorScreen />,
|
||||
},
|
||||
{
|
||||
path: 'relays',
|
||||
async lazy() {
|
||||
const { RelaysScreen } = await import('@app/relays');
|
||||
return { Component: RelaysScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'relays/:url',
|
||||
loader: relayLoader,
|
||||
async lazy() {
|
||||
const { RelayScreen } = await import('@app/relays/relay');
|
||||
return { Component: RelayScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/notes',
|
||||
element: <NoteLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: 'text/:id',
|
||||
async lazy() {
|
||||
const { TextNoteScreen } = await import('@app/notes/text');
|
||||
return { Component: TextNoteScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'article/:id',
|
||||
async lazy() {
|
||||
const { ArticleNoteScreen } = await import('@app/notes/article');
|
||||
return { Component: ArticleNoteScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/splashscreen',
|
||||
errorElement: <ErrorScreen />,
|
||||
async lazy() {
|
||||
const { SplashScreen } = await import('@app/splash');
|
||||
return { Component: SplashScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/auth',
|
||||
element: <AuthLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: 'welcome',
|
||||
async lazy() {
|
||||
const { WelcomeScreen } = await import('@app/auth/welcome');
|
||||
return { Component: WelcomeScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'import',
|
||||
element: <AuthImportScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { ImportStep1Screen } = await import('@app/auth/import/step-1');
|
||||
return { Component: ImportStep1Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-2',
|
||||
async lazy() {
|
||||
const { ImportStep2Screen } = await import('@app/auth/import/step-2');
|
||||
return { Component: ImportStep2Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-3',
|
||||
async lazy() {
|
||||
const { ImportStep3Screen } = await import('@app/auth/import/step-3');
|
||||
return { Component: ImportStep3Screen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
element: <AuthCreateScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
|
||||
return { Component: CreateStep1Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-2',
|
||||
async lazy() {
|
||||
const { CreateStep2Screen } = await import('@app/auth/create/step-2');
|
||||
return { Component: CreateStep2Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-3',
|
||||
async lazy() {
|
||||
const { CreateStep3Screen } = await import('@app/auth/create/step-3');
|
||||
return { Component: CreateStep3Screen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'onboarding',
|
||||
element: <OnboardingScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { OnboardStep1Screen } = await import(
|
||||
'@app/auth/onboarding/step-1'
|
||||
);
|
||||
return { Component: OnboardStep1Screen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'step-2',
|
||||
async lazy() {
|
||||
const { OnboardStep2Screen } = await import(
|
||||
'@app/auth/onboarding/step-2'
|
||||
);
|
||||
return { Component: OnboardStep2Screen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'complete',
|
||||
async lazy() {
|
||||
const { CompleteScreen } = await import('@app/auth/complete');
|
||||
return { Component: CompleteScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'unlock',
|
||||
async lazy() {
|
||||
const { UnlockScreen } = await import('@app/auth/unlock');
|
||||
return { Component: UnlockScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'lock',
|
||||
async lazy() {
|
||||
const { LockScreen } = await import('@app/auth/lock');
|
||||
return { Component: LockScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'migrate',
|
||||
async lazy() {
|
||||
const { MigrateScreen } = await import('@app/auth/migrate');
|
||||
return { Component: MigrateScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'reset',
|
||||
async lazy() {
|
||||
const { ResetScreen } = await import('@app/auth/reset');
|
||||
return { Component: ResetScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <SettingsLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { GeneralSettingsScreen } = await import('@app/settings/general');
|
||||
return { Component: GeneralSettingsScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
async lazy() {
|
||||
const { AccountSettingsScreen } = await import('@app/settings/account');
|
||||
return { Component: AccountSettingsScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<RouterProvider
|
||||
router={router}
|
||||
@@ -269,6 +320,7 @@ export default function App() {
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
|
||||
</Frame>
|
||||
}
|
||||
future={{ v7_startTransition: true }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ImportStep3Screen() {
|
||||
const setStep = useOnboarding((state) => state.setStep);
|
||||
|
||||
const { db } = useStorage();
|
||||
const { fetchUserData, prefetchEvents } = useNostr();
|
||||
const { fetchUserData } = useNostr();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -27,16 +27,14 @@ export function ImportStep3Screen() {
|
||||
|
||||
// prefetch data
|
||||
const user = await fetchUserData();
|
||||
const data = await prefetchEvents();
|
||||
|
||||
// create default widget
|
||||
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
|
||||
|
||||
// redirect to next step
|
||||
if (user.status === 'ok' && data.status === 'ok') {
|
||||
if (user.status === 'ok') {
|
||||
navigate('/auth/onboarding/step-2', { replace: true });
|
||||
} else {
|
||||
console.log('error: ', data.message);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -83,7 +81,7 @@ export function ImportStep3Screen() {
|
||||
</button>
|
||||
<span className="text-center text-sm text-white/50">
|
||||
By clicking 'Continue', Lume will download your old relay list and
|
||||
all events from the last 24 hours. It may take a bit
|
||||
metadata. It may take a bit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function LockScreen() {
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full bg-cover bg-center"
|
||||
style={{ backgroundImage: 'url(/wallpapers/1.png)' }}
|
||||
>
|
||||
<p>TODO</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function OnboardStep1Screen() {
|
||||
const navigate = useNavigate();
|
||||
const setStep = useOnboarding((state) => state.setStep);
|
||||
|
||||
const { publish, fetchUserData, prefetchEvents } = useNostr();
|
||||
const { publish, fetchUserData } = useNostr();
|
||||
const { db } = useStorage();
|
||||
const { status, data } = useQuery(['trending-profiles-widget'], async () => {
|
||||
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
||||
@@ -46,14 +46,12 @@ export function OnboardStep1Screen() {
|
||||
|
||||
// prefetch data
|
||||
const user = await fetchUserData(follows);
|
||||
const data = await prefetchEvents();
|
||||
|
||||
// redirect to next step
|
||||
if (event && user.status === 'ok' && data.status === 'ok') {
|
||||
if (event && user.status === 'ok') {
|
||||
navigate('/auth/onboarding/step-2', { replace: true });
|
||||
} else {
|
||||
setLoading(false);
|
||||
console.log('error: ', data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
@@ -70,7 +68,7 @@ export function OnboardStep1Screen() {
|
||||
<div className="flex h-full w-full flex-col justify-center">
|
||||
<div className="mx-auto mb-4 w-full max-w-md border-b border-white/10 pb-4">
|
||||
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
|
||||
{loading ? 'Prefetching data...' : 'Enrich your network'}
|
||||
{loading ? 'Loading...' : 'Enrich your network'}
|
||||
</h1>
|
||||
<p className="text-white/70">
|
||||
Choose the account you want to follow. These accounts are trending in the last
|
||||
@@ -127,19 +125,12 @@ export function OnboardStep1Screen() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!loading ? (
|
||||
<Link
|
||||
to="/auth/onboarding/step-2"
|
||||
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
|
||||
>
|
||||
Skip, you can add later
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-center text-sm text-white/50">
|
||||
By clicking 'Continue', Lume will download all events related to
|
||||
your follows from the last 24 hours. It may take a bit
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to="/auth/onboarding/step-2"
|
||||
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
|
||||
>
|
||||
Skip, you can add later
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+34
-6
@@ -33,6 +33,7 @@ export function UnlockScreen() {
|
||||
const navigate = useNavigate();
|
||||
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
|
||||
const resetStronghold = useStronghold((state) => state.reset);
|
||||
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@@ -70,6 +71,15 @@ export function UnlockScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
// remove account
|
||||
db.accountLogout();
|
||||
// reset stronghold
|
||||
resetStronghold();
|
||||
// redirect to welcome screen
|
||||
navigate('/auth/welcome');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
@@ -126,12 +136,30 @@ export function UnlockScreen() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/auth/reset"
|
||||
className="mt-1 inline-flex h-12 w-full items-center justify-center rounded-lg text-center text-white/70 hover:bg-white/20"
|
||||
>
|
||||
Reset password
|
||||
</Link>
|
||||
<div className="mt-8 w-full">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-px flex-1 bg-white/10" />
|
||||
<p className="shrink-0 text-sm font-medium text-white/50">
|
||||
Forgot password?
|
||||
</p>
|
||||
<div className="h-px flex-1 bg-white/10" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col">
|
||||
<Link
|
||||
to="/auth/reset"
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-lg text-center text-sm font-medium text-white/70 hover:bg-white/20"
|
||||
>
|
||||
Reset password if you still have a private key
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-lg text-center text-sm font-medium text-white/70 hover:bg-white/20"
|
||||
>
|
||||
Login with another account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
import { LogicalSize, getCurrent } from '@tauri-apps/api/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);
|
||||
await appWindow.center();
|
||||
}
|
||||
|
||||
async function resetWindow() {
|
||||
await appWindow.setSize(new LogicalSize(1080, 800));
|
||||
await appWindow.setResizable(false);
|
||||
await appWindow.center();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setWindow();
|
||||
|
||||
return () => {
|
||||
resetWindow();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col justify-between">
|
||||
<div className="mx-auto flex h-screen w-full max-w-md flex-col justify-center">
|
||||
<div className="flex flex-col gap-10 pt-16">
|
||||
<div className="flex flex-col gap-1.5 text-center">
|
||||
<h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1>
|
||||
@@ -54,8 +30,8 @@ export function WelcomeScreen() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-end justify-center pb-6">
|
||||
<img src="/lume.png" alt="lume" className="h-auto w-1/4" />
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 transform">
|
||||
<img src="/lume.png" alt="lume" className="mx-auto h-auto w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-2">
|
||||
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-3">
|
||||
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||
</div>
|
||||
@@ -24,7 +24,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3',
|
||||
isActive
|
||||
? 'border-fuchsia-500 bg-white/5 text-white'
|
||||
: 'border-transparent text-white/70'
|
||||
@@ -38,7 +38,10 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
|
||||
/>
|
||||
<div className="inline-flex w-full flex-1 items-center justify-between">
|
||||
<h5 className="max-w-[10rem] truncate">
|
||||
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { UnknownsModal } from '@app/chats/components/unknowns';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function ChatsList() {
|
||||
@@ -33,8 +35,10 @@ export function ChatsList() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="inline-flex h-10 items-center gap-2.5 border-l-2 border-transparent pl-4">
|
||||
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||
<div className="relative inline-flex h-7 w-7 shrink-0 items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||
</div>
|
||||
<h5 className="text-white/50">Loading messages...</h5>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -42,8 +46,8 @@ export function ChatsList() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{chats.follows.map((item) => renderItem(item))}
|
||||
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
|
||||
{chats?.follows?.map((item) => renderItem(item))}
|
||||
{chats?.unknowns?.length > 0 && <UnknownsModal data={chats.unknowns} />}
|
||||
<NewMessageModal />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
||||
|
||||
import { TextNote } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
export function ChatMessageItem({
|
||||
@@ -20,13 +21,12 @@ export function ChatMessageItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 backdrop-blur-xl hover:bg-white/10">
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-white/10">
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={message.pubkey} time={message.created_at} variant="chat" />
|
||||
<div className="-mt-[20px] pl-[49px]">
|
||||
<p className="select-text whitespace-pre-line break-words text-base text-white">
|
||||
{message.content}
|
||||
</p>
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<TextNote content={message.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function NewMessageModal() {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-2"
|
||||
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-3"
|
||||
>
|
||||
<div className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-semibold leading-none">
|
||||
{user?.display_name || user?.name}
|
||||
{user?.name || user?.display_name || user?.displayName}
|
||||
</h3>
|
||||
{user?.nip05 ? (
|
||||
<NIP05
|
||||
|
||||
@@ -21,7 +21,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-2"
|
||||
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-3"
|
||||
>
|
||||
<div className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<StrangersIcon className="h-4 w-4 text-white" />
|
||||
|
||||
+18
-37
@@ -1,8 +1,8 @@
|
||||
import { NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { VList, VListHandle } from 'virtua';
|
||||
|
||||
import { ChatMessageForm } from '@app/chats/components/messages/form';
|
||||
import { ChatMessageItem } from '@app/chats/components/messages/item';
|
||||
@@ -18,7 +18,7 @@ import { useStronghold } from '@stores/stronghold';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function ChatScreen() {
|
||||
const virtuosoRef = useRef(null);
|
||||
const listRef = useRef<VListHandle>(null);
|
||||
const userPrivkey = useStronghold((state) => state.privkey);
|
||||
|
||||
const { db } = useStorage();
|
||||
@@ -29,10 +29,8 @@ export function ChatScreen() {
|
||||
return await fetchNIP04Messages(pubkey);
|
||||
});
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: string | number) => {
|
||||
const message = data[index];
|
||||
if (!message) return;
|
||||
const renderItem = useCallback(
|
||||
(message: NDKEvent) => {
|
||||
return (
|
||||
<ChatMessageItem
|
||||
message={message}
|
||||
@@ -44,12 +42,9 @@ export function ChatScreen() {
|
||||
[data]
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: string | number) => {
|
||||
return data[index].id;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (data.length > 0) listRef.current?.scrollToIndex(data.length);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub: NDKSubscription = ndk.subscribe(
|
||||
@@ -86,22 +81,17 @@ export function ChatScreen() {
|
||||
<p className="text-sm font-medium text-white/50">Loading messages</p>
|
||||
</div>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
|
||||
<h3 className="mb-2 text-4xl">🙌</h3>
|
||||
<p className="leading-none text-white/50">
|
||||
You two didn't talk yet, let's send first message
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
itemContent={itemContent}
|
||||
computeItemKey={computeItemKey}
|
||||
initialTopMostItemIndex={data.length - 1}
|
||||
alignToBottom={true}
|
||||
followOutput={true}
|
||||
overscan={50}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
className="scrollbar-hide relative overflow-y-auto"
|
||||
components={{
|
||||
EmptyPlaceholder: () => Empty,
|
||||
}}
|
||||
/>
|
||||
<VList ref={listRef} className="scrollbar-hide h-full" mode="reverse">
|
||||
{data.map((message) => renderItem(message))}
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
<div className="z-50 shrink-0 rounded-b-xl border-t border-white/5 bg-white/10 p-3 px-5 backdrop-blur-xl">
|
||||
@@ -120,12 +110,3 @@ export function ChatScreen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Empty = (
|
||||
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
|
||||
<h3 className="mb-2 text-4xl">🙌</h3>
|
||||
<p className="leading-none text-white/50">
|
||||
You two didn't talk yet, let's send first message
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BaseEdge, EdgeProps, getBezierPath } from 'reactflow';
|
||||
|
||||
export function Edge({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style = {},
|
||||
markerEnd,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={{ ...style, stroke: '#71717a' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
|
||||
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
|
||||
const { status, user } = useProfile(pubkey);
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<h3 className="text-sm font-semibold text-fuchsia-500">{`${
|
||||
user.name || user.display_name
|
||||
}'s network`}</h3>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export function Line({ fromX, fromY, toX, toY }) {
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#f5d0fe"
|
||||
strokeWidth={1.5}
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX} ${toY} ${fromX} ${toY} ${toX},${toY}`}
|
||||
/>
|
||||
<circle cx={toX} cy={toY} fill="#fff" r={3} stroke="#f5d0fe" strokeWidth={1.5} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Handle, Position } from 'reactflow';
|
||||
|
||||
import { UserWithDrawer } from '@app/explore/components/userWithDrawer';
|
||||
|
||||
import { GroupTitle } from './groupTitle';
|
||||
|
||||
export function UserGroupNode({ data }) {
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="h-2 w-5 rounded-full border-none !bg-fuchsia-400"
|
||||
/>
|
||||
<div className="relative mx-3 my-3 flex flex-col gap-1">
|
||||
{data.title ? (
|
||||
<h3 className="text-sm font-semibold text-fuchsia-500">{data.title}</h3>
|
||||
) : (
|
||||
<GroupTitle pubkey={data.pubkey} />
|
||||
)}
|
||||
<div className="grid grid-cols-5 gap-6 rounded-lg border border-fuchsia-500/50 bg-fuchsia-500/10 p-4">
|
||||
{data.list.map((user: string) => (
|
||||
<UserWithDrawer key={user} pubkey={user} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="h-2 w-5 rounded-full border-none !bg-fuchsia-400"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import {
|
||||
ArticleNote,
|
||||
FileNote,
|
||||
NoteWrapper,
|
||||
Repost,
|
||||
TextNote,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function UserLatestPosts({ pubkey }: { pubkey: string }) {
|
||||
const { getEventsByPubkey } = useNostr();
|
||||
const { status, data } = useQuery(['user-posts', pubkey], async () => {
|
||||
return await getEventsByPubkey(pubkey);
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<TextNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return <Repost key={event.id} event={event} />;
|
||||
case 1063:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Article:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<UnknownNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-white/5 pt-3">
|
||||
<h3 className="mb-4 px-3 font-semibold text-white">Latest post</h3>
|
||||
<div>
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3">
|
||||
<div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-white/10 text-sm font-medium text-white/70">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||
Loading latest posts...
|
||||
</div>
|
||||
</div>
|
||||
) : data.length < 1 ? (
|
||||
<div className="px-3">
|
||||
<div className="inline-flex h-16 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white/70">
|
||||
No posts from 24 hours ago
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => renderItem(event))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Handle, Position } from 'reactflow';
|
||||
|
||||
import { User } from '@shared/user';
|
||||
|
||||
export function UserNode({ data }) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative mx-3 my-3 inline-flex h-12 w-12 shrink-0 items-center justify-center">
|
||||
<span className="absolute inline-flex h-8 w-8 animate-ping rounded-lg bg-green-400 opacity-75"></span>
|
||||
<div className="relative z-10">
|
||||
<User pubkey={data.pubkey} variant="avatar" />
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="h-2 w-2 rounded-full border-none !bg-white/20"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
import { NIP05 } from '@shared/nip05';
|
||||
import { TextNote } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
import { UserLatestPosts } from './userLatestPosts';
|
||||
|
||||
export const UserWithDrawer = memo(function UserWithDrawer({
|
||||
pubkey,
|
||||
}: {
|
||||
pubkey: string;
|
||||
}) {
|
||||
const { addContact, removeContact } = useNostr();
|
||||
const { db } = useStorage();
|
||||
const { status, user } = useProfile(pubkey);
|
||||
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const followUser = (pubkey: string) => {
|
||||
try {
|
||||
addContact(pubkey);
|
||||
// update state
|
||||
setFollowed(true);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const unfollowUser = (pubkey: string) => {
|
||||
try {
|
||||
removeContact(pubkey);
|
||||
// update state
|
||||
setFollowed(false);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (db.account.follows.includes(pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button">
|
||||
<User pubkey={pubkey} variant="avatar" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] animate-slideRightAndFade items-center justify-center px-4 pb-4 pt-16 transition-all">
|
||||
<div className="h-full w-full overflow-y-auto rounded-lg border-t border-white/10 bg-white/20 py-3 backdrop-blur-3xl">
|
||||
{status === 'loading' ? (
|
||||
<div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-12 w-12 rounded-lg"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="text-lg font-semibold leading-none">
|
||||
{user?.displayName || user?.name || 'No name'}
|
||||
</h5>
|
||||
{user?.nip05 ? (
|
||||
<NIP05
|
||||
pubkey={pubkey}
|
||||
nip05={user?.nip05}
|
||||
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
|
||||
/>
|
||||
) : (
|
||||
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
|
||||
{displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
)}
|
||||
{user?.about ? <TextNote content={user?.about} /> : null}
|
||||
</div>
|
||||
<div className="mt-3 inline-flex items-center gap-2">
|
||||
{followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unfollowUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => followUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/chats/${pubkey}`}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UserLatestPosts pubkey={pubkey} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ConnectionMode,
|
||||
addEdge,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from 'reactflow';
|
||||
|
||||
import { Edge } from '@app/explore/components/edge';
|
||||
import { Line } from '@app/explore/components/line';
|
||||
import { UserGroupNode } from '@app/explore/components/userGroupNode';
|
||||
import { UserNode } from '@app/explore/components/userNode';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { getMultipleRandom } from '@utils/transform';
|
||||
|
||||
let id = 2;
|
||||
const getId = () => `${id++}`;
|
||||
const nodeTypes = { user: UserNode, userGroup: UserGroupNode };
|
||||
const edgeTypes = { buttonedge: Edge };
|
||||
|
||||
export function ExploreScreen() {
|
||||
const { db } = useStorage();
|
||||
const { getContactsByPubkey } = useNostr();
|
||||
const { project } = useReactFlow();
|
||||
|
||||
const defaultContacts = useMemo(() => getMultipleRandom(db.account.follows, 10), []);
|
||||
const reactFlowWrapper = useRef(null);
|
||||
const connectingNodeId = useRef(null);
|
||||
|
||||
const initialNodes = [
|
||||
{
|
||||
id: '0',
|
||||
type: 'user',
|
||||
position: { x: 141, y: 0 },
|
||||
data: { list: [], title: '', pubkey: db.account.pubkey },
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
type: 'userGroup',
|
||||
position: { x: 0, y: 200 },
|
||||
data: { list: defaultContacts, title: 'Starting Point', pubkey: '' },
|
||||
},
|
||||
];
|
||||
const initialEdges = [{ id: 'e0-1', type: 'buttonedge', source: '0', target: '1' }];
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []);
|
||||
|
||||
const onConnectStart = useCallback((_, { nodeId }) => {
|
||||
connectingNodeId.current = nodeId;
|
||||
}, []);
|
||||
|
||||
const onConnectEnd = useCallback(
|
||||
async (event) => {
|
||||
const targetIsPane = event.target.classList.contains('react-flow__pane');
|
||||
|
||||
if (targetIsPane) {
|
||||
const { top, left } = reactFlowWrapper.current.getBoundingClientRect();
|
||||
|
||||
const id = getId();
|
||||
const prevData = nodes.slice(-1)[0];
|
||||
const randomPubkey = getMultipleRandom(prevData.data.list, 1)[0];
|
||||
|
||||
const newContactList = await getContactsByPubkey(randomPubkey);
|
||||
const newNode = {
|
||||
id,
|
||||
type: 'userGroup',
|
||||
position: project({ x: event.clientX - left, y: event.clientY - top }),
|
||||
data: { list: newContactList, title: null, pubkey: randomPubkey },
|
||||
};
|
||||
|
||||
setNodes((nds) => nds.concat(newNode));
|
||||
setEdges((eds) =>
|
||||
eds.concat({
|
||||
id,
|
||||
type: 'buttonedge',
|
||||
source: connectingNodeId.current,
|
||||
target: id,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
[project]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full" ref={reactFlowWrapper}>
|
||||
<ReactFlow
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
connectionLineComponent={Line}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
minZoom={0.8}
|
||||
maxZoom={1.2}
|
||||
fitView
|
||||
>
|
||||
<Background color="#3f3f46" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function NotiUser({ pubkey }: { pubkey: string }) {
|
||||
className="h-8 w-8 shrink-0 rounded-md object-cover"
|
||||
/>
|
||||
<span className="max-w-[10rem] truncate font-medium leading-none text-white">
|
||||
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
|
||||
{user?.name || user?.display_name || user?.displayName || displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function NWCScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center bg-white/5">
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-bold leading-tight">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import {
|
||||
ArticleNote,
|
||||
FileNote,
|
||||
NoteWrapper,
|
||||
Repost,
|
||||
TextNote,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
|
||||
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
const { fetcher } = useNDK();
|
||||
const { status, data } = useQuery(
|
||||
['relay-event'],
|
||||
async () => {
|
||||
const url = 'wss://' + relayUrl;
|
||||
const events = await fetcher.fetchLatestEvents(
|
||||
[url],
|
||||
{
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article],
|
||||
},
|
||||
100
|
||||
);
|
||||
return events as unknown as NDKEvent[];
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<TextNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return <Repost key={event.id} event={event} />;
|
||||
case 1063:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Article:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<UnknownNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="mx-auto w-full max-w-[500px]">
|
||||
{status === 'loading' ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VList className="scrollbar-hide h-full">
|
||||
<div className="h-10" />
|
||||
{data.map((item) => renderItem(item))}
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { PlusIcon } from '@shared/icons';
|
||||
|
||||
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
|
||||
|
||||
export function RelayForm() {
|
||||
const { db } = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const createRelay = async () => {
|
||||
if (url.length < 1) return setError('Please enter relay url');
|
||||
try {
|
||||
const relay = new URL(url.replace(/\s/g, ''));
|
||||
if (
|
||||
domainRegex.test(relay.host) &&
|
||||
(relay.protocol === 'wss:' || relay.protocol === 'ws:')
|
||||
) {
|
||||
const res = await db.createRelay(url);
|
||||
if (!res) return setError("You're already using this relay");
|
||||
|
||||
queryClient.invalidateQueries(['user-relay']);
|
||||
setError('');
|
||||
setUrl('');
|
||||
} else {
|
||||
return setError(
|
||||
'URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return setError('Relay URL is not valid. Please check again');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex h-10 items-center justify-between rounded-lg bg-white/10 pr-1.5">
|
||||
<input
|
||||
className="h-full w-full bg-transparent pl-3 pr-1.5 placeholder:text-white/70 focus:outline-none"
|
||||
type="url"
|
||||
placeholder="wss://"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createRelay()}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded bg-fuchsia-500 text-white hover:bg-fuchsia-600"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { message } from '@tauri-apps/api/dialog';
|
||||
import { normalizeRelayUrl } from 'nostr-fetch';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon, PlusIcon, ShareIcon } from '@shared/icons';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function RelayList() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { getAllRelaysByUsers } = useNostr();
|
||||
const { db } = useStorage();
|
||||
const { status, data } = useQuery(
|
||||
['relays'],
|
||||
async () => {
|
||||
return await getAllRelaysByUsers();
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
const inspectRelay = (relayUrl: string) => {
|
||||
const url = new URL(relayUrl);
|
||||
navigate(`/relays/${url.hostname}`);
|
||||
};
|
||||
|
||||
const connectRelay = async (relayUrl: string) => {
|
||||
const url = normalizeRelayUrl(relayUrl);
|
||||
const res = await db.createRelay(url);
|
||||
|
||||
if (!res) await message("You're aldready connected to this relay");
|
||||
queryClient.invalidateQueries(['user-relay']);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-2 border-r border-white/5">
|
||||
{status === 'loading' ? (
|
||||
<div className="flex h-full w-full items-center justify-center pb-10">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p>Loading relay...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VList className="scrollbar-hide mt-20 h-full">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-white/5 px-3">
|
||||
<h3 className="bg-gradient-to-r from-fuchsia-200 via-red-200 to-orange-300 bg-clip-text font-semibold text-transparent">
|
||||
All relays used by your follows
|
||||
</h3>
|
||||
</div>
|
||||
{[...data].map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex h-14 w-full items-center justify-between border-b border-white/5 px-3"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 divide-x divide-white/10">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inspectRelay(key)}
|
||||
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-white/10 px-1.5 text-sm font-medium hover:bg-white/20"
|
||||
>
|
||||
<ShareIcon className="h-3 w-3" />
|
||||
Inspect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => connectRelay(key)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-white/10"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 pl-3">
|
||||
<span className="text-sm font-semibold text-white/70">Relay: </span>
|
||||
<span className="max-w-[200px] truncate text-sm font-medium text-white">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="isolate flex -space-x-2">
|
||||
{value.slice(0, 4).map((item) => (
|
||||
<User key={item} pubkey={item} variant="stacked" />
|
||||
))}
|
||||
{value.length > 4 ? (
|
||||
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-black/80 ring-1 ring-white/10 backdrop-blur-xl">
|
||||
<span className="text-xs font-semibold">+{value.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { RelayForm } from '@app/relays/components/relayForm';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { CancelIcon } from '@shared/icons';
|
||||
|
||||
export function UserRelay() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { relayUrls } = useNDK();
|
||||
const { db } = useStorage();
|
||||
const { status, data } = useQuery(
|
||||
['user-relay'],
|
||||
async () => {
|
||||
return await db.getExplicitRelayUrls();
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const removeRelay = async (relayUrl: string) => {
|
||||
await db.removeRelay(relayUrl);
|
||||
queryClient.invalidateQueries(['user-relay']);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 px-3">
|
||||
{status === 'loading' ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="group flex h-10 items-center justify-between rounded-lg bg-white/10 pl-3 pr-1.5"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2.5">
|
||||
{relayUrls.includes(item) ? (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
<p className="max-w-[20rem] truncate text-sm font-medium leading-none">
|
||||
{item}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRelay(item)}
|
||||
className="hidden h-6 w-6 items-center justify-center rounded hover:bg-white/10 group-hover:inline-flex"
|
||||
>
|
||||
<CancelIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<RelayForm />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { RelayList } from '@app/relays/components/relayList';
|
||||
import { UserRelay } from '@app/relays/components/userRelay';
|
||||
|
||||
export function RelaysScreen() {
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3">
|
||||
<RelayList />
|
||||
<div className="col-span-1">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-white/5 px-3">
|
||||
<h3 className="font-semibold text-white">Connected relays</h3>
|
||||
</div>
|
||||
<UserRelay />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Await, useLoaderData, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { RelayEventList } from './components/relayEventList';
|
||||
|
||||
export function RelayScreen() {
|
||||
const { url } = useParams();
|
||||
|
||||
const data: { relay?: { [key: string]: string } } = useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getSoftwareName = (url: string) => {
|
||||
const filename = url.substring(url.lastIndexOf('/') + 1);
|
||||
return filename.replace('.git', '');
|
||||
};
|
||||
|
||||
const titleCase = (s: string) => {
|
||||
return s
|
||||
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
|
||||
.replace(/[-_]+(.)/g, (_, c) => ' ' + c.toUpperCase());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-2 border-r border-white/5">
|
||||
<div className="inline-flex h-16 w-full items-center gap-2.5 border-b border-white/5 px-3">
|
||||
<button type="button" onClick={() => navigate(-1)}>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-white/70 hover:text-white" />
|
||||
</button>
|
||||
<h3 className="font-semibold text-white">Global events</h3>
|
||||
</div>
|
||||
<RelayEventList relayUrl={url} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-white/5 px-3">
|
||||
<h3 className="font-semibold text-white">Information</h3>
|
||||
</div>
|
||||
<div className="mt-4 px-3">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-white">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={data.relay}
|
||||
errorElement={
|
||||
<div className="text-sm font-medium">
|
||||
<p>Could not load relay information 😬</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(resolvedRelay) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h3 className="font-semibold leading-tight text-white">
|
||||
{resolvedRelay.name}
|
||||
</h3>
|
||||
<p className="text-sm font-medium text-white/70">
|
||||
{resolvedRelay.description}
|
||||
</p>
|
||||
</div>
|
||||
{resolvedRelay.pubkey ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h5 className="text-sm font-semibold text-white/70">Owner:</h5>
|
||||
<div className="w-full rounded-lg bg-white/10 px-2 py-2">
|
||||
<User pubkey={resolvedRelay.pubkey} variant="simple" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedRelay.contact ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-white/70">Contact:</h5>
|
||||
<a
|
||||
href={`mailto:${resolvedRelay.contact}`}
|
||||
target="_blank"
|
||||
className="underline after:content-['_↗'] hover:text-fuchsia-500"
|
||||
rel="noreferrer"
|
||||
>
|
||||
mailto:{resolvedRelay.contact}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-white/70">Software:</h5>
|
||||
<a
|
||||
href={resolvedRelay.software}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline after:content-['_↗'] hover:text-fuchsia-500"
|
||||
>
|
||||
{getSoftwareName(resolvedRelay.software) +
|
||||
' - ' +
|
||||
resolvedRelay.version}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-white/70">
|
||||
Supported NIPs:
|
||||
</h5>
|
||||
<div className="mt-2 grid grid-cols-7 gap-2">
|
||||
{resolvedRelay.supported_nips.map((item: string) => (
|
||||
<a
|
||||
key={item}
|
||||
href={`https://nips.be/${item}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex aspect-square h-full w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{resolvedRelay.limitation ? (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-white/70">Limitation</h5>
|
||||
<div className="flex flex-col gap-2 divide-y divide-white/5">
|
||||
{Object.keys(resolvedRelay.limitation).map((key, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-baseline justify-between pt-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{titleCase(key)}:
|
||||
</p>
|
||||
<p className="text-sm font-medium text-white/70">
|
||||
{resolvedRelay.limitation[key].toString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{resolvedRelay.payments_url ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<a
|
||||
href={resolvedRelay.payments_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-fuchsia-500 text-sm font-medium hover:bg-fuchsia-600"
|
||||
>
|
||||
Open payment website
|
||||
</a>
|
||||
<span className="text-center text-xs text-white/70">
|
||||
You need to make a payment to connect this relay
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { PlusIcon } from '@shared/icons';
|
||||
import { HandArrowDownIcon, PlusIcon } from '@shared/icons';
|
||||
|
||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
@@ -9,17 +9,22 @@ export function ToggleWidgetList() {
|
||||
const setWidget = useWidgets((state) => state.setWidget);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full shrink-0 grow-0 basis-[400px] items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setWidget(db, { kind: WidgetKinds.tmp.list, title: '', content: '' })
|
||||
}
|
||||
className="inline-flex items-center gap-2 text-white"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
<p className="text-sm font-bold leading-none">Add widget</p>
|
||||
</button>
|
||||
<div className="flex h-full shrink-0 grow-0 basis-[400px] items-center justify-center">
|
||||
<div className="relative">
|
||||
<div className="absolute -top-44 left-1/2 -translate-x-1/2 transform">
|
||||
<HandArrowDownIcon className="text-white/5" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setWidget(db, { kind: WidgetKinds.tmp.list, title: '', content: '' })
|
||||
}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg border-t border-white/10 bg-white/20 px-3 text-white hover:bg-white/30"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
<p className="text-sm font-semibold leading-none">Add widget</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ export function SpaceScreen() {
|
||||
case WidgetKinds.nostrBand.trendingNotes:
|
||||
return <TrendingNotesWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.tmp.xfeed:
|
||||
return <XhashtagWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.tmp.xhashtag:
|
||||
return <XfeedsWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.tmp.xhashtag:
|
||||
return <XhashtagWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.tmp.list:
|
||||
return <WidgetList key={widget.id} params={widget} />;
|
||||
case WidgetKinds.other.learnNostr:
|
||||
|
||||
+24
-56
@@ -1,6 +1,6 @@
|
||||
import { message } from '@tauri-apps/api/dialog';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
@@ -12,35 +12,10 @@ import { useNostr } from '@utils/hooks/useNostr';
|
||||
export function SplashScreen() {
|
||||
const { db } = useStorage();
|
||||
const { ndk } = useNDK();
|
||||
const { fetchUserData, prefetchEvents } = useNostr();
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const skip = async () => {
|
||||
await invoke('close_splashscreen');
|
||||
};
|
||||
|
||||
const prefetch = async () => {
|
||||
try {
|
||||
const [user, events] = await Promise.all([fetchUserData(), prefetchEvents()]);
|
||||
|
||||
if (user.status === 'ok' && events.status === 'ok') {
|
||||
// update last login = current time
|
||||
await db.updateLastLogin();
|
||||
// close splash screen and open main app screen
|
||||
await invoke('close_splashscreen');
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
await message(e, {
|
||||
title: 'An unexpected error has occurred',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
const { fetchUserData } = useNostr();
|
||||
|
||||
useEffect(() => {
|
||||
async function initial() {
|
||||
async function syncUserData() {
|
||||
if (!db.account) {
|
||||
await invoke('close_splashscreen');
|
||||
} else {
|
||||
@@ -50,46 +25,39 @@ export function SplashScreen() {
|
||||
if (step) {
|
||||
await invoke('close_splashscreen');
|
||||
} else {
|
||||
console.log('prefetching...');
|
||||
prefetch();
|
||||
try {
|
||||
const userData = await fetchUserData();
|
||||
if (userData.status === 'ok') {
|
||||
// update last login = current time
|
||||
await db.updateLastLogin();
|
||||
// close splash screen and open main app screen
|
||||
await invoke('close_splashscreen');
|
||||
}
|
||||
} catch (e) {
|
||||
await message(e, {
|
||||
title: 'An unexpected error has occurred',
|
||||
type: 'error',
|
||||
});
|
||||
await invoke('close_splashscreen');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ndk) {
|
||||
initial();
|
||||
syncUserData();
|
||||
}
|
||||
}, [ndk, db.account]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen w-screen items-center justify-center bg-black">
|
||||
<div data-tauri-drag-region className="absolute left-0 top-0 z-10 h-11 w-full" />
|
||||
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<div className="flex min-h-0 w-full flex-1 items-center justify-center px-8">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-lg font-semibold leading-none text-white">
|
||||
{!ndk ? 'Connecting to relay...' : 'Fetching events from the last login.'}
|
||||
</h3>
|
||||
<p className="text-sm text-white/50">
|
||||
This may take a few seconds, please don't close app.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-col gap-1 text-center">
|
||||
<h3 className="text-lg font-semibold leading-none text-white">
|
||||
An unexpected error has occurred
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={skip}
|
||||
className="mx-auto mt-4 inline-flex h-10 w-max items-center justify-center rounded-md bg-white/10 px-8 text-sm font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20"
|
||||
>
|
||||
Skip this step
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold leading-none text-white">
|
||||
{!ndk ? 'Connecting...' : 'Syncing...'}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="inline-flex flex-col items-center gap-1.5">
|
||||
<h5 className="text-center text-xl font-semibold leading-none">
|
||||
{user.display_name || user.displayName || user.name || 'No name'}
|
||||
{user.name || user.display_name || user.displayName || 'No name'}
|
||||
</h5>
|
||||
{user.nip05 ? (
|
||||
<NIP05
|
||||
|
||||
+22
-81
@@ -1,8 +1,8 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { WVList } from 'virtua';
|
||||
|
||||
import { UserProfile } from '@app/users/components/profile';
|
||||
|
||||
@@ -32,79 +32,35 @@ export function UserScreen() {
|
||||
return [...events] as unknown as NDKEvent[];
|
||||
});
|
||||
|
||||
const parentRef = useRef();
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const event: NDKEvent = data[index];
|
||||
if (!event) return;
|
||||
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<TextNote content={event.content} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<TextNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<Repost key={event.id} event={event} />
|
||||
</div>
|
||||
);
|
||||
return <Repost key={event.id} event={event} />;
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Article:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<ArticleNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<UnknownNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<UnknownNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -112,10 +68,7 @@ export function UserScreen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide relative h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl"
|
||||
>
|
||||
<div className="scrollbar-hide relative h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl">
|
||||
<div data-tauri-drag-region className="absolute left-0 top-0 h-11 w-full" />
|
||||
<UserProfile pubkey={pubkey} />
|
||||
<div className="mt-6 h-full w-full border-t border-white/5 px-1.5">
|
||||
@@ -129,7 +82,7 @@ export function UserScreen() {
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
) : data.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-6 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
@@ -140,22 +93,10 @@ export function UserScreen() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
</div>
|
||||
</div>
|
||||
<WVList>
|
||||
{data.map((item) => renderItem(item))}
|
||||
<div className="h-16" />
|
||||
</WVList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+72
-29
@@ -1,36 +1,12 @@
|
||||
@import 'reactflow/dist/style.css';
|
||||
@import '@vidstack/react/player/styles/default/theme.css';
|
||||
@import '@vidstack/react/player/styles/default/layouts/audio.css';
|
||||
@import '@vidstack/react/player/styles/default/layouts/video.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
|
||||
}
|
||||
|
||||
.markdown-article {
|
||||
@apply prose prose-white max-w-none select-text hyphens-auto text-white/80 prose-headings:mb-1 prose-headings:mt-3 prose-headings:text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-empty::before {
|
||||
@apply text-white/50;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror img.ProseMirror-selectednode {
|
||||
@apply outline-fuchsia-500;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -59,3 +35,70 @@ input::-ms-clear {
|
||||
::-webkit-input-placeholder {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.border {
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
|
||||
}
|
||||
|
||||
.markdown-article {
|
||||
@apply prose prose-white max-w-none select-text hyphens-auto text-white/80 prose-headings:mb-1 prose-headings:mt-3 prose-headings:text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
|
||||
}
|
||||
|
||||
.markdown-simple {
|
||||
@apply prose prose-white max-w-none select-text hyphens-auto text-white/70 prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-empty::before {
|
||||
@apply text-white/50;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror img.ProseMirror-selectednode {
|
||||
@apply outline-fuchsia-500;
|
||||
}
|
||||
|
||||
.player {
|
||||
--brand-color: #f5f5f5;
|
||||
--focus-color: #4e9cf6;
|
||||
|
||||
--audio-brand: var(--brand-color);
|
||||
--audio-focus-ring-color: var(--focus-color);
|
||||
--audio-border-radius: 2px;
|
||||
|
||||
--video-brand: var(--brand-color);
|
||||
--video-focus-ring-color: var(--focus-color);
|
||||
--video-border-radius: 8px;
|
||||
|
||||
/* 👉 https://vidstack.io/docs/player/components/layouts/default#css-variables for more. */
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player[data-view-type='video'] {
|
||||
aspect-ratio: 16 /9;
|
||||
}
|
||||
|
||||
.src-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
margin-top: 40px;
|
||||
margin-inline: auto;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
+32
-30
@@ -1,10 +1,11 @@
|
||||
// inspire by: https://github.com/nostr-dev-kit/ndk-react/
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
|
||||
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
|
||||
import { message } from '@tauri-apps/api/dialog';
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import { NostrFetcher } from 'nostr-fetch';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import TauriAdapter from '@libs/ndk/cache';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
export const NDKInstance = () => {
|
||||
@@ -12,39 +13,41 @@ export const NDKInstance = () => {
|
||||
const [relayUrls, setRelayUrls] = useState<string[]>([]);
|
||||
|
||||
const { db } = useStorage();
|
||||
const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]);
|
||||
const fetcher = useMemo(
|
||||
() => (ndk ? NostrFetcher.withCustomPool(ndkAdapter(ndk)) : null),
|
||||
[ndk]
|
||||
);
|
||||
|
||||
// TODO: fully support NIP-11
|
||||
async function getExplicitRelays() {
|
||||
try {
|
||||
// get relays
|
||||
const relays = await db.getExplicitRelayUrls();
|
||||
const requests = relays.map((relay) => {
|
||||
const onlineRelays = new Set(relays);
|
||||
|
||||
for (const relay of relays) {
|
||||
const url = new URL(relay);
|
||||
return fetch(`https://${url.hostname + url.pathname}`, {
|
||||
method: 'GET',
|
||||
timeout: 10,
|
||||
headers: {
|
||||
Accept: 'application/nostr+json',
|
||||
},
|
||||
});
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://${url.hostname}`, {
|
||||
method: 'GET',
|
||||
timeout: { secs: 5, nanos: 0 },
|
||||
headers: {
|
||||
Accept: 'application/nostr+json',
|
||||
},
|
||||
});
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
const successes = responses.filter((res) => res.ok);
|
||||
|
||||
const verifiedRelays: string[] = successes.map((res) => {
|
||||
// TODO: support payment
|
||||
// @ts-expect-error, not have type yet
|
||||
if (!res.data.limitation?.payment_required) {
|
||||
const url = new URL(res.url);
|
||||
if (url.protocol === 'http:') return `ws://${url.hostname + url.pathname}`;
|
||||
if (url.protocol === 'https:') return `wss://${url.hostname + url.pathname}`;
|
||||
if (!res.ok) {
|
||||
console.info(`${relay} is not working, skipping...`);
|
||||
onlineRelays.delete(relay);
|
||||
}
|
||||
} catch {
|
||||
console.warn(`${relay} is not working, skipping...`);
|
||||
onlineRelays.delete(relay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// return all validated relays
|
||||
return verifiedRelays;
|
||||
// return all online relays
|
||||
return [...onlineRelays];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -52,9 +55,11 @@ export const NDKInstance = () => {
|
||||
|
||||
async function initNDK() {
|
||||
const explicitRelayUrls = await getExplicitRelays();
|
||||
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'lume_ndkcache' });
|
||||
const instance = new NDK({
|
||||
explicitRelayUrls,
|
||||
cacheAdapter,
|
||||
// @ts-expect-error, wtf?
|
||||
cacheAdapter: dexieAdapter,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -72,14 +77,11 @@ export const NDKInstance = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!ndk) initNDK();
|
||||
|
||||
return () => {
|
||||
cacheAdapter.saveCache();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
ndk,
|
||||
relayUrls,
|
||||
fetcher,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// source: https://github.com/nostr-dev-kit/ndk-react/
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
import { NostrFetcher } from 'nostr-fetch';
|
||||
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
|
||||
import { NDKInstance } from '@libs/ndk/instance';
|
||||
@@ -7,21 +8,24 @@ import { NDKInstance } from '@libs/ndk/instance';
|
||||
interface NDKContext {
|
||||
ndk: undefined | NDK;
|
||||
relayUrls: string[];
|
||||
fetcher: NostrFetcher;
|
||||
}
|
||||
|
||||
const NDKContext = createContext<NDKContext>({
|
||||
ndk: undefined,
|
||||
relayUrls: [],
|
||||
fetcher: undefined,
|
||||
});
|
||||
|
||||
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
const { ndk, relayUrls } = NDKInstance();
|
||||
const { ndk, relayUrls, fetcher } = NDKInstance();
|
||||
|
||||
return (
|
||||
<NDKContext.Provider
|
||||
value={{
|
||||
ndk,
|
||||
relayUrls,
|
||||
fetcher,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -10,15 +10,15 @@ import { Account, DBEvent, Relays, Widget } from '@utils/types';
|
||||
|
||||
export class LumeStorage {
|
||||
public db: Database;
|
||||
public platform: Platform;
|
||||
public secureDB: Stronghold;
|
||||
public account: Account | null = null;
|
||||
public account: Account | null;
|
||||
public platform: Platform | null;
|
||||
|
||||
constructor(sqlite: Database, platform: Platform, stronghold?: Stronghold) {
|
||||
constructor(sqlite: Database, platform?: Platform, stronghold?: Stronghold) {
|
||||
this.db = sqlite;
|
||||
this.platform = platform ?? undefined;
|
||||
this.secureDB = stronghold ?? undefined;
|
||||
this.account = null;
|
||||
this.platform = platform ?? undefined;
|
||||
}
|
||||
|
||||
private async getSecureClient(key?: string) {
|
||||
@@ -34,7 +34,6 @@ export class LumeStorage {
|
||||
|
||||
const client = await this.getSecureClient(clientKey);
|
||||
const store = client.getStore();
|
||||
console.log('insert key: ', key);
|
||||
|
||||
await store.insert(key, Array.from(new TextEncoder().encode(value)));
|
||||
await this.secureDB.save();
|
||||
@@ -45,7 +44,6 @@ export class LumeStorage {
|
||||
|
||||
const client = await this.getSecureClient(clientKey);
|
||||
const store = client.getStore();
|
||||
console.log('get key: ', key);
|
||||
|
||||
const value = await store.get(key);
|
||||
if (!value) return null;
|
||||
@@ -58,6 +56,13 @@ export class LumeStorage {
|
||||
return await removeFile('lume.stronghold', { dir: BaseDirectory.AppConfig });
|
||||
}
|
||||
|
||||
public async checkAccount() {
|
||||
const result: Array<{ total: string }> = await this.db.select(
|
||||
'SELECT COUNT(*) AS "total" FROM accounts;'
|
||||
);
|
||||
return parseInt(result[0].total);
|
||||
}
|
||||
|
||||
public async getActiveAccount() {
|
||||
const results: Array<Account> = await this.db.select(
|
||||
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
|
||||
@@ -84,16 +89,24 @@ export class LumeStorage {
|
||||
}
|
||||
|
||||
public async createAccount(npub: string, pubkey: string) {
|
||||
const res = await this.db.execute(
|
||||
'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, is_active) VALUES ($1, $2, $3, $4);',
|
||||
[npub, pubkey, 'privkey is stored in secure storage', 1]
|
||||
const existAccounts: Array<Account> = await this.db.select(
|
||||
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[pubkey]
|
||||
);
|
||||
if (res) {
|
||||
const account = await this.getActiveAccount();
|
||||
return account;
|
||||
|
||||
if (existAccounts.length > 0) {
|
||||
await this.db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [
|
||||
pubkey,
|
||||
]);
|
||||
} else {
|
||||
console.error('create account failed');
|
||||
await this.db.execute(
|
||||
'INSERT OR IGNORE INTO accounts (npub, pubkey, privkey, is_active) VALUES ($1, $2, $3, $4);',
|
||||
[npub, pubkey, 'privkey is stored in secure storage', 1]
|
||||
);
|
||||
}
|
||||
|
||||
const account = await this.getActiveAccount();
|
||||
return account;
|
||||
}
|
||||
|
||||
public async updateAccount(column: string, value: string | string[]) {
|
||||
@@ -110,6 +123,7 @@ export class LumeStorage {
|
||||
|
||||
public async updateLastLogin() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
this.account.last_login_at = now;
|
||||
return await this.db.execute(
|
||||
'UPDATE accounts SET last_login_at = $1 WHERE id = $2;',
|
||||
[now, this.account.id]
|
||||
@@ -183,7 +197,8 @@ export class LumeStorage {
|
||||
|
||||
public async countTotalEvents() {
|
||||
const result: Array<{ total: string }> = await this.db.select(
|
||||
'SELECT COUNT(*) AS "total" FROM events;'
|
||||
'SELECT COUNT(*) AS "total" FROM events WHERE account_id = $1;',
|
||||
[this.account.id]
|
||||
);
|
||||
return parseInt(result[0].total);
|
||||
}
|
||||
@@ -198,8 +213,8 @@ export class LumeStorage {
|
||||
};
|
||||
|
||||
const query: DBEvent[] = await this.db.select(
|
||||
'SELECT * FROM events GROUP BY root_id ORDER BY created_at DESC LIMIT $1 OFFSET $2;',
|
||||
[limit, offset]
|
||||
'SELECT * FROM events WHERE account_id = $1 GROUP BY root_id ORDER BY created_at DESC LIMIT $2 OFFSET $3;',
|
||||
[this.account.id, limit, offset]
|
||||
);
|
||||
|
||||
if (query && query.length > 0) {
|
||||
@@ -256,8 +271,8 @@ export class LumeStorage {
|
||||
};
|
||||
|
||||
const query: DBEvent[] = await this.db.select(
|
||||
`SELECT * FROM events WHERE kinds IN (${authorsArr}) ORDER BY created_at DESC LIMIT $1 OFFSET $2;`,
|
||||
[limit, offset]
|
||||
`SELECT * FROM events WHERE kinds IN (${authorsArr}) AND account_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3;`,
|
||||
[this.account.id, limit, offset]
|
||||
);
|
||||
|
||||
if (query && query.length > 0) {
|
||||
@@ -276,7 +291,8 @@ export class LumeStorage {
|
||||
|
||||
public async isEventsEmpty() {
|
||||
const results: DBEvent[] = await this.db.select(
|
||||
'SELECT * FROM events ORDER BY id DESC LIMIT 1;'
|
||||
'SELECT * FROM events WHERE account_id = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[this.account.id]
|
||||
);
|
||||
|
||||
return results.length < 1;
|
||||
@@ -294,6 +310,13 @@ export class LumeStorage {
|
||||
}
|
||||
|
||||
public async createRelay(relay: string, purpose?: string) {
|
||||
const existRelays: Relays[] = await this.db.select(
|
||||
'SELECT * FROM relays WHERE relay = $1 AND account_id = $2 ORDER BY id DESC LIMIT 1;',
|
||||
[relay, this.account.id]
|
||||
);
|
||||
|
||||
if (existRelays.length > 0) return false;
|
||||
|
||||
return await this.db.execute(
|
||||
'INSERT OR IGNORE INTO relays (account_id, relay, purpose) VALUES ($1, $2, $3);',
|
||||
[this.account.id, relay, purpose || '']
|
||||
@@ -311,6 +334,7 @@ export class LumeStorage {
|
||||
}
|
||||
|
||||
public async accountLogout() {
|
||||
// update current account status
|
||||
await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
|
||||
this.account.id,
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { message } from '@tauri-apps/api/dialog';
|
||||
import { platform } from '@tauri-apps/api/os';
|
||||
import { appConfigDir } from '@tauri-apps/api/path';
|
||||
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
@@ -17,15 +16,12 @@ const StorageContext = createContext<StorageContext>({
|
||||
const StorageProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
const [db, setDB] = useState<LumeStorage>(undefined);
|
||||
|
||||
async function initLumeStorage() {
|
||||
const initLumeStorage = async () => {
|
||||
try {
|
||||
const dir = await appConfigDir();
|
||||
const sqlite = await Database.load('sqlite:lume.db');
|
||||
const platformName = await platform();
|
||||
const lumeStorage = new LumeStorage(sqlite, platformName);
|
||||
|
||||
console.log('App config dir: ', dir);
|
||||
|
||||
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
|
||||
setDB(lumeStorage);
|
||||
} catch (e) {
|
||||
@@ -34,7 +30,7 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!db) initLumeStorage();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { AccountMoreActions } from '@shared/accounts/more';
|
||||
import { SettingsIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
import { Logout } from '@shared/logout';
|
||||
|
||||
import { useActivities } from '@stores/activities';
|
||||
|
||||
@@ -23,34 +25,29 @@ export function ActiveAccount() {
|
||||
useEffect(() => {
|
||||
const filter: NDKFilter = {
|
||||
'#p': [db.account.pubkey],
|
||||
kinds: [
|
||||
NDKKind.Text,
|
||||
NDKKind.Contacts,
|
||||
NDKKind.Repost,
|
||||
NDKKind.Reaction,
|
||||
NDKKind.Zap,
|
||||
],
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
sub(filter, async (event) => {
|
||||
addActivity(event);
|
||||
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return await sendNativeNotification('Mention');
|
||||
case NDKKind.Contacts:
|
||||
return await sendNativeNotification("You've a new follower");
|
||||
case NDKKind.Repost:
|
||||
return await sendNativeNotification('Repost');
|
||||
case NDKKind.Reaction:
|
||||
return await sendNativeNotification('Reaction');
|
||||
case NDKKind.Zap:
|
||||
return await sendNativeNotification('Zap');
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
sub(
|
||||
filter,
|
||||
async (event) => {
|
||||
addActivity(event);
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return await sendNativeNotification('Mention');
|
||||
case NDKKind.Repost:
|
||||
return await sendNativeNotification('Repost');
|
||||
case NDKKind.Reaction:
|
||||
return await sendNativeNotification('Reaction');
|
||||
case NDKKind.Zap:
|
||||
return await sendNativeNotification('Zap');
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (status === 'loading') {
|
||||
@@ -63,23 +60,31 @@ export function ActiveAccount() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-16 items-center justify-between border-l-2 border-transparent pb-2 pl-4 pr-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-16 items-center justify-between border-l-2 border-transparent pb-2 pl-4 pr-3">
|
||||
<Link to={`/users/${db.account.pubkey}`} className="flex items-center gap-1.5">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={db.account.npub}
|
||||
className="h-10 w-10 shrink-0 rounded-lg object-cover"
|
||||
className="h-9 w-9 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1.5">
|
||||
<p className="max-w-[10rem] truncate font-bold leading-none text-white">
|
||||
{user?.name || user?.display_name}
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-0.5">
|
||||
<p className="max-w-[10rem] truncate font-semibold leading-none text-white">
|
||||
{user?.name || user?.display_name || user?.displayName}
|
||||
</p>
|
||||
<span className="max-w-[8rem] truncate text-sm leading-none text-white/50">
|
||||
{displayNpub(db.account.pubkey, 16)}
|
||||
<span className="max-w-[7rem] truncate text-sm leading-none text-white/50">
|
||||
{user?.nip05 || displayNpub(db.account.pubkey, 12)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="inline-flex divide-x divide-white/5 rounded-lg border-t border-white/10 bg-white/20">
|
||||
<Link
|
||||
to="/settings/"
|
||||
className="inline-flex h-9 w-9 items-center justify-center hover:bg-white/10"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4 text-white" />
|
||||
</Link>
|
||||
<Logout />
|
||||
</div>
|
||||
<AccountMoreActions pubkey={db.account.pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,4 +3,6 @@ export * from './modal';
|
||||
export * from './composer';
|
||||
export * from './mention/item';
|
||||
export * from './mention/popup';
|
||||
export * from './mention/suggestion';
|
||||
export * from './mention/inlineList';
|
||||
export * from './mediaUploader';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type SuggestionProps } from '@tiptap/suggestion';
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { MentionItem } from '@shared/composer';
|
||||
|
||||
export const MentionInlineList = forwardRef(
|
||||
(props: SuggestionProps, ref: ForwardedRef<unknown>) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item });
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex w-[250px] flex-col rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
{props.items.length ? (
|
||||
props.items.map((item: string, index: number) => (
|
||||
<button
|
||||
className={twMerge(
|
||||
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-white/10',
|
||||
`${index === selectedIndex ? 'is-selected' : ''}`
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<MentionItem embed={item} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div>No result</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MentionInlineList.displayName = 'MentionList';
|
||||
@@ -3,12 +3,12 @@ import { Image } from '@shared/image';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function MentionItem({ pubkey }: { pubkey: string }) {
|
||||
const { status, user } = useProfile(pubkey);
|
||||
export function MentionItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
|
||||
const { status, user } = useProfile(pubkey, embed);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5 px-2">
|
||||
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ReactRenderer } from '@tiptap/react';
|
||||
import tippy from 'tippy.js';
|
||||
|
||||
import { MentionInlineList } from '@shared/composer';
|
||||
|
||||
export const Suggestion = {
|
||||
items: async ({ query }) => {
|
||||
const users = [];
|
||||
return users
|
||||
.filter((item) => item.ident.toLowerCase().startsWith(query.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component;
|
||||
let popup;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionInlineList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -28,15 +28,13 @@ export function ComposerModal() {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-2 text-white/80"
|
||||
className="flex h-9 items-center gap-2 rounded-full border-t border-white/10 bg-white/20 px-4 text-sm font-semibold text-white/80 hover:bg-fuchsia-500 hover:text-white"
|
||||
>
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<ComposeIcon className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
New postr
|
||||
New
|
||||
<ComposeIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal className="relative z-10">
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ComposerUser({ pubkey }: { pubkey: string }) {
|
||||
className="h-10 w-10 shrink-0 rounded-lg"
|
||||
/>
|
||||
<h5 className="text-base font-semibold leading-none text-white">
|
||||
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
|
||||
{user?.name || user?.display_name || user?.displayName || displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function DotsPattern(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props}>
|
||||
<pattern
|
||||
id="pattern-circles"
|
||||
width="30"
|
||||
height="30"
|
||||
x="0"
|
||||
y="0"
|
||||
patternContentUnits="userSpaceOnUse"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="2" cy="2" r="1.626" fill="currentColor"></circle>
|
||||
</pattern>
|
||||
<rect width="100%" height="100%" x="0" y="0" fill="url(#pattern-circles)"></rect>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function HandArrowDownIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="130"
|
||||
height="130"
|
||||
fill="none"
|
||||
viewBox="0 0 130 130"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M59.95 53.86c.109.564.326 1.126.543 1.687.976 3.028 2.062 6.56 4.015 9.168 2.604 3.419 5.534 4.74 8.463 4.711 4.123-.044 8.355-3.086 10.959-7.254 2.604-4.13 3.69-9.301 2.062-13.466-1.085-2.855-3.472-5.291-7.596-6.62-4.665-1.532-9.114-.278-13.02 2.422-1.52 1.014-2.821 2.233-4.123 3.577-.326-1.768-.652-3.553-.977-5.363-.869-5.925-1.086-12.154-.76-18.127.325-7.901 1.954-15.46 4.992-22.763.217-.674-.109-1.447-.76-1.725-.651-.276-1.41.045-1.736.72-3.146 7.59-4.883 15.446-5.209 23.658-.217 6.146-.107 12.555.87 18.653.433 2.526.867 5.005 1.41 7.457-2.496 3.216-4.45 6.789-5.643 9.827-7.596 20.325 2.278 47.693 12.044 66.11a1.26 1.26 0 001.737.549c.65-.342.867-1.141.542-1.786-9.44-17.825-19.206-44.269-11.828-63.94.868-2.313 2.28-4.968 4.015-7.494zm2.062-2.63c.217 1.161.542 2.315.976 3.465.977 2.776 1.845 6.037 3.58 8.426 1.954 2.581 4.125 3.684 6.295 3.663 3.472-.036 6.727-2.651 8.789-6.022 2.17-3.409 3.255-7.656 1.845-11.094-.868-2.214-2.713-4.04-5.86-5.07-3.906-1.261-7.595-.142-10.742 2.084-1.844 1.238-3.472 2.816-4.883 4.548z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M66.784 126.734c-.542-.384-1.085-.956-1.628-1.53-1.085-1.104-2.061-2.274-2.93-2.89-5.208-3.796-11.501-8.282-17.686-10.272-.65-.225-1.41.156-1.627.85-.217.694.108 1.439.867 1.664 5.86 1.909 11.828 6.255 16.928 9.898.868.666 2.061 2.077 3.146 3.207.977.959 1.954 1.724 2.93 2.002.543.196 1.52-.021 2.17-1.243.868-1.732 1.629-6.541 1.737-7.007 1.085-4.715 2.17-8.865 6.293-11.842.543-.429.651-1.257.326-1.846-.434-.589-1.303-.719-1.845-.289-4.666 3.394-6.185 8.041-7.379 13.416 0 .343-.434 3.218-1.085 5.215-.108.216-.108.454-.217.667z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -64,3 +64,7 @@ export * from './follows';
|
||||
export * from './alby';
|
||||
export * from './stars';
|
||||
export * from './nwc';
|
||||
export * from './timeline';
|
||||
export * from './dots';
|
||||
export * from './handArrowDown';
|
||||
export * from './relay';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function RelayIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M21.25 12V5.75a1 1 0 00-1-1H3.75a1 1 0 00-1 1V12m18.5 0H2.75m18.5 0v6.25a1 1 0 01-1 1H3.75a1 1 0 01-1-1V12"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
d="M6.5 9.125a.75.75 0 100-1.5.75.75 0 000 1.5zm0 7.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function TimeLineIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M22.25 15C17.215 15 15 17.215 15 22.25 15 17.215 12.785 15 7.75 15 12.785 15 15 12.785 15 7.75c0 5.035 2.215 7.25 7.25 7.25zM11.25 6.5c-3.299 0-4.75 1.451-4.75 4.75 0-3.299-1.451-4.75-4.75-4.75 3.299 0 4.75-1.451 4.75-4.75 0 3.299 1.451 4.75 4.75 4.75z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { ImgHTMLAttributes, useState } from 'react';
|
||||
import { ImgHTMLAttributes, memo, useState } from 'react';
|
||||
|
||||
export function Image({ src, ...props }: ImgHTMLAttributes<HTMLImageElement>) {
|
||||
export const Image = memo(function Image({
|
||||
src,
|
||||
...props
|
||||
}: ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
if (isError || !src) {
|
||||
@@ -20,9 +23,10 @@ export function Image({ src, ...props }: ImgHTMLAttributes<HTMLImageElement>) {
|
||||
currentTarget.onerror = null;
|
||||
setIsError(true);
|
||||
}}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="lume default img"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,19 +3,21 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LogoutIcon } from '@shared/icons';
|
||||
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
export function Logout() {
|
||||
const { db } = useStorage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const clearPrivkey = useStronghold((state) => state.clearPrivkey);
|
||||
const resetStronghold = useStronghold((state) => state.reset);
|
||||
|
||||
const logout = async () => {
|
||||
// remove account
|
||||
db.accountLogout();
|
||||
// clear privkey in session storage
|
||||
clearPrivkey();
|
||||
resetStronghold();
|
||||
// redirect to welcome screen
|
||||
navigate('/auth/welcome');
|
||||
};
|
||||
@@ -25,12 +27,12 @@ export function Logout() {
|
||||
<AlertDialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-white/10"
|
||||
className="inline-flex h-9 w-9 items-center justify-center hover:bg-white/10"
|
||||
>
|
||||
Logout
|
||||
<LogoutIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Portal className="relative z-10">
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
|
||||
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-md rounded-xl bg-white/10 backdrop-blur-xl">
|
||||
|
||||
+89
-16
@@ -1,5 +1,5 @@
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { ChatsList } from '@app/chats/components/list';
|
||||
@@ -9,7 +9,16 @@ import { useStorage } from '@libs/storage/provider';
|
||||
import { ActiveAccount } from '@shared/accounts/active';
|
||||
import { ComposerModal } from '@shared/composer';
|
||||
import { Frame } from '@shared/frame';
|
||||
import { BellIcon, NavArrowDownIcon, NwcIcon, SpaceIcon } from '@shared/icons';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
BellIcon,
|
||||
NavArrowDownIcon,
|
||||
NwcIcon,
|
||||
RelayIcon,
|
||||
SpaceIcon,
|
||||
WorldIcon,
|
||||
} from '@shared/icons';
|
||||
|
||||
import { useActivities } from '@stores/activities';
|
||||
import { useSidebar } from '@stores/sidebar';
|
||||
@@ -19,7 +28,9 @@ import { compactNumber } from '@utils/number';
|
||||
export function Navigation() {
|
||||
const { db } = useStorage();
|
||||
|
||||
const [totalNewActivities] = useActivities((state) => [state.totalNewActivities]);
|
||||
const navigate = useNavigate();
|
||||
const totalNewActivities = useActivities((state) => state.totalNewActivities);
|
||||
|
||||
const [chats, toggleChats] = useSidebar((state) => [state.chats, state.toggleChats]);
|
||||
const [integrations, toggleIntegrations] = useSidebar((state) => [
|
||||
state.integrations,
|
||||
@@ -27,19 +38,47 @@ export function Navigation() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<Frame className="relative flex h-full w-[232px] flex-col" lighter>
|
||||
{db.platform === 'darwin' ? (
|
||||
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
|
||||
) : null}
|
||||
<div className="scrollbar-hide flex h-full flex-1 flex-col gap-6 overflow-y-auto pb-32">
|
||||
<div className="flex flex-col pr-2">
|
||||
<ComposerModal />
|
||||
<Frame
|
||||
className="relative flex h-full w-[232px] flex-col border-r border-white/5"
|
||||
lighter
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="inline-flex h-16 w-full items-center justify-between px-3"
|
||||
>
|
||||
{db.platform !== 'darwin' ? (
|
||||
<div className="inline-flex items-center gap-4 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex h-9 items-center justify-center"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-white/50" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(1)}
|
||||
className="inline-flex h-9 items-center justify-center"
|
||||
>
|
||||
<ArrowRightIcon className="h-5 w-5 text-white/50" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<ComposerModal />
|
||||
</div>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="scrollbar-hide flex h-full flex-1 flex-col gap-6 overflow-y-auto pb-32"
|
||||
>
|
||||
<div className="flex flex-col pr-3">
|
||||
<NavLink
|
||||
to="/"
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3',
|
||||
isActive
|
||||
? 'border-fuchsia-500 bg-white/5 text-white'
|
||||
: 'border-transparent text-white/70'
|
||||
@@ -49,14 +88,48 @@ export function Navigation() {
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<SpaceIcon className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
Space
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/relays"
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3',
|
||||
isActive
|
||||
? 'border-fuchsia-500 bg-white/5 text-white'
|
||||
: 'border-transparent text-white/70'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<RelayIcon className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
Relays
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/explore"
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3',
|
||||
isActive
|
||||
? 'border-fuchsia-500 bg-white/5 text-white'
|
||||
: 'border-transparent text-white/70'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<WorldIcon className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
Explore
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/notifications"
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'flex h-10 items-center justify-between rounded-r-lg border-l-2 pl-4 pr-2',
|
||||
'flex h-10 items-center justify-between rounded-r-lg border-l-2 pl-4 pr-3',
|
||||
isActive
|
||||
? 'border-fuchsia-500 bg-white/5 text-white'
|
||||
: 'border-transparent text-white/70'
|
||||
@@ -81,7 +154,7 @@ export function Navigation() {
|
||||
</NavLink>
|
||||
</div>
|
||||
<Collapsible.Root open={integrations} onOpenChange={toggleIntegrations}>
|
||||
<div className="flex flex-col gap-1 pr-2">
|
||||
<div className="flex flex-col gap-1 pr-3">
|
||||
<Collapsible.Trigger asChild>
|
||||
<button className="flex items-center gap-1 pl-[20px] pr-4">
|
||||
<div
|
||||
@@ -103,7 +176,7 @@ export function Navigation() {
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
|
||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3',
|
||||
isActive
|
||||
? 'border-fuchsia-500 bg-white/5 text-white'
|
||||
: 'border-transparent text-white/70'
|
||||
@@ -119,7 +192,7 @@ export function Navigation() {
|
||||
</div>
|
||||
</Collapsible.Root>
|
||||
<Collapsible.Root open={chats} onOpenChange={toggleChats}>
|
||||
<div className="flex flex-col gap-1 pr-2">
|
||||
<div className="flex flex-col gap-1 pr-3">
|
||||
<Collapsible.Trigger asChild>
|
||||
<button className="flex items-center gap-1 pl-[20px] pr-4">
|
||||
<div
|
||||
|
||||
@@ -45,7 +45,7 @@ export function NoteActions({
|
||||
to={`/notes/text/${id}`}
|
||||
className="group inline-flex h-7 w-7 items-center justify-center"
|
||||
>
|
||||
<FocusIcon className="h-5 w-5 text-white group-hover:text-fuchsia-400" />
|
||||
<FocusIcon className="h-5 w-5 text-white/80 group-hover:text-fuchsia-400" />
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
@@ -68,7 +68,7 @@ export function NoteActions({
|
||||
}
|
||||
className="group inline-flex h-7 w-7 items-center justify-center"
|
||||
>
|
||||
<ThreadIcon className="h-5 w-5 text-white group-hover:text-fuchsia-400" />
|
||||
<ThreadIcon className="h-5 w-5 text-white/80 group-hover:text-fuchsia-400" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
|
||||
@@ -30,9 +30,9 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group ml-auto inline-flex h-7 w-7 items-center justify-center"
|
||||
className="group ml-auto inline-flex h-7 w-7 items-center justify-center text-white/80"
|
||||
>
|
||||
<HorizontalDotsIcon className="h-5 w-5 text-white group-hover:text-fuchsia-400" />
|
||||
<HorizontalDotsIcon className="h-5 w-5 text-white/80 group-hover:text-fuchsia-400" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
@@ -62,12 +62,12 @@ export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-white/80"
|
||||
>
|
||||
{reaction ? (
|
||||
<img src={getReactionImage(reaction)} alt={reaction} className="h-6 w-6" />
|
||||
) : (
|
||||
<ReactionIcon className="h-5 w-5 text-white group-hover:text-red-400" />
|
||||
<ReactionIcon className="h-5 w-5 text-white/80 group-hover:text-red-400" />
|
||||
)}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
@@ -21,9 +21,9 @@ export function NoteReply({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReply(id, pubkey, root)}
|
||||
className="group inline-flex h-7 w-7 items-center justify-center"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-white/80"
|
||||
>
|
||||
<ReplyIcon className="h-5 w-5 text-white group-hover:text-green-500" />
|
||||
<ReplyIcon className="h-5 w-5 text-white/80 group-hover:text-green-500" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
|
||||
@@ -44,12 +44,12 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
<AlertDialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-white/80"
|
||||
>
|
||||
<RepostIcon
|
||||
className={twMerge(
|
||||
'h-5 w-5 group-hover:text-blue-400',
|
||||
isRepost ? 'text-blue-400' : 'text-white'
|
||||
'h-5 w-5 group-hover:text-blue-500',
|
||||
isRepost ? 'text-blue-500' : 'text-white/80'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
if (send) {
|
||||
await sendNativeNotification(
|
||||
`You've tipped ${compactNumber.format(send.amount)} sats to ${
|
||||
user?.display_name || user?.name
|
||||
user?.name || user?.display_name || user?.displayName
|
||||
}`
|
||||
);
|
||||
|
||||
@@ -94,9 +94,9 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-white/80"
|
||||
>
|
||||
<ZapIcon className="h-5 w-5 text-white group-hover:text-orange-400" />
|
||||
<ZapIcon className="h-5 w-5 text-white/80 group-hover:text-orange-400" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal className="relative z-10">
|
||||
@@ -106,7 +106,7 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
|
||||
<div className="w-6" />
|
||||
<Dialog.Title className="text-center text-sm font-semibold leading-none text-white">
|
||||
Send tip to {user?.display_name || user?.name}
|
||||
Send tip to {user?.name || user?.display_name || user?.displayName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
|
||||
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||
|
||||
@@ -49,15 +49,15 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
|
||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-white/20 to-white/10" />
|
||||
<div className="relative mb-5 flex flex-col">
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 items-end justify-center rounded-lg bg-black pb-1">
|
||||
<div className="inline-flex h-10 w-10 items-end justify-center rounded-lg bg-black pb-1">
|
||||
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
|
||||
</div>
|
||||
<h5 className="truncate font-semibold leading-none text-white">
|
||||
Lume <span className="text-green-500">(System)</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="-mt-6 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div>
|
||||
<div className="relative z-20 mt-1 flex-1 select-text">
|
||||
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
|
||||
@@ -78,11 +78,11 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-white/20 to-white/10" />
|
||||
<div className="mb-5 flex flex-col">
|
||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.6rem)] w-0.5 bg-gradient-to-t from-white/20 to-white/10" />
|
||||
<div className="mb-6 flex flex-col">
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-6 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="relative z-20 flex-1">
|
||||
{renderKind(data)}
|
||||
<NoteActions id={data.id} pubkey={data.pubkey} root={root} />
|
||||
|
||||
@@ -22,5 +22,6 @@ export * from './child';
|
||||
export * from './skeleton';
|
||||
export * from './actions';
|
||||
export * from './mentions/hashtag';
|
||||
export * from './mentions/boost';
|
||||
export * from './stats';
|
||||
export * from './wrapper';
|
||||
|
||||
@@ -4,19 +4,19 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
export function ArticleNote({ event }: { event: NDKEvent }) {
|
||||
export function ArticleNote(props: { event?: NDKEvent }) {
|
||||
const metadata = useMemo(() => {
|
||||
const title = event.tags.find((tag) => tag[0] === 'title')?.[1];
|
||||
const image = event.tags.find((tag) => tag[0] === 'image')?.[1];
|
||||
const summary = event.tags.find((tag) => tag[0] === 'summary')?.[1];
|
||||
const title = props.event.tags.find((tag) => tag[0] === 'title')?.[1];
|
||||
const image = props.event.tags.find((tag) => tag[0] === 'image')?.[1];
|
||||
const summary = props.event.tags.find((tag) => tag[0] === 'summary')?.[1];
|
||||
|
||||
let publishedAt: Date | string | number = event.tags.find(
|
||||
let publishedAt: Date | string | number = props.event.tags.find(
|
||||
(tag) => tag[0] === 'published_at'
|
||||
)?.[1];
|
||||
if (publishedAt) {
|
||||
publishedAt = new Date(parseInt(publishedAt)).toLocaleDateString('en-US');
|
||||
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
|
||||
} else {
|
||||
publishedAt = new Date(event.created_at * 1000).toLocaleDateString('en-US');
|
||||
publishedAt = new Date(props.event.created_at * 1000).toLocaleDateString('en-US');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -25,15 +25,11 @@ export function ArticleNote({ event }: { event: NDKEvent }) {
|
||||
publishedAt,
|
||||
summary,
|
||||
};
|
||||
}, [event.id]);
|
||||
}, [props.event.id]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/notes/article/${event.id}`}
|
||||
preventScrollReset={true}
|
||||
className="mb-2 mt-3 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-col rounded-lg">
|
||||
<Link to={`/notes/article/${props.event.id}`} preventScrollReset={true}>
|
||||
<div className="mb-2 mt-2 flex flex-col rounded-lg">
|
||||
{metadata.image && (
|
||||
<Image
|
||||
src={metadata.image}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import ReactPlayer from 'react-player';
|
||||
import { MediaPlayer, MediaProvider } from '@vidstack/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { fileType } from '@utils/nip94';
|
||||
|
||||
export function FileNote({ event }: { event: NDKEvent }) {
|
||||
const url = event.tags.find((el) => el[0] === 'url')[1];
|
||||
export function FileNote(props: { event?: NDKEvent }) {
|
||||
const url = props.event.tags.find((el) => el[0] === 'url')[1];
|
||||
const type = fileType(url);
|
||||
|
||||
if (type === 'image') {
|
||||
@@ -15,7 +15,7 @@ export function FileNote({ event }: { event: NDKEvent }) {
|
||||
<div className="mb-2 mt-3">
|
||||
<Image
|
||||
src={url}
|
||||
alt={event.content}
|
||||
alt={props.event.content}
|
||||
className="h-auto w-full rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -25,15 +25,16 @@ export function FileNote({ event }: { event: NDKEvent }) {
|
||||
if (type === 'video') {
|
||||
return (
|
||||
<div className="mb-2 mt-3">
|
||||
<ReactPlayer
|
||||
<MediaPlayer
|
||||
key={url}
|
||||
url={url}
|
||||
width="100%"
|
||||
height="auto"
|
||||
className="!h-auto overflow-hidden rounded-lg object-fill"
|
||||
controls={true}
|
||||
pip={true}
|
||||
/>
|
||||
src={url}
|
||||
poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
|
||||
load="visible"
|
||||
aspectRatio="16/9"
|
||||
crossorigin=""
|
||||
>
|
||||
<MediaProvider />
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
@@ -17,7 +18,13 @@ import {
|
||||
} from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
export function Repost({ event }: { event: NDKEvent }) {
|
||||
export function Repost({
|
||||
event,
|
||||
lighter = false,
|
||||
}: {
|
||||
event: NDKEvent;
|
||||
lighter?: boolean;
|
||||
}) {
|
||||
const embedEvent: null | NDKEvent =
|
||||
event.content.length > 0 ? JSON.parse(event.content) : null;
|
||||
|
||||
@@ -55,12 +62,17 @@ export function Repost({ event }: { event: NDKEvent }) {
|
||||
if (embedEvent) {
|
||||
return (
|
||||
<div className="h-min w-full px-3 pb-3">
|
||||
<div className="relative flex flex-col gap-10 overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<div
|
||||
className={twMerge(
|
||||
'relative flex flex-col gap-1 overflow-hidden rounded-xl px-3 py-3',
|
||||
!lighter ? 'bg-white/10 backdrop-blur-xl' : ''
|
||||
)}
|
||||
>
|
||||
<User pubkey={event.pubkey} time={event.created_at} variant="repost" />
|
||||
<div className="relative flex flex-col">
|
||||
<User pubkey={embedEvent.pubkey} time={embedEvent.created_at} />
|
||||
<div className="-mt-6 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="relative z-20 flex-1">
|
||||
{renderKind(embedEvent)}
|
||||
<NoteActions id={embedEvent.id} pubkey={embedEvent.pubkey} />
|
||||
@@ -89,7 +101,12 @@ export function Repost({ event }: { event: NDKEvent }) {
|
||||
|
||||
return (
|
||||
<div className="h-min w-full px-3 pb-3">
|
||||
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<div
|
||||
className={twMerge(
|
||||
'relative overflow-hidden rounded-xl px-3 py-3',
|
||||
!lighter ? 'bg-white/10 backdrop-blur-xl' : ''
|
||||
)}
|
||||
>
|
||||
<div className="relative flex flex-col">
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 items-end justify-center rounded-lg bg-black pb-1">
|
||||
@@ -122,12 +139,17 @@ export function Repost({ event }: { event: NDKEvent }) {
|
||||
|
||||
return (
|
||||
<div className="h-min w-full px-3 pb-3">
|
||||
<div className="relative flex flex-col gap-10 overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<div
|
||||
className={twMerge(
|
||||
'relative flex flex-col gap-1 overflow-hidden rounded-xl px-3 py-3',
|
||||
!lighter ? 'bg-white/10 backdrop-blur-xl' : ''
|
||||
)}
|
||||
>
|
||||
<User pubkey={event.pubkey} time={event.created_at} variant="repost" />
|
||||
<div className="relative flex flex-col">
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="-mt-2 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="relative z-20 flex-1">
|
||||
{renderKind(data)}
|
||||
<NoteActions id={data.id} pubkey={data.pubkey} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {
|
||||
Boost,
|
||||
Hashtag,
|
||||
ImagePreview,
|
||||
LinkPreview,
|
||||
@@ -13,8 +14,8 @@ import {
|
||||
|
||||
import { parser } from '@utils/parser';
|
||||
|
||||
export function TextNote({ content }: { content: string }) {
|
||||
const richContent = parser(content) ?? null;
|
||||
export function TextNote(props: { content?: string }) {
|
||||
const richContent = parser(props.content) ?? null;
|
||||
|
||||
if (!richContent) {
|
||||
return (
|
||||
@@ -26,7 +27,7 @@ export function TextNote({ content }: { content: string }) {
|
||||
unwrapDisallowed={true}
|
||||
linkTarget={'_blank'}
|
||||
>
|
||||
{content}
|
||||
{props.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
@@ -56,9 +57,12 @@ export function TextNote({ content }: { content: string }) {
|
||||
if (key.startsWith('tag')) {
|
||||
return <Hashtag tag={key.replace('tag-', '')} />;
|
||||
}
|
||||
if (key.startsWith('boost')) {
|
||||
return <Boost boost={key.replace('boost-', '')} />;
|
||||
}
|
||||
},
|
||||
}}
|
||||
disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6']}
|
||||
disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']}
|
||||
unwrapDisallowed={true}
|
||||
linkTarget={'_blank'}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
export function UnknownNote({ event }: { event: NDKEvent }) {
|
||||
export function UnknownNote(props: { event?: NDKEvent }) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="inline-flex flex-col gap-1 rounded-md bg-white/10 px-2 py-2 backdrop-blur-xl">
|
||||
<span className="text-sm font-medium leading-none text-white/50">
|
||||
Unknown kind: {event.kind}
|
||||
Unknown kind: {props.event.kind}
|
||||
</span>
|
||||
<p className="text-sm leading-none text-white">
|
||||
Lume isn't fully support this kind
|
||||
</p>
|
||||
</div>
|
||||
<div className="select-text whitespace-pre-line break-all text-white">
|
||||
<p>{event.content.toString()}</p>
|
||||
<p>{props.event.content.toString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function Boost({ boost }: { boost: string }) {
|
||||
return (
|
||||
<span className="break-words text-fuchsia-400 hover:text-fuchsia-500">{boost}</span>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { db } = useStorage();
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
@@ -18,20 +19,25 @@ export function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
onClick={() =>
|
||||
setWidget(db, {
|
||||
kind: WidgetKinds.local.user,
|
||||
title: user?.name || user?.display_name,
|
||||
title: user?.name || user?.display_name || user?.displayName,
|
||||
content: pubkey,
|
||||
})
|
||||
}
|
||||
onKeyDown={() =>
|
||||
setWidget(db, {
|
||||
kind: WidgetKinds.local.user,
|
||||
title: user?.name || user?.display_name,
|
||||
title: user?.name || user?.display_name || user?.displayName,
|
||||
content: pubkey,
|
||||
})
|
||||
}
|
||||
className="break-words text-fuchsia-400 hover:text-fuchsia-500"
|
||||
>
|
||||
{user?.name || user?.display_name || user?.username || displayNpub(pubkey, 16)}
|
||||
{'@' +
|
||||
(user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
user?.username ||
|
||||
'unknown')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { downloadDir } from '@tauri-apps/api/path';
|
||||
import { download } from 'tauri-plugin-upload-api';
|
||||
|
||||
import { DownloadIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
export function ImagePreview({ urls, truncate }: { urls: string[]; truncate?: boolean }) {
|
||||
const downloadImage = async (url: string) => {
|
||||
@@ -16,12 +15,15 @@ export function ImagePreview({ urls, truncate }: { urls: string[]; truncate?: bo
|
||||
<div className="flex flex-col gap-2">
|
||||
{urls.map((url) => (
|
||||
<div key={url} className="group relative min-w-0 shrink-0 grow-0 basis-full">
|
||||
<Image
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
className={`${
|
||||
truncate ? 'h-auto max-h-[300px]' : 'h-auto'
|
||||
} w-full rounded-lg border border-white/10 object-cover`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { useOpenGraph } from '@utils/hooks/useOpenGraph';
|
||||
|
||||
export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
@@ -38,10 +36,13 @@ export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
) : (
|
||||
<>
|
||||
{data.image && (
|
||||
<Image
|
||||
<img
|
||||
src={data.image}
|
||||
alt={urls[0]}
|
||||
className="h-44 w-full rounded-t-lg object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 border-t border-white/5 px-3 py-3">
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import ReactPlayer from 'react-player/es6';
|
||||
import { MediaPlayer, MediaProvider, Poster } from '@vidstack/react';
|
||||
import {
|
||||
DefaultAudioLayout,
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from '@vidstack/react/player/layouts/default';
|
||||
|
||||
export function VideoPreview({ urls }: { urls: string[] }) {
|
||||
return (
|
||||
<div className="relative mt-3 flex w-full flex-col gap-2">
|
||||
{urls.map((url) => (
|
||||
<ReactPlayer
|
||||
<MediaPlayer
|
||||
key={url}
|
||||
url={url}
|
||||
width="100%"
|
||||
height="auto"
|
||||
className="!h-auto overflow-hidden rounded-lg object-fill"
|
||||
controls={true}
|
||||
pip={true}
|
||||
light={
|
||||
<img
|
||||
src={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
|
||||
src={url}
|
||||
load="visible"
|
||||
aspectRatio="16/9"
|
||||
crossorigin=""
|
||||
className="player"
|
||||
>
|
||||
<MediaProvider>
|
||||
<Poster
|
||||
className="vds-poster"
|
||||
src="https://thumbnail.video/api/get?url=${url}&seconds=1"
|
||||
alt={url}
|
||||
className="h-auto w-full bg-white object-cover"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</MediaProvider>
|
||||
<DefaultAudioLayout icons={defaultLayoutIcons} />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,19 +12,26 @@ export function Reply({ event, root }: { event: NDKEventWithReplies; root?: stri
|
||||
<div className="relative z-10">
|
||||
<div className="relative flex flex-col">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-6 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<TextNote content={event.content} />
|
||||
<NoteActions id={event.id} pubkey={event.pubkey} root={root} />
|
||||
<NoteActions
|
||||
id={event.id}
|
||||
pubkey={event.pubkey}
|
||||
root={root}
|
||||
extraButtons={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{event.replies ? (
|
||||
event.replies.map((sub) => <SubReply key={sub.id} event={sub} />)
|
||||
) : (
|
||||
<div className="pb-3" />
|
||||
)}
|
||||
<div className="pl-14">
|
||||
{event.replies ? (
|
||||
event.replies.map((sub) => <SubReply key={sub.id} event={sub} />)
|
||||
) : (
|
||||
<div className="pb-3" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function RepliesList({ id }: { id: string }) {
|
||||
<h5 className="mb-2 text-lg font-semibold text-white">
|
||||
{data?.length || 0} replies
|
||||
</h5>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
{data?.length === 0 ? (
|
||||
<div className="mt-2 flex w-full items-center justify-center rounded-xl bg-white/10 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||
|
||||
@@ -7,11 +7,11 @@ export function SubReply({ event }: { event: NDKEvent }) {
|
||||
return (
|
||||
<div className="relative z-10 mb-3 mt-5 flex flex-col">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-6 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<TextNote content={event.content} />
|
||||
<NoteActions id={event.id} pubkey={event.pubkey} />
|
||||
<NoteActions id={event.id} pubkey={event.pubkey} extraButtons={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@ export function NoteSkeleton() {
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-lg bg-white/10 backdrop-blur-xl" />
|
||||
<div className="h-3 w-20 rounded bg-white/10 backdrop-blur-xl" />
|
||||
</div>
|
||||
<div className="-mt-6 animate-pulse pl-[49px]">
|
||||
<div className="-mt-5 flex animate-pulse gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="h-3 w-full rounded bg-white/10 backdrop-blur-xl" />
|
||||
<div className="h-3 w-2/3 rounded bg-white/10 backdrop-blur-xl" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactElement, cloneElement } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { ChildNote, NoteActions } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
@@ -9,24 +10,34 @@ export function NoteWrapper({
|
||||
children,
|
||||
root,
|
||||
reply,
|
||||
lighter = false,
|
||||
}: {
|
||||
event: NDKEvent;
|
||||
children: ReactNode;
|
||||
children: ReactElement;
|
||||
repost?: boolean;
|
||||
root?: string;
|
||||
reply?: string;
|
||||
lighter?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-min w-full px-3 pb-3">
|
||||
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<div
|
||||
className={twMerge(
|
||||
'relative overflow-hidden rounded-xl px-3 py-4',
|
||||
!lighter ? 'bg-white/10 backdrop-blur-xl' : 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="relative">{root && <ChildNote id={root} />}</div>
|
||||
<div className="relative">{reply && <ChildNote id={reply} root={root} />}</div>
|
||||
<div className="relative flex flex-col">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-6 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div className="-mt-5 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="relative z-20 flex-1">
|
||||
{children}
|
||||
{cloneElement(
|
||||
children,
|
||||
event.kind === 1 ? { content: event.content } : { event: event }
|
||||
)}
|
||||
<NoteActions id={event.id} pubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+211
-75
@@ -1,9 +1,12 @@
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import * as Avatar from '@radix-ui/react-avatar';
|
||||
import * as HoverCard from '@radix-ui/react-hover-card';
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Link } from 'react-router-dom';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { WorldIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
import { RepostIcon, WorldIcon } from '@shared/icons';
|
||||
import { NIP05 } from '@shared/nip05';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
@@ -18,13 +21,31 @@ export const User = memo(function User({
|
||||
}: {
|
||||
pubkey: string;
|
||||
time?: number;
|
||||
variant?: 'default' | 'simple' | 'mention' | 'repost' | 'chat' | 'large' | 'thread';
|
||||
variant?:
|
||||
| 'default'
|
||||
| 'simple'
|
||||
| 'mention'
|
||||
| 'repost'
|
||||
| 'chat'
|
||||
| 'large'
|
||||
| 'thread'
|
||||
| 'avatar'
|
||||
| 'stacked';
|
||||
embedProfile?: string;
|
||||
}) {
|
||||
const { status, user } = useProfile(pubkey, embedProfile);
|
||||
const createdAt = time ? formatCreatedAt(time, variant === 'chat') : 0;
|
||||
|
||||
const createdAt = formatCreatedAt(time, variant === 'chat');
|
||||
const svgURI =
|
||||
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50));
|
||||
|
||||
if (status === 'loading') {
|
||||
if (variant === 'avatar') {
|
||||
return (
|
||||
<div className="h-12 w-12 animate-pulse overflow-hidden rounded-lg bg-white/10 backdrop-blur-xl" />
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'mention') {
|
||||
return (
|
||||
<div className="relative flex items-center gap-3">
|
||||
@@ -35,8 +56,8 @@ export const User = memo(function User({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start gap-3">
|
||||
<div className="relative z-10 h-11 w-11 shrink-0 animate-pulse overflow-hidden rounded-lg bg-white/10 backdrop-blur-xl" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-white/10 backdrop-blur-xl" />
|
||||
<div className="h-3.5 w-36 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
|
||||
</div>
|
||||
);
|
||||
@@ -44,17 +65,26 @@ export const User = memo(function User({
|
||||
|
||||
if (variant === 'mention') {
|
||||
return (
|
||||
<div className="relative z-10 flex items-center gap-2">
|
||||
<button type="button" className="relative z-40 h-6 w-6 shrink-0 overflow-hidden">
|
||||
<Image
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-6 w-6 rounded object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-6 w-6 rounded"
|
||||
/>
|
||||
</button>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img src={svgURI} alt={pubkey} className="h-6 w-6 rounded bg-black" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex flex-1 items-baseline gap-2">
|
||||
<h5 className="max-w-[10rem] truncate font-semibold leading-none text-white">
|
||||
{user?.display_name || user?.name || displayNpub(pubkey, 16)}
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
<span className="leading-none text-white/50">·</span>
|
||||
<span className="leading-none text-white/50">{createdAt}</span>
|
||||
@@ -66,19 +96,33 @@ export const User = memo(function User({
|
||||
if (variant === 'large') {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2.5">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-14 w-14 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img src={svgURI} alt={pubkey} className="h-14 w-14 rounded-lg bg-black" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex h-full flex-col items-start justify-between">
|
||||
<div className="flex flex-col items-start gap-1 text-start">
|
||||
<p className="max-w-[15rem] truncate text-lg font-semibold leading-none text-white">
|
||||
{user?.name || user?.display_name}
|
||||
{user?.name || user?.display_name || user?.displayName}
|
||||
</p>
|
||||
<p className="line-clamp-6 break-all text-white/70">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
className="markdown-simple line-clamp-6"
|
||||
disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6']}
|
||||
unwrapDisallowed={true}
|
||||
linkTarget={'_blank'}
|
||||
>
|
||||
{user?.about || user?.bio || 'No bio'}
|
||||
</p>
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{user?.website ? (
|
||||
@@ -88,7 +132,7 @@ export const User = memo(function User({
|
||||
className="inline-flex items-center gap-2 text-sm text-white/70"
|
||||
>
|
||||
<WorldIcon className="h-4 w-4" />
|
||||
<p className="max-w-[10rem] truncate">{user.website}</p>
|
||||
<p className="max-w-[10rem] truncate">{user?.website}</p>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -100,16 +144,24 @@ export const User = memo(function User({
|
||||
if (variant === 'simple') {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-10 w-10 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img src={svgURI} alt={pubkey} className="h-10 w-10 rounded-lg bg-black" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex w-full flex-col items-start gap-1">
|
||||
<h3 className="max-w-[15rem] truncate font-medium leading-none text-white">
|
||||
{user?.name || user?.display_name}
|
||||
{user?.name || user?.display_name || user?.displayName}
|
||||
</h3>
|
||||
<p className="text-sm leading-none text-white/70">
|
||||
<p className="max-w-[10rem] truncate text-sm leading-none text-white/70">
|
||||
{user?.nip05 || user?.username || displayNpub(pubkey, 16)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -117,40 +169,99 @@ export const User = memo(function User({
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'avatar') {
|
||||
return (
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-12 w-12 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img src={svgURI} alt={pubkey} className="h-12 w-12 rounded-lg bg-black" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'stacked') {
|
||||
return (
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="inline-block h-8 w-8 rounded-full ring-1 ring-black"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="inline-block h-8 w-8 rounded-full bg-black ring-1 ring-black"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'repost') {
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="relative z-20 inline-block h-11 w-11 rounded-lg"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<div className="inline-flex h-10 w-10 items-center justify-center">
|
||||
<RepostIcon className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-6 w-6 rounded"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img src={svgURI} alt={pubkey} className="h-6 w-6 rounded bg-black" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<h5 className="max-w-[15rem] truncate font-semibold leading-none text-white">
|
||||
{user?.display_name || user?.name || displayNpub(pubkey, 16)}
|
||||
<h5 className="max-w-[10rem] truncate font-medium leading-none text-white/80">
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
<span className="font-semibold text-fuchsia-500">reposted</span>
|
||||
<span className="leading-none text-white/50">·</span>
|
||||
<span className="leading-none text-white/50">{createdAt}</span>
|
||||
<span className="text-blue-500">reposted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute left-[28px] top-16 h-6 w-0.5 bg-gradient-to-t from-white/20 to-white/10" />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'thread') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="relative z-20 inline-block h-11 w-11 rounded-lg"
|
||||
/>
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-10 w-10 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img src={svgURI} alt={pubkey} className="h-10 w-10 rounded-lg bg-black" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<h5 className="max-w-[15rem] truncate font-semibold leading-none text-white">
|
||||
{user?.display_name || user?.name}
|
||||
{user?.name || user?.display_name || user?.displayName || 'Anon'}
|
||||
</h5>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="leading-none text-white/50">{createdAt}</span>
|
||||
@@ -163,48 +274,73 @@ export const User = memo(function User({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<HoverCard.Root>
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-40 h-11 w-11 shrink-0 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
<HoverCard.Trigger asChild>
|
||||
<Avatar.Root className="h-10 w-10 shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-10 w-10 rounded-lg border border-white/5"
|
||||
/>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="h-10 w-10 rounded-lg border border-white/5 bg-black"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</HoverCard.Trigger>
|
||||
<div className="flex flex-1 items-baseline gap-2">
|
||||
<h5 className="max-w-[15rem] truncate font-semibold leading-none text-white">
|
||||
{user?.display_name || user?.name || displayNpub(pubkey, 16)}
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
<span className="leading-none text-white/50">·</span>
|
||||
<span className="leading-none text-white/50">{createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="w-[300px] overflow-hidden rounded-xl border border-white/10 bg-white/10 backdrop-blur-3xl focus:outline-none"
|
||||
<HoverCard.Portal>
|
||||
<HoverCard.Content
|
||||
className="w-[300px] overflow-hidden rounded-xl border border-white/10 bg-white/10 backdrop-blur-3xl focus:outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade data-[state=open]:transition-all"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="flex gap-2.5 border-b border-white/5 px-3 py-3">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-10 w-10 rounded-lg border border-white/5"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="h-10 w-10 rounded-lg border border-white/5 bg-black"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<h5 className="text-sm font-semibold leading-none">
|
||||
{user?.display_name || user?.name || user?.username}
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
user?.username}
|
||||
</h5>
|
||||
{user.nip05 ? (
|
||||
{user?.nip05 ? (
|
||||
<NIP05
|
||||
pubkey={pubkey}
|
||||
nip05={user.nip05}
|
||||
nip05={user?.nip05}
|
||||
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
|
||||
/>
|
||||
) : (
|
||||
@@ -234,8 +370,8 @@ export const User = memo(function User({
|
||||
Message
|
||||
</Link>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Portal>
|
||||
</HoverCard.Root>
|
||||
);
|
||||
});
|
||||
|
||||
+37
-33
@@ -5,7 +5,6 @@ import { UserStats } from '@app/users/components/stats';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
import { NIP05 } from '@shared/nip05';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
@@ -49,15 +48,45 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 rounded-md"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<img
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-12 w-12 shrink-0 rounded-lg"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
/>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unfollowUser(pubkey)}
|
||||
className="inline-flex h-9 w-28 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => followUser(pubkey)}
|
||||
className="inline-flex h-9 w-28 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/chats/${pubkey}`}
|
||||
className="inline-flex h-9 w-28 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h5 className="text-lg font-semibold leading-none">
|
||||
{user?.displayName || user?.name || 'No name'}
|
||||
{user?.name || user?.display_name || user?.displayName || 'Anon'}
|
||||
</h5>
|
||||
{user?.nip05 ? (
|
||||
<NIP05
|
||||
@@ -72,36 +101,11 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="mt-2 max-w-[500px] select-text break-words text-white">
|
||||
<p className="mb-3 mt-2 max-w-[500px] select-text break-words text-sm text-white">
|
||||
{user?.about}
|
||||
</p>
|
||||
<UserStats pubkey={pubkey} />
|
||||
</div>
|
||||
<div className="mt-4 inline-flex items-center gap-2">
|
||||
{followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unfollowUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => followUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/chats/${pubkey}`}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function EventLoader({ firstTime }: { firstTime: boolean }) {
|
||||
const { db } = useStorage();
|
||||
const { getAllEventsSinceLastLogin } = useNostr();
|
||||
|
||||
const setIsFetched = useStronghold((state) => state.setIsFetched);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
async function getEvents() {
|
||||
const events = await getAllEventsSinceLastLogin();
|
||||
console.log('total new events has found: ', events.data.length);
|
||||
|
||||
const promises = await Promise.all(
|
||||
events.data.map(async (event) => await db.createEvent(event))
|
||||
);
|
||||
|
||||
if (promises) {
|
||||
setProgress(100);
|
||||
setIsFetched();
|
||||
// invalidate queries
|
||||
queryClient.invalidateQueries(['local-network-widget']);
|
||||
}
|
||||
}
|
||||
|
||||
// only start download if progress === 0
|
||||
if (progress === 0) getEvents();
|
||||
|
||||
// auto increase progress after 2 secs
|
||||
setInterval(() => setProgress((prev) => (prev += 5)), 2000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mb-3 px-3">
|
||||
<div className="h-max w-full rounded-lg border-t border-white/10 bg-white/20 p-3">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{firstTime ? (
|
||||
<div>
|
||||
<span className="text-4xl">👋</span>
|
||||
<h3 className="mt-2 font-semibold leading-tight">
|
||||
Hello, this is the first time you're using Lume
|
||||
</h3>
|
||||
<p className="text-sm text-white/70">
|
||||
Lume is downloading all events since the last 24 hours. It will auto
|
||||
refresh when it done, please be patient
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Downloading all events while you're away...
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-white/20">
|
||||
<div
|
||||
className="flex flex-col justify-center overflow-hidden bg-fuchsia-500 transition-all duration-1000 ease-smooth"
|
||||
role="progressbar"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +1,74 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { ArticleNote, NoteSkeleton, NoteWrapper } from '@shared/notes';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { ArticleNote, NoteWrapper } from '@shared/notes';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { Widget } from '@utils/types';
|
||||
|
||||
export function GlobalArticlesWidget({ params }: { params: Widget }) {
|
||||
const { ndk } = useNDK();
|
||||
const { status, data } = useQuery(
|
||||
[params.id + '-' + params.title],
|
||||
['global-articles'],
|
||||
async () => {
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [NDKKind.Article],
|
||||
limit: 100,
|
||||
limit: 200,
|
||||
});
|
||||
return [...events] as unknown as NDKEvent[];
|
||||
const sortedEvents = [...events].sort((x, y) => y.created_at - x.created_at);
|
||||
return sortedEvents;
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const event: NDKEvent = data[index];
|
||||
if (!event) return;
|
||||
|
||||
(event: NDKEvent) => {
|
||||
return (
|
||||
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
|
||||
<NoteWrapper event={event}>
|
||||
<ArticleNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
<div className="flex h-full w-full items-center justify-center ">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">Loading article...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-6 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm font-medium text-white">
|
||||
There have been no new articles in the last 24 hours.
|
||||
</p>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Oops, it looks like there are no articles.
|
||||
</h3>
|
||||
<p className="text-white/50">You can close this widget</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
</div>
|
||||
</div>
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{data.map((item) => renderItem(item))}
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,78 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { FileNote, NoteSkeleton, NoteWrapper } from '@shared/notes';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { FileNote, NoteWrapper } from '@shared/notes';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { Widget } from '@utils/types';
|
||||
|
||||
export function GlobalFilesWidget({ params }: { params: Widget }) {
|
||||
const { ndk } = useNDK();
|
||||
const { status, data } = useQuery(
|
||||
[params.id + '-' + params.title],
|
||||
['global-file-sharing'],
|
||||
async () => {
|
||||
const events = await ndk.fetchEvents({
|
||||
// @ts-expect-error, NDK not support file metadata yet
|
||||
kinds: [1063],
|
||||
limit: 100,
|
||||
since: nHoursAgo(24),
|
||||
});
|
||||
return [...events] as unknown as NDKEvent[];
|
||||
const sortedEvents = [...events].sort((x, y) => y.created_at - x.created_at);
|
||||
return sortedEvents;
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const event: NDKEvent = data[index];
|
||||
if (!event) return;
|
||||
|
||||
(event: NDKEvent) => {
|
||||
return (
|
||||
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
<div className="flex h-full w-full items-center justify-center ">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
Loading file sharing event...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-6 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm font-medium text-white">
|
||||
There have been no new files in the last 24 hours.
|
||||
</p>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Oops, it looks like there are no file sharing events.
|
||||
</h3>
|
||||
<p className="text-white/50">You can close this widget</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
</div>
|
||||
</div>
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{data.map((item) => renderItem(item))}
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import {
|
||||
ArticleNote,
|
||||
FileNote,
|
||||
NoteSkeleton,
|
||||
NoteWrapper,
|
||||
Repost,
|
||||
TextNote,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { Widget } from '@utils/types';
|
||||
@@ -22,91 +23,48 @@ import { Widget } from '@utils/types';
|
||||
export function GlobalHashtagWidget({ params }: { params: Widget }) {
|
||||
const { ndk } = useNDK();
|
||||
const { status, data } = useQuery(
|
||||
[params.id + '-' + params.title],
|
||||
['hashtag-' + params.title],
|
||||
async () => {
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Article],
|
||||
'#t': [params.content],
|
||||
since: nHoursAgo(24),
|
||||
});
|
||||
return [...events] as unknown as NDKEvent[];
|
||||
const sortedEvents = [...events].sort((x, y) => y.created_at - x.created_at);
|
||||
return sortedEvents;
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const event: NDKEvent = data[index];
|
||||
if (!event) return;
|
||||
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<TextNote content={event.content} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<TextNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<Repost key={event.id} event={event} />
|
||||
</div>
|
||||
);
|
||||
return <Repost key={event.id} event={event} />;
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Article:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<ArticleNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={event.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<UnknownNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<UnknownNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -114,44 +72,39 @@ export function GlobalHashtagWidget({ params }: { params: Widget }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<TitleBar id={params.id} title={params.title + ' in 24 hours ago'} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
<div className="flex h-full w-full items-center justify-center ">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
Loading event related to the hashtag {params.title}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-6 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm font-medium text-white">
|
||||
There have been no new posts with this hashtag in the last 24 hours.
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Oops, it looks like there are no events related to {params.title}.
|
||||
</h3>
|
||||
<p className="text-white/50">
|
||||
You can close this widget or try with other hashtag
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
</div>
|
||||
</div>
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{data.map((item) => renderItem(item))}
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './wrapper';
|
||||
export * from './local/feeds';
|
||||
export * from './local/network';
|
||||
export * from './local/user';
|
||||
@@ -13,3 +14,4 @@ export * from './nostrBand/trendingAccounts';
|
||||
export * from './tmp/feeds';
|
||||
export * from './tmp/hashtag';
|
||||
export * from './other/learnNostr';
|
||||
export * from './eventLoader';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
import { FileNote, NoteSkeleton, NoteWrapper } from '@shared/notes';
|
||||
import { ArticleNote, NoteWrapper } from '@shared/notes';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { DBEvent, Widget } from '@utils/types';
|
||||
|
||||
@@ -15,7 +16,7 @@ export function LocalArticlesWidget({ params }: { params: Widget }) {
|
||||
const { db } = useStorage();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [params.id + '-' + params.title],
|
||||
queryKey: ['local-articles'],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await db.getAllEventsByKinds([NDKKind.Article], 20, pageParam);
|
||||
},
|
||||
@@ -26,105 +27,76 @@ export function LocalArticlesWidget({ params }: { params: Widget }) {
|
||||
() => (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: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const event: NDKEvent = data[index];
|
||||
if (!event) return;
|
||||
|
||||
(dbEvent: DBEvent) => {
|
||||
const event: NDKEvent = JSON.parse(dbEvent.event as string);
|
||||
return (
|
||||
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
<div className="flex h-full w-full items-center justify-center ">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">Loading article...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="bbg-white/10 rounded-xl px-3 py-6 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-white">
|
||||
There have been no new posts.
|
||||
</p>
|
||||
) : dbEvents.length === 0 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Oops, it looks like there are no articles.
|
||||
</h3>
|
||||
<p className="text-white/50">You can close this widget</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{dbEvents.map((item) => renderItem(item))}
|
||||
<div className="flex items-center justify-center px-3 py-1.5">
|
||||
{dbEvents.length > 0 ? (
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<span>Loading...</span>
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Load more</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Nothing more to load</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<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>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
TextNote,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { DBEvent, Widget } from '@utils/types';
|
||||
|
||||
@@ -23,7 +23,7 @@ export function LocalFeedsWidget({ params }: { params: Widget }) {
|
||||
const { db } = useStorage();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [params.id + '-' + params.title],
|
||||
queryKey: ['group-feeds-' + params.id],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const authors = JSON.parse(params.content);
|
||||
return await db.getAllEventsByAuthors(authors, 20, pageParam);
|
||||
@@ -35,80 +35,42 @@ export function LocalFeedsWidget({ params }: { params: Widget }) {
|
||||
() => (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: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const dbEvent: DBEvent = dbEvents[index];
|
||||
if (!dbEvent) return;
|
||||
|
||||
(dbEvent: DBEvent) => {
|
||||
const event: NDKEvent = JSON.parse(dbEvent.event as string);
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
<NoteWrapper
|
||||
key={dbEvent.id + dbEvent.root_id + dbEvent.reply_id}
|
||||
event={event}
|
||||
root={dbEvent.root_id}
|
||||
reply={dbEvent.reply_id}
|
||||
>
|
||||
<NoteWrapper event={event} root={dbEvent.root_id} reply={dbEvent.reply_id}>
|
||||
<TextNote content={event.content} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<TextNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<Repost key={dbEvent.id} event={event} />
|
||||
</div>
|
||||
);
|
||||
return <Repost key={dbEvent.id} event={event} />;
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Article:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<ArticleNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<UnknownNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<UnknownNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -116,78 +78,61 @@ export function LocalFeedsWidget({ params }: { params: Widget }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="bbg-white/10 rounded-xl px-3 py-6 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-white">
|
||||
There have been no new posts.
|
||||
</p>
|
||||
) : dbEvents.length === 0 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Oops, it looks like there are no posts.
|
||||
</h3>
|
||||
<p className="text-white/50">You can close this widget</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{dbEvents.map((item) => renderItem(item))}
|
||||
<div className="flex items-center justify-center px-3 py-1.5">
|
||||
{dbEvents.length > 0 ? (
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<span>Loading...</span>
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Load more</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Nothing more to load</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<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>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
import { FileNote, NoteSkeleton, NoteWrapper } from '@shared/notes';
|
||||
import { FileNote, NoteWrapper } from '@shared/notes';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { DBEvent, Widget } from '@utils/types';
|
||||
|
||||
@@ -15,7 +16,7 @@ export function LocalFilesWidget({ params }: { params: Widget }) {
|
||||
const { db } = useStorage();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [params.id + '-' + params.title],
|
||||
queryKey: ['local-file-sharing'],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await db.getAllEventsByKinds([1063], 20, pageParam);
|
||||
},
|
||||
@@ -26,105 +27,78 @@ export function LocalFilesWidget({ params }: { params: Widget }) {
|
||||
() => (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: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const event: NDKEvent = data[index];
|
||||
if (!event) return;
|
||||
|
||||
(dbEvent: DBEvent) => {
|
||||
const event: NDKEvent = JSON.parse(dbEvent.event as string);
|
||||
return (
|
||||
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={event.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
<div className="flex h-full w-full items-center justify-center ">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
Loading file sharing event...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="bbg-white/10 rounded-xl px-3 py-6 backdrop-blur-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-white">
|
||||
There have been no new posts.
|
||||
</p>
|
||||
) : dbEvents.length === 0 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Oops, it looks like there are no file sharing events.
|
||||
</h3>
|
||||
<p className="text-white/50">You can close this widget</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{dbEvents.map((item) => renderItem(item))}
|
||||
<div className="flex items-center justify-center px-3 py-1.5">
|
||||
{dbEvents.length > 0 ? (
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<span>Loading...</span>
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Load more</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Nothing more to load</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<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>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
TextNote,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { DBEvent, Widget } from '@utils/types';
|
||||
|
||||
@@ -23,7 +23,7 @@ export function LocalFollowsWidget({ params }: { params: Widget }) {
|
||||
const { db } = useStorage();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [params.id + '-' + params.title],
|
||||
queryKey: ['follows-' + params.title],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await db.getAllEventsByAuthors(db.account.follows, 20, pageParam);
|
||||
},
|
||||
@@ -34,80 +34,42 @@ export function LocalFollowsWidget({ params }: { params: Widget }) {
|
||||
() => (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: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const dbEvent: DBEvent = dbEvents[index];
|
||||
if (!dbEvent) return;
|
||||
|
||||
(dbEvent: DBEvent) => {
|
||||
const event: NDKEvent = JSON.parse(dbEvent.event as string);
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
<NoteWrapper
|
||||
key={dbEvent.id + dbEvent.root_id + dbEvent.reply_id}
|
||||
event={event}
|
||||
root={dbEvent.root_id}
|
||||
reply={dbEvent.reply_id}
|
||||
>
|
||||
<NoteWrapper event={event} root={dbEvent.root_id} reply={dbEvent.reply_id}>
|
||||
<TextNote content={event.content} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<TextNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<Repost key={dbEvent.id} event={event} />
|
||||
</div>
|
||||
);
|
||||
return <Repost key={dbEvent.id} event={event} />;
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Article:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<ArticleNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<UnknownNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<UnknownNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -115,13 +77,14 @@ export function LocalFollowsWidget({ params }: { params: Widget }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title="Follows" />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
<div className="flex h-full w-full items-center justify-center ">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
<p className="text-sm font-medium text-white/80">Loading post...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : dbEvents.length === 0 ? (
|
||||
@@ -129,70 +92,46 @@ export function LocalFollowsWidget({ params }: { params: Widget }) {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold leading-tight">
|
||||
Your newsfeed is empty
|
||||
<h3 className="font-semibold leading-tight">
|
||||
Oops, it looks like there are no posts.
|
||||
</h3>
|
||||
<p className="text-center text-white/50">
|
||||
Connect more people to explore more content
|
||||
</p>
|
||||
<p className="text-white/50">You can close this widget</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{dbEvents.map((item) => renderItem(item))}
|
||||
<div className="flex items-center justify-center px-3 py-1.5">
|
||||
{dbEvents.length > 0 ? (
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<span>Loading...</span>
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Load more</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Nothing more to load</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="mb-20 px-3">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5">
|
||||
{dbEvents.length > 0 ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NDKEvent, NDKFilter, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from '@shared/notes';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { EventLoader, WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { toRawEvent } from '@utils/rawEvent';
|
||||
@@ -33,84 +36,47 @@ export function LocalNetworkWidget() {
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const isFetched = useStronghold((state) => state.isFetched);
|
||||
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: () => 650,
|
||||
overscan: 4,
|
||||
});
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const dbEvent: DBEvent = dbEvents[index];
|
||||
if (!dbEvent) return;
|
||||
|
||||
(dbEvent: DBEvent) => {
|
||||
const event: NDKEvent = JSON.parse(dbEvent.event as string);
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
<NoteWrapper
|
||||
key={dbEvent.id + dbEvent.root_id + dbEvent.reply_id}
|
||||
event={event}
|
||||
root={dbEvent.root_id}
|
||||
reply={dbEvent.reply_id}
|
||||
>
|
||||
<NoteWrapper event={event} root={dbEvent.root_id} reply={dbEvent.reply_id}>
|
||||
<TextNote content={event.content} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<TextNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Repost:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<Repost key={dbEvent.id} event={event} />
|
||||
</div>
|
||||
);
|
||||
return <Repost key={dbEvent.id} event={event} />;
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<FileNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<FileNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
case NDKKind.Article:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<ArticleNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<ArticleNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={dbEvent.id + index}
|
||||
data-index={index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<NoteWrapper event={event}>
|
||||
<UnknownNote event={event} />
|
||||
</NoteWrapper>
|
||||
</div>
|
||||
<NoteWrapper key={dbEvent.id} event={event}>
|
||||
<UnknownNote />
|
||||
</NoteWrapper>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -120,28 +86,24 @@ export function LocalNetworkWidget() {
|
||||
// subscribe for new event
|
||||
// sub will be managed by lru-cache
|
||||
useEffect(() => {
|
||||
if (db.account && db.account.network) {
|
||||
if (db.account && db.account.network && dbEvents.length > 0) {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: db.account.network,
|
||||
since: db.account.last_login_at ?? Math.floor(Date.now() / 1000),
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
sub(
|
||||
filter,
|
||||
async (event) => {
|
||||
const rawEvent = toRawEvent(event);
|
||||
await db.createEvent(rawEvent);
|
||||
},
|
||||
false // don't close sub on eose
|
||||
);
|
||||
sub(filter, async (event) => {
|
||||
const rawEvent = toRawEvent(event);
|
||||
await db.createEvent(rawEvent);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
|
||||
<TitleBar title="👋 Network" />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<WidgetWrapper>
|
||||
<TitleBar title="Network" />
|
||||
<div className="flex-1">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
@@ -149,74 +111,41 @@ export function LocalNetworkWidget() {
|
||||
</div>
|
||||
</div>
|
||||
) : dbEvents.length === 0 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src="/ghost.png" alt="empty feeds" className="h-16 w-16" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold leading-tight">
|
||||
Your newsfeed is empty
|
||||
</h3>
|
||||
<p className="text-center text-white/50">
|
||||
Connect more people to explore more content
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EventLoader firstTime={true} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${items[0].start}px)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => renderItem(item.index))}
|
||||
<VList className="scrollbar-hide h-full">
|
||||
{!isFetched ? <EventLoader firstTime={false} /> : null}
|
||||
{dbEvents.map((item) => renderItem(item))}
|
||||
<div className="flex items-center justify-center px-3 py-1.5">
|
||||
{dbEvents.length > 0 ? (
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<span>Loading...</span>
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</>
|
||||
) : hasNextPage ? (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Load more</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="h-5 w-5 text-white" />
|
||||
<span>Nothing more to load</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="mb-20 px-3">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5">
|
||||
{dbEvents.length > 0 ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RepliesList } from '@shared/notes/replies/list';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { User } from '@shared/user';
|
||||
import { WidgetWrapper } from '@shared/widgets';
|
||||
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
import { Widget } from '@utils/types';
|
||||
@@ -41,9 +42,9 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide relative shrink-0 grow-0 basis-[400px] overflow-y-auto bg-white/10 backdrop-blur-xl">
|
||||
<WidgetWrapper>
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div className="h-full">
|
||||
<div className="scrollbar-hide h-full overflow-y-auto">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
@@ -68,7 +69,8 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
|
||||
<NoteReplyForm id={params.content} pubkey={db.account.pubkey} />
|
||||
<RepliesList id={params.content} />
|
||||
</div>
|
||||
<div className="h-16" />
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user