Merge pull request #23 from reyamir/feat/chats

Implemented e2e encrypted direct message and new data model
This commit is contained in:
Ren Amamiya
2023-04-07 16:11:28 +07:00
committed by GitHub
73 changed files with 4065 additions and 1529 deletions

2
.gitignore vendored
View File

@@ -15,6 +15,8 @@ out
.next .next
.vscode .vscode
pnpm-lock.yaml pnpm-lock.yaml
*.db
*.db-journal
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -93,6 +93,12 @@ Install dependencies
pnpm install pnpm install
``` ```
Generate prisma database
```
pnpm init-db
```
Run development window Run development window
``` ```

View File

@@ -6,6 +6,7 @@
"dev": "next dev -p 1420", "dev": "next dev -p 1420",
"build": "next build && next export -o dist", "build": "next build && next export -o dist",
"tauri": "tauri", "tauri": "tauri",
"init-db": "cd src-tauri/ && cargo prisma generate",
"prepare": "husky install" "prepare": "husky install"
}, },
"lint-staged": { "lint-staged": {
@@ -20,24 +21,22 @@
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.0.5", "@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3",
"@supabase/supabase-js": "^2.13.0", "@supabase/supabase-js": "^2.15.0",
"@tauri-apps/api": "^1.2.0", "@tauri-apps/api": "^1.2.0",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"destr": "^1.2.2", "destr": "^1.2.2",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"framer-motion": "^9.1.7", "framer-motion": "^9.1.7",
"jotai": "^2.0.3", "jotai": "^2.0.3",
"jotai-cache": "^0.3.0", "next": "^13.3.0",
"next": "^13.2.4",
"nostr-relaypool": "^0.5.18", "nostr-relaypool": "^0.5.18",
"nostr-tools": "^1.8.1", "nostr-tools": "^1.8.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-player": "^2.12.0", "react-player": "^2.12.0",
"react-string-replace": "^1.1.0", "react-string-replace": "^1.1.0",
"react-virtuoso": "^4.1.1", "react-virtuoso": "^4.2.0",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"ws": "^8.13.0" "ws": "^8.13.0"
}, },
@@ -46,14 +45,14 @@
"@tauri-apps/cli": "^1.2.3", "@tauri-apps/cli": "^1.2.3",
"@trivago/prettier-plugin-sort-imports": "^4.1.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.15.11", "@types/node": "^18.15.11",
"@types/react": "^18.0.31", "@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@typescript-eslint/eslint-plugin": "^5.57.0", "@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.0", "@typescript-eslint/parser": "^5.57.1",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"csstype": "^3.1.1", "csstype": "^3.1.2",
"eslint": "^8.37.0", "eslint": "^8.37.0",
"eslint-config-next": "^13.2.4", "eslint-config-next": "^13.3.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
@@ -61,7 +60,7 @@
"lint-staged": "^13.2.0", "lint-staged": "^13.2.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^2.8.7", "prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.6", "prettier-plugin-tailwindcss": "^0.2.7",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.1",
"typescript": "^4.9.5" "typescript": "^4.9.5"

473
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
[alias]
prisma = "run --bin prisma --"

View File

@@ -2,3 +2,5 @@
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
# prisma
src/db.rs

2407
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,11 @@ tauri-build = { version = "1.2", features = [] }
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["clipboard-read-text", "clipboard-write-text", "http-request", "os-all", "shell-open", "system-tray", "window-close", "window-start-dragging"] } tauri = { version = "1.2", features = ["clipboard-read-text", "clipboard-write-text", "http-request", "os-all", "shell-open", "system-tray", "window-close", "window-start-dragging"] }
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.7", default-features = false, features = ["sqlite", "migrations", "mocking", "specta"] }
[dependencies.tauri-plugin-sql] prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.7", default-features = false, features = ["sqlite", "migrations", "mocking", "specta"] }
git = "https://github.com/tauri-apps/plugins-workspace" specta = "1.0.0"
branch = "dev" tauri-specta = { version = "1.0.0", features = ["typescript"] }
features = ["sqlite"] tokio = { version = "1.26.0", features = ["macros"] }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2.7" objc = "0.2.7"

View File

@@ -1,104 +0,0 @@
-- Add migration script here
-- create relays
CREATE TABLE
relays (
id INTEGER PRIMARY KEY,
relay_url TEXT NOT NULL,
relay_status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- add default relays
-- relay status:
-- 0: off
-- 1: on
INSERT INTO
relays (relay_url, relay_status)
VALUES
("wss://relay.damus.io", "1"),
("wss://eden.nostr.land", "0"),
("wss://nostr-pub.wellorder.net", "1"),
("wss://nostr.bongbong.com", "1"),
("wss://nostr.zebedee.cloud", "1"),
("wss://nostr.fmt.wiz.biz", "1"),
("wss://nostr.walletofsatoshi.com", "0"),
("wss://relay.snort.social", "1"),
("wss://offchain.pub", "1"),
("wss://brb.io", "0"),
("wss://relay.current.fyi", "1"),
("wss://nostr.relayer.se", "0"),
("wss://nostr.bitcoiner.social", "1"),
("wss://relay.nostr.info", "1"),
("wss://relay.zeh.app", "0"),
("wss://nostr-01.dorafactory.org", "1"),
("wss://nostr.zhongwen.world", "1"),
("wss://nostro.cc", "1"),
("wss://relay.nostr.net.in", "1"),
("wss://nos.lol", "1");
-- create accounts
-- is_active (part of multi-account feature):
-- 0: false
-- 1: true
CREATE TABLE
accounts (
id TEXT PRIMARY KEY,
privkey TEXT NOT NULL,
npub TEXT NOT NULL,
nsec TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 0,
metadata TEXT
);
-- create follows
-- kind (part of multi-newsfeed feature):
-- 0: direct
-- 1: follow of follow
CREATE TABLE
follows (
id INTEGER PRIMARY KEY,
pubkey TEXT NOT NULL,
account TEXT NOT NULL,
kind INTEGER NOT NULL DEFAULT 0,
metadata TEXT
);
-- create index for pubkey in follows
CREATE UNIQUE INDEX index_pubkey_on_follows ON follows (pubkey);
-- create cache profiles
CREATE TABLE
cache_profiles (
id TEXT PRIMARY KEY,
metadata TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- create cache notes
CREATE TABLE
cache_notes (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
created_at TEXT,
kind INTEGER NOT NULL DEFAULT 1,
tags TEXT NOT NULL,
content TEXT NOT NULL,
parent_id TEXT,
parent_comment_id TEXT
);
-- create settings
CREATE TABLE
settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
setting_key TEXT NOT NULL,
setting_value TEXT NOT NULL
);
-- add default setting
INSERT INTO
settings (setting_key, setting_value)
VALUES
("last_login", "0");

View File

View File

@@ -0,0 +1,73 @@
-- CreateTable
CREATE TABLE "Account" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pubkey" TEXT NOT NULL,
"privkey" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT false,
"metadata" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Follow" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pubkey" TEXT NOT NULL,
"kind" INTEGER NOT NULL,
"metadata" TEXT NOT NULL,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Follow_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Note" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"eventId" TEXT NOT NULL,
"pubkey" TEXT NOT NULL,
"kind" INTEGER NOT NULL,
"tags" TEXT NOT NULL,
"content" TEXT NOT NULL,
"parent_id" TEXT NOT NULL,
"parent_comment_id" TEXT NOT NULL,
"createdAt" INTEGER NOT NULL,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Note_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Message" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pubkey" TEXT NOT NULL,
"content" TEXT NOT NULL,
"tags" TEXT NOT NULL,
"createdAt" INTEGER NOT NULL,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Message_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Relay" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"url" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true
);
-- CreateTable
CREATE TABLE "Setting" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_privkey_key" ON "Account"("privkey");
-- CreateIndex
CREATE INDEX "Account_pubkey_idx" ON "Account"("pubkey");
-- CreateIndex
CREATE UNIQUE INDEX "Note_eventId_key" ON "Note"("eventId");
-- CreateIndex
CREATE INDEX "Note_eventId_idx" ON "Note"("eventId");
-- CreateIndex
CREATE INDEX "Message_pubkey_idx" ON "Message"("pubkey");

View File

@@ -0,0 +1,11 @@
-- DropIndex
DROP INDEX "Message_pubkey_idx";
-- DropIndex
DROP INDEX "Note_eventId_idx";
-- CreateIndex
CREATE INDEX "Message_pubkey_createdAt_idx" ON "Message"("pubkey", "createdAt");
-- CreateIndex
CREATE INDEX "Note_eventId_createdAt_idx" ON "Note"("eventId", "createdAt");

View File

