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
commit 6089cdb034
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 4065 additions and 1529 deletions

2
.gitignore vendored
View File

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

View File

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

View File

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

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
/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 = { 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"] }
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"
branch = "dev"
features = ["sqlite"]
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.7", default-features = false, features = ["sqlite", "migrations", "mocking", "specta"] }
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.7", default-features = false, features = ["sqlite", "migrations", "mocking", "specta"] }
specta = "1.0.0"
tauri-specta = { version = "1.0.0", features = ["typescript"] }
tokio = { version = "1.26.0", features = ["macros"] }
[target.'cfg(target_os = "macos")'.dependencies]
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]
extern crate objc;
use prisma_client_rust::Direction;
use tauri::{Manager, WindowEvent};
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg(target_os = "macos")]
use window_ext::WindowExt;
#[cfg(target_os = "macos")]
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()
.setup(|app| {
let main_window = app.get_window("main").unwrap();
@ -25,23 +248,11 @@ fn main() {
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| {
#[cfg(target_os = "macos")]
let apply_offset = || {
let win = e.window();
// keep inset for traffic lights when window resize (macos)
win.position_traffic_lights(8.0, 20.0);
};
#[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!())
.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 { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { noteContentAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext } from 'react';
@ -15,23 +14,21 @@ import { useContext } from 'react';
export default function FormBase() {
const [pool, relays]: any = useContext(RelayContext);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [value, setValue] = useAtom(noteContentAtom);
const resetValue = useResetAtom(noteContentAtom);
const pubkey = activeAccount.id;
const privkey = activeAccount.privkey;
const submitEvent = () => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 1,
pubkey: pubkey,
pubkey: activeAccount.pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, privkey);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
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,
created_at: dateToUnix(),
kind: 1,
pubkey: activeAccount.id,
pubkey: activeAccount.pubkey,
tags: [['e', eventID]],
};
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">
<ImageWithFallback
src={profile?.picture}
alt={activeAccount.id}
alt={activeAccount.pubkey}
fill={true}
className="rounded-md object-cover"
/>

View File

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

View File

@ -1,17 +1,24 @@
import destr from 'destr';
import { DEFAULT_AVATAR } from '@stores/constants';
import Image from 'next/image';
import { memo } from 'react';
export const InactiveAccount = memo(function InactiveAccount({ user }: { user: any }) {
const userData = destr(user.metadata);
const userData = JSON.parse(user.metadata);
const setCurrentUser = () => {
console.log('clicked');
};
return (
<button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-md">
<Image src={userData.picture} alt="user's avatar" fill={true} className="rounded-md object-cover" />
<button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-lg">
<Image
src={userData.picture || DEFAULT_AVATAR}
alt="user's avatar"
fill={true}
className="rounded-lg object-cover"
priority
/>
</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(() => {
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;
}, [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 selection = window.getSelection();
@ -87,7 +92,9 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
>
<>{getParent}</>
<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="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">
@ -97,10 +104,10 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={event.id}
eventID={event.eventId}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
eventTime={event.createdAt || event.created_at}
/>
</div>
</div>

View File

@ -60,7 +60,7 @@ export const NoteComment = memo(function NoteComment({ event }: { event: any })
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 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="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">
@ -70,10 +70,10 @@ export const NoteComment = memo(function NoteComment({ event }: { event: any })
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={event.id}
eventID={event.eventId}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
eventTime={event.createdAt || event.created_at}
/>
</div>
</div>

View File

@ -1,69 +1,90 @@
import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { lastLoginAtom } from '@stores/account';
import { hasNewerNoteAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate';
import { createCacheNote, getAllFollowsByID, updateLastLoginTime } from '@utils/storage';
import { pubkeyArray } from '@utils/transform';
import { getParentID, pubkeyArray } from '@utils/transform';
import { TauriEvent } from '@tauri-apps/api/event';
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';
export default function NoteConnector() {
const [pool, relays]: any = useContext(RelayContext);
const setLastLoginAtom = useSetAtom(lastLoginAtom);
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [isOnline] = useState(true);
const now = useRef(new Date());
const subscribe = useCallback(() => {
getAllFollowsByID(activeAccount.id).then((follows) => {
pool.subscribe(
[
{
kinds: [1],
authors: pubkeyArray(follows),
since: dateToUnix(now.current),
},
],
relays,
(event: any) => {
// insert event to local database
createCacheNote(event);
setHasNewerNote(true);
}
);
});
}, [activeAccount.id, pool, relays, setHasNewerNote]);
const now = useRef(new Date());
const unsubscribe = useRef(null);
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],
authors: pubkeyArray(follows),
since: dateToUnix(now.current),
},
],
relays,
(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,
})
.then(() =>
// notify user reload to get newer note
setHasNewerNote(true)
)
.catch(console.error);
},
10000
);
}, [pool, relays, setHasNewerNote]);
useEffect(() => {
subscribe();
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
updateLastLoginTime(now.current);
setLastLoginAtom(now.current);
appWindow.close();
});
}, [activeAccount.id, pool, relays, setHasNewerNote, subscribe]);
return () => {
unsubscribe.current;
};
}, [setHasNewerNote, setLastLoginAtom, subscribe]);
return (
<>
<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={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${
isOnline ? 'bg-green-400' : 'bg-red-400'
}`}
></span>
<span
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${isOnline ? 'bg-green-400' : 'bg-amber-400'}`}
></span>
</span>
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
</div>
</>
<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={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${
isOnline ? 'bg-green-400' : 'bg-red-400'
}`}
></span>
<span
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${isOnline ? 'bg-green-400' : 'bg-amber-400'}`}
></span>
</span>
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
</div>
);
}

