Merge pull request #75 from luminous-devs/wip/optimize

[WIP]: Refactor DB and optimize codebase
This commit is contained in:
Ren Amamiya 2023-08-20 15:59:36 +07:00 committed by GitHub
commit 917e49b25d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
143 changed files with 3232 additions and 6159 deletions

View File

@ -1,8 +1,5 @@
name: 'publish'
on:
push:
branches:
- main
on: workflow_dispatch
env:
CARGO_INCREMENTAL: 0
@ -27,7 +24,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 18
node-version: 20
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin

View File

@ -18,18 +18,18 @@
},
"dependencies": {
"@ctrl/magnet-link": "^3.1.2",
"@headlessui/react": "^1.7.16",
"@nostr-dev-kit/ndk": "^0.8.13",
"@nostr-fetch/adapter-ndk": "^0.11.0",
"@headlessui/react": "^1.7.17",
"@nostr-dev-kit/ndk": "^0.8.18",
"@nostr-fetch/adapter-ndk": "^0.12.2",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.32.6",
"@tanstack/react-query-devtools": "^4.32.6",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "2.0.0-alpha.5",
"@tauri-apps/api": "2.0.0-alpha.6",
"@tauri-apps/cli": "2.0.0-alpha.11",
"@tauri-apps/plugin-app": "github:tauri-apps/tauri-plugin-app#v2",
"@tauri-apps/plugin-autostart": "github:tauri-apps/tauri-plugin-autostart#v2",
"@tauri-apps/plugin-clipboard-manager": "github:tauri-apps/tauri-plugin-clipboard-manager#v2",
@ -44,23 +44,22 @@
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#v2",
"@tauri-apps/plugin-stronghold": "github:tauri-apps/tauri-plugin-stronghold#v2",
"@tauri-apps/plugin-upload": "github:tauri-apps/tauri-plugin-upload#v2",
"@tauri-apps/plugin-window": "2.0.0-alpha.0",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-mention": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"@tauri-apps/plugin-window": "github:tauri-apps/tauri-plugin-window#v2",
"@tiptap/extension-image": "^2.1.6",
"@tiptap/extension-mention": "^2.1.6",
"@tiptap/extension-placeholder": "^2.1.6",
"@tiptap/pm": "^2.1.6",
"@tiptap/react": "^2.1.6",
"@tiptap/starter-kit": "^2.1.6",
"@tiptap/suggestion": "^2.1.6",
"@void-cat/api": "^1.0.7",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9",
"destr": "^1.2.2",
"get-urls": "^11.0.0",
"destr": "^2.0.1",
"get-urls": "^12.1.0",
"html-to-text": "^9.0.5",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1",
"minidenticons": "^4.2.0",
"nostr-fetch": "^0.12.2",
"nostr-tools": "^1.14.0",
"qrcode.react": "^3.1.0",
@ -71,7 +70,6 @@
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-router-dom": "^6.15.0",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.5.0",
"remark-gfm": "^3.0.1",
"tippy.js": "^6.3.7",
@ -79,37 +77,35 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "2.0.0-alpha.10",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1",
"@types/node": "^18.17.5",
"@types/node": "^20.5.1",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@typescript-eslint/eslint-plugin": "^6.4.0",
"@typescript-eslint/parser": "^6.4.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"autoprefixer": "^10.4.15",
"clsx": "^2.0.0",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.47.0",
"eslint-config-prettier": "^8.10.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
"postcss": "^8.4.27",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"lint-staged": "^14.0.0",
"postcss": "^8.4.28",
"prettier": "^3.0.2",
"prettier-plugin-tailwindcss": "^0.5.3",
"prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.5",
"typescript": "^5.1.6",
"vite": "^4.4.9",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
}
}

File diff suppressed because it is too large Load Diff