@@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the `Follow` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[pubkey]` on the table `Account` will be added. If there are existing duplicate values, this will fail.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "Follow";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "Pleb" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pubkey" TEXT NOT NULL,
"kind" INTEGER NOT NULL,
"metadata" TEXT NOT NULL,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Pleb_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Pleb_pubkey_key" ON "Pleb"("pubkey");
-- CreateIndex
CREATE INDEX "Pleb_pubkey_idx" ON "Pleb"("pubkey");
-- CreateIndex
CREATE UNIQUE INDEX "Account_pubkey_key" ON "Account"("pubkey");

View File

@@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "Pleb_pubkey_idx";
-- DropIndex
DROP INDEX "Pleb_pubkey_key";

View File

@@ -0,0 +1,23 @@
/*
Warnings:
- Added the required column `plebId` to the `Pleb` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Pleb" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"plebId" TEXT NOT NULL,
"pubkey" TEXT NOT NULL,
"kind" INTEGER NOT NULL,
"metadata" TEXT NOT NULL,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Pleb_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Pleb" ("accountId", "id", "kind", "metadata", "pubkey") SELECT "accountId", "id", "kind", "metadata", "pubkey" FROM "Pleb";
DROP TABLE "Pleb";
ALTER TABLE "new_Pleb" RENAME TO "Pleb";
CREATE UNIQUE INDEX "Pleb_plebId_key" ON "Pleb"("plebId");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@@ -0,0 +1,79 @@
datasource db {
provider = "sqlite"
url = "file:../../lume.db"
}
generator client {
provider = "cargo prisma"
// The location to generate the client. Is relative to the position of the schema
output = "../src/db.rs"
module_path = "db"
}
model Account {
id Int @id @default(autoincrement())
pubkey String @unique
privkey String @unique
active Boolean @default(false)
metadata String
// related
plebs Pleb[]
messages Message[]
notes Note[]
@@index([pubkey])
}
model Pleb {
id Int @id @default(autoincrement())
plebId String @unique
pubkey String
kind Int
metadata String
Account Account @relation(fields: [accountId], references: [id])
accountId Int
}
model Note {
id Int @id @default(autoincrement())
eventId String @unique
pubkey String
kind Int
tags String
content String
parent_id String
parent_comment_id String
createdAt Int
Account Account @relation(fields: [accountId], references: [id])
accountId Int
@@index([eventId, createdAt])
}
model Message {
id Int @id @default(autoincrement())
pubkey String
content String
tags String
createdAt Int
Account Account @relation(fields: [accountId], references: [id])
accountId Int
@@index([pubkey, createdAt])
}
model Relay {
id Int @id @default(autoincrement())
url String
active Boolean @default(true)
}
model Setting {
id Int @id @default(autoincrement())
key String
value String
}

View File

@@ -0,0 +1,3 @@
fn main() {
prisma_client_rust_cli::run();
}

View File

@@ -7,15 +7,238 @@
#[macro_use] #[macro_use]
extern crate objc; extern crate objc;
use prisma_client_rust::Direction;
use tauri::{Manager, WindowEvent}; use tauri::{Manager, WindowEvent};
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use window_ext::WindowExt; use window_ext::WindowExt;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod window_ext; mod window_ext;
fn main() { mod db;
use db::*;
use serde::Deserialize;
use specta::{collect_types, Type};
use std::{sync::Arc, vec};
use tauri::State;
use tauri_specta::ts;
type DbState<'a> = State<'a, Arc<PrismaClient>>;
#[derive(Deserialize, Type)]
struct CreateAccountData {
pubkey: String,
privkey: String,
metadata: String,
}
#[derive(Deserialize, Type)]
struct GetPlebData {
account_id: i32,
}
#[derive(Deserialize, Type)]
struct GetPlebPubkeyData {
pubkey: String,
}
#[derive(Deserialize, Type)]
struct CreatePlebData {
pleb_id: String,
pubkey: String,
kind: i32,
metadata: String,
account_id: i32,
}
#[derive(Deserialize, Type)]
struct CreateNoteData {
event_id: String,
pubkey: String,
kind: i32,
tags: String,
content: String,
parent_id: String,
parent_comment_id: String,
created_at: i32,
account_id: i32,
}
#[derive(Deserialize, Type)]
struct GetNoteByIdData {
event_id: String,
}
#[derive(Deserialize, Type)]
struct GetNoteData {
date: i32,
limit: i32,
offset: i32,
}
#[derive(Deserialize, Type)]
struct GetLatestNoteData {
date: i32,
}
#[tauri::command]
#[specta::specta]
async fn get_accounts(db: DbState<'_>) -> Result<Vec<account::Data>, ()> {
db.account()
.find_many(vec![account::active::equals(false)])
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn create_account(db: DbState<'_>, data: CreateAccountData) -> Result<account::Data, ()> {
db.account()
.create(data.pubkey, data.privkey, data.metadata, vec![])
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_plebs(db: DbState<'_>, data: GetPlebData) -> Result<Vec<pleb::Data>, ()> {
db.pleb()
.find_many(vec![pleb::account_id::equals(data.account_id)])
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_pleb_by_pubkey(
db: DbState<'_>,
data: GetPlebPubkeyData,
) -> Result<Option<pleb::Data>, ()> {
db.pleb()
.find_first(vec![pleb::pubkey::equals(data.pubkey)])
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn create_pleb(db: DbState<'_>, data: CreatePlebData) -> Result<pleb::Data, ()> {
let pleb_id = data.pleb_id.clone();
let metadata = data.metadata.clone();
db.pleb()
.upsert(
pleb::pleb_id::equals(pleb_id),
pleb::create(
data.pleb_id,
data.pubkey,
data.kind,
data.metadata,
account::id::equals(data.account_id),
vec![],
),
vec![pleb::metadata::set(metadata)],
)
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn create_note(db: DbState<'_>, data: CreateNoteData) -> Result<note::Data, ()> {
let event_id = data.event_id.clone();
let content = data.content.clone();
db.note()
.upsert(
note::event_id::equals(event_id),
note::create(
data.event_id,
data.pubkey,
data.kind,
data.tags,
data.content,
data.parent_id,
data.parent_comment_id,
data.created_at,
account::id::equals(data.account_id),
vec![],
),
vec![note::content::set(content)],
)
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_notes(db: DbState<'_>, data: GetNoteData) -> Result<Vec<note::Data>, ()> {
db.note()
.find_many(vec![note::created_at::lte(data.date)])
.order_by(note::created_at::order(Direction::Desc))
.take(data.limit.into())
.skip(data.offset.into())
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_latest_notes(db: DbState<'_>, data: GetLatestNoteData) -> Result<Vec<note::Data>, ()> {
db.note()
.find_many(vec![note::created_at::gt(data.date)])
.order_by(note::created_at::order(Direction::Desc))
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_note_by_id(db: DbState<'_>, data: GetNoteByIdData) -> Result<Option<note::Data>, ()> {
db.note()
.find_unique(note::event_id::equals(data.event_id))
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
async fn count_total_notes(db: DbState<'_>) -> Result<i64, ()> {
db.note().count(vec![]).exec().await.map_err(|_| ())
}
#[tokio::main]
async fn main() {
let db = PrismaClient::_builder().build().await.unwrap();
#[cfg(debug_assertions)]
ts::export(
collect_types![
get_accounts,
create_account,
get_plebs,
get_pleb_by_pubkey,
create_pleb,
create_note,
get_notes,
get_latest_notes,
get_note_by_id
],
"../src/utils/bindings.ts",
)
.unwrap();
#[cfg(debug_assertions)]
db._db_push().await.unwrap();
tauri::Builder::default() tauri::Builder::default()
.setup(|app| { .setup(|app| {
let main_window = app.get_window("main").unwrap(); let main_window = app.get_window("main").unwrap();
@@ -25,23 +248,11 @@ fn main() {
Ok(()) Ok(())
}) })
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
"sqlite:lume.db",
vec![Migration {
version: 1,
description: "create default tables",
sql: include_str!("../migrations/20230226004139_create_tables.sql"),
kind: MigrationKind::Up,
}],
)
.build(),
)
.on_window_event(|e| { .on_window_event(|e| {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let apply_offset = || { let apply_offset = || {
let win = e.window(); let win = e.window();
// keep inset for traffic lights when window resize (macos)
win.position_traffic_lights(8.0, 20.0); win.position_traffic_lights(8.0, 20.0);
}; };
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -51,6 +262,19 @@ fn main() {
_ => {} _ => {}
} }
}) })
.invoke_handler(tauri::generate_handler![
get_accounts,
create_account,
get_plebs,
get_pleb_by_pubkey,
create_pleb,
create_note,
get_notes,
get_latest_notes,
get_note_by_id,
count_total_notes
])
.manage(Arc::new(db))
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -0,0 +1,77 @@
import { ChatListItem } from '@components/chats/chatListItem';
import { ChatModal } from '@components/chats/chatModal';
import { ImageWithFallback } from '@components/imageWithFallback';
import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { useContext, useEffect, useState } from 'react';
export default function ChatList() {
const [pool, relays]: any = useContext(RelayContext);
const router = useRouter();
const activeAccount: any = useAtomValue(activeAccountAtom);
const accountProfile = JSON.parse(activeAccount.metadata);
const [list, setList] = useState(new Set());
const openSelfChat = () => {
router.push({
pathname: '/chats/[pubkey]',
query: { pubkey: activeAccount.pubkey },
});
};
useEffect(() => {
const unsubscribe = pool.subscribe(
[
{
kinds: [4],
'#p': [activeAccount.pubkey],
since: 0,
},
],
relays,
(event: any) => {
if (event.pubkey !== activeAccount.pubkey) {
setList((list) => new Set(list).add(event.pubkey));
}
}
);
return () => {
unsubscribe;
};
}, [pool, relays, activeAccount.pubkey]);
return (
<div className="flex flex-col gap-px">
<div
onClick={() => openSelfChat()}
className="inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-zinc-900"
>
<div className="relative h-5 w-5 shrink overflow-hidden rounded bg-white">
<ImageWithFallback
src={accountProfile.picture || DEFAULT_AVATAR}
alt={activeAccount.pubkey}
fill={true}
className="rounded object-cover"
/>
</div>
<div>
<h5 className="text-sm font-medium text-zinc-400">
{accountProfile.display_name || accountProfile.name} <span className="text-zinc-500">(you)</span>
</h5>
</div>
</div>
{[...list].map((item: string, index) => (
<ChatListItem key={index} pubkey={item} />
))}
<ChatModal />
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
import { useRouter } from 'next/router';
export const ChatListItem = ({ pubkey }: { pubkey: string }) => {
const router = useRouter();
const profile = useMetadata(pubkey);
const openChat = () => {
router.push({
pathname: '/chats/[pubkey]',
query: { pubkey: pubkey },
});
};
return (
<div
onClick={() => openChat()}
className="inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-zinc-900"
>
<div className="relative h-5 w-5 shrink overflow-hidden rounded">
<ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
fill={true}
className="rounded object-cover"
/>
</div>
<div>
<h5 className="text-sm font-medium text-zinc-400">
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
</h5>
</div>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { ChatModalUser } from '@components/chats/chatModalUser';
import { activeAccountAtom } from '@stores/account';
import * as Dialog from '@radix-ui/react-dialog';
import { Cross1Icon, PlusIcon } from '@radix-ui/react-icons';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
export const ChatModal = () => {
const [plebs, setPlebs] = useState([]);
const activeAccount: any = useAtomValue(activeAccountAtom);
const fetchPlebsByAccount = useCallback(async (id) => {
const { getPlebs } = await import('@utils/bindings');
return await getPlebs({ account_id: id });
}, []);
useEffect(() => {
fetchPlebsByAccount(activeAccount.id)
.then((res) => setPlebs(res))
.catch(console.error);
}, [activeAccount.id, fetchPlebsByAccount]);
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<div className="group inline-flex items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-zinc-950">
<div className="inline-flex h-5 w-5 shrink items-center justify-center rounded bg-zinc-900">
<PlusIcon className="h-3 w-3 text-zinc-500" />
</div>
<div>
<h5 className="text-sm font-medium text-zinc-500 group-hover:text-zinc-400">Add a new chat</h5>
</div>
</div>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-sm data-[state=open]:animate-overlayShow" />
<Dialog.Content className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center">
<div className="relative flex h-[500px] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 text-zinc-100 ring-1 ring-zinc-800">
<div className="sticky left-0 top-0 flex h-12 w-full shrink-0 items-center justify-between rounded-t-lg border-b border-zinc-800 bg-zinc-950 px-3">
<div className="flex items-center gap-2">
<Dialog.Close asChild>
<button className="inline-flex h-5 w-5 items-center justify-center rounded bg-zinc-900">
<Cross1Icon className="h-3 w-3 text-zinc-300" />
</button>
</Dialog.Close>
<h5 className="font-semibold leading-none text-zinc-500">New chat</h5>
</div>
</div>
<div className="flex flex-col overflow-y-auto">
{plebs.map((pleb) => (
<ChatModalUser key={pleb.id} data={pleb} />
))}
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,48 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { truncate } from '@utils/truncate';
import { useRouter } from 'next/router';
export const ChatModalUser = ({ data }: { data: any }) => {
const router = useRouter();
const profile = JSON.parse(data.metadata);
const openNewChat = () => {
router.push({
pathname: '/chats/[pubkey]',
query: { pubkey: data.pubkey },
});
};
return (
<div className="group flex items-center justify-between px-3 py-2 hover:bg-zinc-800">
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink overflow-hidden rounded-md">
<ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR}
alt={data.pubkey}
fill={true}
className="rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate text-sm font-semibold leading-tight text-zinc-200">
{profile?.display_name || profile?.name}
</span>
<span className="text-sm leading-tight text-zinc-400">{truncate(data.pubkey, 16, ' .... ')}</span>
</div>
</div>
<div>
<button
onClick={() => openNewChat()}
className="hidden h-8 items-center justify-center rounded-md bg-fuchsia-500 px-3 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 group-hover:inline-flex"
>
Send message
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,46 @@
import MessageListItem from '@components/chats/messageListItem';
import { useCallback, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
export const MessageList = ({ data }: { data: any }) => {
const virtuosoRef = useRef(null);
const itemContent: any = useCallback(
(index: string | number) => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
return (
<MessageListItem
data={data[index]}
activeAccountPubkey={activeAccount.pubkey}
activeAccountPrivkey={activeAccount.privkey}
/>
);
},
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { MessageUser } from '@components/chats/messageUser';
import { nip04 } from 'nostr-tools';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
const MessageListItem = ({
data,
activeAccountPubkey,
activeAccountPrivkey,
}: {
data: any;
activeAccountPubkey: string;
activeAccountPrivkey: string;
}) => {
const [content, setContent] = useState('');
const sender = useMemo(() => {
const pTag = data.tags.find(([k, v]) => k === 'p' && v && v !== '')[1];
if (pTag === activeAccountPubkey) {
return data.pubkey;
} else {
return pTag;
}
}, [data.pubkey, data.tags, activeAccountPubkey]);
const decryptContent = useCallback(async () => {
const result = await nip04.decrypt(activeAccountPrivkey, sender, data.content);
setContent(result);
}, [data.content, activeAccountPrivkey, sender]);
useEffect(() => {
decryptContent().catch(console.error);
}, [decryptContent]);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-2 hover:bg-black/20">
<div className="flex flex-col">
<MessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[17px] pl-[48px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-sm leading-tight dark:prose-invert prose-p:m-0 prose-p:text-sm prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
{content}
</div>
</div>
</div>
</div>
</div>
);
};
export default memo(MessageListItem);

View File

@@ -0,0 +1,37 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
export const MessageUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
const profile = useMetadata(pubkey);
return (
<div className="group flex items-start gap-3">
<div className="relative h-9 w-9 shrink overflow-hidden rounded-md">
<ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
fill={true}
className="rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-sm">
<span className="font-semibold leading-none text-zinc-200 group-hover:underline">
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
</div>
</div>
</div>
);
};

View File

@@ -1,47 +0,0 @@
import AccountList from '@components/columns/account/list';
import LumeSymbol from '@assets/icons/Lume';
import { PlusIcon } from '@radix-ui/react-icons';
import { getVersion } from '@tauri-apps/api/app';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
export default function AccountColumn() {
const [version, setVersion] = useState(null);
const getAppVersion = useCallback(async () => {
const appVersion = await getVersion();
setVersion(appVersion);
}, []);
useEffect(() => {
getAppVersion().catch(console.error);
}, [getAppVersion]);
return (
<div className="flex h-full flex-col items-center justify-between px-2 pb-4 pt-4">
<div className="flex flex-col gap-4">
<Link
href="/explore"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-md bg-zinc-900 hover:bg-zinc-800"
>
<LumeSymbol className="h-6 w-auto text-zinc-400 group-hover:text-zinc-200" />
</Link>
<AccountList />
<Link
href="/onboarding"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-zinc-600 hover:border-zinc-400"
>
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-200" />
</Link>
</div>
<div className="flex flex-col gap-0.5 text-center">
<span className="animate-moveBg from-fuchsia-300 via-orange-100 to-amber-300 text-sm font-black uppercase leading-tight text-zinc-600 hover:bg-gradient-to-r hover:bg-clip-text hover:text-transparent">
Lume
</span>
<span className="text-xs font-medium text-zinc-700">v{version}</span>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { ActiveAccount } from '@components/columns/account/active';
import { InactiveAccount } from '@components/columns/account/inactive';
import { activeAccountAtom } from '@stores/account';
import { getAccounts } from '@utils/storage';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
export default function AccountList() {
const activeAccount: any = useAtomValue(activeAccountAtom);
const [users, setUsers] = useState([]);
const renderAccount = useCallback(
(user: { id: string }) => {
if (user.id === activeAccount.id) {
return <ActiveAccount key={user.id} user={user} />;
} else {
return <InactiveAccount key={user.id} user={user} />;
}
},
[activeAccount.id]
);
useEffect(() => {
const fetchAccount = async () => {
const result: any = await getAccounts();
setUsers(result);
};
fetchAccount().catch(console.error);
}, []);
return <>{users.map((user) => renderAccount(user))}</>;
}

View File

@@ -1,27 +0,0 @@
import * as Collapsible from '@radix-ui/react-collapsible';
import { TriangleUpIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
export default function Chats() {
const [open, setOpen] = useState(true);
return (
<Collapsible.Root open={open} onOpenChange={setOpen} className="h-full shrink-0">
<div className="flex h-full flex-col gap-1 px-2 pb-8">
<Collapsible.Trigger className="flex cursor-pointer items-center gap-2 px-2 py-1">
<div
className={`inline-flex h-6 w-6 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<TriangleUpIcon className="h-4 w-4 text-zinc-500" />
</div>
<h3 className="bg-gradient-to-r from-red-300 via-pink-100 to-blue-300 bg-clip-text text-xs font-bold uppercase tracking-wide text-transparent">
Chats
</h3>
</Collapsible.Trigger>
<Collapsible.Content className="h-full"></Collapsible.Content>
</div>
</Collapsible.Root>
);
}