View File

@ -60,7 +60,7 @@ export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
return (
<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">
<UserLarge pubkey={event.pubkey} time={event.created_at} />
<UserLarge pubkey={event.pubkey} time={event.createdAt || event.created_at} />
<div className="mt-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">
@ -70,10 +70,10 @@ export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
</div>
<div className="mt-5 flex items-center border-b border-t border-zinc-800 py-2">
<NoteMetadata
eventID={event.id}
eventID={event.eventId}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
eventTime={event.createdAt || event.created_at}
/>
</div>
</div>

View File

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

View File

@ -38,7 +38,7 @@ export const NoteReaction = memo(function NoteReaction({
['p', eventPubkey],
],
created_at: dateToUnix(),
pubkey: activeAccount.id,
pubkey: activeAccount.pubkey,
};
event.id = getEventHash(event);
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 { RelayContext } from '@components/relaysProvider';
import { createCacheCommentNote } from '@utils/storage';
import { useContext, useEffect, useState } from 'react';
export default function NoteMetadata({
@ -39,7 +37,7 @@ export default function NoteMetadata({
// update state
setComments((comments) => (comments += 1));
// save comment to database
createCacheCommentNote(event, eventID);
// createCacheCommentNote(event, eventID);
break;
case 7:
if (event.content === '🤙' || event.content === '+') {

View File

@ -6,7 +6,7 @@ import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import { createCacheNote, getNoteByID } from '@utils/storage';
import { getParentID } from '@utils/transform';
import destr from 'destr';
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 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(
[
{
@ -31,7 +34,19 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
// update state
setEvent(event);
// 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,
@ -41,19 +56,26 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
);
}, [id, pool, relays]);
const checkNoteExist = useCallback(async () => {
const { getNoteById } = await import('@utils/bindings');
getNoteById({ event_id: id })
.then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
})
.catch(console.error);
}, [fetchEvent, id]);
useEffect(() => {
getNoteByID(id).then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
});
checkNoteExist();
return () => {
unsubscribe.current;
};
}, [fetchEvent, id]);
}, [checkNoteExist]);
const content = useMemo(() => {
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="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">
<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="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">
@ -120,10 +142,10 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={event.id}
eventID={event.eventId}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
eventTime={event.createdAt || event.created_at}
/>
</div>
</div>

View File

@ -2,7 +2,7 @@ import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import { createCacheNote, getNoteByID } from '@utils/storage';
import { getParentID } from '@utils/transform';
import destr from 'destr';
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 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(
[
{
@ -27,7 +30,19 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
// update state
setEvent(event);
// 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,
@ -37,19 +52,26 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
);
}, [id, pool, relays]);
const checkNoteExist = useCallback(async () => {
const { getNoteById } = await import('@utils/bindings');
getNoteById({ event_id: id })
.then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
})
.catch(console.error);
}, [fetchEvent, id]);
useEffect(() => {
getNoteByID(id).then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
});
checkNoteExist();
return () => {
unsubscribe.current;
};
}, [fetchEvent, id]);
}, [checkNoteExist]);
const content = useMemo(() => {
let parsedContent = event ? event.content : null;
@ -89,7 +111,7 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
return (
<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">
<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="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">

View File

@ -2,32 +2,13 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { createCacheProfile } from '@utils/storage';
import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
import { fetch } from '@tauri-apps/api/http';
import destr from 'destr';
import { memo, useCallback, useEffect, useState } from 'react';
import { memo } from 'react';
export const UserBase = memo(function UserBase({ 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]);
const profile = useMetadata(pubkey);
return (
<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 { createCacheProfile, getCacheProfile } from '@utils/storage';
import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
import { fetch } from '@tauri-apps/api/http';
import dayjs from 'dayjs';
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);
export const UserExtend = memo(function UserExtend({ pubkey, time }: { pubkey: string; time: any }) {
const router = useRouter();
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]);
export const UserExtend = ({ pubkey, time }: { pubkey: string; time: number }) => {
const profile = useMetadata(pubkey);
return (
<div className="group flex items-start gap-2">
<div
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"
>
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
<ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
fill={true}
className="rounded-md border border-white/10 object-cover"
className="rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full justify-between">
<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, ' .... ')}
</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>
);
});
};