497
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,14 +11,14 @@ rust-version = "1.71"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.0-alpha.6", features = [] }
tauri-build = { version = "2.0.0-alpha.8", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.0.0-alpha.10", features = [
tauri = { version = "2.0.0-alpha.11", features = [
"protocol-asset",
"macos-private-api",
"system-tray",
] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
@ -36,12 +36,10 @@ tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", bra
tauri-plugin-window = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
window-vibrancy = { git = "https://github.com/tauri-apps/window-vibrancy", branch = "dev" }
sqlx-cli = { version = "0.7.0", default-features = false, features = [
"sqlite",
] }
rust-argon2 = "1.0"
rand = "0.8.5"
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"
@ -54,7 +52,7 @@ features = ["sqlite"]
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
custom-protocol = []
# Optimized for bundle size. If you want faster builds comment out/delete this section.
[profile.release]

View File

@ -0,0 +1,13 @@
-- Add migration script here
CREATE TABLE
events (
id TEXT NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL,
event TEXT NOT NULL,
author TEXT NOT NULL,
kind NUMBER NOT NULL DEFAULt 1,
root_id TEXT,
reply_id TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);

View File

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

View File

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

View File

@ -3,11 +3,9 @@
windows_subsystem = "windows"
)]
// use rand::distributions::{Alphanumeric, DistString};
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind};
use window_vibrancy::{apply_mica, apply_vibrancy, NSVisualEffectMaterial};
#[derive(Clone, serde::Serialize)]
struct Payload {
@ -27,6 +25,11 @@ async fn close_splashscreen(window: tauri::Window) {
fn main() {
tauri::Builder::default()
.setup(|app| {
#[cfg(debug_assertions)]
app.get_window("main").unwrap().open_devtools();
Ok(())
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
@ -116,6 +119,24 @@ fn main() {
sql: include_str!("../migrations/20230811074423_rename_blocks_to_widgets.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230814083543,
description: "add events",
sql: include_str!("../migrations/20230814083543_add_events_table.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230816090508,
description: "clean up tables",
sql: include_str!("../migrations/20230816090508_clean_up_tables.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230817014932,
description: "add last login to account",
sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
@ -131,7 +152,6 @@ fn main() {
..Default::default()
};
// let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12);
let key = argon2::hash_raw(
password.as_ref(),
b"LUME_NEED_RUST_DEVELOPER_HELP_MAKE_SALT_RANDOM",
@ -166,19 +186,6 @@ fn main() {
.plugin(tauri_plugin_window::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_shell::init())
.setup(|app| {
let window = app.get_window("main").unwrap();
#[cfg(target_os = "macos")]
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
#[cfg(target_os = "windows")]
apply_mica(&window, Some(true))
.expect("Unsupported platform! 'apply_mica' is only supported on Windows");
Ok(())
})
.invoke_handler(tauri::generate_handler![close_splashscreen])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -67,18 +67,12 @@
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
"signingIdentity": null,
"minimumSystemVersion": "10.15.0"
},
"resources": [],
"shortDescription": "",
"targets": "all",
"updater": {
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
"windows": {
"installMode": "passive"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
@ -86,11 +80,18 @@
}
},
"security": {
"csp": "upgrade-insecure-requests"
},
"systemTray": {
"iconAsTemplate": true,
"iconPath": "icons/icon.png"
"csp": {
"connect-src": "ipc: https://ipc.localhost",
"content-security-policy": "upgrade-insecure-requests"
},
"freezePrototype": false,
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["$APPCONFIG/*.db", "$RESOURCE/**"],
"deny": ["$APPCONFIG/*.stronghold"]
}
}
},
"windows": [
{
@ -106,7 +107,12 @@
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false
"visible": false,
"fileDropEnabled": true,
"windowEffects": {
"effects": ["mica", "hudWindow"],
"state": "followsWindowActiveState"
}
},
{
"width": 400,
@ -117,7 +123,11 @@
"resizable": false,
"titleBarStyle": "Overlay",
"label": "splashscreen",
"url": "splashscreen"
"url": "splashscreen",
"windowEffects": {
"effects": ["mica", "hudWindow"],
"state": "followsWindowActiveState"
}
}
],
"macOSPrivateApi": true

View File

@ -5,47 +5,47 @@ import { AuthImportScreen } from '@app/auth/import';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ErrorScreen } from '@app/error';
import { getActiveAccount } from '@libs/storage';
import { AppLayout } from '@shared/appLayout';
import { AuthLayout } from '@shared/authLayout';
import { LoaderIcon } from '@shared/icons';
import { SettingsLayout } from '@shared/settingsLayout';
import { checkActiveAccount } from '@utils/checkActiveAccount';
import './index.css';
const appLoader = async () => {
const account = await getActiveAccount();
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;
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 (step) {
return redirect(step);
if (step) {
return redirect(step);
}
if (!account) {
return redirect('/auth/welcome');
} else {
if (!privkey) {
return redirect('/auth/unlock');
}
}
return null;
} catch (e) {
throw new Error('App failed to load');
}
if (!account) {
return redirect('/auth/welcome');
}
if (account && account.privkey.length > 35) {
return redirect('/auth/migrate');
}
if (account && !privkey) {
return redirect('/auth/unlock');
}
return null;
};
}
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
errorElement: <ErrorScreen />,
loader: appLoader,
loader: Loader,
children: [
{
path: '',
@ -54,13 +54,6 @@ const router = createBrowserRouter([
return { Component: SpaceScreen };
},
},
{
path: 'trending',
async lazy() {
const { TrendingScreen } = await import('@app/trending');
return { Component: TrendingScreen };
},
},
{
path: 'events/:id',
async lazy() {
@ -245,7 +238,6 @@ export default function App() {
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
</div>
}
future={{ v7_startTransition: true }}
/>
);
}

View File

@ -1,7 +1,5 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@ -25,7 +23,6 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
<div className="relative h-10 w-10 shrink rounded-md">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-10 w-10 rounded-md object-cover"
/>

View File

@ -1,7 +1,5 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@ -26,7 +24,6 @@ export function UserRelay({ pubkey }: { pubkey: string }) {
<div className="inline-flex items-center gap-1">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-5 w-5 shrink-0 rounded object-cover"
/>

View File

@ -1,10 +1,9 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BaseDirectory, writeTextFile } from '@tauri-apps/plugin-fs';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createAccount } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
@ -14,7 +13,8 @@ import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
export function CreateStep1Screen() {
const queryClient = useQueryClient();
const { db } = useStorage();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
@ -40,42 +40,29 @@ export function CreateStep1Screen() {
};
const download = async () => {
await writeTextFile('lume-keys.txt', `Public key: ${npub}\nPrivate key: ${nsec}`, {
dir: BaseDirectory.Download,
});
await writeTextFile(
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
{
dir: BaseDirectory.Download,
}
);
setDownloaded(true);
};
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[][];
is_active: number;
}) => {
return createAccount(data.npub, data.pubkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const submit = () => {
setLoading(true);
// update state
setPrivkey(privkey);
setTempPrivkey(privkey); // only use if user close app and reopen it
setPubkey(pubkey);
account.mutate({
npub,
pubkey,
follows: null,
is_active: 1,
});
// save to database
db.createAccount(npub, pubkey);
// redirect to next step
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
navigate('/auth/create/step-2', { replace: true });
};
useEffect(() => {

View File

@ -1,15 +1,17 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@ -37,7 +39,7 @@ export function CreateStep2Screen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { save } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@ -58,8 +60,13 @@ export function CreateStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
db.secureDB = stronghold;
// save privkey to secure storage
await save(pubkey, privkey, data.password);
await db.secureSave(pubkey, privkey);
// redirect to next step
navigate('/auth/create/step-3', { replace: true });

View File

@ -1,4 +1,3 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
@ -9,7 +8,6 @@ import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
@ -17,10 +15,9 @@ import { useNostr } from '@utils/hooks/useNostr';
export function CreateStep3Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState('');
const { publish } = useNostr();
@ -47,10 +44,8 @@ export function CreateStep3Screen() {
tags: [],
});
queryClient.invalidateQueries(['currentAccount']);
if (event) {
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1000);
navigate('/auth/onboarding', { replace: true });
}
} catch (e) {
console.log('error: ', e);
@ -76,7 +71,6 @@ export function CreateStep3Screen() {
<div className="relative h-44 w-full bg-white/10">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
/>
@ -88,7 +82,6 @@ export function CreateStep3Screen() {
<div className="relative z-10 -mt-7 h-14 w-14">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-white/10"
/>

View File

@ -1,10 +1,9 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { createAccount } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
@ -31,7 +30,6 @@ const resolver: Resolver<FormValues> = async (values) => {
};
export function ImportStep1Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setTempPubkey = useOnboarding((state) => state.setTempPrivkey);
@ -40,20 +38,7 @@ export function ImportStep1Screen() {
const [loading, setLoading] = useState(false);
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[];
is_active: number | boolean;
}) => {
return createAccount(data.npub, data.pubkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const { db } = useStorage();
const {
register,
setError,
@ -79,15 +64,10 @@ export function ImportStep1Screen() {
setPubkey(pubkey);
// add account to local database
account.mutate({
npub,
pubkey,
follows: null,
is_active: 1,
});
db.createAccount(npub, pubkey);
// redirect to step 2
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
navigate('/auth/import/step-2', { replace: true });
}
} catch (error) {
setError('privkey', {

View File

@ -1,15 +1,17 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@ -37,7 +39,7 @@ export function ImportStep2Screen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { save } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@ -58,8 +60,13 @@ export function ImportStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
db.secureDB = stronghold;
// save privkey to secure storage
await save(pubkey, privkey, data.password);
await db.secureSave(pubkey, privkey);
// redirect to next step
navigate('/auth/import/step-3', { replace: true });

View File

@ -1,41 +1,38 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { updateLastLogin } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
export function ImportStep3Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { status, account } = useAccount();
const { fetchNotes, fetchChats } = useNostr();
const { db } = useStorage();
const { fetchUserData } = useNostr();
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const now = Math.floor(Date.now() / 1000);
await fetchNotes();
await fetchChats();
await updateLastLogin(now);
const data = await fetchUserData();
queryClient.invalidateQueries(['currentAccount']);
navigate('/auth/onboarding/step-2', { replace: true });
if (data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
console.log('error: ', data.message);
setLoading(false);
}
} catch (e) {
console.log('error: ', e);
setLoading(false);
@ -55,40 +52,28 @@ export function ImportStep3Screen() {
</h1>
</div>
<div className="w-full rounded-xl bg-white/10 p-4">
{status === 'loading' ? (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="h-11 w-11 animate-pulse rounded-lg bg-white/10" />
<div>
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-white/10" />
<div className="h-3 w-36 animate-pulse rounded bg-white/10" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<button
type="button"
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"
onClick={() => submit()}
>
{loading ? (
<>
<span className="w-5" />
<span>It might take a bit, please patient...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
)}
<div className="flex flex-col gap-3">
<User pubkey={db.account.pubkey} />
<button
type="button"
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"
onClick={() => submit()}
>
{loading ? (
<>
<span className="w-5" />
<span>It might take a bit, please patient...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</div>
);

View File

@ -1,17 +1,16 @@
import { useQueryClient } from '@tanstack/react-query';
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { removePrivkey } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@ -38,8 +37,7 @@ export function MigrateScreen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { account } = useAccount();
const { save } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@ -63,13 +61,18 @@ export function MigrateScreen() {
// load private in secure storage
try {
// save privkey to secure storage
await save(account.pubkey, account.privkey, data.password);
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
await db.secureSave(db.account.pubkey, db.account.privkey);
// add privkey to state
setPrivkey(account.privkey);
setPrivkey(db.account.privkey);
// remove privkey in db
await removePrivkey();
await db.removePrivkey();
// clear cache
await queryClient.invalidateQueries(['currentAccount']);
await queryClient.invalidateQueries(['account']);
// redirect to home
navigate('/', { replace: true });
} catch {

View File

@ -1,26 +1,24 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { updateAccount } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardStep1Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const { publish, fetchNotes } = useNostr();
const { account } = useAccount();
const { publish, fetchUserData, prefetchEvents } = useNostr();
const { db } = useStorage();
const { status, data } = useQuery(['trending-profiles'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
@ -44,22 +42,23 @@ export function OnboardStep1Screen() {
try {
setLoading(true);
const tags = arrayToNIP02([...follows, account.pubkey]);
const tags = arrayToNIP02([...follows, db.account.pubkey]);
const event = await publish({ content: '', kind: 3, tags: tags });
await updateAccount('follows', follows);
// prefetch notes with current follows
const notes = await fetchNotes(follows);
// prefetch data
const user = await fetchUserData(follows);
const data = await prefetchEvents();
// redirect to next step
if (event && notes) {
setTimeout(() => {
queryClient.invalidateQueries(['currentAccount']);
navigate('/auth/onboarding/step-2', { replace: true });
}, 1000);
if (event && user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
setLoading(false);
console.log('error: ', data.message);
}
} catch {
console.log('error');
} catch (e) {
setLoading(false);
console.log('error: ', e);
}
};

View File

@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { createWidget } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { BLOCK_KINDS } from '@stores/constants';
import { widgetKinds } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
const data = [
@ -33,6 +33,8 @@ export function OnboardStep2Screen() {
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>());
const { db } = useStorage();
const toggleTag = (tag: string) => {
if (tags.has(tag)) {
setTags((prev) => {
@ -50,10 +52,10 @@ export function OnboardStep2Screen() {
setLoading(true);
for (const tag of tags) {
await createWidget(BLOCK_KINDS.hashtag, tag, tag.replace('#', ''));
await db.createWidget(widgetKinds.hashtag, tag, tag.replace('#', ''));
}
setTimeout(() => navigate('/auth/onboarding/step-3', { replace: true }), 1000);
navigate('/auth/onboarding/step-3', { replace: true });
} catch {
console.log('error');
}

View File

@ -5,14 +5,13 @@ import { useNavigate } from 'react-router-dom';
import { UserRelay } from '@app/auth/components/userRelay';
import { useNDK } from '@libs/ndk/provider';
import { createRelay } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { FULL_RELAYS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
export function OnboardStep3Screen() {
@ -23,13 +22,16 @@ export function OnboardStep3Screen() {
const [relays, setRelays] = useState(new Set<string>());
const { publish } = useNostr();
const { account } = useAccount();
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery(
['relays'],
async () => {
const tmp = new Map<string, string>();
const events = await ndk.fetchEvents({ kinds: [10002], authors: account.follows });
const events = await ndk.fetchEvents({
kinds: [10002],
authors: db.account.follows,
});
if (events) {
events.forEach((event) => {
@ -42,7 +44,8 @@ export function OnboardStep3Screen() {
return tmp;
},
{
enabled: account ? true : false,
enabled: db.account ? true : false,
refetchOnWindowFocus: false,
}
);
@ -62,21 +65,22 @@ export function OnboardStep3Screen() {
try {
if (!skip) {
for (const relay of relays) {
await createRelay(relay);
await db.createRelay(relay);
}
const tags = Array.from(relays).map((relay) => ['r', relay.replace(/\/+$/, '')]);
await publish({ content: '', kind: 10002, tags: tags });
} else {
for (const relay of FULL_RELAYS) {
await createRelay(relay);
await db.createRelay(relay);
}
}
setTimeout(() => {
clearStep();
navigate('/', { replace: true });
}, 1000);
// update last login
await db.updateLastLogin();
clearStep();
navigate('/', { replace: true });
} catch (e) {
setLoading(false);
console.log('error: ', e);

View File

@ -1,15 +1,16 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
privkey: string;
@ -36,8 +37,7 @@ export function ResetScreen() {
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { account } = useAccount();
const { save, reset } = useSecureStorage();
const { db } = useStorage();
// toggle private key
const showPassword = () => {
@ -66,7 +66,7 @@ export function ResetScreen() {
const tmpPubkey = getPublicKey(privkey);
if (tmpPubkey !== account.pubkey) {
if (tmpPubkey !== db.account.pubkey) {
setLoading(false);
setError('password', {
type: 'custom',
@ -75,11 +75,20 @@ export function ResetScreen() {
});
} else {
// remove old stronghold
await reset();
await db.secureReset();
// save privkey to secure storage
await save(account.pubkey, account.privkey, data.password);
const dir = await appConfigDir();
const stronghold = await Stronghold.load(
`${dir}/lume.stronghold`,
data.password
);
if (!db.secureDB) db.secureDB = stronghold;
await db.secureSave(db.account.pubkey, db.account.privkey);
// add privkey to state
setPrivkey(account.privkey);
setPrivkey(db.account.privkey);
// redirect to home
navigate('/auth/unlock', { replace: true });
}

View File

@ -1,14 +1,17 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@ -31,21 +34,10 @@ export function UnlockScreen() {
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { account } = useAccount();
const { load } = useSecureStorage();
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
const [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const { db } = useStorage();
const {
register,
setError,
@ -54,26 +46,24 @@ export function UnlockScreen() {
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
// load private in secure storage
try {
const privkey = await load(account.pubkey, data.password);
setPrivkey(privkey);
// redirect to home
navigate('/', { replace: true });
} catch {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Wrong password',
});
}
} else {
try {
setLoading(true);
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
const privkey = await db.secureLoad(db.account.pubkey);
setPrivkey(privkey);
// redirect to home
navigate('/', { replace: true });
} catch (e) {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3',
message: e,
});
}
};
@ -84,40 +74,52 @@ export function UnlockScreen() {
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-1">
<div className="flex flex-col rounded-lg bg-white/5">
<div className="w-full rounded-t-lg border-b border-white/10 bg-white/5 p-4">
<User pubkey={db.account.pubkey} />
</div>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-12 w-full rounded-lg bg-white/10 py-1 text-center text-white !outline-none placeholder:text-white/10"
{...register('password', { required: true, minLength: 4 })}
type={'password'}
placeholder="Password"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center text-white !outline-none placeholder:text-white/50"
/>
<button
type="button"
onClick={() => showPassword()}
onClick={() => setShowPassword((prev) => !prev)}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
>
{passwordInput === 'password' ? (
{showPassword ? (
<EyeOffIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
<div className="flex flex-col items-center justify-center">
<span className="mb-3 text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-12 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600"
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"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
<>
<span className="w-5" />
<span>Decryting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
'Continue →'
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<Link

View File

@ -1,31 +1,36 @@
import { LogicalSize, appWindow } from '@tauri-apps/plugin-window';
import { LogicalSize, getCurrent } from '@tauri-apps/plugin-window';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() {
useEffect(() => {
async function setWindow() {
await appWindow.setSize(new LogicalSize(400, 500));
await appWindow.setResizable(false);
await appWindow.center();
}
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 () => {
appWindow.setSize(new LogicalSize(1080, 800)).then(() => {
appWindow.setResizable(false);
appWindow.center();
});
resetWindow();
};
}, []);
return (
<div className="flex h-screen w-full flex-col justify-between bg-white/10">
<div className="flex flex-col gap-10 pt-16">
<div className="flex flex-col gap-2 text-center">
<div className="sflex flex-col gap-2 text-center">
<h1 className="text-3xl font-medium text-white">Welcome to Lume</h1>
<h3 className="mx-auto w-2/3 text-white/50">
Let&apos;s get you up and connecting with all peoples around the world on

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,8 +3,6 @@ import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { Chats } from '@utils/types';
@ -34,7 +32,6 @@ export function ChatsListItem({ data }: { data: Chats }) {
>
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.sender_pubkey}
className="h-6 w-6 shrink-0 rounded object-cover"
/>

View File

@ -6,24 +6,19 @@ import { NewMessageModal } from '@app/chats/components/modal';
import { ChatsListSelfItem } from '@app/chats/components/self';
import { UnknownsModal } from '@app/chats/components/unknowns';
import { getChats } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { useAccount } from '@utils/hooks/useAccount';
import { Chats } from '@utils/types';
export function ChatsList() {
const { account } = useAccount();
const {
status,
data: chats,
isFetching,
} = useQuery(['chats'], async () => {
return await getChats();
const { db } = useStorage();
const { status, data: chats } = useQuery(['chats'], async () => {
return { follows: [], unknowns: [] };
});
const renderItem = useCallback(
(item: Chats) => {
if (account?.pubkey !== item.sender_pubkey) {
if (db.account.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />;
}
},
@ -47,21 +42,8 @@ export function ChatsList() {
return (
<div className="flex flex-col">
{account ? (
<ChatsListSelfItem data={account} />
) : (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
</div>
)}
<ChatsListSelfItem pubkey={db.account.pubkey} />
{chats.follows.map((item) => renderItem(item))}
{isFetching && (
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
</div>
)}
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
<NewMessageModal />
</div>

View File

@ -4,15 +4,15 @@ import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { useStorage } from '@libs/storage/provider';
import { useAccount } from '@utils/hooks/useAccount';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
export function NewMessageModal() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { status, account } = useAccount();
const { db } = useStorage();
const openChat = (pubkey: string) => {
setOpen(false);
@ -59,7 +59,7 @@ export function NewMessageModal() {
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
account?.follows?.map((follow) => (
db.account?.follows?.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"

View File

@ -3,13 +3,11 @@ import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
const { status, user } = useProfile(data.pubkey);
export function ChatsListSelfItem({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
@ -22,7 +20,7 @@ export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
return (
<NavLink
to={`/chats/${data.pubkey}`}
to={`/chats/${pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
@ -33,13 +31,12 @@ export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
>
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.pubkey}
alt={pubkey}
className="h-6 w-6 shrink-0 rounded bg-white object-cover"
/>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate">
{user?.nip05 || user?.name || displayNpub(data.pubkey, 16)}
{user?.nip05 || user?.name || displayNpub(pubkey, 16)}
</h5>
<span className="text-white/50">(you)</span>
</div>

View File

@ -2,8 +2,6 @@ import { Link } from 'react-router-dom';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@ -16,7 +14,6 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>

View File

@ -1,5 +1,5 @@
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
@ -9,29 +9,19 @@ import { ChatMessageItem } from '@app/chats/components/messages/item';
import { ChatSidebar } from '@app/chats/components/sidebar';
import { useNDK } from '@libs/ndk/provider';
import { createChat, getChatMessages } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { Chats } from '@utils/types';
export function ChatScreen() {
const queryClient = useQueryClient();
const virtuosoRef = useRef(null);
const { ndk } = useNDK();
const { db } = useStorage();
const { pubkey } = useParams();
const { account } = useAccount();
const { status, data } = useQuery(
['chat', pubkey],
async () => {
return await getChatMessages(account.pubkey, pubkey);
},
{
enabled: account ? true : false,
}
);
const { status, data } = useQuery(['chat', pubkey], async () => {
return [];
});
const userPrivkey = useStronghold((state) => state.privkey);
@ -40,7 +30,7 @@ export function ChatScreen() {
return (
<ChatMessageItem
data={data[index]}
userPubkey={account.pubkey}
userPubkey={db.account.pubkey}
userPrivkey={userPrivkey}
/>
);
@ -55,27 +45,11 @@ export function ChatScreen() {
[data]
);
const chat = useMutation({
mutationFn: (data: Chats) => {
return createChat(
data.id,
data.receiver_pubkey,
data.sender_pubkey,
data.content,
data.tags,
data.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chat', pubkey] });
},
});
useEffect(() => {
const sub: NDKSubscription = ndk.subscribe(
{
kinds: [4],
authors: [account.pubkey],
authors: [db.account.pubkey],
'#p': [pubkey],
since: Math.floor(Date.now() / 1000),
},
@ -85,14 +59,7 @@ export function ChatScreen() {
);
sub.addListener('event', (event) => {
chat.mutate({
id: event.id,
receiver_pubkey: pubkey,
sender_pubkey: event.pubkey,
content: event.content,
tags: event.tags,
created_at: event.created_at,
});
console.log(event);
});
return () => {
@ -129,7 +96,7 @@ export function ChatScreen() {
<div className="z-50 shrink-0 rounded-b-xl border-t border-white/5 bg-white/10 p-3 px-5">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPubkey={db.account.pubkey}
userPrivkey={userPrivkey}
/>
</div>

View File

@ -1,21 +1,79 @@
import { useRouteError } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useLocation, useRouteError } from 'react-router-dom';
interface IRouteError {
statusText: string;
message: string;
}
interface IDebugInfo {
os: null | string;
version: null | string;
}
export function ErrorScreen() {
const error = useRouteError() as IRouteError;
const location = useLocation();
const [debugInfo, setDebugInfo] = useState<IDebugInfo>({ os: null, version: null });
useEffect(() => {
async function getInformation() {
const { platform, version } = await import('@tauri-apps/plugin-os');
const { getVersion } = await import('@tauri-apps/plugin-app');
const platformName = await platform();
const osVersion = await version();
const appVersion = await getVersion();
setDebugInfo({ os: platformName + ' ' + osVersion, version: appVersion });
}
getInformation();
}, []);
return (
<div className="flex h-full w-full items-center justify-center">
<div>
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
<div className="flex h-full items-center justify-center bg-black/90">
<div className="flex max-w-lg flex-col gap-4">
<div className="flex flex-col">
<h1 className="mb-1 text-2xl font-semibold text-white">
Sorry, an unexpected error has occurred.
</h1>
<div className="mt-4 inline-flex h-16 items-center justify-center rounded-xl border border-dashed border-red-400 bg-red-200/10 px-5">
<p className="text-sm font-medium text-red-400">
{error.statusText || error.message}
</p>
</div>
<div className="mt-4">
<p className="font-medium text-white/50">
Current location: {location.pathname}
</p>
<p className="font-medium text-white/50">App version: {debugInfo.version}</p>
<p className="font-medium text-white/50">Platform: {debugInfo.os}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<a
href="https://github.com/luminous-devs/lume/issues/new"
target="_blank"
rel="noreferrer"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white hover:bg-white/20"
>
Click here to report the issue on GitHub
</a>
<button
type="button"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white hover:bg-white/20"
>
Reload app
</button>
<button
type="button"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white hover:bg-white/20"
>
Reset app
</button>
</div>
</div>
</div>
);

View File

@ -1,5 +1,7 @@
import { useParams } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import {
NoteActions,
NoteContent,
@ -10,12 +12,11 @@ import {
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { useAccount } from '@utils/hooks/useAccount';
import { useEvent } from '@utils/hooks/useEvent';
export function EventScreen() {
const { id } = useParams();
const { account } = useAccount();
const { db } = useStorage();
const { status, data } = useEvent(id);
return (
@ -30,19 +31,19 @@ export function EventScreen() {
) : (
<div className="h-min w-full px-3 pt-1.5">
<div className="rounded-xl bg-white/10 px-3 pt-3">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<ThreadUser pubkey={data.event.pubkey} time={data.event.created_at} />
<div className="mt-2">
<NoteContent content={data.content} />
<NoteContent content={data.richContent} />
</div>
<div>
<NoteActions id={id} pubkey={data.pubkey} noOpenThread={true} />
<NoteActions id={id} pubkey={data.event.pubkey} noOpenThread={true} />
<NoteStats id={id} />
</div>
</div>
</div>
)}
<div className="px-3">
<NoteReplyForm id={id} pubkey={account.pubkey} />
<NoteReplyForm id={id} pubkey={db.account.pubkey} />
<RepliesList id={id} />
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -1,165 +0,0 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useEffect, useRef } from 'react';
import { getNotesByAuthors } from '@libs/storage';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { LumeEvent, Widget } from '@utils/types';
const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: Widget }) {
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
queryFn: async ({ pageParam = 0 }) => {
return await getNotesByAuthors(params.content, ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
if (!lastItem) {
return;
}
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const renderItem = useCallback(
(index: string | number) => {
const note: LumeEvent = notes[index];
if (!note) return;
switch (note.kind) {
case 1: {
const root = note.tags.find((el) => el[3] === 'root')?.[1];
const reply = note.tags.find((el) => el[3] === 'reply')?.[1];
if (root || reply) {
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteThread event={note} root={root} reply={reply} />
</div>
);
} else {
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={note} skipMetadata={false} />
</div>
);
}
}
case 6:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<Repost key={note.event_id} event={note} />
</div>
);
case 1063:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1063 key={note.event_id} event={note} />
</div>
);
default:
return (
<div
key={note.event_id || note.id}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKindUnsupport key={note.event_id} event={note} />
</div>
);
}
},
[notes]
);
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar id={params.id} title={params.title} />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<div className="px-3 py-1.5">
<div className="bbg-white/10 rounded-xl px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
Not found any posts from last 48 hours
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${totalSize}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -1,84 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { nHoursAgo } from '@utils/date';
import { LumeEvent, Widget } from '@utils/types';
export function HashtagBlock({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(['hashtag', params.content], async () => {
const events = await ndk.fetchEvents({
kinds: [1],
'#t': [params.content],
since: nHoursAgo(24),
});
return [...events] as unknown as LumeEvent[];
});
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar id={params.id} title={params.title + ' in 24 hours ago'} />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm font-medium text-white">
No new posts about this hashtag in 24 hours ago
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${totalSize}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={data[virtualRow.index]} />
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

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

View File

@ -1,185 +0,0 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useNewsfeed } from '@app/space/hooks/useNewsfeed';
import { getNotes } from '@libs/storage';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { LumeEvent } from '@utils/types';
const ITEM_PER_PAGE = 10;
export function NetworkBlock() {
// subscribe for live update
useNewsfeed();
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed-circle'],
queryFn: async ({ pageParam = 0 }) => {
return await getNotes(ITEM_PER_PAGE, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
const parentRef = useRef();
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? notes.length + 1 : notes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 500,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
useEffect(() => {
const [lastItem] = [...itemsVirtualizer].reverse();
if (!lastItem) {
return;
}
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, itemsVirtualizer]);
const renderItem = useCallback(
(index: string | number) => {
const note: LumeEvent = notes[index];
if (!note) return;
switch (note.kind) {
case 1: {
let root: string;
let reply: string;
if (note.tags?.[0]?.[0] === 'e' && !note.tags?.[0]?.[3]) {
root = note.tags[0][1];
} else {
root = note.tags.find((el) => el[3] === 'root')?.[1];
reply = note.tags.find((el) => el[3] === 'reply')?.[1];
}
if (root || reply) {
return (
<div
key={(root || reply) + (note.event_id || note.id) + index}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteThread event={note} root={root} reply={reply} />
</div>
);
} else {
return (
<div
key={(note.event_id || note.id) + index}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={note} skipMetadata={false} />
</div>
);
}
}
case 6:
return (
<div
key={(note.event_id || note.id) + index}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<Repost key={note.event_id} event={note} />
</div>
);
case 1063:
return (
<div
key={(note.event_id || note.id) + index}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1063 key={note.event_id} event={note} />
</div>
);
default:
return (
<div
key={(note.event_id || note.id) + index}
data-index={index}
ref={rowVirtualizer.measureElement}
>
<NoteKindUnsupport key={note.event_id} event={note} />
</div>
);
}
},
[notes]
);
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar title="Network" />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
You not have any posts to see yet
<br />
Follow more people to have more fun.
</p>
<Link
to="/trending"
className="inline-flex w-max rounded bg-fuchsia-500 px-2.5 py-1.5 text-sm hover:bg-fuchsia-600"
>
Trending
</Link>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${totalSize}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
</div>
</div>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -1,95 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { UserProfile } from '@shared/userProfile';
import { nHoursAgo } from '@utils/date';
import { LumeEvent, Widget } from '@utils/types';
export function UserBlock({ params }: { params: Widget }) {
const parentRef = useRef<HTMLDivElement>(null);
const { ndk } = useNDK();
const { status, data } = useQuery(['user-feed', params.content], async () => {
const events = await ndk.fetchEvents({
kinds: [1],
authors: [params.content],
since: nHoursAgo(48),
});
return [...events] as unknown as LumeEvent[];
});
const rowVirtualizer = useVirtualizer({
count: data ? data.length : 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 400,
overscan: 2,
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar id={params.id} title={params.title} />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
<div className="px-3 pt-1.5">
<UserProfile pubkey={params.content} />
</div>
<div>
<h3 className="mt-4 px-3 text-lg font-semibold text-white">Latest postrs</h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
No new posts from this user in 48 hours ago
</p>
</div>
</div>
</div>
) : (
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
<div
className="absolute left-0 top-0 w-full"
style={{
transform: `translateY(${
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
}px)`,
}}
>
{itemsVirtualizer.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteKind_1 event={data[virtualRow.index]} />
</div>
))}
<div className="h-20" />
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -3,30 +3,25 @@ import * as Dialog from '@radix-ui/react-dialog';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { useAccount } from '@utils/hooks/useAccount';
export function FeedModal() {
const setWidget = useWidgets((state) => state.setWidget);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState([]);
const [query, setQuery] = useState('');
const { status, account } = useAccount();
const setWidget = useWidgets((state) => state.setWidget);
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => setOpen(true));
const { db } = useStorage();
const {
register,
handleSubmit,
@ -43,9 +38,9 @@ export function FeedModal() {
}
});
// insert to database
setWidget({
kind: BLOCK_KINDS.feed,
// update state
setWidget(db, {
kind: widgetKinds.feed,
title: data.title,
content: JSON.stringify(selected),
});
@ -139,7 +134,6 @@ export function FeedModal() {
<div className="flex items-center gap-2">
<img
alt={query}
src={DEFAULT_AVATAR}
className="h-11 w-11 shrink-0 rounded object-cover"
/>
<div className="inline-flex flex-col gap-1">
@ -155,26 +149,22 @@ export function FeedModal() {
)}
</Combobox.Option>
)}
{status === 'loading' ? (
<p>Loading...</p>
) : (
account?.follows?.map((follow) => (
<Combobox.Option
key={follow}
value={follow}
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-white/10"
>
{({ selected }) => (
<>
<User pubkey={follow} />
{selected && (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
))
)}
{db.account?.follows?.map((follow) => (
<Combobox.Option
key={follow}
value={follow}
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-white/10"
>
{({ selected }) => (
<>
<User pubkey={follow} />
{selected && (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</div>

View File

@ -1,22 +1,21 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { BLOCK_KINDS } from '@stores/constants';
import { ADD_HASHTAGBLOCK_SHORTCUT } from '@stores/shortcuts';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
export function HashtagModal() {
const setWidget = useWidgets((state) => state.setWidget);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const setWidget = useWidgets((state) => state.setWidget);
useHotkeys(ADD_HASHTAGBLOCK_SHORTCUT, () => setOpen(false));
const { db } = useStorage();
const {
register,
handleSubmit,
@ -27,9 +26,9 @@ export function HashtagModal() {
const onSubmit = async (data: { hashtag: string }) => {
setLoading(true);
// mutate
setWidget({
kind: BLOCK_KINDS.hashtag,
// update state
setWidget(db, {
kind: widgetKinds.hashtag,
title: data.hashtag,
content: data.hashtag.replace('#', ''),
});

View File

@ -1,13 +1,13 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
import { widgetKinds } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { useImageUploader } from '@utils/hooks/useUploader';
@ -20,8 +20,7 @@ export function ImageModal() {
const [loading, setLoading] = useState(false);
const [image, setImage] = useState('');
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => setOpen(false));
const { db } = useStorage();
const {
register,
handleSubmit,
@ -41,7 +40,7 @@ export function ImageModal() {
setLoading(true);
// mutate
setWidget({ kind: BLOCK_KINDS.image, title: data.title, content: data.content });
setWidget(db, { kind: widgetKinds.image, title: data.title, content: data.content });
setLoading(false);
// reset form
@ -124,7 +123,6 @@ export function ImageModal() {
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg bg-white/10">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="content"
className="relative z-10 h-auto max-h-[156px] w-[150px] rounded-md object-cover"
/>

View File

@ -1,17 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { useEffect, useState } from 'react';
import { FollowIcon, LoaderIcon, UnfollowIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useSocial } from '@utils/hooks/useSocial';
import { compactNumber } from '@utils/number';
import { shortenKey } from '@utils/shortenKey';
export function Profile({ data }: { data: any }) {
export interface Profile {
pubkey: string;
profile: { content: string };
}
export function UserProfile({ data }: { data: Profile }) {
const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
const { status, data: userStats } = useQuery(
['user-stats', data.pubkey],
@ -74,7 +76,6 @@ export function Profile({ data }: { data: any }) {
<div className="inline-flex items-center gap-2">
<Image
src={profile.picture}
fallback={DEFAULT_AVATAR}
className="h-11 w-11 shrink-0 rounded-lg object-cover"
/>
<div className="inline-flex flex-col gap-1">

View File

@ -0,0 +1,187 @@
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 { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { DBEvent, Widget } from '@utils/types';
export function FeedWidget({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
queryFn: async ({ pageParam = 0 }) => {
const authors = JSON.parse(params.content);
return await db.getAllEventsByAuthors(authors, 20, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
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;
const event: NDKEvent = JSON.parse(dbEvent.event as string);
switch (event.kind) {
case 1: {
if (dbEvent.root_id || dbEvent.reply_id) {
return (
<div
key={(dbEvent.root_id || dbEvent.reply_id) + dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteThread
event={event}
root={dbEvent.root_id}
reply={dbEvent.reply_id}
/>
</div>
);
} else {
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
}
}
case 6:
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={dbEvent.id} event={event} />
</div>
);
case 1063:
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKind_1063 key={dbEvent.id} event={event} />
</div>
);
default:
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={dbEvent.id} event={event} />
</div>
);
}
},
[dbEvents]
);
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar id={params.id} title={params.title} />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : items.length === 0 ? (
<div className="px-3 py-1.5">
<div className="bbg-white/10 rounded-xl px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
Not found any postrs from last 48 hours
</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>
)}
{isFetchingNextPage && (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
<div className="px-3 py-1.5">
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{isFetchingNextPage ? (
<>
<span className="w-5" />
<span>Loading...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : hasNextPage ? (
<>
<span className="w-5" />
<span>Load more</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Nothing more to load</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,114 @@
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 { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { TitleBar } from '@shared/titleBar';
import { nHoursAgo } from '@utils/date';
import { Widget } from '@utils/types';
export function HashtagWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(['hashtag-widget', params.content], async () => {
const events = await ndk.fetchEvents({
kinds: [1],
'#t': [params.content],
since: nHoursAgo(24),
});
return [...events] as unknown as NDKEvent[];
});
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;
switch (event.kind) {
case 1:
return (
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
case 6:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={event.id} event={event} />
</div>
);
default:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={event.id} event={event} />
</div>
);
}
},
[data]
);
return (
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar id={params.id} title={params.title + ' in 24 hours ago'} />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : items.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm font-medium text-white">
No new postrs about this hashtag in 24 hours ago
</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>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,234 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { useNostr } from '@utils/hooks/useNostr';
import { DBEvent } from '@utils/types';
export function NetworkWidget() {
const { sub } = useNostr();
const { db } = useStorage();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['network-widget'],
queryFn: async ({ pageParam = 0 }) => {
return await db.getAllEvents(20, pageParam);
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const 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;
const event: NDKEvent = JSON.parse(dbEvent.event as string);
switch (event.kind) {
case 1: {
if (dbEvent.root_id || dbEvent.reply_id) {
return (
<div
key={(dbEvent.root_id || dbEvent.reply_id) + dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteThread
event={event}
root={dbEvent.root_id}
reply={dbEvent.reply_id}
/>
</div>
);
} else {
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
}
}
case 6:
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={dbEvent.id} event={event} />
</div>
);
case 1063:
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKind_1063 key={dbEvent.id} event={event} />
</div>
);
default:
return (
<div
key={dbEvent.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={dbEvent.id} event={event} />
</div>
);
}
},
[dbEvents]
);
// subscribe for new event
// sub will be managed by lru-cache
useEffect(() => {
if (db.account && db.account.network) {
const filter: NDKFilter = {
kinds: [1, 6],
authors: db.account.network,
since: db.account.last_login_at ?? Math.floor(Date.now() / 1000),
};
sub(filter, async (event) => {
console.log('[network] new event', event.id);
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
await db.createEvent(
event.id,
JSON.stringify(event),
event.pubkey,
event.kind,
root,
reply,
event.created_at
);
});
}
}, []);
return (
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10">
<TitleBar title="Network" />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : dbEvents.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
You not have any postrs to see yet
<br />
Follow more people to have more fun.
</p>
<Link
to="/trending"
className="inline-flex w-max rounded bg-fuchsia-500 px-2.5 py-1.5 text-sm hover:bg-fuchsia-600"
>
Trending users
</Link>
</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>
)}
{isFetchingNextPage && (
<div className="mb-20 px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
)}
<div className="px-3 py-1.5">
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{isFetchingNextPage ? (
<>
<span className="w-5" />
<span>Loading...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : hasNextPage ? (
<>
<span className="w-5" />
<span>Load more</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Nothing more to load</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
// import { useLiveThread } from '@app/space/hooks/useLiveThread';
import { useStorage } from '@libs/storage/provider';
import {
NoteActions,
NoteContent,
@ -10,19 +11,15 @@ import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { useAccount } from '@utils/hooks/useAccount';
import { useEvent } from '@utils/hooks/useEvent';
import { Widget } from '@utils/types';
export function ThreadBlock({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data } = useEvent(params.content);
const { account } = useAccount();
// subscribe to live reply
// useLiveThread(params.content);
return (
<div className="scrollbar-hide h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
<div className="scrollbar-hide h-full w-[400px] shrink-0 overflow-y-auto bg-white/10">
<TitleBar id={params.id} title={params.title} />
<div className="h-full">
{status === 'loading' ? (
@ -34,14 +31,14 @@ export function ThreadBlock({ params }: { params: Widget }) {
) : (
<div className="h-min w-full px-3 pt-1.5">
<div className="rounded-xl bg-white/10 px-3 pt-3">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<ThreadUser pubkey={data.event.pubkey} time={data.event.created_at} />
<div className="mt-2">
<NoteContent content={data.content} />
<NoteContent content={data.richContent} />
</div>
<div>
<NoteActions
id={params.content}
pubkey={data.pubkey}
pubkey={data.event.pubkey}
noOpenThread={true}
/>
<NoteStats id={params.content} />
@ -50,7 +47,7 @@ export function ThreadBlock({ params }: { params: Widget }) {
</div>
)}
<div className="px-3">
{account && <NoteReplyForm id={params.content} pubkey={account.pubkey} />}
<NoteReplyForm id={params.content} pubkey={db.account.pubkey} />
<RepliesList id={params.content} />
</div>
</div>

View File

@ -1,25 +1,26 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { NoteKind_1 } from '@shared/notes';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { LumeEvent } from '@utils/types';
import { Widget } from '@utils/types';
interface Response {
notes: Array<{ event: LumeEvent }>;
notes: Array<{ event: NDKEvent }>;
}
export function TrendingNotes() {
const { status, data, error } = useQuery(
export function TrendingNotesWidget({ params }: { params: Widget }) {
const { status, data } = useQuery(
['trending-notes'],
async () => {
const res = await fetch('https://api.nostr.band/v0/trending/notes');
const res = await fetch(params.content);
if (!res.ok) {
throw new Error('Error');
throw new Error('failed to fecht trending notes');
}
const json: Response = await res.json();
if (!json.notes) return null;
return json.notes;
},
{
@ -30,19 +31,24 @@ export function TrendingNotes() {
}
);
console.log('notes: ', data);
return (
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
<TitleBar title="Trending Posts" />
<TitleBar title={params.title} />
<div className="h-full">
{error && <p>Failed to fetch</p>}
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : status === 'error' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<p className="text-center text-sm font-medium text-white">
Sorry, an unexpected error has occurred.
</p>
</div>
</div>
) : (
<div className="relative flex w-full flex-col">
{data.map((item) => (

View File

@ -1,24 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { Profile } from '@app/trending/components/profile';
import { type Profile, UserProfile } from '@app/space/components/userProfile';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { Widget } from '@utils/types';
interface Response {
profiles: Array<{ pubkey: string }>;
}
export function TrendingProfiles() {
const { status, data, error } = useQuery(
export function TrendingProfilesWidget({ params }: { params: Widget }) {
const { status, data } = useQuery(
['trending-profiles'],
async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
const res = await fetch(params.content);
if (!res.ok) {
throw new Error('Error');
}
const json: Response = await res.json();
if (!json.profiles) return [];
return json.profiles;
},
{
@ -29,23 +31,28 @@ export function TrendingProfiles() {
}
);
console.log('profiles: ', data);
return (
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
<TitleBar title="Trending Profiles" />
<TitleBar title={params.title} />
<div className="h-full">
{error && <p>Failed to fetch</p>}
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : status === 'error' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<p className="text-center text-sm font-medium text-white">
Sorry, an unexpected error has occurred.
</p>
</div>
</div>
) : (
<div className="relative flex w-full flex-col gap-3 px-3 pt-1.5">
{data?.map((item) => (
<Profile key={item.pubkey} data={item} />
{data.map((item: Profile) => (
<UserProfile key={item.pubkey} data={item} />
))}
</div>
)}

View File

@ -0,0 +1,132 @@
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 { useNDK } from '@libs/ndk/provider';
import { NoteKind_1, NoteSkeleton, Repost } from '@shared/notes';
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { TitleBar } from '@shared/titleBar';
import { UserProfile } from '@shared/userProfile';
import { nHoursAgo } from '@utils/date';
import { Widget } from '@utils/types';
export function UserWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(
['user-widget', params.content],
async () => {
const events = await ndk.fetchEvents({
kinds: [1, 6],
authors: [params.content],
since: nHoursAgo(24),
});
return [...events] as unknown as NDKEvent[];
},
{
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
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;
switch (event.kind) {
case 1:
return (
<div key={event.id} data-index={index} ref={virtualizer.measureElement}>
<NoteKind_1 event={event} skipMetadata={false} />
</div>
);
case 6:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<Repost key={event.id} event={event} />
</div>
);
default:
return (
<div
key={event.id + index}
data-index={index}
ref={virtualizer.measureElement}
>
<NoteKindUnsupport key={event.id} event={event} />
</div>
);
}
},
[data]
);
return (
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10">
<TitleBar id={params.id} title={params.title} />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
<div className="px-3 pt-1.5">
<UserProfile pubkey={params.content} />
</div>
<div>
<h3 className="mt-4 px-3 text-lg font-semibold text-white">Latest postrs</h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{status === 'loading' ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : items.length === 0 ? (
<div className="px-3 py-1.5">
<div className="rounded-xl bg-white/10 px-3 py-6">
<div className="flex flex-col items-center gap-4">
<p className="text-center text-sm text-white">
No new postr from user in 24 hours ago
</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>
)}
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -1,22 +1,22 @@
import { useCallback, useEffect } from 'react';
import { FeedBlock } from '@app/space/components/blocks/feed';
import { HashtagBlock } from '@app/space/components/blocks/hashtag';
import { ImageBlock } from '@app/space/components/blocks/image';
import { NetworkBlock } from '@app/space/components/blocks/network';
import { ThreadBlock } from '@app/space/components/blocks/thread';
import { UserBlock } from '@app/space/components/blocks/user';
import { FeedModal } from '@app/space/components/modals/feed';
import { HashtagModal } from '@app/space/components/modals/hashtag';
import { ImageModal } from '@app/space/components/modals/image';
import { FeedWidget } from '@app/space/components/widgets/feed';
import { HashtagWidget } from '@app/space/components/widgets/hashtag';
import { NetworkWidget } from '@app/space/components/widgets/network';
import { ThreadBlock } from '@app/space/components/widgets/thread';
import { UserWidget } from '@app/space/components/widgets/user';
import { LoaderIcon } from '@shared/icons';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useWidgets } from '@stores/widgets';
import { Widget } from '@utils/types';
export function SpaceScreen() {
const { db } = useStorage();
const [widgets, fetchWidgets] = useWidgets((state) => [
state.widgets,
state.fetchWidgets,
@ -24,17 +24,18 @@ export function SpaceScreen() {
const renderItem = useCallback(
(widget: Widget) => {
if (!widget) return;
switch (widget.kind) {
case 0:
return <ImageBlock key={widget.id} params={widget} />;
case 1:
return <FeedBlock key={widget.id} params={widget} />;
return <FeedWidget key={widget.id} params={widget} />;
case 2:
return <ThreadBlock key={widget.id} params={widget} />;
case 3:
return <HashtagBlock key={widget.id} params={widget} />;
return <HashtagWidget key={widget.id} params={widget} />;
case 5:
return <UserBlock key={widget.id} params={widget} />;
return <UserWidget key={widget.id} params={widget} />;
case 9999:
return <NetworkWidget key={widget.id} />;
default:
break;
}
@ -43,14 +44,13 @@ export function SpaceScreen() {
);
useEffect(() => {
fetchWidgets();
fetchWidgets(db);
}, [fetchWidgets]);
return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
<NetworkBlock />
<div className="scrollbar-hide inline-flex h-full w-full min-w-full flex-nowrap items-start divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
{!widgets ? (
<div className="flex w-[350px] shrink-0 flex-col">
<div className="flex shrink-0 grow-0 basis-[400px] flex-col">
<div className="flex w-full flex-1 items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-white/10" />
</div>
@ -58,14 +58,16 @@ export function SpaceScreen() {
) : (
widgets.map((widget) => renderItem(widget))
)}
<div className="flex w-[350px] shrink-0 flex-col">
<div className="inline-flex h-full w-full flex-col items-center justify-center gap-1">
<FeedModal />
<ImageModal />
<HashtagModal />
<div className="flex h-full shrink-0 grow-0 basis-[400px] flex-col">
<div className="inline-flex h-full w-full flex-col items-center justify-center">
<button type="button" className="flex flex-col items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/5 hover:bg-white/10">
<PlusIcon className="h-5 w-5 text-white" />
</div>
<p className="font-medium text-white/50">Add widget</p>
</button>
</div>
</div>
<div className="w-[250px] shrink-0" />
</div>
);
}

View File

@ -2,20 +2,19 @@ import { invoke } from '@tauri-apps/api/tauri';
import { useEffect, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { updateLastLogin } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
export function SplashScreen() {
const { db } = useStorage();
const { ndk, relayUrls } = useNDK();
const { status, account } = useAccount();
const { fetchChats, fetchNotes } = useNostr();
const { fetchUserData, prefetchEvents } = useNostr();
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<null | string>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const skip = async () => {
await invoke('close_splashscreen');
@ -26,39 +25,43 @@ export function SplashScreen() {
const step = JSON.parse(onboarding).state.step || null;
if (step) await invoke('close_splashscreen');
const notes = await fetchNotes();
const chats = await fetchChats();
try {
const user = await fetchUserData();
const data = await prefetchEvents();
if (notes.status === 'ok' && chats.status === 'ok') {
const now = Math.floor(Date.now() / 1000);
await updateLastLogin(now);
invoke('close_splashscreen');
} else {
setLoading(false);
setError(notes.message || chats.message);
console.log('fetch notes failed, error: ', notes.message);
console.log('fetch chats failed, error: ', chats.message);
if (user.status === 'ok' && data.status === 'ok') {
await db.updateLastLogin();
await invoke('close_splashscreen');
} else {
setIsLoading(false);
setErrorMessage(user.message);
console.log('fetch failed, error: ', user.message);
}
} catch (e) {
setIsLoading(false);
setErrorMessage(e);
console.log('prefetch failed, error: ', e);
}
};
useEffect(() => {
if (status === 'success' && !account) {
if (!db.account) {
invoke('close_splashscreen');
}
if (ndk && account) {
if (ndk && db.account) {
console.log('prefetching...');
prefetch();
}
}, [ndk, account]);
}, [ndk, db.account]);
return (
<div className="relative flex h-screen w-screen items-center justify-center bg-black">
<div className="relative flex h-screen w-screen items-center justify-center bg-black/90">
<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">
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
{loading ? (
{isLoading ? (
<div className="mt-2 flex flex-col gap-1 text-center">
<h3 className="text-lg font-semibold leading-none text-white">
{!ndk
@ -74,7 +77,7 @@ export function SplashScreen() {
<h3 className="text-lg font-semibold leading-none text-white">
Something wrong!
</h3>
<p className="text-sm text-white/50">{error}</p>
<p className="text-sm text-white/50">{errorMessage}</p>
<button
type="button"
onClick={skip}

View File

@ -1,11 +0,0 @@
import { TrendingNotes } from '@app/trending/components/trendingNotes';
import { TrendingProfiles } from '@app/trending/components/trendingProfiles';
export function TrendingScreen() {
return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
<TrendingProfiles />
<TrendingNotes />
</div>
);
}

View File

@ -3,19 +3,18 @@ import { Link } from 'react-router-dom';
import { UserMetadata } from '@app/users/components/metadata';
import { useStorage } from '@libs/storage/provider';
import { EditProfileModal } from '@shared/editProfileModal';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useAccount } from '@utils/hooks/useAccount';
import { useProfile } from '@utils/hooks/useProfile';
import { useSocial } from '@utils/hooks/useSocial';
import { shortenKey } from '@utils/shortenKey';
export function UserProfile({ pubkey }: { pubkey: string }) {
const { db } = useStorage();
const { user } = useProfile(pubkey);
const { account } = useAccount();
const { status, userFollows, follow, unfollow } = useSocial();
const [followed, setFollowed] = useState(false);
@ -53,17 +52,11 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
return (
<>
<div className="h-56 w-full bg-white">
<Image
src={user?.banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt={'banner'}
className="h-full w-full object-cover"
/>
<img src={user?.banner} alt={'banner'} className="h-full w-full object-cover" />
</div>
<div className="-mt-7 w-full px-5">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-14 w-14 rounded-md ring-2 ring-white/50"
/>
@ -109,7 +102,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
Message
</Link>
<span className="mx-2 inline-flex h-4 w-px bg-white/10" />
{account && account.pubkey === pubkey && <EditProfileModal />}
{db.account.pubkey === pubkey && <EditProfileModal />}
</div>
</div>
<div className="flex flex-col gap-8">

View File

@ -15,7 +15,7 @@ button {
}
.markdown {
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-p:mb-2 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 prose-a:leading-tight prose-a:after:content-['_↗'] hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-p:mb-2 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 prose-a:leading-tight prose-a:after:content-['_↗'] 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-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-li:leading-tight prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.ProseMirror p.is-empty::before {

View File

@ -7,7 +7,7 @@ export default class TauriAdapter implements NDKCacheAdapter {
readonly locking: boolean;
constructor() {
this.store = new Store('.ndkcache.dat');
this.store = new Store('.ndk_cache.dat');
this.locking = true;
}
@ -28,12 +28,8 @@ export default class TauriAdapter implements NDKCacheAdapter {
for (const result of results) {
if (result) {
const event = await this.store.get(result as string);
if (event) {
const ndkEvent = new NDKEvent(subscription.ndk, JSON.parse(event as string));
subscription.eventReceived(ndkEvent, undefined, true);
}
const ndkEvent = new NDKEvent(subscription.ndk, JSON.parse(result as string));
subscription.eventReceived(ndkEvent, undefined, true);
}
}
}
@ -44,14 +40,13 @@ export default class TauriAdapter implements NDKCacheAdapter {
const key = `${nostrEvent.pubkey}:${nostrEvent.kind}`;
return new Promise((resolve) => {
Promise.all([
this.store.set(event.id, JSON.stringify(nostrEvent)),
this.store.set(key, event.id),
]).then(() => resolve());
Promise.all([this.store.set(key, JSON.stringify(nostrEvent))]).then(() =>
resolve()
);
});
}
public save() {
return this.store.save();
public async saveCache(): Promise<void> {
return await this.store.save();
}
}

View File

@ -1,18 +1,19 @@
// inspire by: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { fetch } from '@tauri-apps/plugin-http';
import { useEffect, useMemo, useState } from 'react';
import TauriAdapter from '@libs/ndk/cache';
import { getExplicitRelayUrls } from '@libs/storage';
import { useStorage } from '@libs/storage/provider';
import { FULL_RELAYS } from '@stores/constants';
export const NDKInstance = () => {
const { db } = useStorage();
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
const cacheAdapter = useMemo(() => new TauriAdapter(), []);
const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]);
// TODO: fully support NIP-11
async function verifyRelays(relays: string[]) {
@ -30,14 +31,21 @@ export const NDKInstance = () => {
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), 5000);
const res = await fetch(url, {
headers: { Accept: 'application/nostr+json' },
signal: controller.signal,
});
if (res.ok) {
const data = await res.json();
console.log('relay information: ', data);
verifiedRelays.push(relay);
clearTimeout(timeoutId);
} else {
console.log('relay not working: ', res);
}
} catch (e) {
console.log('fetch error', e);
@ -49,7 +57,8 @@ export const NDKInstance = () => {
async function initNDK() {
let explicitRelayUrls: string[];
const explicitRelayUrlsFromDB = await getExplicitRelayUrls();
const explicitRelayUrlsFromDB = await db.getExplicitRelayUrls();
console.log('relays in db: ', explicitRelayUrlsFromDB);
if (explicitRelayUrlsFromDB) {
explicitRelayUrls = await verifyRelays(explicitRelayUrlsFromDB);
@ -57,6 +66,7 @@ export const NDKInstance = () => {
explicitRelayUrls = await verifyRelays(FULL_RELAYS);
}
console.log('ndk cache adapter: ', cacheAdapter);
const instance = new NDK({ explicitRelayUrls, cacheAdapter });
try {
@ -73,7 +83,7 @@ export const NDKInstance = () => {
if (!ndk) initNDK();
return () => {
cacheAdapter.save();
cacheAdapter.saveCache();
};
}, []);

View File

@ -1,452 +0,0 @@
import { fetch } from '@tauri-apps/plugin-http';
import * as cheerio from 'cheerio';
import { OPENGRAPH } from '@stores/constants';
interface ILinkPreviewOptions {
headers?: Record<string, string>;
imagesPropertyType?: string;
proxyUrl?: string;
timeout?: number;
followRedirects?: `follow` | `error` | `manual`;
resolveDNSHost?: (url: string) => Promise<string>;
handleRedirects?: (baseURL: string, forwardedURL: string) => boolean;
}
interface IPreFetchedResource {
headers: Record<string, string>;
status?: number;
imagesPropertyType?: string;
proxyUrl?: string;
url: string;
data: string;
}
function throwOnLoopback(address: string) {
if (OPENGRAPH.REGEX_LOOPBACK.test(address)) {
throw new Error('SSRF request detected, trying to query host');
}
}
function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) {
const nodes = doc(`meta[${attr}='${type}']`);
return nodes.length ? nodes : null;
}
function metaTagContent(doc: cheerio.CheerioAPI, type: string, attr: string) {
return doc(`meta[${attr}='${type}']`).attr(`content`);
}
function getTitle(doc: cheerio.CheerioAPI) {
let title =
metaTagContent(doc, `og:title`, `property`) ||
metaTagContent(doc, `og:title`, `name`);
if (!title) {
title = doc(`title`).text();
}
return title;
}
function getSiteName(doc: cheerio.CheerioAPI) {
const siteName =
metaTagContent(doc, `og:site_name`, `property`) ||
metaTagContent(doc, `og:site_name`, `name`);
return siteName;
}
function getDescription(doc: cheerio.CheerioAPI) {
const description =
metaTagContent(doc, `description`, `name`) ||
metaTagContent(doc, `Description`, `name`) ||
metaTagContent(doc, `og:description`, `property`);
return description;
}
function getMediaType(doc: cheerio.CheerioAPI) {
const node = metaTag(doc, `medium`, `name`);
if (node) {
const content = node.attr(`content`);
return content === `image` ? `photo` : content;
}
return (
metaTagContent(doc, `og:type`, `property`) || metaTagContent(doc, `og:type`, `name`)
);
}
function getImages(
doc: cheerio.CheerioAPI,
rootUrl: string,
imagesPropertyType?: string
) {
let images: string[] = [];
let nodes: cheerio.Cheerio<cheerio.Element> | null;
let src: string | undefined;
let dic: Record<string, boolean> = {};
const imagePropertyType = imagesPropertyType ?? `og`;
nodes =
metaTag(doc, `${imagePropertyType}:image`, `property`) ||
metaTag(doc, `${imagePropertyType}:image`, `name`);
if (nodes) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === `tag`) {
src = node.attribs.content;
if (src) {
src = new URL(src, rootUrl).href;
images.push(src);
}
}
});
}
if (images.length <= 0 && !imagesPropertyType) {
src = doc(`link[rel=image_src]`).attr(`href`);
if (src) {
src = new URL(src, rootUrl).href;
images = [src];
} else {
nodes = doc(`img`);
if (nodes?.length) {
dic = {};
images = [];
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === `tag`) src = node.attribs.src;
if (src && !dic[src]) {
dic[src] = true;
// width = node.attribs.width;
// height = node.attribs.height;
images.push(new URL(src, rootUrl).href);
}
});
}
}
}
return images;
}
function getVideos(doc: cheerio.CheerioAPI) {
const videos = [];
let nodeTypes;
let nodeSecureUrls;
let nodeType;
let nodeSecureUrl;
let video;
let videoType;
let videoSecureUrl;
let width;
let height;
let videoObj;
let index;
const nodes = metaTag(doc, `og:video`, `property`) || metaTag(doc, `og:video`, `name`);
if (nodes?.length) {
nodeTypes =
metaTag(doc, `og:video:type`, `property`) || metaTag(doc, `og:video:type`, `name`);
nodeSecureUrls =
metaTag(doc, `og:video:secure_url`, `property`) ||
metaTag(doc, `og:video:secure_url`, `name`);
width =
metaTagContent(doc, `og:video:width`, `property`) ||
metaTagContent(doc, `og:video:width`, `name`);
height =
metaTagContent(doc, `og:video:height`, `property`) ||
metaTagContent(doc, `og:video:height`, `name`);
for (index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node.type === `tag`) video = node.attribs.content;
nodeType = nodeTypes?.[index];
if (nodeType?.type === `tag`) {
videoType = nodeType ? nodeType.attribs.content : null;
}
nodeSecureUrl = nodeSecureUrls?.[index];
if (nodeSecureUrl?.type === `tag`) {
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
}
videoObj = {
url: video,
secureUrl: videoSecureUrl,
type: videoType,
width,
height,
};
if (videoType && videoType.indexOf(`video/`) === 0) {
videos.splice(0, 0, videoObj);
} else {
videos.push(videoObj);
}
}
}
return videos;
}
// returns default favicon (//hostname/favicon.ico) for a url
function getDefaultFavicon(rootUrl: string) {
return `${new URL(rootUrl).origin}/favicon.ico`;
}
// returns an array of URLs to favicon images
function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
const images = [];
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
let src: string | undefined;
const relSelectors = [`rel=icon`, `rel="shortcut icon"`, `rel=apple-touch-icon`];
relSelectors.forEach((relSelector) => {
// look for all icon tags
nodes = doc(`link[${relSelector}]`);
// collect all images from icon tags
if (nodes.length) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === `tag`) src = node.attribs.href;
if (src) {
src = new URL(src, rootUrl).href;
images.push(src);
}
});
}
});
// if no icon images, use default favicon location
if (images.length <= 0) {
images.push(getDefaultFavicon(rootUrl));
}
return images;
}
function parseImageResponse(url: string, contentType: string) {
return {
url,
mediaType: `image`,
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseAudioResponse(url: string, contentType: string) {
return {
url,
mediaType: `audio`,
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseVideoResponse(url: string, contentType: string) {
return {
url,
mediaType: `video`,
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseApplicationResponse(url: string, contentType: string) {
return {
url,
mediaType: `application`,
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseTextResponse(
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string
) {
const doc = cheerio.load(body);
return {
url,
title: getTitle(doc),
siteName: getSiteName(doc),
description: getDescription(doc),
mediaType: getMediaType(doc) || `website`,
contentType,
images: getImages(doc, url, options.imagesPropertyType),
videos: getVideos(doc),
favicons: getFavicons(doc, url),
};
}
function parseUnknownResponse(
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string
) {
return parseTextResponse(body, url, options, contentType);
}
function parseResponse(response: IPreFetchedResource, options?: ILinkPreviewOptions) {
try {
let contentType = response.headers[`content-type`];
// console.warn(`original content type`, contentType);
if (contentType?.indexOf(`;`)) {
// eslint-disable-next-line prefer-destructuring
contentType = contentType.split(`;`)[0];
// console.warn(`splitting content type`, contentType);
}
if (!contentType) {
return parseUnknownResponse(response.data, response.url, options);
}
if ((contentType as any) instanceof Array) {
// eslint-disable-next-line no-param-reassign, prefer-destructuring
contentType = contentType[0];
}
// parse response depending on content type
if (OPENGRAPH.REGEX_CONTENT_TYPE_IMAGE.test(contentType)) {
return parseImageResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_AUDIO.test(contentType)) {
return parseAudioResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_VIDEO.test(contentType)) {
return parseVideoResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_TEXT.test(contentType)) {
const htmlString = response.data;
return parseTextResponse(htmlString, response.url, options, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_APPLICATION.test(contentType)) {
return parseApplicationResponse(response.url, contentType);
}
const htmlString = response.data;
return parseUnknownResponse(htmlString, response.url, options);
} catch (e) {
throw new Error(
`link-preview-js could not fetch link information ${(e as any).toString()}`
);
}
}
/**
* Parses the text, extracts the first link it finds and does a HTTP request
* to fetch the website content, afterwards it tries to parse the internal HTML
* and extract the information via meta tags
* @param text string, text to be parsed
* @param options ILinkPreviewOptions
*/
export async function getLinkPreview(text: string, options?: ILinkPreviewOptions) {
if (!text || typeof text !== `string`) {
throw new Error(`link-preview-js did not receive a valid url or text`);
}
const detectedUrl = text
.replace(/\n/g, ` `)
.split(` `)
.find((token) => OPENGRAPH.REGEX_VALID_URL.test(token));
if (!detectedUrl) {
throw new Error(`link-preview-js did not receive a valid a url or text`);
}
if (options?.followRedirects === `manual` && !options?.handleRedirects) {
throw new Error(
`link-preview-js followRedirects is set to manual, but no handleRedirects function was provided`
);
}
if (options?.resolveDNSHost) {
const resolvedUrl = await options.resolveDNSHost(detectedUrl);
throwOnLoopback(resolvedUrl);
}
const timeout = options?.timeout ?? 3000; // 3 second timeout default
const controller = new AbortController();
const timeoutCounter = setTimeout(() => controller.abort(), timeout);
const fetchOptions = {
headers: options?.headers ?? {},
redirect: options?.followRedirects ?? `error`,
signal: controller.signal,
};
const fetchUrl = options?.proxyUrl ? options.proxyUrl.concat(detectedUrl) : detectedUrl;
// Seems like fetchOptions type definition is out of date
// https://github.com/node-fetch/node-fetch/issues/741
let response = await fetch(fetchUrl, fetchOptions as any).catch((e) => {
if (e.name === `AbortError`) {
throw new Error(`Request timeout`);
}
clearTimeout(timeoutCounter);
throw e;
});
if (
response.status > 300 &&
response.status < 309 &&
fetchOptions.redirect === `manual` &&
options?.handleRedirects
) {
const forwardedUrl = response.headers.get(`location`) || ``;
if (!options.handleRedirects(fetchUrl, forwardedUrl)) {
throw new Error(`link-preview-js could not handle redirect`);
}
if (options?.resolveDNSHost) {
const resolvedUrl = await options.resolveDNSHost(forwardedUrl);
throwOnLoopback(resolvedUrl);
}
response = await fetch(forwardedUrl, fetchOptions as any);
}
clearTimeout(timeoutCounter);
const headers: Record<string, string> = {};
response.headers.forEach((header, key) => {
headers[key] = header;
});
const normalizedResponse: IPreFetchedResource = {
url: options?.proxyUrl ? response.url.replace(options.proxyUrl, ``) : response.url,
headers,
data: await response.text(),
};
return parseResponse(normalizedResponse, options);
}
/**
* Skip the library fetching the website for you, instead pass a response object
* from whatever source you get and use the internal parsing of the HTML to return
* the necessary information
* @param response Preview Response
* @param options IPreviewLinkOptions
*/
export async function getPreviewFromContent(
response: IPreFetchedResource,
options?: ILinkPreviewOptions
) {
if (!response || typeof response !== `object`) {
throw new Error(`link-preview-js did not receive a valid response object`);
}
if (!response.url) {
throw new Error(`link-preview-js did not receive a valid response object`);
}
return parseResponse(response, options);
}

View File

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

View File

@ -0,0 +1,265 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { BaseDirectory, removeFile } from '@tauri-apps/plugin-fs';
import Database from '@tauri-apps/plugin-sql';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { Account, DBEvent, Relays, Widget } from '@utils/types';
export class LumeStorage {
public db: Database;
public secureDB: Stronghold;
public account: Account | null = null;
constructor(sqlite: Database, stronghold?: Stronghold) {
this.db = sqlite;
this.secureDB = stronghold ?? undefined;
this.account = null;
}
private async getSecureClient() {
try {
return await this.secureDB.loadClient('lume');
} catch {
return await this.secureDB.createClient('lume');
}
}
public async secureSave(key: string, value: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const store = client.getStore();
await store.insert(key, Array.from(new TextEncoder().encode(value)));
return await this.secureDB.save();
}
public async secureLoad(key: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const store = client.getStore();
const value = await store.get(key);
const decoded = new TextDecoder().decode(new Uint8Array(value));
return decoded;
}
public async secureReset() {
return await removeFile('lume.stronghold', { dir: BaseDirectory.AppConfig });
}
public async getActiveAccount() {
const results: Array<Account> = await this.db.select(
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
if (results.length > 0) {
const account = results[0];
if (typeof account.follows === 'string')
account.follows = JSON.parse(account.follows);
if (typeof account.network === 'string')
account.network = JSON.parse(account.network);
if (typeof account.last_login_at === 'string')
account.last_login_at = parseInt(account.last_login_at);
this.account = account;
return account;
} else {
console.log('no active account, please create new account');
return null;
}
}
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]
);
if (res) {
const account = await this.getActiveAccount();
return account;
} else {
console.error('create account failed');
}
}
public async updateAccount(column: string, value: string | string[]) {
const insert = await this.db.execute(
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
[value, this.account.id]
);
if (insert) {
const account = await this.getActiveAccount();
return account;
}
}
public async updateLastLogin() {
const now = Math.floor(Date.now() / 1000);
return await this.db.execute(
'UPDATE accounts SET last_login_at = $1 WHERE id = $2;',
[now, this.account.id]
);
}
public async getWidgets() {
const widgets: Array<Widget> = await this.db.select(
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',
[this.account.id]
);
return widgets;
}
public async createWidget(kind: number, title: string, content: string | string[]) {
const insert = await this.db.execute(
'INSERT INTO widgets (account_id, kind, title, content) VALUES ($1, $2, $3, $4);',
[this.account.id, kind, title, content]
);
if (insert) {
const widgets: Array<Widget> = await this.db.select(
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
);
if (widgets.length < 1) console.error('get created widget failed');
return widgets[0];
} else {
console.error('create widget failed');
}
}
public async removeWidget(id: string) {
return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
}
public async createEvent(
id: string,
event: string,
author: string,
kind: number,
root_id: string,
reply_id: string,
created_at: number
) {
return await this.db.execute(
'INSERT OR IGNORE INTO events (id, account_id, event, author, kind, root_id, reply_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);',
[id, this.account.id, event, author, kind, root_id, reply_id, created_at]
);
}
public async getEventByID(id: string) {
const results: DBEvent[] = await this.db.select(
'SELECT * FROM events WHERE id = $1 LIMIT 1;',
[id]
);
if (results.length < 1) return null;
return JSON.parse(results[0].event as string) as NDKEvent;
}
public async countTotalEvents() {
const result: Array<{ total: string }> = await this.db.select(
'SELECT COUNT(*) AS "total" FROM events;'
);
return parseInt(result[0].total);
}
public async getAllEvents(limit: number, offset: number) {
const totalEvents = await this.countTotalEvents();
const nextCursor = offset + limit;
const events: { data: DBEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: DBEvent[] = await this.db.select(
'SELECT * FROM events GROUP BY root_id ORDER BY created_at DESC LIMIT $1 OFFSET $2;',
[limit, offset]
);
if (query && query.length > 0) {
events['data'] = query;
events['nextCursor'] =
Math.round(totalEvents / nextCursor) > 1 ? nextCursor : undefined;
return events;
}
return {
data: [],
nextCursor: 0,
};
}
public async getAllEventsByAuthors(authors: string[], limit: number, offset: number) {
const totalEvents = await this.countTotalEvents();
const nextCursor = offset + limit;
const authorsArr = `'${authors.join("','")}'`;
const events: { data: DBEvent[] | null; nextCursor: number } = {
data: null,
nextCursor: 0,
};
const query: DBEvent[] = await this.db.select(
'SELECT * FROM events WHERE author IN ($1) ORDER BY created_at DESC LIMIT $2 OFFSET $3;',
[authorsArr, limit, offset]
);
if (query && query.length > 0) {
events['data'] = query;
events['nextCursor'] =
Math.round(totalEvents / nextCursor) > 1 ? nextCursor : undefined;
return events;
}
return {
data: [],
nextCursor: 0,
};
}
public async isEventsEmpty() {
const results: DBEvent[] = await this.db.select(
'SELECT * FROM events ORDER BY id DESC LIMIT 1;'
);
return results.length < 1;
}
public async getExplicitRelayUrls() {
if (!this.account) return null;
const result: Relays[] = await this.db.select(
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
);
if (result.length < 1) return null;
return result.map((el) => el.relay);
}
public async createRelay(relay: string, purpose?: string) {
return await this.db.execute(
'INSERT OR IGNORE INTO relays (account_id, relay, purpose) VALUES ($1, $2, $3);',
[this.account.id, relay, purpose || '']
);
}
public async removeRelay(relay: string) {
return await this.db.execute(`DELETE FROM relays WHERE relay = "${relay}";`);
}
public async removePrivkey() {
return await this.db.execute(
`UPDATE accounts SET privkey = "privkey is stored in secure storage" WHERE id = "${this.account.id}";`
);
}
public async close() {
return this.db.close();
}
}

View File

@ -0,0 +1,42 @@
import Database from '@tauri-apps/plugin-sql';
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
import { LumeStorage } from '@libs/storage/instance';
interface StorageContext {
db: LumeStorage;
}
const StorageContext = createContext<StorageContext>({
db: undefined,
});
const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const [db, setDB] = useState<LumeStorage>(undefined);
async function initLumeStorage() {
const sqlite = await Database.load('sqlite:lume.db');
const lumeStorage = new LumeStorage(sqlite);
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
setDB(lumeStorage);
}
useEffect(() => {
if (!db) initLumeStorage();
}, []);
if (db) {
return <StorageContext.Provider value={{ db }}>{children}</StorageContext.Provider>;
}
};
const useStorage = () => {
const context = useContext(StorageContext);
if (context === undefined) {
throw new Error('Storage not found');
}
return context;
};
export { StorageProvider, useStorage };

View File

@ -2,27 +2,21 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client';
import { NDKProvider } from '@libs/ndk/provider';
import { getSetting } from '@libs/storage';
import { StorageProvider } from '@libs/storage/provider';
import App from './app';
const cacheTime = await getSetting('cache_time');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: parseInt(cacheTime),
},
},
});
const queryClient = new QueryClient();
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<NDKProvider>
<App />
</NDKProvider>
<StorageProvider>
<NDKProvider>
<App />
</NDKProvider>
</StorageProvider>
</QueryClientProvider>
);

View File

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

View File

@ -1,7 +1,5 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function InactiveAccount({ data }: { data: any }) {
@ -9,12 +7,7 @@ export function InactiveAccount({ data }: { data: any }) {
return (
<div className="relative h-9 w-9 shrink-0">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.npub}
className="h-9 w-9 rounded object-cover"
/>
<Image src={user?.image} alt={data.npub} className="h-9 w-9 rounded object-cover" />
</div>
);
}

View File

@ -87,13 +87,13 @@ export function Composer() {
if (reply.id && reply.pubkey) {
if (reply.root && reply.root.length > 1) {
tags = [
['e', reply.root, 'wss://relayable.org', 'root'],
['e', reply.id, 'wss://relayable.org', 'reply'],
['e', reply.root, '', 'root'],
['e', reply.id, '', 'reply'],
['p', reply.pubkey],
];
} else {
tags = [
['e', reply.id, 'wss://relayable.org', 'reply'],
['e', reply.id, '', 'reply'],
['p', reply.pubkey],
];
}

View File

@ -1,7 +1,5 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { displayNpub } from '@utils/shortenKey';
import { Profile } from '@utils/types';
@ -11,7 +9,6 @@ export function MentionItem({ profile }: { profile: Profile }) {
<div className="h-8 w-8 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={profile.picture || profile.image}
fallback={DEFAULT_AVATAR}
alt={profile.pubkey}
className="h-8 w-8 object-cover"
/>

View File

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

View File

@ -1,5 +1,6 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useHotkeys } from 'react-hotkeys-hook';
import { useStorage } from '@libs/storage/provider';
import { Composer, ComposerUser } from '@shared/composer';
import {
@ -10,22 +11,17 @@ import {
} from '@shared/icons';
import { useComposer } from '@stores/composer';
import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
import { useAccount } from '@utils/hooks/useAccount';
export function ComposerModal() {
const { account } = useAccount();
const { db } = useStorage();
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
useHotkeys(COMPOSE_SHORTCUT, () => toggle(true));
return (
<Dialog.Root open={open} onOpenChange={toggle}>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center gap-1 rounded-md bg-white/10 px-4 text-sm font-medium text-white hover:bg-fuchsia-500 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
className="inline-flex h-9 w-min items-center justify-center gap-1 rounded-md bg-white/10 px-8 text-sm font-medium text-white hover:bg-fuchsia-500 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
<ComposeIcon className="h-4 w-4" />
Postr
@ -37,7 +33,7 @@ export function ComposerModal() {
<div className="relative h-min w-full max-w-2xl rounded-xl bg-white/10">
<div className="flex items-center justify-between px-4 py-4">
<div className="flex items-center gap-2">
{account && <ComposerUser pubkey={account.pubkey} />}
<ComposerUser pubkey={db.account.pubkey} />
<span>
<ChevronRightIcon className="h-4 w-4 text-white/50" />
</span>

View File

@ -1,7 +1,5 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function ComposerUser({ pubkey }: { pubkey: string }) {
@ -11,7 +9,6 @@ export function ComposerUser({ pubkey }: { pubkey: string }) {
<div className="flex items-center gap-3">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-8 w-8 shrink-0 rounded-md object-cover"
/>

View File

@ -1,18 +1,16 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQueryClient } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { Fragment, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { CancelIcon, CheckCircleIcon, LoaderIcon, UnverifiedIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useAccount } from '@utils/hooks/useAccount';
import { useNostr } from '@utils/hooks/useNostr';
export function EditProfileModal() {
@ -20,12 +18,12 @@ export function EditProfileModal() {
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState(DEFAULT_AVATAR);
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: false, text: '' });
const { db } = useStorage();
const { publish } = useNostr();
const { account } = useAccount();
const {
register,
handleSubmit,
@ -34,7 +32,7 @@ export function EditProfileModal() {
formState: { isValid, errors },
} = useForm({
defaultValues: async () => {
const res: any = queryClient.getQueryData(['user', account.pubkey]);
const res: any = queryClient.getQueryData(['user', db.account.pubkey]);
if (res.image) {
setPicture(res.image);
}
@ -71,7 +69,7 @@ export function EditProfileModal() {
});
if (!res.ok) return false;
if (res.data.names[username] === account.pubkey) {
if (res.data.names[username] === db.account.pubkey) {
setNIP05((prev) => ({ ...prev, verified: true }));
return true;
} else {
@ -120,13 +118,13 @@ export function EditProfileModal() {
if (event.id) {
setTimeout(() => {
// invalid cache
queryClient.invalidateQueries(['user', account.pubkey]);
queryClient.invalidateQueries(['user', db.account.pubkey]);
// reset form
reset();
// reset state
setLoading(false);
setIsOpen(false);
setPicture(DEFAULT_AVATAR);
setPicture('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
setBanner(null);
}, 1200);
} else {
@ -206,9 +204,8 @@ export function EditProfileModal() {
/>
<div className="relative">
<div className="relative h-44 w-full bg-zinc-800">
<Image
<img
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
/>
@ -220,7 +217,6 @@ export function EditProfileModal() {
<div className="relative z-10 -mt-7 h-14 w-14">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
/>

View File

@ -1,17 +1,24 @@
import { ImgHTMLAttributes } from 'react';
import { minidenticon } from 'minidenticons';
import { ImgHTMLAttributes, useState } from 'react';
interface Props extends ImgHTMLAttributes<any> {
fallback: string;
}
export function Image({ src, ...props }: ImgHTMLAttributes<HTMLImageElement>) {
const [isError, setIsError] = useState(false);
if (isError || !src) {
const svgURI =
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(props.alt, 90, 50));
return (
<img src={svgURI} alt={props.alt} {...props} style={{ backgroundColor: '#000' }} />
);
}
export function Image({ src, fallback, ...props }: Props) {
return (
<img
{...props}
src={src || fallback}
src={src}
onError={({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src = fallback;
setIsError(true);
}}
decoding="async"
alt="lume default img"

View File

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

View File

@ -1,29 +1,20 @@
import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ActiveAccount } from '@shared/accounts/active';
import { SettingsIcon } from '@shared/icons';
import { Logout } from '@shared/logout';
import { NotificationModal } from '@shared/notification/modal';
import { useAccount } from '@utils/hooks/useAccount';
export function LumeBar() {
const { status, account } = useAccount();
const { db } = useStorage();
return (
<div className="rounded-xl bg-white/10 p-2 backdrop-blur-xl">
<div className="flex items-center justify-between">
{status === 'loading' ? (
<>
<div className="group relative flex h-9 w-9 shrink animate-pulse items-center justify-center rounded-md bg-zinc-900" />
<div className="group relative flex h-9 w-9 shrink animate-pulse items-center justify-center rounded-md bg-zinc-900" />
</>
) : (
<>
<ActiveAccount data={account} />
<NotificationModal pubkey={account.pubkey} />
</>
)}
<ActiveAccount data={db.account} />
<NotificationModal pubkey={db.account.pubkey} />
<Link
to="/settings/general"
className="inline-flex h-9 w-9 transform items-center justify-center rounded-md bg-white/20 active:translate-y-1"

View File

@ -3,15 +3,13 @@ import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { ChatsList } from '@app/chats/components/list';
// import { ChatsList } from '@app/chats/components/list';
import { ComposerModal } from '@shared/composer/modal';
import {
ArrowLeftIcon,
ArrowRightIcon,
NavArrowDownIcon,
SpaceIcon,
TrendingIcon,
} from '@shared/icons';
import { LumeBar } from '@shared/lumeBar';
@ -19,7 +17,7 @@ export function Navigation() {
const navigate = useNavigate();
const [feeds, setFeeds] = useState(true);
const [chats, setChats] = useState(true);
// const [chats, setChats] = useState(true);
return (
<div className="relative h-full w-[232px] bg-black/80">
@ -54,10 +52,15 @@ export function Navigation() {
open ? '' : 'rotate-180'
)}
>
<NavArrowDownIcon className="h-3 w-3 text-white/50" />
<NavArrowDownIcon
className={twMerge(
'h-3 w-3 text-white/50',
feeds ? '' : 'rotate-180'
)}
/>
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-white/50">
Feeds
Personal
</h3>
</button>
</Collapsible.Trigger>
@ -76,76 +79,12 @@ export function Navigation() {
<span className="inline-flex h-6 w-6 items-center justify-center rounded bg-white/10">
<SpaceIcon className="h-3 w-3 text-white" />
</span>
<span className="font-medium">Spaces</span>
</NavLink>
<NavLink
to="/trending"
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex h-9 items-center gap-2.5 rounded-md px-2 ',
isActive ? 'bg-white/10 text-white' : 'text-white/80'
)
}
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded bg-white/10">
<TrendingIcon className="h-3 w-3 text-white" />
</span>
<span className="font-medium">Trending</span>
<span className="font-medium">Space</span>
</NavLink>
</div>
</Collapsible.Content>
</div>
</Collapsible.Root>
<Collapsible.Root open={chats} onOpenChange={setChats}>
<div className="flex flex-col gap-1 px-2">
<Collapsible.Trigger asChild>
<button className="flex items-center gap-1">
<div
className={twMerge(
'inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out',
open ? '' : 'rotate-180'
)}
>
<NavArrowDownIcon className="h-3 w-3 text-white/50" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-white/50">
Chats
</h3>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<ChatsList />
</Collapsible.Content>
</div>
</Collapsible.Root>
{/* Channels
<Disclosure defaultOpen={true}>
{({ open }) => (
<div className="flex flex-col gap-0.5 px-1.5">
<Disclosure.Button className="flex items-center gap-1 px-3">
<div
className={`inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? "" : "rotate-180"
}`}
>
<NavArrowDownIcon
width={12}
height={12}
className="text-zinc-700"
/>
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">
Channels
</h3>
</Disclosure.Button>
<Disclosure.Panel>
<ChannelsList />
</Disclosure.Panel>
</div>
)}
</Disclosure>
*/}
</div>
<div className="absolute bottom-3 left-0 w-full px-10">
<LumeBar />

View File

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

View File

@ -45,7 +45,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
</Tooltip.Portal>
</Tooltip.Root>
<Popover.Portal>
<Popover.Content className="w-[200px] overflow-hidden rounded-md bg-white/10 backdrop-blur-xl focus:outline-none">
<Popover.Content className="w-[200px] overflow-hidden rounded-md bg-white/10 backdrop-blur-3xl focus:outline-none">
<div className="flex flex-col p-2">
<Link
to={`/events/${id}`}

View File

@ -10,9 +10,9 @@ import {
VideoPreview,
} from '@shared/notes';
import { Content } from '@utils/types';
import { RichContent } from '@utils/types';
export function NoteContent({ content, long }: { content: Content; long?: boolean }) {
export function NoteContent({ content, long }: { content: RichContent; long?: boolean }) {
if (long) {
return (
<ReactMarkdown className="markdown" remarkPlugins={[remarkGfm]}>
@ -29,8 +29,9 @@ export function NoteContent({ content, long }: { content: Content; long?: boolea
components={{
del: ({ children }) => {
const key = children[0] as string;
if (key.startsWith('pub')) return <MentionUser pubkey={key.slice(3)} />;
if (key.startsWith('tag')) return <Hashtag tag={key.slice(3)} />;
if (key.startsWith('pub') && key.length > 50 && key.length < 100)
return <MentionUser pubkey={key.replace('pub-', '')} />;
if (key.startsWith('tag')) return <Hashtag tag={key.replace('tag-', '')} />;
},
}}
>

View File

@ -23,5 +23,5 @@ export * from './kinds/sub';
export * from './skeleton';
export * from './actions';
export * from './content';
export * from './hashtag';
export * from './mentions/hashtag';
export * from './stats';

Some files were not shown because too many files have changed in this diff Show More