View File

@@ -1,13 +0,0 @@
import Chats from '@components/columns/navigator/chats';
import Newsfeed from '@components/columns/navigator/newsfeed';
export default function NavigatorColumn() {
return (
<div className="relative flex h-full flex-col gap-1 overflow-hidden pt-4">
{/* Newsfeed */}
<Newsfeed />
{/* Chats */}
<Chats />
</div>
);
}

View File

@@ -1,50 +0,0 @@
import ActiveLink from '@components/activeLink';
import * as Collapsible from '@radix-ui/react-collapsible';
import { TriangleUpIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
export default function Newsfeed() {
const [open, setOpen] = useState(true);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<div className="flex flex-col gap-1 px-2">
<Collapsible.Trigger className="flex cursor-pointer items-center gap-2 px-2 py-1">
<div
className={`inline-flex h-6 w-6 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<TriangleUpIcon className="h-4 w-4 text-zinc-500" />
</div>
<h3 className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-xs font-bold uppercase tracking-wide text-transparent">
Newsfeed
</h3>
</Collapsible.Trigger>
<Collapsible.Content className="flex flex-col gap-1 text-zinc-400">
<ActiveLink
href={`/newsfeed/following`}
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white hover:dark:bg-zinc-800"
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:bg-zinc-900"
>
<div className="inline-flex h-5 w-5 items-center justify-center">
<span className="h-4 w-3 rounded-sm bg-gradient-to-br from-fuchsia-500 via-purple-300 to-pink-300"></span>
</div>
<span>Following</span>
</ActiveLink>
<ActiveLink
href={`/newsfeed/circle`}
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white hover:dark:bg-zinc-800"
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:bg-zinc-900"
>
<div className="inline-flex h-5 w-5 items-center justify-center">
<span className="h-4 w-3 rounded-sm bg-gradient-to-br from-amber-500 via-orange-200 to-yellow-300"></span>
</div>
<span>Circle</span>
</ActiveLink>
</Collapsible.Content>
</div>
</Collapsible.Root>
);
}

View File

@@ -2,12 +2,11 @@ import EmojiPicker from '@components/form/emojiPicker';
import ImagePicker from '@components/form/imagePicker'; import ImagePicker from '@components/form/imagePicker';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { noteContentAtom } from '@stores/note'; import { noteContentAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate'; import { dateToUnix } from '@utils/getDate';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom } from 'jotai';
import { useResetAtom } from 'jotai/utils'; import { useResetAtom } from 'jotai/utils';
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from 'nostr-tools';
import { useContext } from 'react'; import { useContext } from 'react';
@@ -15,23 +14,21 @@ import { useContext } from 'react';
export default function FormBase() { export default function FormBase() {
const [pool, relays]: any = useContext(RelayContext); const [pool, relays]: any = useContext(RelayContext);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [value, setValue] = useAtom(noteContentAtom); const [value, setValue] = useAtom(noteContentAtom);
const resetValue = useResetAtom(noteContentAtom); const resetValue = useResetAtom(noteContentAtom);
const pubkey = activeAccount.id;
const privkey = activeAccount.privkey;
const submitEvent = () => { const submitEvent = () => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const event: any = { const event: any = {
content: value, content: value,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 1, kind: 1,
pubkey: pubkey, pubkey: activeAccount.pubkey,
tags: [], tags: [],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, privkey); event.sig = signEvent(event, activeAccount.privkey);
// publish note // publish note
pool.publish(event, relays); pool.publish(event, relays);

View File

@@ -0,0 +1,79 @@
import ImagePicker from '@components/form/imagePicker';
import { RelayContext } from '@components/relaysProvider';
import { dateToUnix } from '@utils/getDate';
import { getEventHash, nip04, signEvent } from 'nostr-tools';
import { useCallback, useContext, useState } from 'react';
export default function FormChat({ receiverPubkey }: { receiverPubkey: string }) {
const [pool, relays]: any = useContext(RelayContext);
const [value, setValue] = useState('');
const encryptMessage = useCallback(
async (privkey: string) => {
return await nip04.encrypt(privkey, receiverPubkey, value);
},
[receiverPubkey, value]
);
const submitEvent = useCallback(() => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
encryptMessage(activeAccount.privkey)
.then((encryptedContent) => {
const event: any = {
content: encryptedContent,
created_at: dateToUnix(),
kind: 4,
pubkey: activeAccount.pubkey,
tags: [['p', receiverPubkey]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, relays);
// reset state
setValue('');
})
.catch(console.error);
}, [encryptMessage, receiverPubkey, pool, relays]);
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitEvent();
}
};
return (
<div className="relative h-24 w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className="relative h-24 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker />
<div className="flex items-center gap-2 pl-2"></div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -23,7 +23,7 @@ export default function FormComment({ eventID }: { eventID: any }) {
content: value, content: value,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 1, kind: 1,
pubkey: activeAccount.id, pubkey: activeAccount.pubkey,
tags: [['e', eventID]], tags: [['e', eventID]],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
@@ -42,7 +42,7 @@ export default function FormComment({ eventID }: { eventID: any }) {
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10"> <div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<ImageWithFallback <ImageWithFallback
src={profile?.picture} src={profile?.picture}
alt={activeAccount.id} alt={activeAccount.pubkey}
fill={true} fill={true}
className="rounded-md object-cover" className="rounded-md object-cover"
/> />

View File

@@ -1,50 +1,65 @@
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { dateToUnix } from '@utils/getDate'; import { DEFAULT_AVATAR } from '@stores/constants';
import { createFollows } from '@utils/storage';
import { tagsToArray } from '@utils/transform'; import { fetchMetadata } from '@utils/metadata';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { AvatarIcon, ExitIcon, GearIcon } from '@radix-ui/react-icons'; import { AvatarIcon, ExitIcon, GearIcon } from '@radix-ui/react-icons';
import { writeText } from '@tauri-apps/api/clipboard'; import { writeText } from '@tauri-apps/api/clipboard';
import destr from 'destr';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { memo, useContext, useEffect, useRef } from 'react'; import { memo, useCallback, useContext, useEffect } from 'react';
export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }) { export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }) {
const [pool, relays]: any = useContext(RelayContext); const [pool, relays]: any = useContext(RelayContext);
const router = useRouter(); const router = useRouter();
const userData = destr(user.metadata); const userData = JSON.parse(user.metadata);
const now = useRef(new Date());
const openProfilePage = () => { const openProfilePage = () => {
router.push(`/users/${user.id}`); router.push(`/users/${user.pubkey}`);
}; };
const copyPublicKey = async () => { const copyPublicKey = async () => {
await writeText(nip19.npubEncode(user.id)); await writeText(nip19.npubEncode(user.pubkey));
}; };
const insertFollowsToStorage = useCallback(
async (tags) => {
const { createPleb } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
for (const tag of tags) {
const metadata: any = await fetchMetadata(tag[1], pool, relays);
createPleb({
pleb_id: tag[1] + '-lume' + activeAccount.id.toString(),
pubkey: tag[1],
kind: 0,
metadata: metadata.content,
account_id: activeAccount.id,
}).catch(console.error);
}
},
[pool, relays]
);
useEffect(() => { useEffect(() => {
const unsubscribe = pool.subscribe( const unsubscribe = pool.subscribe(
[ [
{ {
kinds: [3], kinds: [3],
authors: [user.id], authors: [user.pubkey],
since: dateToUnix(now.current),
}, },
], ],
relays, relays,
(event: any) => { (event: any) => {
if (event.tags.length > 0) { if (event.tags.length > 0) {
createFollows(tagsToArray(event.tags), user.id, 0); insertFollowsToStorage(event.tags);
} }
}, },
undefined, 20000,
undefined, undefined,
{ {
unsubscribeOnEose: true, unsubscribeOnEose: true,
@@ -54,19 +69,17 @@ export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }
return () => { return () => {
unsubscribe; unsubscribe;
}; };
}, [pool, relays, user.id]); }, [insertFollowsToStorage, pool, relays, user.pubkey]);
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<button className="relative h-11 w-11 rounded-md"> <button className="relative h-11 w-11 rounded-lg">
<Image <Image
src={userData.picture} src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar" alt="user's avatar"
fill={true} fill={true}
className="rounded-md object-cover" className="rounded-lg object-cover"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
priority priority
/> />
</button> </button>

View File

@@ -1,17 +1,24 @@
import destr from 'destr'; import { DEFAULT_AVATAR } from '@stores/constants';
import Image from 'next/image'; import Image from 'next/image';
import { memo } from 'react'; import { memo } from 'react';
export const InactiveAccount = memo(function InactiveAccount({ user }: { user: any }) { export const InactiveAccount = memo(function InactiveAccount({ user }: { user: any }) {
const userData = destr(user.metadata); const userData = JSON.parse(user.metadata);
const setCurrentUser = () => { const setCurrentUser = () => {
console.log('clicked'); console.log('clicked');
}; };
return ( return (
<button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-md"> <button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-lg">
<Image src={userData.picture} alt="user's avatar" fill={true} className="rounded-md object-cover" /> <Image
src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar"
fill={true}
className="rounded-lg object-cover"
priority
/>
</button> </button>
); );
}); });

View File

@@ -0,0 +1,61 @@
import { ActiveAccount } from '@components/multiAccounts/activeAccount';
import { InactiveAccount } from '@components/multiAccounts/inactiveAccount';
import { APP_VERSION } from '@stores/constants';
import LumeSymbol from '@assets/icons/Lume';
import { PlusIcon } from '@radix-ui/react-icons';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
export default function MultiAccounts() {
const [users, setUsers] = useState([]);
const renderAccount = useCallback((user: { pubkey: string }) => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
if (user.pubkey === activeAccount.pubkey) {
return <ActiveAccount key={user.pubkey} user={user} />;
} else {
return <InactiveAccount key={user.pubkey} user={user} />;
}
}, []);
const fetchAccounts = useCallback(async () => {
const { getAccounts } = await import('@utils/bindings');
const accounts = await getAccounts();
// update state
setUsers(accounts);
}, []);
useEffect(() => {
fetchAccounts().catch(console.error);
}, [fetchAccounts]);
return (
<div className="flex h-full flex-col items-center justify-between px-2 pb-4 pt-3">
<div className="flex flex-col gap-4">
<Link
href="/explore"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-lg bg-zinc-900 hover:bg-zinc-800"
>
<LumeSymbol className="h-6 w-auto text-zinc-400 group-hover:text-zinc-200" />
</Link>
<div>{users.map((user) => renderAccount(user))}</div>
<Link
href="/onboarding"
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-zinc-600 hover:border-zinc-400"
>
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-200" />
</Link>
</div>
<div className="flex flex-col gap-0.5 text-center">
<span className="animate-moveBg from-fuchsia-300 via-orange-100 to-amber-300 text-sm font-black uppercase leading-tight text-zinc-600 hover:bg-gradient-to-r hover:bg-clip-text hover:text-transparent">
Lume
</span>
<span className="text-xs font-medium text-zinc-700">v{APP_VERSION}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import * as Collapsible from '@radix-ui/react-collapsible';
import { TriangleUpIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
export default function Channels() {
const [open, setOpen] = useState(true);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<div className="flex flex-col px-2">
<Collapsible.Trigger className="flex cursor-pointer items-center gap-1 px-1 py-1">
<div
className={`inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<TriangleUpIcon className="h-4 w-4 text-zinc-700" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">Channels</h3>
</Collapsible.Trigger>
<Collapsible.Content></Collapsible.Content>
</div>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,29 @@
import ChatList from '@components/chats/chatList';
import * as Collapsible from '@radix-ui/react-collapsible';
import { TriangleUpIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
export default function Chats() {
const [open, setOpen] = useState(true);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<div className="flex flex-col px-2">
<Collapsible.Trigger className="flex cursor-pointer items-center gap-1 px-1 py-1">
<div
className={`inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<TriangleUpIcon className="h-4 w-4 text-zinc-700" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">Chats</h3>
</Collapsible.Trigger>
<Collapsible.Content>
<ChatList />
</Collapsible.Content>
</div>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,16 @@
import Channels from '@components/navigation/channels';
import Chats from '@components/navigation/chats';
import Newsfeed from '@components/navigation/newsfeed';
export default function Navigation() {
return (
<div className="relative flex h-full flex-col gap-1 overflow-hidden pt-3">
{/* Newsfeed */}
<Newsfeed />
{/* Channels */}
<Channels />
{/* Chats */}
<Chats />
</div>
);
}

View File

@@ -0,0 +1,42 @@
import ActiveLink from '@components/activeLink';
import * as Collapsible from '@radix-ui/react-collapsible';
import { TriangleUpIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
export default function Newsfeed() {
const [open, setOpen] = useState(true);
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<div className="flex flex-col px-2">
<Collapsible.Trigger className="flex cursor-pointer items-center gap-1 px-1 py-1">
<div
className={`inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out ${
open ? 'rotate-180' : ''
}`}
>
<TriangleUpIcon className="h-4 w-4 text-zinc-700" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">Newsfeed</h3>
</Collapsible.Trigger>
<Collapsible.Content className="flex flex-col text-zinc-400">
<ActiveLink
href={`/newsfeed/following`}
activeClassName="dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:text-zinc-200"
>
<span>Following</span>
</ActiveLink>
<ActiveLink
href={`/newsfeed/circle`}
activeClassName="dark:bg-zinc-900 dark:text-zinc-100 hover:dark:bg-zinc-800"
className="flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm font-medium hover:text-zinc-200"
>
<span>Circle</span>
</ActiveLink>
</Collapsible.Content>
</div>
</Collapsible.Root>
);
}

View File

@@ -63,13 +63,18 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
const getParent = useMemo(() => { const getParent = useMemo(() => {
if (event.parent_id) { if (event.parent_id) {
if (event.parent_id !== event.id && !event.content.includes('#[0]')) { if (event.parent_id !== event.eventId && !event.content.includes('#[0]')) {
return <NoteParent id={event.parent_id} />; return <NoteParent id={event.parent_id} />;
} }
} }
return; return;
}, [event.content, event.id, event.parent_id]); }, [event.content, event.eventId, event.parent_id]);
const openUserPage = (e) => {
e.stopPropagation();
router.push(`/users/${event.pubkey}`);
};
const openThread = (e) => { const openThread = (e) => {
const selection = window.getSelection(); const selection = window.getSelection();
@@ -87,7 +92,9 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
> >
<>{getParent}</> <>{getParent}</>
<div className="relative z-10 flex flex-col"> <div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} /> <div onClick={(e) => openUserPage(e)}>
<UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
</div>
<div className="-mt-5 pl-[52px]"> <div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0"> <div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
@@ -97,10 +104,10 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
</div> </div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"> <div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata <NoteMetadata
eventID={event.id} eventID={event.eventId}
eventPubkey={event.pubkey} eventPubkey={event.pubkey}
eventContent={event.content} eventContent={event.content}
eventTime={event.created_at} eventTime={event.createdAt || event.created_at}
/> />
</div> </div>
</div> </div>

View File

@@ -60,7 +60,7 @@ export const NoteComment = memo(function NoteComment({ event }: { event: any })
return ( return (
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20"> <div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20">
<div className="relative z-10 flex flex-col"> <div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} /> <UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
<div className="-mt-5 pl-[52px]"> <div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0"> <div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
@@ -70,10 +70,10 @@ export const NoteComment = memo(function NoteComment({ event }: { event: any })
</div> </div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"> <div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata <NoteMetadata
eventID={event.id} eventID={event.eventId}
eventPubkey={event.pubkey} eventPubkey={event.pubkey}
eventContent={event.content} eventContent={event.content}
eventTime={event.created_at} eventTime={event.createdAt || event.created_at}
/> />
</div> </div>
</div> </div>

View File

@@ -1,29 +1,33 @@
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account'; import { lastLoginAtom } from '@stores/account';
import { hasNewerNoteAtom } from '@stores/note'; import { hasNewerNoteAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate'; import { dateToUnix } from '@utils/getDate';
import { createCacheNote, getAllFollowsByID, updateLastLoginTime } from '@utils/storage'; import { getParentID, pubkeyArray } from '@utils/transform';
import { pubkeyArray } from '@utils/transform';
import { TauriEvent } from '@tauri-apps/api/event'; import { TauriEvent } from '@tauri-apps/api/event';
import { appWindow, getCurrent } from '@tauri-apps/api/window'; import { appWindow, getCurrent } from '@tauri-apps/api/window';
import { useAtomValue, useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useCallback, useContext, useEffect, useRef, useState } from 'react';
export default function NoteConnector() { export default function NoteConnector() {
const [pool, relays]: any = useContext(RelayContext); const [pool, relays]: any = useContext(RelayContext);
const setLastLoginAtom = useSetAtom(lastLoginAtom);
const setHasNewerNote = useSetAtom(hasNewerNoteAtom); const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [isOnline] = useState(true); const [isOnline] = useState(true);
const now = useRef(new Date());
const subscribe = useCallback(() => { const now = useRef(new Date());
getAllFollowsByID(activeAccount.id).then((follows) => { const unsubscribe = useRef(null);
pool.subscribe(
const subscribe = useCallback(async () => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const follows = JSON.parse(localStorage.getItem('activeAccountFollows'));
unsubscribe.current = pool.subscribe(
[ [
{ {
kinds: [1], kinds: [1],
@@ -32,25 +36,43 @@ export default function NoteConnector() {
}, },
], ],
relays, relays,
(event: any) => { (event) => {
const parentID = getParentID(event.tags, event.id);
// insert event to local database // insert event to local database
createCacheNote(event); createNote({
setHasNewerNote(true); event_id: event.id,
} pubkey: event.pubkey,
kind: event.kind,
tags: JSON.stringify(event.tags),
content: event.content,
parent_id: parentID,
parent_comment_id: '',
created_at: event.created_at,
account_id: activeAccount.id,
})
.then(() =>
// notify user reload to get newer note
setHasNewerNote(true)
)
.catch(console.error);
},
10000
); );
}); }, [pool, relays, setHasNewerNote]);
}, [activeAccount.id, pool, relays, setHasNewerNote]);
useEffect(() => { useEffect(() => {
subscribe(); subscribe();
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => { getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
updateLastLoginTime(now.current); setLastLoginAtom(now.current);
appWindow.close(); appWindow.close();
}); });
}, [activeAccount.id, pool, relays, setHasNewerNote, subscribe]);
return () => {
unsubscribe.current;
};
}, [setHasNewerNote, setLastLoginAtom, subscribe]);
return ( return (
<>
<div className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-zinc-900"> <div className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-zinc-900">
<span className="relative flex h-1.5 w-1.5"> <span className="relative flex h-1.5 w-1.5">
<span <span
@@ -64,6 +86,5 @@ export default function NoteConnector() {
</span> </span>
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p> <p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
</div> </div>
</>
); );
} }

View File

@@ -60,7 +60,7 @@ export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
return ( return (
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col"> <div className="relative z-10 flex h-min min-h-min w-full select-text flex-col">
<div className="relative z-10 flex flex-col"> <div className="relative z-10 flex flex-col">
<UserLarge pubkey={event.pubkey} time={event.created_at} /> <UserLarge pubkey={event.pubkey} time={event.createdAt || event.created_at} />
<div className="mt-2"> <div className="mt-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0"> <div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
@@ -70,10 +70,10 @@ export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
</div> </div>
<div className="mt-5 flex items-center border-b border-t border-zinc-800 py-2"> <div className="mt-5 flex items-center border-b border-t border-zinc-800 py-2">
<NoteMetadata <NoteMetadata
eventID={event.id} eventID={event.eventId}
eventPubkey={event.pubkey} eventPubkey={event.pubkey}
eventContent={event.content} eventContent={event.content}
eventTime={event.created_at} eventTime={event.createdAt || event.created_at}
/> />
</div> </div>
</div> </div>

View File

@@ -3,7 +3,6 @@ import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend'; import { UserExtend } from '@components/user/extend';
import { activeAccountAtom } from '@stores/account'; import { activeAccountAtom } from '@stores/account';
import { relaysAtom } from '@stores/relays';
import { dateToUnix } from '@utils/getDate'; import { dateToUnix } from '@utils/getDate';
@@ -33,11 +32,10 @@ export const NoteComment = memo(function NoteComment({
const router = useRouter(); const router = useRouter();
const [pool, relays]: any = useContext(RelayContext); const [pool, relays]: any = useContext(RelayContext);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const activeAccount: any = useAtomValue(activeAccountAtom);
const profile = destr(activeAccount.metadata); const profile = destr(activeAccount.metadata);
const openThread = () => { const openThread = () => {
@@ -49,7 +47,7 @@ export const NoteComment = memo(function NoteComment({
content: value, content: value,
created_at: dateToUnix(), created_at: dateToUnix(),
kind: 1, kind: 1,
pubkey: activeAccount.id, pubkey: activeAccount.pubkey,
tags: [['e', eventID]], tags: [['e', eventID]],
}; };
event.id = getEventHash(event); event.id = getEventHash(event);

View File

@@ -38,7 +38,7 @@ export const NoteReaction = memo(function NoteReaction({
['p', eventPubkey], ['p', eventPubkey],
], ],
created_at: dateToUnix(), created_at: dateToUnix(),
pubkey: activeAccount.id, pubkey: activeAccount.pubkey,
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey); event.sig = signEvent(event, activeAccount.privkey);

View File

@@ -2,8 +2,6 @@ import { NoteComment } from '@components/note/meta/comment';
import { NoteReaction } from '@components/note/meta/reaction'; import { NoteReaction } from '@components/note/meta/reaction';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { createCacheCommentNote } from '@utils/storage';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
export default function NoteMetadata({ export default function NoteMetadata({
@@ -39,7 +37,7 @@ export default function NoteMetadata({
// update state // update state
setComments((comments) => (comments += 1)); setComments((comments) => (comments += 1));
// save comment to database // save comment to database
createCacheCommentNote(event, eventID); // createCacheCommentNote(event, eventID);
break; break;
case 7: case 7:
if (event.content === '🤙' || event.content === '+') { if (event.content === '🤙' || event.content === '+') {

View File

@@ -6,7 +6,7 @@ import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend'; import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention'; import { UserMention } from '@components/user/mention';
import { createCacheNote, getNoteByID } from '@utils/storage'; import { getParentID } from '@utils/transform';
import destr from 'destr'; import destr from 'destr';
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
@@ -18,7 +18,10 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
const [event, setEvent] = useState(null); const [event, setEvent] = useState(null);
const unsubscribe = useRef(null); const unsubscribe = useRef(null);
const fetchEvent = useCallback(() => { const fetchEvent = useCallback(async () => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
unsubscribe.current = pool.subscribe( unsubscribe.current = pool.subscribe(
[ [
{ {
@@ -31,7 +34,19 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
// update state // update state
setEvent(event); setEvent(event);
// insert to database // insert to database
createCacheNote(event); const parentID = getParentID(event.tags, event.id);
// insert event to local database
createNote({
event_id: event.id,
pubkey: event.pubkey,
kind: event.kind,
tags: JSON.stringify(event.tags),
content: event.content,
parent_id: parentID,
parent_comment_id: '',
created_at: event.created_at,
account_id: activeAccount.id,
}).catch(console.error);
}, },
undefined, undefined,
undefined, undefined,
@@ -41,19 +56,26 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
); );
}, [id, pool, relays]); }, [id, pool, relays]);
useEffect(() => { const checkNoteExist = useCallback(async () => {
getNoteByID(id).then((res) => { const { getNoteById } = await import('@utils/bindings');
getNoteById({ event_id: id })
.then((res) => {
if (res) { if (res) {
setEvent(res); setEvent(res);
} else { } else {
fetchEvent(); fetchEvent();
} }
}); })
.catch(console.error);
}, [fetchEvent, id]);
useEffect(() => {
checkNoteExist();
return () => { return () => {
unsubscribe.current; unsubscribe.current;
}; };
}, [fetchEvent, id]); }, [checkNoteExist]);
const content = useMemo(() => { const content = useMemo(() => {
let parsedContent = event ? event.content : null; let parsedContent = event ? event.content : null;
@@ -110,7 +132,7 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
<div className="relative pb-5"> <div className="relative pb-5">
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div> <div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
<div className="relative z-10 flex flex-col"> <div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} /> <UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
<div className="-mt-5 pl-[52px]"> <div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0"> <div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
@@ -120,10 +142,10 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
</div> </div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"> <div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata <NoteMetadata
eventID={event.id} eventID={event.eventId}
eventPubkey={event.pubkey} eventPubkey={event.pubkey}
eventContent={event.content} eventContent={event.content}
eventTime={event.created_at} eventTime={event.createdAt || event.created_at}
/> />
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@ import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend'; import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention'; import { UserMention } from '@components/user/mention';
import { createCacheNote, getNoteByID } from '@utils/storage'; import { getParentID } from '@utils/transform';
import destr from 'destr'; import destr from 'destr';
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
@@ -14,7 +14,10 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
const [event, setEvent] = useState(null); const [event, setEvent] = useState(null);
const unsubscribe = useRef(null); const unsubscribe = useRef(null);
const fetchEvent = useCallback(() => { const fetchEvent = useCallback(async () => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
unsubscribe.current = pool.subscribe( unsubscribe.current = pool.subscribe(
[ [
{ {
@@ -27,7 +30,19 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
// update state // update state
setEvent(event); setEvent(event);
// insert to database // insert to database
createCacheNote(event); const parentID = getParentID(event.tags, event.id);
// insert event to local database
createNote({
event_id: event.id,
pubkey: event.pubkey,
kind: event.kind,
tags: JSON.stringify(event.tags),
content: event.content,
parent_id: parentID,
parent_comment_id: '',
created_at: event.created_at,
account_id: activeAccount.id,
}).catch(console.error);
}, },
undefined, undefined,
undefined, undefined,
@@ -37,19 +52,26 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
); );
}, [id, pool, relays]); }, [id, pool, relays]);
useEffect(() => { const checkNoteExist = useCallback(async () => {
getNoteByID(id).then((res) => { const { getNoteById } = await import('@utils/bindings');
getNoteById({ event_id: id })
.then((res) => {
if (res) { if (res) {
setEvent(res); setEvent(res);
} else { } else {
fetchEvent(); fetchEvent();
} }
}); })
.catch(console.error);
}, [fetchEvent, id]);
useEffect(() => {
checkNoteExist();
return () => { return () => {
unsubscribe.current; unsubscribe.current;
}; };
}, [fetchEvent, id]); }, [checkNoteExist]);
const content = useMemo(() => { const content = useMemo(() => {
let parsedContent = event ? event.content : null; let parsedContent = event ? event.content : null;
@@ -89,7 +111,7 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
return ( return (
<div className="relative mb-2 mt-3 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3"> <div className="relative mb-2 mt-3 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
<div className="relative z-10 flex flex-col"> <div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} /> <UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
<div className="-mt-5 pl-[52px]"> <div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0"> <div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">

View File

@@ -2,32 +2,13 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from '@stores/constants';
import { createCacheProfile } from '@utils/storage'; import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate'; import { truncate } from '@utils/truncate';
import { fetch } from '@tauri-apps/api/http'; import { memo } from 'react';
import destr from 'destr';
import { memo, useCallback, useEffect, useState } from 'react';
export const UserBase = memo(function UserBase({ pubkey }: { pubkey: string }) { export const UserBase = memo(function UserBase({ pubkey }: { pubkey: string }) {
const [profile, setProfile] = useState(null); const profile = useMetadata(pubkey);
const fetchProfile = useCallback(async (id: string) => {
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
method: 'GET',
timeout: 30,
});
return res.data;
}, []);
useEffect(() => {
fetchProfile(pubkey)
.then((res: any) => {
setProfile(destr(res.content));
createCacheProfile(res.pubkey, res.content);
})
.catch(console.error);
}, [fetchProfile, pubkey]);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -2,68 +2,32 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from '@stores/constants';
import { createCacheProfile, getCacheProfile } from '@utils/storage'; import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate'; import { truncate } from '@utils/truncate';
import { DotsHorizontalIcon } from '@radix-ui/react-icons'; import { DotsHorizontalIcon } from '@radix-ui/react-icons';
import { fetch } from '@tauri-apps/api/http';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import destr from 'destr';
import { useRouter } from 'next/router';
import { memo, useCallback, useEffect, useState } from 'react';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export const UserExtend = memo(function UserExtend({ pubkey, time }: { pubkey: string; time: any }) { export const UserExtend = ({ pubkey, time }: { pubkey: string; time: number }) => {
const router = useRouter(); const profile = useMetadata(pubkey);
const [profile, setProfile] = useState(null);
const openUserPage = (e) => {
e.stopPropagation();
router.push(`/users/${pubkey}`);
};
const fetchProfile = useCallback(async (id: string) => {
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
method: 'GET',
timeout: 30,
});
return res.data;
}, []);
useEffect(() => {
getCacheProfile(pubkey).then((res) => {
if (res) {
setProfile(destr(res.metadata));
} else {
fetchProfile(pubkey)
.then((res: any) => {
setProfile(destr(res.content));
createCacheProfile(pubkey, res.content);
})
.catch(console.error);
}
});
}, [fetchProfile, pubkey]);
return ( return (
<div className="group flex items-start gap-2"> <div className="group flex items-start gap-2">
<div <div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
onClick={(e) => openUserPage(e)}
className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-900 ring-fuchsia-500 ring-offset-1 ring-offset-zinc-900 group-hover:ring-1"
>
<ImageWithFallback <ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR} src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
fill={true} fill={true}
className="rounded-md border border-white/10 object-cover" className="rounded-md object-cover"
/> />
</div> </div>
<div className="flex w-full flex-1 items-start justify-between"> <div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
<div className="flex items-baseline gap-2 text-sm"> <div className="flex items-baseline gap-2 text-sm">
<span onClick={(e) => openUserPage(e)} className="font-bold leading-tight group-hover:underline"> <span className="font-bold leading-tight group-hover:underline">
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')} {profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
</span> </span>
<span className="leading-tight text-zinc-500">·</span> <span className="leading-tight text-zinc-500">·</span>
@@ -78,4 +42,4 @@ export const UserExtend = memo(function UserExtend({ pubkey, time }: { pubkey: s
</div> </div>
</div> </div>
); );
}); };

View File

@@ -2,32 +2,11 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from '@stores/constants';
import { createCacheProfile } from '@utils/storage'; import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate'; import { truncate } from '@utils/truncate';
import { fetch } from '@tauri-apps/api/http'; export const UserFollow = ({ pubkey }: { pubkey: string }) => {
import destr from 'destr'; const profile = useMetadata(pubkey);
import { memo, useCallback, useEffect, useState } from 'react';
export const UserFollow = memo(function UserFollow({ pubkey }: { pubkey: string }) {
const [profile, setProfile] = useState(null);
const fetchProfile = useCallback(async (id: string) => {
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
method: 'GET',
timeout: 30,
});
return res.data;
}, []);
useEffect(() => {
fetchProfile(pubkey)
.then((res: any) => {
setProfile(destr(res.content));
createCacheProfile(res.pubkey, res.content);
})
.catch(console.error);
}, [fetchProfile, pubkey]);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -47,4 +26,4 @@ export const UserFollow = memo(function UserFollow({ pubkey }: { pubkey: string
</div> </div>
</div> </div>
); );
}); };

View File

@@ -2,47 +2,21 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants'; import { DEFAULT_AVATAR } from '@stores/constants';
import { createCacheProfile, getCacheProfile } from '@utils/storage'; import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate'; import { truncate } from '@utils/truncate';
import { DotsHorizontalIcon } from '@radix-ui/react-icons'; import { DotsHorizontalIcon } from '@radix-ui/react-icons';
import { fetch } from '@tauri-apps/api/http';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import destr from 'destr';
import { memo, useCallback, useEffect, useState } from 'react';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export const UserLarge = memo(function UserLarge({ pubkey, time }: { pubkey: string; time: any }) { export const UserLarge = ({ pubkey, time }: { pubkey: string; time: number }) => {
const [profile, setProfile] = useState(null); const profile = useMetadata(pubkey);
const fetchProfile = useCallback(async (id: string) => {
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
method: 'GET',
timeout: 30,
});
return res.data;
}, []);
useEffect(() => {
getCacheProfile(pubkey).then((res) => {
if (res) {
setProfile(destr(res.metadata));
} else {
fetchProfile(pubkey)
.then((res: any) => {
setProfile(destr(res.content));
createCacheProfile(pubkey, res.content);
})
.catch(console.error);
}
});
}, [fetchProfile, pubkey]);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-900"> <div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
<ImageWithFallback <ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR} src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey} alt={pubkey}
@@ -69,4 +43,4 @@ export const UserLarge = memo(function UserLarge({ pubkey, time }: { pubkey: str
</div> </div>
</div> </div>
); );
}); };

View File

@@ -1,35 +1,11 @@
import { createCacheProfile, getCacheProfile } from '@utils/storage'; import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate'; import { truncate } from '@utils/truncate';
import { fetch } from '@tauri-apps/api/http'; export const UserMention = ({ pubkey }: { pubkey: string }) => {
import destr from 'destr'; const profile = useMetadata(pubkey);
import { memo, useCallback, useEffect, useState } from 'react'; return (
<span className="cursor-pointer text-fuchsia-500">
export const UserMention = memo(function UserMention({ pubkey }: { pubkey: string }) { @{profile?.name || profile?.username || truncate(pubkey, 16, ' .... ')}
const [profile, setProfile] = useState(null); </span>
);
const fetchProfile = useCallback(async (id: string) => { };
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
method: 'GET',
timeout: 30,
});
return res.data;
}, []);
useEffect(() => {
getCacheProfile(pubkey).then((res) => {
if (res) {
setProfile(destr(res.metadata));
} else {
fetchProfile(pubkey)
.then((res: any) => {
setProfile(destr(res.content));
createCacheProfile(pubkey, res.content);
})
.catch(console.error);
}
});
}, [fetchProfile, pubkey]);
return <span className="cursor-pointer text-fuchsia-500">@{profile?.name || truncate(pubkey, 16, ' .... ')}</span>;
});

View File

@@ -1,44 +0,0 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { getCacheProfile } from '@utils/storage';
import { truncate } from '@utils/truncate';
import { useCallback, useEffect, useState } from 'react';
export const UserMini = ({ pubkey }: { pubkey: string }) => {
const [profile, setProfile] = useState(null);
const fetchCacheProfile = useCallback(async (id: string) => {
const res = await getCacheProfile(id);
const data = JSON.parse(res.metadata);
setProfile(data);
}, []);
useEffect(() => {
fetchCacheProfile(pubkey).catch(console.error);
}, [fetchCacheProfile, pubkey]);
if (profile) {
return (
<div className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm font-medium hover:bg-zinc-900">
<div className="relative h-5 w-5 shrink-0 overflow-hidden rounded">
<ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
fill={true}
className="rounded object-cover"
/>
</div>
<div className="inline-flex w-full flex-1 flex-col overflow-hidden">
<p className="truncate leading-tight text-zinc-300">
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
</p>
</div>
</div>
);
} else {
return <></>;
}
};

View File

@@ -1,6 +1,6 @@
import AppHeader from '@components/appHeader'; import AppHeader from '@components/appHeader';
import AccountColumn from '@components/columns/account'; import MultiAccounts from '@components/multiAccounts';
import NavigatorColumn from '@components/columns/navigator'; import Navigation from '@components/navigation';
export default function WithSidebarLayout({ children }: { children: React.ReactNode }) { export default function WithSidebarLayout({ children }: { children: React.ReactNode }) {
return ( return (
@@ -13,11 +13,11 @@ export default function WithSidebarLayout({ children }: { children: React.ReactN
</div> </div>
<div className="relative flex min-h-0 w-full flex-1"> <div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900"> <div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<AccountColumn /> <MultiAccounts />
</div> </div>
<div className="grid w-full grid-cols-4 xl:grid-cols-5"> <div className="grid w-full grid-cols-4 xl:grid-cols-5">
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900"> <div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
<NavigatorColumn /> <Navigation />
</div> </div>
<div className="col-span-3 m-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20 xl:col-span-2 xl:mr-1.5"> <div className="col-span-3 m-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20 xl:col-span-2 xl:mr-1.5">
<div className="h-full w-full rounded-lg">{children}</div> <div className="h-full w-full rounded-lg">{children}</div>

View File

@@ -2,6 +2,7 @@ import RelayProvider from '@components/relaysProvider';
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import { ReactElement, ReactNode } from 'react'; import { ReactElement, ReactNode } from 'react';
import '../App.css'; import '../App.css';
@@ -16,8 +17,9 @@ type AppPropsWithLayout = AppProps & {
}; };
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const router = useRouter();
// Use the layout defined at the page level, if available // Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page); const getLayout = Component.getLayout ?? ((page) => page);
return <RelayProvider>{getLayout(<Component {...pageProps} />)}</RelayProvider>; return <RelayProvider>{getLayout(<Component key={router.asPath} {...pageProps} />)}</RelayProvider>;
} }

View File

@@ -0,0 +1,80 @@
import BaseLayout from '@layouts/base';
import WithSidebarLayout from '@layouts/withSidebar';
import { MessageList } from '@components/chats/messageList';
import FormChat from '@components/form/chat';
import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import {
JSXElementConstructor,
ReactElement,
ReactFragment,
ReactPortal,
useContext,
useEffect,
useState,
} from 'react';
export default function Page() {
const [pool, relays]: any = useContext(RelayContext);
const router = useRouter();
const pubkey: any = router.query.pubkey || null;
const activeAccount: any = useAtomValue(activeAccountAtom);
const [messages, setMessages] = useState([]);
useEffect(() => {
const unsubscribe = pool.subscribe(
[
{
kinds: [4],
authors: [pubkey],
'#p': [activeAccount.pubkey],
},
{
kinds: [4],
authors: [activeAccount.pubkey],
'#p': [pubkey],
},
],
relays,
(event: any) => {
setMessages((messages) => [event, ...messages]);
}
);
return () => {
unsubscribe;
};
}, [pool, relays, pubkey, activeAccount.pubkey]);
return (
<div className="flex h-full w-full flex-col justify-between">
<MessageList data={messages.sort((a, b) => a.created_at - b.created_at)} />
<div className="shrink-0 p-3">
<FormChat receiverPubkey={pubkey} />
</div>
</div>
);
}
Page.getLayout = function getLayout(
page:
| string
| number
| boolean
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
| ReactFragment
| ReactPortal
) {
return (
<BaseLayout>
<WithSidebarLayout>{page}</WithSidebarLayout>
</BaseLayout>
);
};

View File

@@ -1,25 +1,38 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import { activeAccountAtom } from '@stores/account'; import { activeAccountAtom, activeAccountFollowsAtom } from '@stores/account';
import { getActiveAccount } from '@utils/storage';
import LumeSymbol from '@assets/icons/Lume'; import LumeSymbol from '@assets/icons/Lume';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useEffect } from 'react'; import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useCallback, useEffect } from 'react';
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
const setActiveAccount = useSetAtom(activeAccountAtom); const setActiveAccount = useSetAtom(activeAccountAtom);
const setActiveAccountFollows = useSetAtom(activeAccountFollowsAtom);
const fetchActiveAccount = useCallback(async () => {
const { getAccounts } = await import('@utils/bindings');
return await getAccounts();
}, []);
const fetchFollowsByAccount = useCallback(async (id) => {
const { getPlebs } = await import('@utils/bindings');
return await getPlebs({ account_id: id });
}, []);
useEffect(() => { useEffect(() => {
getActiveAccount() fetchActiveAccount()
.then((res: any) => { .then((res: any) => {
if (res) { if (res.length > 0) {
// fetch follows
fetchFollowsByAccount(res[0].id).then((follows) => {
setActiveAccountFollows(follows);
});
// update local storage // update local storage
setActiveAccount(res); setActiveAccount(res[0]);
// redirect // redirect
router.replace('/init'); router.replace('/init');
} else { } else {
@@ -27,7 +40,7 @@ export default function Page() {
} }
}) })
.catch(console.error); .catch(console.error);
}, [router, setActiveAccount]); }, [fetchActiveAccount, setActiveAccount, fetchFollowsByAccount, setActiveAccountFollows, router]);
return ( return (
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">

View File

@@ -2,16 +2,12 @@ import BaseLayout from '@layouts/base';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { relaysAtom } from '@stores/relays';
import { dateToUnix, hoursAgo } from '@utils/getDate'; import { dateToUnix, hoursAgo } from '@utils/getDate';
import { countTotalNotes, createCacheNote, getAllFollowsByID, getLastLoginTime } from '@utils/storage'; import { getParentID, pubkeyArray } from '@utils/transform';
import { pubkeyArray } from '@utils/transform';
import LumeSymbol from '@assets/icons/Lume'; import LumeSymbol from '@assets/icons/Lume';
import { useAtomValue } from 'jotai'; import { invoke } from '@tauri-apps/api/tauri';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { import {
JSXElementConstructor, JSXElementConstructor,
@@ -29,16 +25,17 @@ export default function Page() {
const router = useRouter(); const router = useRouter();
const [pool, relays]: any = useContext(RelayContext); const [pool, relays]: any = useContext(RelayContext);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [done, setDone] = useState(false);
const now = useRef(new Date()); const now = useRef(new Date());
const unsubscribe = useRef(null); const unsubscribe = useRef(null);
const timer = useRef(null);
const [eose, setEose] = useState(false);
const fetchData = useCallback( const fetchData = useCallback(
(since) => { async (since: Date) => {
getAllFollowsByID(activeAccount.id).then((follows) => { const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const follows = JSON.parse(localStorage.getItem('activeAccountFollows'));
unsubscribe.current = pool.subscribe( unsubscribe.current = pool.subscribe(
[ [
{ {
@@ -50,44 +47,52 @@ export default function Page() {
], ],
relays, relays,
(event) => { (event) => {
const parentID = getParentID(event.tags, event.id);
// insert event to local database // insert event to local database
createCacheNote(event); createNote({
event_id: event.id,
pubkey: event.pubkey,
kind: event.kind,
tags: JSON.stringify(event.tags),
content: event.content,
parent_id: parentID,
parent_comment_id: '',
created_at: event.created_at,
account_id: activeAccount.id,
}).catch(console.error);
}, },
undefined, undefined,
() => { () => {
// wait for 8 seconds setEose(true);
timer.current = setTimeout(() => setDone(true), 8000);
},
{
unsubscribeOnEose: true,
} }
); );
});
}, },
[activeAccount.id, pool, relays] [pool, relays]
); );
useEffect(() => { const isNoteExist = useCallback(async () => {
if (!done) { invoke('count_total_notes').then((res: number) => {
countTotalNotes().then((count) => { if (res > 0) {
if (count.total === 0) { const lastLogin = JSON.parse(localStorage.getItem('lastLogin'));
fetchData(hoursAgo(24, now.current)); const parseDate = new Date(lastLogin);
} else {
getLastLoginTime().then((time) => {
const parseDate = new Date(time.setting_value);
fetchData(parseDate); fetchData(parseDate);
}); } else {
fetchData(hoursAgo(24, now.current));
} }
}); });
}, [fetchData]);
useEffect(() => {
if (eose === false) {
isNoteExist();
} else { } else {
router.replace('/newsfeed/following'); router.replace('/newsfeed/following');
} }
return () => { return () => {
unsubscribe.current; unsubscribe.current;
clearTimeout(timer.current);
}; };
}, [activeAccount.id, done, pool, relays, router, fetchData]); }, [router, eose, isNoteExist]);
return ( return (
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">

View File

@@ -6,8 +6,6 @@ import { NoteComment } from '@components/note/comment';
import { NoteExtend } from '@components/note/extend'; import { NoteExtend } from '@components/note/extend';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { getAllCommentNotes, getNoteByID } from '@utils/storage';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { import {
JSXElementConstructor, JSXElementConstructor,
@@ -29,12 +27,12 @@ export default function Page() {
const [comments, setComments] = useState([]); const [comments, setComments] = useState([]);
useEffect(() => { useEffect(() => {
getNoteByID(id) /*getNoteByID(id)
.then((res) => { .then((res) => {
setRootEvent(res); setRootEvent(res);
getAllCommentNotes(id).then((res: any) => setComments(res)); getAllCommentNotes(id).then((res: any) => setComments(res));
}) })
.catch(console.error); .catch(console.error);*/
}, [id, pool, relays]); }, [id, pool, relays]);
return ( return (

View File

@@ -8,7 +8,7 @@ import { Placeholder } from '@components/note/placeholder';
import { hasNewerNoteAtom } from '@stores/note'; import { hasNewerNoteAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate'; import { dateToUnix } from '@utils/getDate';
import { getLatestNotes, getNotes } from '@utils/storage'; import { filterDuplicateParentID } from '@utils/transform';
import { ArrowUpIcon } from '@radix-ui/react-icons'; import { ArrowUpIcon } from '@radix-ui/react-icons';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
@@ -42,29 +42,43 @@ export default function Page() {
const computeItemKey = useCallback( const computeItemKey = useCallback(
(index: string | number) => { (index: string | number) => {
return data[index].id; return data[index].eventId;
}, },
[data] [data]
); );
const initialData = useCallback(async () => { const initialData = useCallback(async () => {
const result: any = await getNotes(dateToUnix(now.current), limit.current, offset.current); const { getNotes } = await import('@utils/bindings');
const result: any = await getNotes({
date: dateToUnix(now.current),
limit: limit.current,
offset: offset.current,
});
setData((data) => [...data, ...result]); setData((data) => [...data, ...result]);
}, []); }, []);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
const { getNotes } = await import('@utils/bindings');
offset.current += limit.current; offset.current += limit.current;
// next query // next query
const result: any = await getNotes(dateToUnix(now.current), limit.current, offset.current); const result: any = await getNotes({
date: dateToUnix(now.current),
limit: limit.current,
offset: offset.current,
});
setData((data) => [...data, ...result]); setData((data) => [...data, ...result]);
}, []); }, []);
const loadLatest = useCallback(async () => { const loadLatest = useCallback(async () => {
offset.current += limit.current; const { getLatestNotes } = await import('@utils/bindings');
// next query // next query
const result: any = await getLatestNotes(dateToUnix(now.current)); const result: any = await getLatestNotes({ date: dateToUnix(now.current) });
// update data // update data
setData((data) => [...result, ...data]); if (result.length > 0) {
setData((data) => [...data, ...result]);
} else {
setData((data) => [...data, result]);
}
// hide newer trigger // hide newer trigger
setHasNewerNote(false); setHasNewerNote(false);
// scroll to top // scroll to top
@@ -90,7 +104,7 @@ export default function Page() {
)} )}
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
data={data} data={filterDuplicateParentID(data)}
itemContent={itemContent} itemContent={itemContent}
computeItemKey={computeItemKey} computeItemKey={computeItemKey}
components={COMPONENTS} components={COMPONENTS}

View File

@@ -2,13 +2,20 @@ import BaseLayout from '@layouts/base';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { createAccount } from '@utils/storage';
import { ArrowLeftIcon, EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons'; import { ArrowLeftIcon, EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools'; import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useMemo, useState } from 'react'; import {
JSXElementConstructor,
ReactElement,
ReactFragment,
ReactPortal,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { Config, names, uniqueNamesGenerator } from 'unique-names-generator'; import { Config, names, uniqueNamesGenerator } from 'unique-names-generator';
const config: Config = { const config: Config = {
@@ -34,28 +41,16 @@ export default function Page() {
}; };
// auto-generated profile metadata // auto-generated profile metadata
const metadata = useMemo( const metadata: any = useMemo(
() => ({ () => ({
display_name: name, display_name: name,
name: name, name: name,
username: name.toLowerCase(), username: name.toLowerCase(),
picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89', picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp',
}), }),
[name] [name]
); );
// build profile
const data = useMemo(
() => ({
pubkey: pubKey,
privkey: privKey,
npub: npub,
nsec: nsec,
metadata: metadata,
}),
[metadata, npub, nsec, privKey, pubKey]
);
// toggle privatek key // toggle privatek key
const showPrivateKey = () => { const showPrivateKey = () => {
if (type === 'password') { if (type === 'password') {
@@ -66,7 +61,8 @@ export default function Page() {
}; };
// create account and broadcast to all relays // create account and broadcast to all relays
const submit = () => { const submit = useCallback(async () => {
const { createAccount } = await import('@utils/bindings');
setLoading(true); setLoading(true);
// build event // build event
@@ -81,16 +77,16 @@ export default function Page() {
event.sig = signEvent(event, privKey); event.sig = signEvent(event, privKey);
// insert to database then broadcast // insert to database then broadcast
createAccount(data) createAccount({ pubkey: pubKey, privkey: privKey, metadata: metadata })
.then(() => { .then((res) => {
pool.publish(event, relays); pool.publish(event, relays);
router.push({ router.push({
pathname: '/onboarding/create/step-2', pathname: '/onboarding/create/step-2',
query: { id: pubKey, privkey: privKey }, query: { id: res.id, pubkey: res.pubkey, privkey: res.privkey },
}); });
}) })
.catch(console.error); .catch(console.error);
}; }, [pool, pubKey, privKey, metadata, relays, router]);
return ( return (
<div className="grid h-full w-full grid-rows-5"> <div className="grid h-full w-full grid-rows-5">

View File

@@ -3,7 +3,8 @@ import BaseLayout from '@layouts/base';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { UserBase } from '@components/user/base'; import { UserBase } from '@components/user/base';
import { createFollows } from '@utils/storage'; import { fetchMetadata } from '@utils/metadata';
import { followsTag } from '@utils/transform';
import { CheckCircledIcon } from '@radix-ui/react-icons'; import { CheckCircledIcon } from '@radix-ui/react-icons';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
@@ -15,6 +16,7 @@ import {
ReactElement, ReactElement,
ReactFragment, ReactFragment,
ReactPortal, ReactPortal,
useCallback,
useContext, useContext,
useEffect, useEffect,
useState, useState,
@@ -64,7 +66,7 @@ export default function Page() {
const [pool, relays]: any = useContext(RelayContext); const [pool, relays]: any = useContext(RelayContext);
const router = useRouter(); const router = useRouter();
const { id, privkey }: any = router.query || ''; const { id, pubkey, privkey }: any = router.query || '';
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [list, setList]: any = useState(initialList); const [list, setList]: any = useState(initialList);
@@ -76,41 +78,36 @@ export default function Page() {
setFollows(arr); setFollows(arr);
}; };
// build event tags
const tags = () => {
const arr = [];
// push item to tags
follows.forEach((item) => {
arr.push(['p', item]);
});
return arr;
};
// save follows to database then broadcast // save follows to database then broadcast
const submit = () => { const submit = useCallback(async () => {
const { createPleb } = await import('@utils/bindings');
setLoading(true); setLoading(true);
for (const follow of follows) {
const metadata: any = await fetchMetadata(follow, pool, relays);
createPleb({
pleb_id: follow + '-lume' + id,
pubkey: follow,
kind: 0,
metadata: metadata.content,
account_id: parseInt(id),
}).catch(console.error);
}
// build event // build event
const event: any = { const event: any = {
content: '', content: '',
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
kind: 3, kind: 3,
pubkey: id, pubkey: pubkey,
tags: tags(), tags: followsTag(follows),
}; };
event.id = getEventHash(event); event.id = getEventHash(event);
event.sig = signEvent(event, privkey); event.sig = signEvent(event, privkey);
createFollows(follows, id, 0)
.then((res) => {
if (res === 'ok') {
// publish to relays
pool.publish(event, relays); pool.publish(event, relays);
router.replace('/'); router.replace('/');
} }, [follows, id, pool, pubkey, privkey, relays, router]);
})
.catch(console.error);
};
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {

View File

@@ -2,21 +2,23 @@ import BaseLayout from '@layouts/base';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { createAccount, createFollows } from '@utils/storage'; import { DEFAULT_AVATAR } from '@stores/constants';
import { tagsToArray } from '@utils/transform';
import { fetchMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate'; import { truncate } from '@utils/truncate';
import destr from 'destr';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPublicKey, nip19 } from 'nostr-tools'; import { getPublicKey } from 'nostr-tools';
import { import {
JSXElementConstructor, JSXElementConstructor,
ReactElement, ReactElement,
ReactFragment, ReactFragment,
ReactPortal, ReactPortal,
useCallback,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
} from 'react'; } from 'react';
@@ -27,9 +29,40 @@ export default function Page() {
const privkey: any = router.query.privkey || null; const privkey: any = router.query.privkey || null;
const pubkey = privkey ? getPublicKey(privkey) : null; const pubkey = privkey ? getPublicKey(privkey) : null;
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState({ id: null, metadata: null });
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const insertAccountToStorage = useCallback(async (pubkey, privkey, metadata) => {
const { createAccount } = await import('@utils/bindings');
createAccount({ pubkey: pubkey, privkey: privkey, metadata: metadata })
.then((res) =>
setProfile({
id: res.id,
metadata: JSON.parse(res.metadata),
})
)
.catch(console.error);
}, []);
const insertFollowsToStorage = useCallback(
async (tags) => {
const { createPleb } = await import('@utils/bindings');
if (profile?.id !== null) {
for (const tag of tags) {
const metadata: any = await fetchMetadata(tag[1], pool, relays);
createPleb({
pleb_id: tag[1] + '-lume' + profile.id.toString(),
pubkey: tag[1],
kind: 0,
metadata: metadata.content,
account_id: profile.id,
}).catch(console.error);
}
}
},
[pool, profile.id, relays]
);
useEffect(() => { useEffect(() => {
const unsubscribe = pool.subscribe( const unsubscribe = pool.subscribe(
[ [
@@ -42,34 +75,23 @@ export default function Page() {
relays, relays,
(event: any) => { (event: any) => {
if (event.kind === 0) { if (event.kind === 0) {
const data = { insertAccountToStorage(pubkey, privkey, event.content);
pubkey: pubkey,
privkey: privkey,
npub: nip19.npubEncode(pubkey),
nsec: nip19.nsecEncode(privkey),
metadata: event.content,
};
setProfile(destr(event.content));
createAccount(data);
} else { } else {
if (event.tags.length > 0) { if (event.tags.length > 0) {
createFollows(tagsToArray(event.tags), pubkey, 0); insertFollowsToStorage(event.tags);
} }
} }
}, },
undefined, undefined,
() => { () => {
setDone(true); setDone(true);
},
{
unsubscribeOnEose: true,
} }
); );
return () => { return () => {
unsubscribe; unsubscribe;
}; };
}, [pool, privkey, pubkey, relays]); }, [insertAccountToStorage, insertFollowsToStorage, pool, relays, privkey, pubkey]);
// submit then redirect to home // submit then redirect to home
const submit = () => { const submit = () => {
@@ -91,13 +113,20 @@ export default function Page() {
<div className="w-full rounded-lg bg-zinc-900 p-4 shadow-input ring-1 ring-zinc-800"> <div className="w-full rounded-lg bg-zinc-900 p-4 shadow-input ring-1 ring-zinc-800">
<div className="flex space-x-4"> <div className="flex space-x-4">
<div className="relative h-10 w-10 rounded-full"> <div className="relative h-10 w-10 rounded-full">
<Image className="inline-block rounded-full" src={profile?.picture} alt="" fill={true} /> <Image
className="inline-block rounded-full"
src={profile.metadata?.picture || DEFAULT_AVATAR}
alt=""
fill={true}
/>
</div> </div>
<div className="flex-1 space-y-4 py-1"> <div className="flex-1 space-y-4 py-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-semibold">{profile?.display_name || profile?.name}</p> <p className="font-semibold">{profile.metadata?.display_name || profile.metadata?.name}</p>
<span className="leading-tight text-zinc-500">·</span> <span className="leading-tight text-zinc-500">·</span>
<p className="text-zinc-500">@{profile?.username || (pubkey && truncate(pubkey, 16, ' .... '))}</p> <p className="text-zinc-500">
@{profile.metadata?.username || (pubkey && truncate(pubkey, 16, ' .... '))}
</p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">

View File

@@ -10,3 +10,5 @@ const createMyJsonStorage = () => {
}; };
export const activeAccountAtom = atomWithStorage('activeAccount', {}, createMyJsonStorage()); export const activeAccountAtom = atomWithStorage('activeAccount', {}, createMyJsonStorage());
export const activeAccountFollowsAtom = atomWithStorage('activeAccountFollows', [], createMyJsonStorage());
export const lastLoginAtom = atomWithStorage('lastLogin', [], createMyJsonStorage());

View File

@@ -1 +1,2 @@
export const APP_VERSION = '0.2.1';
export const DEFAULT_AVATAR = 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp'; export const DEFAULT_AVATAR = 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp';

View File

@@ -1,9 +0,0 @@
import { isSSR } from '@utils/ssr';
import { getAllRelays } from '@utils/storage';
import { atomWithCache } from 'jotai-cache';
export const relaysAtom = atomWithCache(async () => {
const response = isSSR ? [] : await getAllRelays();
return response;
});

78
src/utils/bindings.ts Normal file
View File

@@ -0,0 +1,78 @@
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
declare global {
interface Window {
__TAURI_INVOKE__<T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
}
}
const invoke = window.__TAURI_INVOKE__;
export function getAccounts() {
return invoke<Account[]>('get_accounts');
}
export function createAccount(data: CreateAccountData) {
return invoke<Account>('create_account', { data });
}
export function getPlebs(data: GetPlebData) {
return invoke<Pleb[]>('get_plebs', { data });
}
export function getPlebByPubkey(data: GetPlebPubkeyData) {
return invoke<Pleb | null>('get_pleb_by_pubkey', { data });
}
export function createPleb(data: CreatePlebData) {
return invoke<Pleb>('create_pleb', { data });
}
export function createNote(data: CreateNoteData) {
return invoke<Note>('create_note', { data });
}
export function getNotes(data: GetNoteData) {
return invoke<Note[]>('get_notes', { data });
}
export function getLatestNotes(data: GetLatestNoteData) {
return invoke<Note[]>('get_latest_notes', { data });
}
export function getNoteById(data: GetNoteByIdData) {
return invoke<Note | null>('get_note_by_id', { data });
}
export type CreateNoteData = {
event_id: string;
pubkey: string;
kind: number;
tags: string;
content: string;
parent_id: string;
parent_comment_id: string;
created_at: number;
account_id: number;
};
export type CreatePlebData = { pleb_id: string; pubkey: string; kind: number; metadata: string; account_id: number };
export type GetNoteByIdData = { event_id: string };
export type Pleb = { id: number; plebId: string; pubkey: string; kind: number; metadata: string; accountId: number };
export type Note = {
id: number;
eventId: string;
pubkey: string;
kind: number;
tags: string;
content: string;
parent_id: string;
parent_comment_id: string;
createdAt: number;
accountId: number;
};
export type Account = { id: number; pubkey: string; privkey: string; active: boolean; metadata: string };
export type GetPlebPubkeyData = { pubkey: string };
export type GetPlebData = { account_id: number };
export type CreateAccountData = { pubkey: string; privkey: string; metadata: string };
export type GetLatestNoteData = { date: number };
export type GetNoteData = { date: number; limit: number; offset: number };

39
src/utils/metadata.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { RelayContext } from '@components/relaysProvider';
import { Author } from 'nostr-relaypool';
import { useCallback, useContext, useEffect, useState } from 'react';
export const fetchMetadata = (pubkey: string, pool: any, relays: any) => {
const author = new Author(pool, relays, pubkey);
return new Promise((resolve) => author.metaData(resolve, 0));
};
export const useMetadata = (pubkey) => {
const [pool, relays]: any = useContext(RelayContext);
const [profile, setProfile] = useState(null);
const getCachedMetadata = useCallback(async () => {
const { getPlebByPubkey } = await import('@utils/bindings');
getPlebByPubkey({ pubkey: pubkey })
.then((res) => {
if (res) {
const metadata = JSON.parse(res.metadata);
setProfile(metadata);
} else {
fetchMetadata(pubkey, pool, relays).then((res: any) => {
if (res.content) {
const metadata = JSON.parse(res.content);
setProfile(metadata);
}
});
}
})
.catch(console.error);
}, [pool, relays, pubkey]);
useEffect(() => {
getCachedMetadata().catch(console.error);
}, [getCachedMetadata]);
return profile;
};

View File

@@ -1,175 +0,0 @@
import { getParentID } from '@utils/transform';
import Database from 'tauri-plugin-sql-api';
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 all relays
export async function getAllRelays() {
const db = await connect();
const result: any = await db.select('SELECT relay_url FROM relays WHERE relay_status = "1";');
return result.reduce((relays, { relay_url }) => {
relays.push(relay_url);
return relays;
}, []);
}
// get active account
export async function getActiveAccount() {
const db = await connect();
const result = await db.select(`SELECT * FROM accounts LIMIT 1;`);
return result[0];
}
// get all accounts
export async function getAccounts() {
const db = await connect();
return await db.select(`SELECT * FROM accounts`);
}
// get all follows by account id
export async function getAllFollowsByID(id) {
const db = await connect();
return await db.select(`SELECT pubkey FROM follows WHERE account = "${id}";`);
}
// create account
export async function createAccount(data) {
const db = await connect();
return await db.execute(
'INSERT OR IGNORE INTO accounts (id, privkey, npub, nsec, metadata) VALUES (?, ?, ?, ?, ?);',
[data.pubkey, data.privkey, data.npub, data.nsec, data.metadata]
);
}
// create follow
export async function createFollow(pubkey, account, kind) {
const db = await connect();
return await db.execute('INSERT OR IGNORE INTO follows (pubkey, account, kind) VALUES (?, ?, ?);', [
pubkey,
account,
kind || 0,
]);
}
// create follow
export async function createFollows(data, account, kind) {
const db = await connect();
data.forEach(async (item) => {
await db.execute('INSERT OR IGNORE INTO follows (pubkey, account, kind) VALUES (?, ?, ?);', [
item,
account,
kind || 0,
]);
});
return 'ok';
}
// create cache profile
export async function createCacheProfile(id, metadata) {
const db = await connect();
return await db.execute('INSERT OR IGNORE INTO cache_profiles (id, metadata) VALUES (?, ?);', [id, metadata]);
}
// get cache profile
export async function getCacheProfile(id) {
const db = await connect();
const result = await db.select(`SELECT metadata FROM cache_profiles WHERE id = "${id}"`);
return result[0];
}
// get all notes
export async function getNotes(time, limit, offset) {
const db = await connect();
return await db.select(
`SELECT * FROM cache_notes WHERE created_at <= "${time}" GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}"`
);
}
// get all latest notes
export async function getLatestNotes(time) {
const db = await connect();
return await db.select(
`SELECT * FROM cache_notes WHERE created_at > "${time}" GROUP BY parent_id ORDER BY created_at DESC`
);
}
// get note by id
export async function getNoteByID(id) {
const db = await connect();
const result = await db.select(`SELECT * FROM cache_notes WHERE id = "${id}"`);
return result[0];
}
// create cache note
export async function createCacheNote(data) {
const db = await connect();
return await db.execute(
'INSERT OR IGNORE INTO cache_notes (id, pubkey, created_at, kind, content, tags, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?);',
[
data.id,
data.pubkey,
data.created_at,
data.kind,
data.content,
JSON.stringify(data.tags),
getParentID(data.tags, data.id),
]
);
}
// get all comment notes
export async function getAllCommentNotes(eid) {
const db = await connect();
return await db.select(
`SELECT * FROM cache_notes WHERE parent_comment_id = "${eid}" ORDER BY created_at DESC LIMIT 500`
);
}
// create cache comment note
export async function createCacheCommentNote(data, eid) {
const db = await connect();
return await db.execute(
'INSERT OR IGNORE INTO cache_notes (id, pubkey, created_at, kind, content, tags, parent_id, parent_comment_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?);',
[
data.id,
data.pubkey,
data.created_at,
data.kind,
data.content,
JSON.stringify(data.tags),
getParentID(data.tags, data.id),
eid,
]
);
}
// create cache comment note
export async function countTotalNotes() {
const db = await connect();
const result = await db.select('SELECT COUNT(*) AS "total" FROM cache_notes;');
return result[0];
}
// get last login time
export async function getLastLoginTime() {
const db = await connect();
const result = await db.select('SELECT setting_value FROM settings WHERE setting_key = "last_login"');
return result[0];
}
// update last login time
export async function updateLastLoginTime(time) {
const db = await connect();
return await db.execute(`UPDATE settings SET setting_value = "${time}" WHERE setting_key = "last_login"`);
}

View File

@@ -9,6 +9,15 @@ export const tagsToArray = (arr) => {
return newarr; return newarr;
}; };
export const followsTag = (arr) => {
const newarr = [];
// push item to tags
arr.forEach((item) => {
newarr.push(['p', item]);
});
return newarr;
};
export const pubkeyArray = (arr) => { export const pubkeyArray = (arr) => {
const newarr = []; const newarr = [];
// push item to newarr // push item to newarr
@@ -36,3 +45,11 @@ export const getParentID = (arr, fallback) => {
return parentID; return parentID;
}; };
export const filterDuplicateParentID = (arr) => {
const filteredArray = arr.filter(
(item, index) => index === arr.findIndex((other) => item.parent_id === other.parent_id)
);
return filteredArray;
};