View File

@ -2,32 +2,11 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { createCacheProfile } from '@utils/storage';
import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
import { fetch } from '@tauri-apps/api/http';
import destr from 'destr';
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]);
export const UserFollow = ({ pubkey }: { pubkey: string }) => {
const profile = useMetadata(pubkey);
return (
<div className="flex items-center gap-2">
@ -47,4 +26,4 @@ export const UserFollow = memo(function UserFollow({ pubkey }: { pubkey: string
</div>
</div>
);
});
};

View File

@ -2,47 +2,21 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { createCacheProfile, getCacheProfile } from '@utils/storage';
import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
import { fetch } from '@tauri-apps/api/http';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import destr from 'destr';
import { memo, useCallback, useEffect, useState } from 'react';
dayjs.extend(relativeTime);
export const UserLarge = memo(function UserLarge({ pubkey, time }: { pubkey: string; time: any }) {
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(() => {
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]);
export const UserLarge = ({ pubkey, time }: { pubkey: string; time: number }) => {
const profile = useMetadata(pubkey);
return (
<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
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
@ -69,4 +43,4 @@ export const UserLarge = memo(function UserLarge({ pubkey, time }: { pubkey: str
</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 { fetch } from '@tauri-apps/api/http';
import destr from 'destr';
import { memo, useCallback, useEffect, useState } from 'react';
export const UserMention = memo(function UserMention({ 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(() => {
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>;
});
export const UserMention = ({ pubkey }: { pubkey: string }) => {
const profile = useMetadata(pubkey);
return (
<span className="cursor-pointer text-fuchsia-500">
@{profile?.name || profile?.username || 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 AccountColumn from '@components/columns/account';
import NavigatorColumn from '@components/columns/navigator';
import MultiAccounts from '@components/multiAccounts';
import Navigation from '@components/navigation';
export default function WithSidebarLayout({ children }: { children: React.ReactNode }) {
return (
@ -13,11 +13,11 @@ export default function WithSidebarLayout({ children }: { children: React.ReactN
</div>
<div className="relative flex min-h-0 w-full flex-1">
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
<AccountColumn />
<MultiAccounts />
</div>
<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">
<NavigatorColumn />
<Navigation />
</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="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 { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import { ReactElement, ReactNode } from 'react';
import '../App.css';
@ -16,8 +17,9 @@ type AppPropsWithLayout = AppProps & {
};
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const router = useRouter();
// Use the layout defined at the page level, if available
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 { activeAccountAtom } from '@stores/account';
import { getActiveAccount } from '@utils/storage';
import { activeAccountAtom, activeAccountFollowsAtom } from '@stores/account';
import LumeSymbol from '@assets/icons/Lume';
import { useSetAtom } from 'jotai';
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() {
const router = useRouter();
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(() => {
getActiveAccount()
fetchActiveAccount()
.then((res: any) => {
if (res) {
if (res.length > 0) {
// fetch follows
fetchFollowsByAccount(res[0].id).then((follows) => {
setActiveAccountFollows(follows);
});
// update local storage
setActiveAccount(res);
setActiveAccount(res[0]);
// redirect
router.replace('/init');
} else {
@ -27,7 +40,7 @@ export default function Page() {
}
})
.catch(console.error);
}, [router, setActiveAccount]);
}, [fetchActiveAccount, setActiveAccount, fetchFollowsByAccount, setActiveAccountFollows, router]);
return (
<div className="relative h-full overflow-hidden">

View File

@ -2,16 +2,12 @@ import BaseLayout from '@layouts/base';
import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { relaysAtom } from '@stores/relays';
import { dateToUnix, hoursAgo } from '@utils/getDate';
import { countTotalNotes, createCacheNote, getAllFollowsByID, getLastLoginTime } from '@utils/storage';
import { pubkeyArray } from '@utils/transform';
import { getParentID, pubkeyArray } from '@utils/transform';
import LumeSymbol from '@assets/icons/Lume';
import { useAtomValue } from 'jotai';
import { invoke } from '@tauri-apps/api/tauri';
import { useRouter } from 'next/router';
import {
JSXElementConstructor,
@ -29,65 +25,74 @@ export default function Page() {
const router = useRouter();
const [pool, relays]: any = useContext(RelayContext);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [done, setDone] = useState(false);
const now = useRef(new Date());
const unsubscribe = useRef(null);
const timer = useRef(null);
const [eose, setEose] = useState(false);
const fetchData = useCallback(
(since) => {
getAllFollowsByID(activeAccount.id).then((follows) => {
unsubscribe.current = pool.subscribe(
[
{
kinds: [1],
authors: pubkeyArray(follows),
since: dateToUnix(since),
until: dateToUnix(now.current),
},
],
relays,
(event) => {
// insert event to local database
createCacheNote(event);
},
undefined,
() => {
// wait for 8 seconds
timer.current = setTimeout(() => setDone(true), 8000);
},
async (since: Date) => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const follows = JSON.parse(localStorage.getItem('activeAccountFollows'));
unsubscribe.current = pool.subscribe(
[
{
unsubscribeOnEose: true,
}
);
});
kinds: [1],
authors: pubkeyArray(follows),
since: dateToUnix(since),
until: dateToUnix(now.current),
},
],
relays,
(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,
() => {
setEose(true);
}
);
},
[activeAccount.id, pool, relays]
[pool, relays]
);
const isNoteExist = useCallback(async () => {
invoke('count_total_notes').then((res: number) => {
if (res > 0) {
const lastLogin = JSON.parse(localStorage.getItem('lastLogin'));
const parseDate = new Date(lastLogin);
fetchData(parseDate);
} else {
fetchData(hoursAgo(24, now.current));
}
});
}, [fetchData]);
useEffect(() => {
if (!done) {
countTotalNotes().then((count) => {
if (count.total === 0) {
fetchData(hoursAgo(24, now.current));
} else {
getLastLoginTime().then((time) => {
const parseDate = new Date(time.setting_value);
fetchData(parseDate);
});
}
});
if (eose === false) {
isNoteExist();
} else {
router.replace('/newsfeed/following');
}
return () => {
unsubscribe.current;
clearTimeout(timer.current);
};
}, [activeAccount.id, done, pool, relays, router, fetchData]);
}, [router, eose, isNoteExist]);
return (
<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 { RelayContext } from '@components/relaysProvider';
import { getAllCommentNotes, getNoteByID } from '@utils/storage';
import { useRouter } from 'next/router';
import {
JSXElementConstructor,
@ -29,12 +27,12 @@ export default function Page() {
const [comments, setComments] = useState([]);
useEffect(() => {
getNoteByID(id)
/*getNoteByID(id)
.then((res) => {
setRootEvent(res);
getAllCommentNotes(id).then((res: any) => setComments(res));
})
.catch(console.error);
.catch(console.error);*/
}, [id, pool, relays]);
return (

View File

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

View File

@ -2,13 +2,20 @@ import BaseLayout from '@layouts/base';
import { RelayContext } from '@components/relaysProvider';
import { createAccount } from '@utils/storage';
import { ArrowLeftIcon, EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
import Image from 'next/image';
import { useRouter } from 'next/router';
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';
const config: Config = {
@ -34,28 +41,16 @@ export default function Page() {
};
// auto-generated profile metadata
const metadata = useMemo(
const metadata: any = useMemo(
() => ({
display_name: name,
name: name,
username: name.toLowerCase(),
picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89',
picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp',
}),
[name]
);
// build profile
const data = useMemo(
() => ({
pubkey: pubKey,
privkey: privKey,
npub: npub,
nsec: nsec,
metadata: metadata,
}),
[metadata, npub, nsec, privKey, pubKey]
);
// toggle privatek key
const showPrivateKey = () => {
if (type === 'password') {
@ -66,7 +61,8 @@ export default function Page() {
};
// create account and broadcast to all relays
const submit = () => {
const submit = useCallback(async () => {
const { createAccount } = await import('@utils/bindings');
setLoading(true);
// build event
@ -81,16 +77,16 @@ export default function Page() {
event.sig = signEvent(event, privKey);
// insert to database then broadcast
createAccount(data)
.then(() => {
createAccount({ pubkey: pubKey, privkey: privKey, metadata: metadata })
.then((res) => {
pool.publish(event, relays);
router.push({
pathname: '/onboarding/create/step-2',
query: { id: pubKey, privkey: privKey },
query: { id: res.id, pubkey: res.pubkey, privkey: res.privkey },
});
})
.catch(console.error);
};
}, [pool, pubKey, privKey, metadata, relays, router]);
return (
<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 { 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 { createClient } from '@supabase/supabase-js';
@ -15,6 +16,7 @@ import {
ReactElement,
ReactFragment,
ReactPortal,
useCallback,
useContext,
useEffect,
useState,
@ -64,7 +66,7 @@ export default function Page() {
const [pool, relays]: any = useContext(RelayContext);
const router = useRouter();
const { id, privkey }: any = router.query || '';
const { id, pubkey, privkey }: any = router.query || '';
const [loading, setLoading] = useState(false);
const [list, setList]: any = useState(initialList);
@ -76,41 +78,36 @@ export default function Page() {
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
const submit = () => {
const submit = useCallback(async () => {
const { createPleb } = await import('@utils/bindings');
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
const event: any = {
content: '',
created_at: Math.floor(Date.now() / 1000),
kind: 3,
pubkey: id,
tags: tags(),
pubkey: pubkey,
tags: followsTag(follows),
};
event.id = getEventHash(event);
event.sig = signEvent(event, privkey);
createFollows(follows, id, 0)
.then((res) => {
if (res === 'ok') {
// publish to relays
pool.publish(event, relays);
router.replace('/');
}
})
.catch(console.error);
};
pool.publish(event, relays);
router.replace('/');
}, [follows, id, pool, pubkey, privkey, relays, router]);
useEffect(() => {
const fetchData = async () => {

View File

@ -2,21 +2,23 @@ import BaseLayout from '@layouts/base';
import { RelayContext } from '@components/relaysProvider';
import { createAccount, createFollows } from '@utils/storage';
import { tagsToArray } from '@utils/transform';
import { DEFAULT_AVATAR } from '@stores/constants';
import { fetchMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
import destr from 'destr';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { getPublicKey, nip19 } from 'nostr-tools';
import { getPublicKey } from 'nostr-tools';
import {
JSXElementConstructor,
ReactElement,
ReactFragment,
ReactPortal,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
@ -27,9 +29,40 @@ export default function Page() {
const privkey: any = router.query.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 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(() => {
const unsubscribe = pool.subscribe(
[
@ -42,34 +75,23 @@ export default function Page() {
relays,
(event: any) => {
if (event.kind === 0) {
const data = {
pubkey: pubkey,
privkey: privkey,
npub: nip19.npubEncode(pubkey),
nsec: nip19.nsecEncode(privkey),
metadata: event.content,
};
setProfile(destr(event.content));
createAccount(data);
insertAccountToStorage(pubkey, privkey, event.content);
} else {
if (event.tags.length > 0) {
createFollows(tagsToArray(event.tags), pubkey, 0);
insertFollowsToStorage(event.tags);
}
}
},
undefined,
() => {
setDone(true);
},
{
unsubscribeOnEose: true,
}
);
return () => {
unsubscribe;
};
}, [pool, privkey, pubkey, relays]);
}, [insertAccountToStorage, insertFollowsToStorage, pool, relays, privkey, pubkey]);
// submit then redirect to home
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="flex space-x-4">
<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 className="flex-1 space-y-4 py-1">
<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>
<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 className="space-y-3">
<div className="grid grid-cols-3 gap-4">

View File

@ -10,3 +10,5 @@ const 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';

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