Merge pull request #23 from reyamir/feat/chats
Implemented e2e encrypted direct message and new data model
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,6 +15,8 @@ out
|
|||||||
.next
|
.next
|
||||||
.vscode
|
.vscode
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ Install dependencies
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Generate prisma database
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm init-db
|
||||||
|
```
|
||||||
|
|
||||||
Run development window
|
Run development window
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -6,6 +6,7 @@
|
|||||||
"dev": "next dev -p 1420",
|
"dev": "next dev -p 1420",
|
||||||
"build": "next build && next export -o dist",
|
"build": "next build && next export -o dist",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
|
"init-db": "cd src-tauri/ && cargo prisma generate",
|
||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -20,24 +21,22 @@
|
|||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-popover": "^1.0.5",
|
"@radix-ui/react-popover": "^1.0.5",
|
||||||
"@radix-ui/react-tabs": "^1.0.3",
|
"@radix-ui/react-tabs": "^1.0.3",
|
||||||
"@supabase/supabase-js": "^2.13.0",
|
"@supabase/supabase-js": "^2.15.0",
|
||||||
"@tauri-apps/api": "^1.2.0",
|
"@tauri-apps/api": "^1.2.0",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"destr": "^1.2.2",
|
"destr": "^1.2.2",
|
||||||
"emoji-mart": "^5.5.2",
|
"emoji-mart": "^5.5.2",
|
||||||
"framer-motion": "^9.1.7",
|
"framer-motion": "^9.1.7",
|
||||||
"jotai": "^2.0.3",
|
"jotai": "^2.0.3",
|
||||||
"jotai-cache": "^0.3.0",
|
"next": "^13.3.0",
|
||||||
"next": "^13.2.4",
|
|
||||||
"nostr-relaypool": "^0.5.18",
|
"nostr-relaypool": "^0.5.18",
|
||||||
"nostr-tools": "^1.8.1",
|
"nostr-tools": "^1.8.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-player": "^2.12.0",
|
"react-player": "^2.12.0",
|
||||||
"react-string-replace": "^1.1.0",
|
"react-string-replace": "^1.1.0",
|
||||||
"react-virtuoso": "^4.1.1",
|
"react-virtuoso": "^4.2.0",
|
||||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
@@ -46,14 +45,14 @@
|
|||||||
"@tauri-apps/cli": "^1.2.3",
|
"@tauri-apps/cli": "^1.2.3",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
|
||||||
"@types/node": "^18.15.11",
|
"@types/node": "^18.15.11",
|
||||||
"@types/react": "^18.0.31",
|
"@types/react": "^18.0.33",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||||
"@typescript-eslint/parser": "^5.57.0",
|
"@typescript-eslint/parser": "^5.57.1",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"csstype": "^3.1.1",
|
"csstype": "^3.1.2",
|
||||||
"eslint": "^8.37.0",
|
"eslint": "^8.37.0",
|
||||||
"eslint-config-next": "^13.2.4",
|
"eslint-config-next": "^13.3.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
@@ -61,7 +60,7 @@
|
|||||||
"lint-staged": "^13.2.0",
|
"lint-staged": "^13.2.0",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"prettier": "^2.8.7",
|
"prettier": "^2.8.7",
|
||||||
"prettier-plugin-tailwindcss": "^0.2.6",
|
"prettier-plugin-tailwindcss": "^0.2.7",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"tailwindcss": "^3.3.1",
|
"tailwindcss": "^3.3.1",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^4.9.5"
|
||||||
|
|||||||
473
pnpm-lock.yaml
generated
473
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
src-tauri/.cargo/config.toml
Normal file
2
src-tauri/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[alias]
|
||||||
|
prisma = "run --bin prisma --"
|
||||||
2
src-tauri/.gitignore
vendored
2
src-tauri/.gitignore
vendored
@@ -2,3 +2,5 @@
|
|||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
|
|
||||||
|
# prisma
|
||||||
|
src/db.rs
|
||||||
|
|||||||
2407
src-tauri/Cargo.lock
generated
2407
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,11 @@ tauri-build = { version = "1.2", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tauri = { version = "1.2", features = ["clipboard-read-text", "clipboard-write-text", "http-request", "os-all", "shell-open", "system-tray", "window-close", "window-start-dragging"] }
|
tauri = { version = "1.2", features = ["clipboard-read-text", "clipboard-write-text", "http-request", "os-all", "shell-open", "system-tray", "window-close", "window-start-dragging"] }
|
||||||
|
prisma-client-rust = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.7", default-features = false, features = ["sqlite", "migrations", "mocking", "specta"] }
|
||||||
[dependencies.tauri-plugin-sql]
|
prisma-client-rust-cli = { git = "https://github.com/Brendonovich/prisma-client-rust", tag = "0.6.7", default-features = false, features = ["sqlite", "migrations", "mocking", "specta"] }
|
||||||
git = "https://github.com/tauri-apps/plugins-workspace"
|
specta = "1.0.0"
|
||||||
branch = "dev"
|
tauri-specta = { version = "1.0.0", features = ["typescript"] }
|
||||||
features = ["sqlite"]
|
tokio = { version = "1.26.0", features = ["macros"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc = "0.2.7"
|
objc = "0.2.7"
|
||||||
|
|||||||
@@ -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");
|
|
||||||
0
src-tauri/prisma/migrations/.keep
Normal file
0
src-tauri/prisma/migrations/.keep
Normal 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");
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Pleb_pubkey_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Pleb_pubkey_key";
|
||||||
@@ -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;
|
||||||
3
src-tauri/prisma/migrations/migration_lock.toml
Normal file
3
src-tauri/prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
79
src-tauri/prisma/schema.prisma
Normal file
79
src-tauri/prisma/schema.prisma
Normal 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
|
||||||
|
}
|
||||||
3
src-tauri/src/bin/prisma.rs
Normal file
3
src-tauri/src/bin/prisma.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
prisma_client_rust_cli::run();
|
||||||
|
}
|
||||||
@@ -7,15 +7,238 @@
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate objc;
|
extern crate objc;
|
||||||
|
|
||||||
|
use prisma_client_rust::Direction;
|
||||||
use tauri::{Manager, WindowEvent};
|
use tauri::{Manager, WindowEvent};
|
||||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use window_ext::WindowExt;
|
use window_ext::WindowExt;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod window_ext;
|
mod window_ext;
|
||||||
|
|
||||||
fn main() {
|
mod db;
|
||||||
|
|
||||||
|
use db::*;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use specta::{collect_types, Type};
|
||||||
|
use std::{sync::Arc, vec};
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_specta::ts;
|
||||||
|
|
||||||
|
type DbState<'a> = State<'a, Arc<PrismaClient>>;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct CreateAccountData {
|
||||||
|
pubkey: String,
|
||||||
|
privkey: String,
|
||||||
|
metadata: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct GetPlebData {
|
||||||
|
account_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct GetPlebPubkeyData {
|
||||||
|
pubkey: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct CreatePlebData {
|
||||||
|
pleb_id: String,
|
||||||
|
pubkey: String,
|
||||||
|
kind: i32,
|
||||||
|
metadata: String,
|
||||||
|
account_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct CreateNoteData {
|
||||||
|
event_id: String,
|
||||||
|
pubkey: String,
|
||||||
|
kind: i32,
|
||||||
|
tags: String,
|
||||||
|
content: String,
|
||||||
|
parent_id: String,
|
||||||
|
parent_comment_id: String,
|
||||||
|
created_at: i32,
|
||||||
|
account_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct GetNoteByIdData {
|
||||||
|
event_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct GetNoteData {
|
||||||
|
date: i32,
|
||||||
|
limit: i32,
|
||||||
|
offset: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Type)]
|
||||||
|
struct GetLatestNoteData {
|
||||||
|
date: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn get_accounts(db: DbState<'_>) -> Result<Vec<account::Data>, ()> {
|
||||||
|
db.account()
|
||||||
|
.find_many(vec![account::active::equals(false)])
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn create_account(db: DbState<'_>, data: CreateAccountData) -> Result<account::Data, ()> {
|
||||||
|
db.account()
|
||||||
|
.create(data.pubkey, data.privkey, data.metadata, vec![])
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn get_plebs(db: DbState<'_>, data: GetPlebData) -> Result<Vec<pleb::Data>, ()> {
|
||||||
|
db.pleb()
|
||||||
|
.find_many(vec![pleb::account_id::equals(data.account_id)])
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn get_pleb_by_pubkey(
|
||||||
|
db: DbState<'_>,
|
||||||
|
data: GetPlebPubkeyData,
|
||||||
|
) -> Result<Option<pleb::Data>, ()> {
|
||||||
|
db.pleb()
|
||||||
|
.find_first(vec![pleb::pubkey::equals(data.pubkey)])
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn create_pleb(db: DbState<'_>, data: CreatePlebData) -> Result<pleb::Data, ()> {
|
||||||
|
let pleb_id = data.pleb_id.clone();
|
||||||
|
let metadata = data.metadata.clone();
|
||||||
|
|
||||||
|
db.pleb()
|
||||||
|
.upsert(
|
||||||
|
pleb::pleb_id::equals(pleb_id),
|
||||||
|
pleb::create(
|
||||||
|
data.pleb_id,
|
||||||
|
data.pubkey,
|
||||||
|
data.kind,
|
||||||
|
data.metadata,
|
||||||
|
account::id::equals(data.account_id),
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
vec![pleb::metadata::set(metadata)],
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn create_note(db: DbState<'_>, data: CreateNoteData) -> Result<note::Data, ()> {
|
||||||
|
let event_id = data.event_id.clone();
|
||||||
|
let content = data.content.clone();
|
||||||
|
|
||||||
|
db.note()
|
||||||
|
.upsert(
|
||||||
|
note::event_id::equals(event_id),
|
||||||
|
note::create(
|
||||||
|
data.event_id,
|
||||||
|
data.pubkey,
|
||||||
|
data.kind,
|
||||||
|
data.tags,
|
||||||
|
data.content,
|
||||||
|
data.parent_id,
|
||||||
|
data.parent_comment_id,
|
||||||
|
data.created_at,
|
||||||
|
account::id::equals(data.account_id),
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
|
vec![note::content::set(content)],
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn get_notes(db: DbState<'_>, data: GetNoteData) -> Result<Vec<note::Data>, ()> {
|
||||||
|
db.note()
|
||||||
|
.find_many(vec![note::created_at::lte(data.date)])
|
||||||
|
.order_by(note::created_at::order(Direction::Desc))
|
||||||
|
.take(data.limit.into())
|
||||||
|
.skip(data.offset.into())
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn get_latest_notes(db: DbState<'_>, data: GetLatestNoteData) -> Result<Vec<note::Data>, ()> {
|
||||||
|
db.note()
|
||||||
|
.find_many(vec![note::created_at::gt(data.date)])
|
||||||
|
.order_by(note::created_at::order(Direction::Desc))
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
async fn get_note_by_id(db: DbState<'_>, data: GetNoteByIdData) -> Result<Option<note::Data>, ()> {
|
||||||
|
db.note()
|
||||||
|
.find_unique(note::event_id::equals(data.event_id))
|
||||||
|
.exec()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn count_total_notes(db: DbState<'_>) -> Result<i64, ()> {
|
||||||
|
db.note().count(vec![]).exec().await.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let db = PrismaClient::_builder().build().await.unwrap();
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
ts::export(
|
||||||
|
collect_types![
|
||||||
|
get_accounts,
|
||||||
|
create_account,
|
||||||
|
get_plebs,
|
||||||
|
get_pleb_by_pubkey,
|
||||||
|
create_pleb,
|
||||||
|
create_note,
|
||||||
|
get_notes,
|
||||||
|
get_latest_notes,
|
||||||
|
get_note_by_id
|
||||||
|
],
|
||||||
|
"../src/utils/bindings.ts",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
db._db_push().await.unwrap();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let main_window = app.get_window("main").unwrap();
|
let main_window = app.get_window("main").unwrap();
|
||||||
@@ -25,23 +248,11 @@ fn main() {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(
|
|
||||||
tauri_plugin_sql::Builder::default()
|
|
||||||
.add_migrations(
|
|
||||||
"sqlite:lume.db",
|
|
||||||
vec![Migration {
|
|
||||||
version: 1,
|
|
||||||
description: "create default tables",
|
|
||||||
sql: include_str!("../migrations/20230226004139_create_tables.sql"),
|
|
||||||
kind: MigrationKind::Up,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.on_window_event(|e| {
|
.on_window_event(|e| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let apply_offset = || {
|
let apply_offset = || {
|
||||||
let win = e.window();
|
let win = e.window();
|
||||||
|
// keep inset for traffic lights when window resize (macos)
|
||||||
win.position_traffic_lights(8.0, 20.0);
|
win.position_traffic_lights(8.0, 20.0);
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -51,6 +262,19 @@ fn main() {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
get_accounts,
|
||||||
|
create_account,
|
||||||
|
get_plebs,
|
||||||
|
get_pleb_by_pubkey,
|
||||||
|
create_pleb,
|
||||||
|
create_note,
|
||||||
|
get_notes,
|
||||||
|
get_latest_notes,
|
||||||
|
get_note_by_id,
|
||||||
|
count_total_notes
|
||||||
|
])
|
||||||
|
.manage(Arc::new(db))
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/components/chats/chatList.tsx
Normal file
77
src/components/chats/chatList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/chats/chatListItem.tsx
Normal file
41
src/components/chats/chatListItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
src/components/chats/chatModal.tsx
Normal file
63
src/components/chats/chatModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/chats/chatModalUser.tsx
Normal file
48
src/components/chats/chatModalUser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
src/components/chats/messageList.tsx
Normal file
46
src/components/chats/messageList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
src/components/chats/messageListItem.tsx
Normal file
51
src/components/chats/messageListItem.tsx
Normal 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);
|
||||||
37
src/components/chats/messageUser.tsx
Normal file
37
src/components/chats/messageUser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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))}</>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,11 @@ import EmojiPicker from '@components/form/emojiPicker';
|
|||||||
import ImagePicker from '@components/form/imagePicker';
|
import ImagePicker from '@components/form/imagePicker';
|
||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { activeAccountAtom } from '@stores/account';
|
|
||||||
import { noteContentAtom } from '@stores/note';
|
import { noteContentAtom } from '@stores/note';
|
||||||
|
|
||||||
import { dateToUnix } from '@utils/getDate';
|
import { dateToUnix } from '@utils/getDate';
|
||||||
|
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { useResetAtom } from 'jotai/utils';
|
import { useResetAtom } from 'jotai/utils';
|
||||||
import { getEventHash, signEvent } from 'nostr-tools';
|
import { getEventHash, signEvent } from 'nostr-tools';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
@@ -15,23 +14,21 @@ import { useContext } from 'react';
|
|||||||
export default function FormBase() {
|
export default function FormBase() {
|
||||||
const [pool, relays]: any = useContext(RelayContext);
|
const [pool, relays]: any = useContext(RelayContext);
|
||||||
|
|
||||||
const activeAccount: any = useAtomValue(activeAccountAtom);
|
|
||||||
const [value, setValue] = useAtom(noteContentAtom);
|
const [value, setValue] = useAtom(noteContentAtom);
|
||||||
const resetValue = useResetAtom(noteContentAtom);
|
const resetValue = useResetAtom(noteContentAtom);
|
||||||
|
|
||||||
const pubkey = activeAccount.id;
|
|
||||||
const privkey = activeAccount.privkey;
|
|
||||||
|
|
||||||
const submitEvent = () => {
|
const submitEvent = () => {
|
||||||
|
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
|
||||||
|
|
||||||
const event: any = {
|
const event: any = {
|
||||||
content: value,
|
content: value,
|
||||||
created_at: dateToUnix(),
|
created_at: dateToUnix(),
|
||||||
kind: 1,
|
kind: 1,
|
||||||
pubkey: pubkey,
|
pubkey: activeAccount.pubkey,
|
||||||
tags: [],
|
tags: [],
|
||||||
};
|
};
|
||||||
event.id = getEventHash(event);
|
event.id = getEventHash(event);
|
||||||
event.sig = signEvent(event, privkey);
|
event.sig = signEvent(event, activeAccount.privkey);
|
||||||
|
|
||||||
// publish note
|
// publish note
|
||||||
pool.publish(event, relays);
|
pool.publish(event, relays);
|
||||||
|
|||||||
79
src/components/form/chat.tsx
Normal file
79
src/components/form/chat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export default function FormComment({ eventID }: { eventID: any }) {
|
|||||||
content: value,
|
content: value,
|
||||||
created_at: dateToUnix(),
|
created_at: dateToUnix(),
|
||||||
kind: 1,
|
kind: 1,
|
||||||
pubkey: activeAccount.id,
|
pubkey: activeAccount.pubkey,
|
||||||
tags: [['e', eventID]],
|
tags: [['e', eventID]],
|
||||||
};
|
};
|
||||||
event.id = getEventHash(event);
|
event.id = getEventHash(event);
|
||||||
@@ -42,7 +42,7 @@ export default function FormComment({ eventID }: { eventID: any }) {
|
|||||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
|
||||||
<ImageWithFallback
|
<ImageWithFallback
|
||||||
src={profile?.picture}
|
src={profile?.picture}
|
||||||
alt={activeAccount.id}
|
alt={activeAccount.pubkey}
|
||||||
fill={true}
|
fill={true}
|
||||||
className="rounded-md object-cover"
|
className="rounded-md object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,50 +1,65 @@
|
|||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { dateToUnix } from '@utils/getDate';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
import { createFollows } from '@utils/storage';
|
|
||||||
import { tagsToArray } from '@utils/transform';
|
import { fetchMetadata } from '@utils/metadata';
|
||||||
|
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import { AvatarIcon, ExitIcon, GearIcon } from '@radix-ui/react-icons';
|
import { AvatarIcon, ExitIcon, GearIcon } from '@radix-ui/react-icons';
|
||||||
import { writeText } from '@tauri-apps/api/clipboard';
|
import { writeText } from '@tauri-apps/api/clipboard';
|
||||||
import destr from 'destr';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { memo, useContext, useEffect, useRef } from 'react';
|
import { memo, useCallback, useContext, useEffect } from 'react';
|
||||||
|
|
||||||
export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }) {
|
export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }) {
|
||||||
const [pool, relays]: any = useContext(RelayContext);
|
const [pool, relays]: any = useContext(RelayContext);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const userData = destr(user.metadata);
|
const userData = JSON.parse(user.metadata);
|
||||||
|
|
||||||
const now = useRef(new Date());
|
|
||||||
|
|
||||||
const openProfilePage = () => {
|
const openProfilePage = () => {
|
||||||
router.push(`/users/${user.id}`);
|
router.push(`/users/${user.pubkey}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyPublicKey = async () => {
|
const copyPublicKey = async () => {
|
||||||
await writeText(nip19.npubEncode(user.id));
|
await writeText(nip19.npubEncode(user.pubkey));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const insertFollowsToStorage = useCallback(
|
||||||
|
async (tags) => {
|
||||||
|
const { createPleb } = await import('@utils/bindings');
|
||||||
|
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
const metadata: any = await fetchMetadata(tag[1], pool, relays);
|
||||||
|
createPleb({
|
||||||
|
pleb_id: tag[1] + '-lume' + activeAccount.id.toString(),
|
||||||
|
pubkey: tag[1],
|
||||||
|
kind: 0,
|
||||||
|
metadata: metadata.content,
|
||||||
|
account_id: activeAccount.id,
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pool, relays]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = pool.subscribe(
|
const unsubscribe = pool.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [3],
|
kinds: [3],
|
||||||
authors: [user.id],
|
authors: [user.pubkey],
|
||||||
since: dateToUnix(now.current),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
relays,
|
relays,
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (event.tags.length > 0) {
|
if (event.tags.length > 0) {
|
||||||
createFollows(tagsToArray(event.tags), user.id, 0);
|
insertFollowsToStorage(event.tags);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
20000,
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
unsubscribeOnEose: true,
|
unsubscribeOnEose: true,
|
||||||
@@ -54,19 +69,17 @@ export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe;
|
unsubscribe;
|
||||||
};
|
};
|
||||||
}, [pool, relays, user.id]);
|
}, [insertFollowsToStorage, pool, relays, user.pubkey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button className="relative h-11 w-11 rounded-md">
|
<button className="relative h-11 w-11 rounded-lg">
|
||||||
<Image
|
<Image
|
||||||
src={userData.picture}
|
src={userData.picture || DEFAULT_AVATAR}
|
||||||
alt="user's avatar"
|
alt="user's avatar"
|
||||||
fill={true}
|
fill={true}
|
||||||
className="rounded-md object-cover"
|
className="rounded-lg object-cover"
|
||||||
placeholder="blur"
|
|
||||||
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
|
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
import destr from 'destr';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
export const InactiveAccount = memo(function InactiveAccount({ user }: { user: any }) {
|
export const InactiveAccount = memo(function InactiveAccount({ user }: { user: any }) {
|
||||||
const userData = destr(user.metadata);
|
const userData = JSON.parse(user.metadata);
|
||||||
|
|
||||||
const setCurrentUser = () => {
|
const setCurrentUser = () => {
|
||||||
console.log('clicked');
|
console.log('clicked');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-md">
|
<button onClick={() => setCurrentUser()} className="relative h-11 w-11 shrink rounded-lg">
|
||||||
<Image src={userData.picture} alt="user's avatar" fill={true} className="rounded-md object-cover" />
|
<Image
|
||||||
|
src={userData.picture || DEFAULT_AVATAR}
|
||||||
|
alt="user's avatar"
|
||||||
|
fill={true}
|
||||||
|
className="rounded-lg object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
61
src/components/multiAccounts/index.tsx
Normal file
61
src/components/multiAccounts/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/navigation/channels.tsx
Normal file
25
src/components/navigation/channels.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/navigation/chats.tsx
Normal file
29
src/components/navigation/chats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/navigation/index.tsx
Normal file
16
src/components/navigation/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/navigation/newsfeed.tsx
Normal file
42
src/components/navigation/newsfeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,13 +63,18 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
|
|||||||
|
|
||||||
const getParent = useMemo(() => {
|
const getParent = useMemo(() => {
|
||||||
if (event.parent_id) {
|
if (event.parent_id) {
|
||||||
if (event.parent_id !== event.id && !event.content.includes('#[0]')) {
|
if (event.parent_id !== event.eventId && !event.content.includes('#[0]')) {
|
||||||
return <NoteParent id={event.parent_id} />;
|
return <NoteParent id={event.parent_id} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}, [event.content, event.id, event.parent_id]);
|
}, [event.content, event.eventId, event.parent_id]);
|
||||||
|
|
||||||
|
const openUserPage = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/users/${event.pubkey}`);
|
||||||
|
};
|
||||||
|
|
||||||
const openThread = (e) => {
|
const openThread = (e) => {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -87,7 +92,9 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
|
|||||||
>
|
>
|
||||||
<>{getParent}</>
|
<>{getParent}</>
|
||||||
<div className="relative z-10 flex flex-col">
|
<div className="relative z-10 flex flex-col">
|
||||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
<div onClick={(e) => openUserPage(e)}>
|
||||||
|
<UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
|
||||||
|
</div>
|
||||||
<div className="-mt-5 pl-[52px]">
|
<div className="-mt-5 pl-[52px]">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||||
@@ -97,10 +104,10 @@ export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
|
|||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||||
<NoteMetadata
|
<NoteMetadata
|
||||||
eventID={event.id}
|
eventID={event.eventId}
|
||||||
eventPubkey={event.pubkey}
|
eventPubkey={event.pubkey}
|
||||||
eventContent={event.content}
|
eventContent={event.content}
|
||||||
eventTime={event.created_at}
|
eventTime={event.createdAt || event.created_at}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const NoteComment = memo(function NoteComment({ event }: { event: any })
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20">
|
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20">
|
||||||
<div className="relative z-10 flex flex-col">
|
<div className="relative z-10 flex flex-col">
|
||||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
<UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
|
||||||
<div className="-mt-5 pl-[52px]">
|
<div className="-mt-5 pl-[52px]">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||||
@@ -70,10 +70,10 @@ export const NoteComment = memo(function NoteComment({ event }: { event: any })
|
|||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||||
<NoteMetadata
|
<NoteMetadata
|
||||||
eventID={event.id}
|
eventID={event.eventId}
|
||||||
eventPubkey={event.pubkey}
|
eventPubkey={event.pubkey}
|
||||||
eventContent={event.content}
|
eventContent={event.content}
|
||||||
eventTime={event.created_at}
|
eventTime={event.createdAt || event.created_at}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { activeAccountAtom } from '@stores/account';
|
import { lastLoginAtom } from '@stores/account';
|
||||||
import { hasNewerNoteAtom } from '@stores/note';
|
import { hasNewerNoteAtom } from '@stores/note';
|
||||||
|
|
||||||
import { dateToUnix } from '@utils/getDate';
|
import { dateToUnix } from '@utils/getDate';
|
||||||
import { createCacheNote, getAllFollowsByID, updateLastLoginTime } from '@utils/storage';
|
import { getParentID, pubkeyArray } from '@utils/transform';
|
||||||
import { pubkeyArray } from '@utils/transform';
|
|
||||||
|
|
||||||
import { TauriEvent } from '@tauri-apps/api/event';
|
import { TauriEvent } from '@tauri-apps/api/event';
|
||||||
import { appWindow, getCurrent } from '@tauri-apps/api/window';
|
import { appWindow, getCurrent } from '@tauri-apps/api/window';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export default function NoteConnector() {
|
export default function NoteConnector() {
|
||||||
const [pool, relays]: any = useContext(RelayContext);
|
const [pool, relays]: any = useContext(RelayContext);
|
||||||
|
|
||||||
|
const setLastLoginAtom = useSetAtom(lastLoginAtom);
|
||||||
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
|
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
|
||||||
const activeAccount: any = useAtomValue(activeAccountAtom);
|
|
||||||
|
|
||||||
const [isOnline] = useState(true);
|
const [isOnline] = useState(true);
|
||||||
const now = useRef(new Date());
|
|
||||||
|
|
||||||
const subscribe = useCallback(() => {
|
const now = useRef(new Date());
|
||||||
getAllFollowsByID(activeAccount.id).then((follows) => {
|
const unsubscribe = useRef(null);
|
||||||
pool.subscribe(
|
|
||||||
|
const subscribe = useCallback(async () => {
|
||||||
|
const { createNote } = await import('@utils/bindings');
|
||||||
|
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
|
||||||
|
const follows = JSON.parse(localStorage.getItem('activeAccountFollows'));
|
||||||
|
|
||||||
|
unsubscribe.current = pool.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [1],
|
kinds: [1],
|
||||||
@@ -32,25 +36,43 @@ export default function NoteConnector() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
relays,
|
relays,
|
||||||
(event: any) => {
|
(event) => {
|
||||||
|
const parentID = getParentID(event.tags, event.id);
|
||||||
// insert event to local database
|
// insert event to local database
|
||||||
createCacheNote(event);
|
createNote({
|
||||||
setHasNewerNote(true);
|
event_id: event.id,
|
||||||
}
|
pubkey: event.pubkey,
|
||||||
|
kind: event.kind,
|
||||||
|
tags: JSON.stringify(event.tags),
|
||||||
|
content: event.content,
|
||||||
|
parent_id: parentID,
|
||||||
|
parent_comment_id: '',
|
||||||
|
created_at: event.created_at,
|
||||||
|
account_id: activeAccount.id,
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
// notify user reload to get newer note
|
||||||
|
setHasNewerNote(true)
|
||||||
|
)
|
||||||
|
.catch(console.error);
|
||||||
|
},
|
||||||
|
10000
|
||||||
);
|
);
|
||||||
});
|
}, [pool, relays, setHasNewerNote]);
|
||||||
}, [activeAccount.id, pool, relays, setHasNewerNote]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subscribe();
|
subscribe();
|
||||||
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
|
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
|
||||||
updateLastLoginTime(now.current);
|
setLastLoginAtom(now.current);
|
||||||
appWindow.close();
|
appWindow.close();
|
||||||
});
|
});
|
||||||
}, [activeAccount.id, pool, relays, setHasNewerNote, subscribe]);
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe.current;
|
||||||
|
};
|
||||||
|
}, [setHasNewerNote, setLastLoginAtom, subscribe]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-zinc-900">
|
<div className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-zinc-900">
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
<span
|
<span
|
||||||
@@ -64,6 +86,5 @@ export default function NoteConnector() {
|
|||||||
</span>
|
</span>
|
||||||
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
|
<p className="text-xs font-medium text-zinc-500">{isOnline ? 'Online' : 'Offline'}</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col">
|
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col">
|
||||||
<div className="relative z-10 flex flex-col">
|
<div className="relative z-10 flex flex-col">
|
||||||
<UserLarge pubkey={event.pubkey} time={event.created_at} />
|
<UserLarge pubkey={event.pubkey} time={event.createdAt || event.created_at} />
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||||
@@ -70,10 +70,10 @@ export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex items-center border-b border-t border-zinc-800 py-2">
|
<div className="mt-5 flex items-center border-b border-t border-zinc-800 py-2">
|
||||||
<NoteMetadata
|
<NoteMetadata
|
||||||
eventID={event.id}
|
eventID={event.eventId}
|
||||||
eventPubkey={event.pubkey}
|
eventPubkey={event.pubkey}
|
||||||
eventContent={event.content}
|
eventContent={event.content}
|
||||||
eventTime={event.created_at}
|
eventTime={event.createdAt || event.created_at}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { RelayContext } from '@components/relaysProvider';
|
|||||||
import { UserExtend } from '@components/user/extend';
|
import { UserExtend } from '@components/user/extend';
|
||||||
|
|
||||||
import { activeAccountAtom } from '@stores/account';
|
import { activeAccountAtom } from '@stores/account';
|
||||||
import { relaysAtom } from '@stores/relays';
|
|
||||||
|
|
||||||
import { dateToUnix } from '@utils/getDate';
|
import { dateToUnix } from '@utils/getDate';
|
||||||
|
|
||||||
@@ -33,11 +32,10 @@ export const NoteComment = memo(function NoteComment({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [pool, relays]: any = useContext(RelayContext);
|
const [pool, relays]: any = useContext(RelayContext);
|
||||||
|
|
||||||
const activeAccount: any = useAtomValue(activeAccountAtom);
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
const activeAccount: any = useAtomValue(activeAccountAtom);
|
||||||
const profile = destr(activeAccount.metadata);
|
const profile = destr(activeAccount.metadata);
|
||||||
|
|
||||||
const openThread = () => {
|
const openThread = () => {
|
||||||
@@ -49,7 +47,7 @@ export const NoteComment = memo(function NoteComment({
|
|||||||
content: value,
|
content: value,
|
||||||
created_at: dateToUnix(),
|
created_at: dateToUnix(),
|
||||||
kind: 1,
|
kind: 1,
|
||||||
pubkey: activeAccount.id,
|
pubkey: activeAccount.pubkey,
|
||||||
tags: [['e', eventID]],
|
tags: [['e', eventID]],
|
||||||
};
|
};
|
||||||
event.id = getEventHash(event);
|
event.id = getEventHash(event);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const NoteReaction = memo(function NoteReaction({
|
|||||||
['p', eventPubkey],
|
['p', eventPubkey],
|
||||||
],
|
],
|
||||||
created_at: dateToUnix(),
|
created_at: dateToUnix(),
|
||||||
pubkey: activeAccount.id,
|
pubkey: activeAccount.pubkey,
|
||||||
};
|
};
|
||||||
event.id = getEventHash(event);
|
event.id = getEventHash(event);
|
||||||
event.sig = signEvent(event, activeAccount.privkey);
|
event.sig = signEvent(event, activeAccount.privkey);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { NoteComment } from '@components/note/meta/comment';
|
|||||||
import { NoteReaction } from '@components/note/meta/reaction';
|
import { NoteReaction } from '@components/note/meta/reaction';
|
||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { createCacheCommentNote } from '@utils/storage';
|
|
||||||
|
|
||||||
import { useContext, useEffect, useState } from 'react';
|
import { useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export default function NoteMetadata({
|
export default function NoteMetadata({
|
||||||
@@ -39,7 +37,7 @@ export default function NoteMetadata({
|
|||||||
// update state
|
// update state
|
||||||
setComments((comments) => (comments += 1));
|
setComments((comments) => (comments += 1));
|
||||||
// save comment to database
|
// save comment to database
|
||||||
createCacheCommentNote(event, eventID);
|
// createCacheCommentNote(event, eventID);
|
||||||
break;
|
break;
|
||||||
case 7:
|
case 7:
|
||||||
if (event.content === '🤙' || event.content === '+') {
|
if (event.content === '🤙' || event.content === '+') {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { RelayContext } from '@components/relaysProvider';
|
|||||||
import { UserExtend } from '@components/user/extend';
|
import { UserExtend } from '@components/user/extend';
|
||||||
import { UserMention } from '@components/user/mention';
|
import { UserMention } from '@components/user/mention';
|
||||||
|
|
||||||
import { createCacheNote, getNoteByID } from '@utils/storage';
|
import { getParentID } from '@utils/transform';
|
||||||
|
|
||||||
import destr from 'destr';
|
import destr from 'destr';
|
||||||
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -18,7 +18,10 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
|||||||
const [event, setEvent] = useState(null);
|
const [event, setEvent] = useState(null);
|
||||||
const unsubscribe = useRef(null);
|
const unsubscribe = useRef(null);
|
||||||
|
|
||||||
const fetchEvent = useCallback(() => {
|
const fetchEvent = useCallback(async () => {
|
||||||
|
const { createNote } = await import('@utils/bindings');
|
||||||
|
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
|
||||||
|
|
||||||
unsubscribe.current = pool.subscribe(
|
unsubscribe.current = pool.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -31,7 +34,19 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
|||||||
// update state
|
// update state
|
||||||
setEvent(event);
|
setEvent(event);
|
||||||
// insert to database
|
// insert to database
|
||||||
createCacheNote(event);
|
const parentID = getParentID(event.tags, event.id);
|
||||||
|
// insert event to local database
|
||||||
|
createNote({
|
||||||
|
event_id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
kind: event.kind,
|
||||||
|
tags: JSON.stringify(event.tags),
|
||||||
|
content: event.content,
|
||||||
|
parent_id: parentID,
|
||||||
|
parent_comment_id: '',
|
||||||
|
created_at: event.created_at,
|
||||||
|
account_id: activeAccount.id,
|
||||||
|
}).catch(console.error);
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -41,19 +56,26 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}, [id, pool, relays]);
|
}, [id, pool, relays]);
|
||||||
|
|
||||||
useEffect(() => {
|
const checkNoteExist = useCallback(async () => {
|
||||||
getNoteByID(id).then((res) => {
|
const { getNoteById } = await import('@utils/bindings');
|
||||||
|
getNoteById({ event_id: id })
|
||||||
|
.then((res) => {
|
||||||
if (res) {
|
if (res) {
|
||||||
setEvent(res);
|
setEvent(res);
|
||||||
} else {
|
} else {
|
||||||
fetchEvent();
|
fetchEvent();
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [fetchEvent, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkNoteExist();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe.current;
|
unsubscribe.current;
|
||||||
};
|
};
|
||||||
}, [fetchEvent, id]);
|
}, [checkNoteExist]);
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
let parsedContent = event ? event.content : null;
|
let parsedContent = event ? event.content : null;
|
||||||
@@ -110,7 +132,7 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
|||||||
<div className="relative pb-5">
|
<div className="relative pb-5">
|
||||||
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||||
<div className="relative z-10 flex flex-col">
|
<div className="relative z-10 flex flex-col">
|
||||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
<UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
|
||||||
<div className="-mt-5 pl-[52px]">
|
<div className="-mt-5 pl-[52px]">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||||
@@ -120,10 +142,10 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
|
||||||
<NoteMetadata
|
<NoteMetadata
|
||||||
eventID={event.id}
|
eventID={event.eventId}
|
||||||
eventPubkey={event.pubkey}
|
eventPubkey={event.pubkey}
|
||||||
eventContent={event.content}
|
eventContent={event.content}
|
||||||
eventTime={event.created_at}
|
eventTime={event.createdAt || event.created_at}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { RelayContext } from '@components/relaysProvider';
|
|||||||
import { UserExtend } from '@components/user/extend';
|
import { UserExtend } from '@components/user/extend';
|
||||||
import { UserMention } from '@components/user/mention';
|
import { UserMention } from '@components/user/mention';
|
||||||
|
|
||||||
import { createCacheNote, getNoteByID } from '@utils/storage';
|
import { getParentID } from '@utils/transform';
|
||||||
|
|
||||||
import destr from 'destr';
|
import destr from 'destr';
|
||||||
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -14,7 +14,10 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
|
|||||||
const [event, setEvent] = useState(null);
|
const [event, setEvent] = useState(null);
|
||||||
const unsubscribe = useRef(null);
|
const unsubscribe = useRef(null);
|
||||||
|
|
||||||
const fetchEvent = useCallback(() => {
|
const fetchEvent = useCallback(async () => {
|
||||||
|
const { createNote } = await import('@utils/bindings');
|
||||||
|
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
|
||||||
|
|
||||||
unsubscribe.current = pool.subscribe(
|
unsubscribe.current = pool.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -27,7 +30,19 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
|
|||||||
// update state
|
// update state
|
||||||
setEvent(event);
|
setEvent(event);
|
||||||
// insert to database
|
// insert to database
|
||||||
createCacheNote(event);
|
const parentID = getParentID(event.tags, event.id);
|
||||||
|
// insert event to local database
|
||||||
|
createNote({
|
||||||
|
event_id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
kind: event.kind,
|
||||||
|
tags: JSON.stringify(event.tags),
|
||||||
|
content: event.content,
|
||||||
|
parent_id: parentID,
|
||||||
|
parent_comment_id: '',
|
||||||
|
created_at: event.created_at,
|
||||||
|
account_id: activeAccount.id,
|
||||||
|
}).catch(console.error);
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -37,19 +52,26 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}, [id, pool, relays]);
|
}, [id, pool, relays]);
|
||||||
|
|
||||||
useEffect(() => {
|
const checkNoteExist = useCallback(async () => {
|
||||||
getNoteByID(id).then((res) => {
|
const { getNoteById } = await import('@utils/bindings');
|
||||||
|
getNoteById({ event_id: id })
|
||||||
|
.then((res) => {
|
||||||
if (res) {
|
if (res) {
|
||||||
setEvent(res);
|
setEvent(res);
|
||||||
} else {
|
} else {
|
||||||
fetchEvent();
|
fetchEvent();
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, [fetchEvent, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkNoteExist();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe.current;
|
unsubscribe.current;
|
||||||
};
|
};
|
||||||
}, [fetchEvent, id]);
|
}, [checkNoteExist]);
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
let parsedContent = event ? event.content : null;
|
let parsedContent = event ? event.content : null;
|
||||||
@@ -89,7 +111,7 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative mb-2 mt-3 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
|
<div className="relative mb-2 mt-3 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
|
||||||
<div className="relative z-10 flex flex-col">
|
<div className="relative z-10 flex flex-col">
|
||||||
<UserExtend pubkey={event.pubkey} time={event.created_at} />
|
<UserExtend pubkey={event.pubkey} time={event.createdAt || event.created_at} />
|
||||||
<div className="-mt-5 pl-[52px]">
|
<div className="-mt-5 pl-[52px]">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||||
|
|||||||
@@ -2,32 +2,13 @@ import { ImageWithFallback } from '@components/imageWithFallback';
|
|||||||
|
|
||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { createCacheProfile } from '@utils/storage';
|
import { useMetadata } from '@utils/metadata';
|
||||||
import { truncate } from '@utils/truncate';
|
import { truncate } from '@utils/truncate';
|
||||||
|
|
||||||
import { fetch } from '@tauri-apps/api/http';
|
import { memo } from 'react';
|
||||||
import destr from 'destr';
|
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export const UserBase = memo(function UserBase({ pubkey }: { pubkey: string }) {
|
export const UserBase = memo(function UserBase({ pubkey }: { pubkey: string }) {
|
||||||
const [profile, setProfile] = useState(null);
|
const profile = useMetadata(pubkey);
|
||||||
|
|
||||||
const fetchProfile = useCallback(async (id: string) => {
|
|
||||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
|
||||||
method: 'GET',
|
|
||||||
timeout: 30,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchProfile(pubkey)
|
|
||||||
.then((res: any) => {
|
|
||||||
setProfile(destr(res.content));
|
|
||||||
createCacheProfile(res.pubkey, res.content);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, [fetchProfile, pubkey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -2,68 +2,32 @@ import { ImageWithFallback } from '@components/imageWithFallback';
|
|||||||
|
|
||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { createCacheProfile, getCacheProfile } from '@utils/storage';
|
import { useMetadata } from '@utils/metadata';
|
||||||
import { truncate } from '@utils/truncate';
|
import { truncate } from '@utils/truncate';
|
||||||
|
|
||||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||||
import { fetch } from '@tauri-apps/api/http';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import destr from 'destr';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export const UserExtend = memo(function UserExtend({ pubkey, time }: { pubkey: string; time: any }) {
|
export const UserExtend = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||||
const router = useRouter();
|
const profile = useMetadata(pubkey);
|
||||||
const [profile, setProfile] = useState(null);
|
|
||||||
|
|
||||||
const openUserPage = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
router.push(`/users/${pubkey}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchProfile = useCallback(async (id: string) => {
|
|
||||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
|
||||||
method: 'GET',
|
|
||||||
timeout: 30,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getCacheProfile(pubkey).then((res) => {
|
|
||||||
if (res) {
|
|
||||||
setProfile(destr(res.metadata));
|
|
||||||
} else {
|
|
||||||
fetchProfile(pubkey)
|
|
||||||
.then((res: any) => {
|
|
||||||
setProfile(destr(res.content));
|
|
||||||
createCacheProfile(pubkey, res.content);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [fetchProfile, pubkey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-start gap-2">
|
<div className="group flex items-start gap-2">
|
||||||
<div
|
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||||
onClick={(e) => openUserPage(e)}
|
|
||||||
className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-900 ring-fuchsia-500 ring-offset-1 ring-offset-zinc-900 group-hover:ring-1"
|
|
||||||
>
|
|
||||||
<ImageWithFallback
|
<ImageWithFallback
|
||||||
src={profile?.picture || DEFAULT_AVATAR}
|
src={profile?.picture || DEFAULT_AVATAR}
|
||||||
alt={pubkey}
|
alt={pubkey}
|
||||||
fill={true}
|
fill={true}
|
||||||
className="rounded-md border border-white/10 object-cover"
|
className="rounded-md object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-1 items-start justify-between">
|
<div className="flex w-full flex-1 items-start justify-between">
|
||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full justify-between">
|
||||||
<div className="flex items-baseline gap-2 text-sm">
|
<div className="flex items-baseline gap-2 text-sm">
|
||||||
<span onClick={(e) => openUserPage(e)} className="font-bold leading-tight group-hover:underline">
|
<span className="font-bold leading-tight group-hover:underline">
|
||||||
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
|
{profile?.display_name || profile?.name || truncate(pubkey, 16, ' .... ')}
|
||||||
</span>
|
</span>
|
||||||
<span className="leading-tight text-zinc-500">·</span>
|
<span className="leading-tight text-zinc-500">·</span>
|
||||||
@@ -78,4 +42,4 @@ export const UserExtend = memo(function UserExtend({ pubkey, time }: { pubkey: s
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -2,32 +2,11 @@ import { ImageWithFallback } from '@components/imageWithFallback';
|
|||||||
|
|
||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { createCacheProfile } from '@utils/storage';
|
import { useMetadata } from '@utils/metadata';
|
||||||
import { truncate } from '@utils/truncate';
|
import { truncate } from '@utils/truncate';
|
||||||
|
|
||||||
import { fetch } from '@tauri-apps/api/http';
|
export const UserFollow = ({ pubkey }: { pubkey: string }) => {
|
||||||
import destr from 'destr';
|
const profile = useMetadata(pubkey);
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export const UserFollow = memo(function UserFollow({ pubkey }: { pubkey: string }) {
|
|
||||||
const [profile, setProfile] = useState(null);
|
|
||||||
|
|
||||||
const fetchProfile = useCallback(async (id: string) => {
|
|
||||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
|
||||||
method: 'GET',
|
|
||||||
timeout: 30,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchProfile(pubkey)
|
|
||||||
.then((res: any) => {
|
|
||||||
setProfile(destr(res.content));
|
|
||||||
createCacheProfile(res.pubkey, res.content);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, [fetchProfile, pubkey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -47,4 +26,4 @@ export const UserFollow = memo(function UserFollow({ pubkey }: { pubkey: string
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -2,47 +2,21 @@ import { ImageWithFallback } from '@components/imageWithFallback';
|
|||||||
|
|
||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { createCacheProfile, getCacheProfile } from '@utils/storage';
|
import { useMetadata } from '@utils/metadata';
|
||||||
import { truncate } from '@utils/truncate';
|
import { truncate } from '@utils/truncate';
|
||||||
|
|
||||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
|
||||||
import { fetch } from '@tauri-apps/api/http';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import destr from 'destr';
|
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export const UserLarge = memo(function UserLarge({ pubkey, time }: { pubkey: string; time: any }) {
|
export const UserLarge = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||||
const [profile, setProfile] = useState(null);
|
const profile = useMetadata(pubkey);
|
||||||
|
|
||||||
const fetchProfile = useCallback(async (id: string) => {
|
|
||||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
|
||||||
method: 'GET',
|
|
||||||
timeout: 30,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getCacheProfile(pubkey).then((res) => {
|
|
||||||
if (res) {
|
|
||||||
setProfile(destr(res.metadata));
|
|
||||||
} else {
|
|
||||||
fetchProfile(pubkey)
|
|
||||||
.then((res: any) => {
|
|
||||||
setProfile(destr(res.content));
|
|
||||||
createCacheProfile(pubkey, res.content);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [fetchProfile, pubkey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-900">
|
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||||
<ImageWithFallback
|
<ImageWithFallback
|
||||||
src={profile?.picture || DEFAULT_AVATAR}
|
src={profile?.picture || DEFAULT_AVATAR}
|
||||||
alt={pubkey}
|
alt={pubkey}
|
||||||
@@ -69,4 +43,4 @@ export const UserLarge = memo(function UserLarge({ pubkey, time }: { pubkey: str
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -1,35 +1,11 @@
|
|||||||
import { createCacheProfile, getCacheProfile } from '@utils/storage';
|
import { useMetadata } from '@utils/metadata';
|
||||||
import { truncate } from '@utils/truncate';
|
import { truncate } from '@utils/truncate';
|
||||||
|
|
||||||
import { fetch } from '@tauri-apps/api/http';
|
export const UserMention = ({ pubkey }: { pubkey: string }) => {
|
||||||
import destr from 'destr';
|
const profile = useMetadata(pubkey);
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
return (
|
||||||
|
<span className="cursor-pointer text-fuchsia-500">
|
||||||
export const UserMention = memo(function UserMention({ pubkey }: { pubkey: string }) {
|
@{profile?.name || profile?.username || truncate(pubkey, 16, ' .... ')}
|
||||||
const [profile, setProfile] = useState(null);
|
</span>
|
||||||
|
);
|
||||||
const fetchProfile = useCallback(async (id: string) => {
|
};
|
||||||
const res = await fetch(`https://rbr.bio/${id}/metadata.json`, {
|
|
||||||
method: 'GET',
|
|
||||||
timeout: 30,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getCacheProfile(pubkey).then((res) => {
|
|
||||||
if (res) {
|
|
||||||
setProfile(destr(res.metadata));
|
|
||||||
} else {
|
|
||||||
fetchProfile(pubkey)
|
|
||||||
.then((res: any) => {
|
|
||||||
setProfile(destr(res.content));
|
|
||||||
createCacheProfile(pubkey, res.content);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [fetchProfile, pubkey]);
|
|
||||||
|
|
||||||
return <span className="cursor-pointer text-fuchsia-500">@{profile?.name || truncate(pubkey, 16, ' .... ')}</span>;
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 <></>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import AppHeader from '@components/appHeader';
|
import AppHeader from '@components/appHeader';
|
||||||
import AccountColumn from '@components/columns/account';
|
import MultiAccounts from '@components/multiAccounts';
|
||||||
import NavigatorColumn from '@components/columns/navigator';
|
import Navigation from '@components/navigation';
|
||||||
|
|
||||||
export default function WithSidebarLayout({ children }: { children: React.ReactNode }) {
|
export default function WithSidebarLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -13,11 +13,11 @@ export default function WithSidebarLayout({ children }: { children: React.ReactN
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative flex min-h-0 w-full flex-1">
|
<div className="relative flex min-h-0 w-full flex-1">
|
||||||
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
<div className="relative w-[68px] shrink-0 border-r border-zinc-900">
|
||||||
<AccountColumn />
|
<MultiAccounts />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
<div className="grid w-full grid-cols-4 xl:grid-cols-5">
|
||||||
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
<div className="scrollbar-hide col-span-1 overflow-y-auto overflow-x-hidden border-r border-zinc-900">
|
||||||
<NavigatorColumn />
|
<Navigation />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3 m-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20 xl:col-span-2 xl:mr-1.5">
|
<div className="col-span-3 m-3 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20 xl:col-span-2 xl:mr-1.5">
|
||||||
<div className="h-full w-full rounded-lg">{children}</div>
|
<div className="h-full w-full rounded-lg">{children}</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import RelayProvider from '@components/relaysProvider';
|
|||||||
|
|
||||||
import type { NextPage } from 'next';
|
import type { NextPage } from 'next';
|
||||||
import type { AppProps } from 'next/app';
|
import type { AppProps } from 'next/app';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { ReactElement, ReactNode } from 'react';
|
import { ReactElement, ReactNode } from 'react';
|
||||||
|
|
||||||
import '../App.css';
|
import '../App.css';
|
||||||
@@ -16,8 +17,9 @@ type AppPropsWithLayout = AppProps & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||||
|
const router = useRouter();
|
||||||
// Use the layout defined at the page level, if available
|
// Use the layout defined at the page level, if available
|
||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
const getLayout = Component.getLayout ?? ((page) => page);
|
||||||
|
|
||||||
return <RelayProvider>{getLayout(<Component {...pageProps} />)}</RelayProvider>;
|
return <RelayProvider>{getLayout(<Component key={router.asPath} {...pageProps} />)}</RelayProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/pages/chats/[pubkey].tsx
Normal file
80
src/pages/chats/[pubkey].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,25 +1,38 @@
|
|||||||
import BaseLayout from '@layouts/base';
|
import BaseLayout from '@layouts/base';
|
||||||
|
|
||||||
import { activeAccountAtom } from '@stores/account';
|
import { activeAccountAtom, activeAccountFollowsAtom } from '@stores/account';
|
||||||
|
|
||||||
import { getActiveAccount } from '@utils/storage';
|
|
||||||
|
|
||||||
import LumeSymbol from '@assets/icons/Lume';
|
import LumeSymbol from '@assets/icons/Lume';
|
||||||
|
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useEffect } from 'react';
|
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setActiveAccount = useSetAtom(activeAccountAtom);
|
const setActiveAccount = useSetAtom(activeAccountAtom);
|
||||||
|
const setActiveAccountFollows = useSetAtom(activeAccountFollowsAtom);
|
||||||
|
|
||||||
|
const fetchActiveAccount = useCallback(async () => {
|
||||||
|
const { getAccounts } = await import('@utils/bindings');
|
||||||
|
return await getAccounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchFollowsByAccount = useCallback(async (id) => {
|
||||||
|
const { getPlebs } = await import('@utils/bindings');
|
||||||
|
return await getPlebs({ account_id: id });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getActiveAccount()
|
fetchActiveAccount()
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
if (res) {
|
if (res.length > 0) {
|
||||||
|
// fetch follows
|
||||||
|
fetchFollowsByAccount(res[0].id).then((follows) => {
|
||||||
|
setActiveAccountFollows(follows);
|
||||||
|
});
|
||||||
// update local storage
|
// update local storage
|
||||||
setActiveAccount(res);
|
setActiveAccount(res[0]);
|
||||||
// redirect
|
// redirect
|
||||||
router.replace('/init');
|
router.replace('/init');
|
||||||
} else {
|
} else {
|
||||||
@@ -27,7 +40,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [router, setActiveAccount]);
|
}, [fetchActiveAccount, setActiveAccount, fetchFollowsByAccount, setActiveAccountFollows, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full overflow-hidden">
|
<div className="relative h-full overflow-hidden">
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ import BaseLayout from '@layouts/base';
|
|||||||
|
|
||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { activeAccountAtom } from '@stores/account';
|
|
||||||
import { relaysAtom } from '@stores/relays';
|
|
||||||
|
|
||||||
import { dateToUnix, hoursAgo } from '@utils/getDate';
|
import { dateToUnix, hoursAgo } from '@utils/getDate';
|
||||||
import { countTotalNotes, createCacheNote, getAllFollowsByID, getLastLoginTime } from '@utils/storage';
|
import { getParentID, pubkeyArray } from '@utils/transform';
|
||||||
import { pubkeyArray } from '@utils/transform';
|
|
||||||
|
|
||||||
import LumeSymbol from '@assets/icons/Lume';
|
import LumeSymbol from '@assets/icons/Lume';
|
||||||
|
|
||||||
import { useAtomValue } from 'jotai';
|
import { invoke } from '@tauri-apps/api/tauri';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import {
|
import {
|
||||||
JSXElementConstructor,
|
JSXElementConstructor,
|
||||||
@@ -29,16 +25,17 @@ export default function Page() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [pool, relays]: any = useContext(RelayContext);
|
const [pool, relays]: any = useContext(RelayContext);
|
||||||
|
|
||||||
const activeAccount: any = useAtomValue(activeAccountAtom);
|
|
||||||
const [done, setDone] = useState(false);
|
|
||||||
|
|
||||||
const now = useRef(new Date());
|
const now = useRef(new Date());
|
||||||
const unsubscribe = useRef(null);
|
const unsubscribe = useRef(null);
|
||||||
const timer = useRef(null);
|
|
||||||
|
const [eose, setEose] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(
|
const fetchData = useCallback(
|
||||||
(since) => {
|
async (since: Date) => {
|
||||||
getAllFollowsByID(activeAccount.id).then((follows) => {
|
const { createNote } = await import('@utils/bindings');
|
||||||
|
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
|
||||||
|
const follows = JSON.parse(localStorage.getItem('activeAccountFollows'));
|
||||||
|
|
||||||
unsubscribe.current = pool.subscribe(
|
unsubscribe.current = pool.subscribe(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -50,44 +47,52 @@ export default function Page() {
|
|||||||
],
|
],
|
||||||
relays,
|
relays,
|
||||||
(event) => {
|
(event) => {
|
||||||
|
const parentID = getParentID(event.tags, event.id);
|
||||||
// insert event to local database
|
// insert event to local database
|
||||||
createCacheNote(event);
|
createNote({
|
||||||
|
event_id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
kind: event.kind,
|
||||||
|
tags: JSON.stringify(event.tags),
|
||||||
|
content: event.content,
|
||||||
|
parent_id: parentID,
|
||||||
|
parent_comment_id: '',
|
||||||
|
created_at: event.created_at,
|
||||||
|
account_id: activeAccount.id,
|
||||||
|
}).catch(console.error);
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
() => {
|
() => {
|
||||||
// wait for 8 seconds
|
setEose(true);
|
||||||
timer.current = setTimeout(() => setDone(true), 8000);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
unsubscribeOnEose: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[activeAccount.id, pool, relays]
|
[pool, relays]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const isNoteExist = useCallback(async () => {
|
||||||
if (!done) {
|
invoke('count_total_notes').then((res: number) => {
|
||||||
countTotalNotes().then((count) => {
|
if (res > 0) {
|
||||||
if (count.total === 0) {
|
const lastLogin = JSON.parse(localStorage.getItem('lastLogin'));
|
||||||
fetchData(hoursAgo(24, now.current));
|
const parseDate = new Date(lastLogin);
|
||||||
} else {
|
|
||||||
getLastLoginTime().then((time) => {
|
|
||||||
const parseDate = new Date(time.setting_value);
|
|
||||||
fetchData(parseDate);
|
fetchData(parseDate);
|
||||||
});
|
} else {
|
||||||
|
fetchData(hoursAgo(24, now.current));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (eose === false) {
|
||||||
|
isNoteExist();
|
||||||
} else {
|
} else {
|
||||||
router.replace('/newsfeed/following');
|
router.replace('/newsfeed/following');
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe.current;
|
unsubscribe.current;
|
||||||
clearTimeout(timer.current);
|
|
||||||
};
|
};
|
||||||
}, [activeAccount.id, done, pool, relays, router, fetchData]);
|
}, [router, eose, isNoteExist]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full overflow-hidden">
|
<div className="relative h-full overflow-hidden">
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { NoteComment } from '@components/note/comment';
|
|||||||
import { NoteExtend } from '@components/note/extend';
|
import { NoteExtend } from '@components/note/extend';
|
||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { getAllCommentNotes, getNoteByID } from '@utils/storage';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import {
|
import {
|
||||||
JSXElementConstructor,
|
JSXElementConstructor,
|
||||||
@@ -29,12 +27,12 @@ export default function Page() {
|
|||||||
const [comments, setComments] = useState([]);
|
const [comments, setComments] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getNoteByID(id)
|
/*getNoteByID(id)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setRootEvent(res);
|
setRootEvent(res);
|
||||||
getAllCommentNotes(id).then((res: any) => setComments(res));
|
getAllCommentNotes(id).then((res: any) => setComments(res));
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);*/
|
||||||
}, [id, pool, relays]);
|
}, [id, pool, relays]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Placeholder } from '@components/note/placeholder';
|
|||||||
import { hasNewerNoteAtom } from '@stores/note';
|
import { hasNewerNoteAtom } from '@stores/note';
|
||||||
|
|
||||||
import { dateToUnix } from '@utils/getDate';
|
import { dateToUnix } from '@utils/getDate';
|
||||||
import { getLatestNotes, getNotes } from '@utils/storage';
|
import { filterDuplicateParentID } from '@utils/transform';
|
||||||
|
|
||||||
import { ArrowUpIcon } from '@radix-ui/react-icons';
|
import { ArrowUpIcon } from '@radix-ui/react-icons';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
@@ -42,29 +42,43 @@ export default function Page() {
|
|||||||
|
|
||||||
const computeItemKey = useCallback(
|
const computeItemKey = useCallback(
|
||||||
(index: string | number) => {
|
(index: string | number) => {
|
||||||
return data[index].id;
|
return data[index].eventId;
|
||||||
},
|
},
|
||||||
[data]
|
[data]
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialData = useCallback(async () => {
|
const initialData = useCallback(async () => {
|
||||||
const result: any = await getNotes(dateToUnix(now.current), limit.current, offset.current);
|
const { getNotes } = await import('@utils/bindings');
|
||||||
|
const result: any = await getNotes({
|
||||||
|
date: dateToUnix(now.current),
|
||||||
|
limit: limit.current,
|
||||||
|
offset: offset.current,
|
||||||
|
});
|
||||||
setData((data) => [...data, ...result]);
|
setData((data) => [...data, ...result]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
|
const { getNotes } = await import('@utils/bindings');
|
||||||
offset.current += limit.current;
|
offset.current += limit.current;
|
||||||
// next query
|
// next query
|
||||||
const result: any = await getNotes(dateToUnix(now.current), limit.current, offset.current);
|
const result: any = await getNotes({
|
||||||
|
date: dateToUnix(now.current),
|
||||||
|
limit: limit.current,
|
||||||
|
offset: offset.current,
|
||||||
|
});
|
||||||
setData((data) => [...data, ...result]);
|
setData((data) => [...data, ...result]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadLatest = useCallback(async () => {
|
const loadLatest = useCallback(async () => {
|
||||||
offset.current += limit.current;
|
const { getLatestNotes } = await import('@utils/bindings');
|
||||||
// next query
|
// next query
|
||||||
const result: any = await getLatestNotes(dateToUnix(now.current));
|
const result: any = await getLatestNotes({ date: dateToUnix(now.current) });
|
||||||
// update data
|
// update data
|
||||||
setData((data) => [...result, ...data]);
|
if (result.length > 0) {
|
||||||
|
setData((data) => [...data, ...result]);
|
||||||
|
} else {
|
||||||
|
setData((data) => [...data, result]);
|
||||||
|
}
|
||||||
// hide newer trigger
|
// hide newer trigger
|
||||||
setHasNewerNote(false);
|
setHasNewerNote(false);
|
||||||
// scroll to top
|
// scroll to top
|
||||||
@@ -90,7 +104,7 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
data={data}
|
data={filterDuplicateParentID(data)}
|
||||||
itemContent={itemContent}
|
itemContent={itemContent}
|
||||||
computeItemKey={computeItemKey}
|
computeItemKey={computeItemKey}
|
||||||
components={COMPONENTS}
|
components={COMPONENTS}
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import BaseLayout from '@layouts/base';
|
|||||||
|
|
||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { createAccount } from '@utils/storage';
|
|
||||||
|
|
||||||
import { ArrowLeftIcon, EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
|
import { ArrowLeftIcon, EyeClosedIcon, EyeOpenIcon } from '@radix-ui/react-icons';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools';
|
import { generatePrivateKey, getEventHash, getPublicKey, nip19, signEvent } from 'nostr-tools';
|
||||||
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useContext, useMemo, useState } from 'react';
|
import {
|
||||||
|
JSXElementConstructor,
|
||||||
|
ReactElement,
|
||||||
|
ReactFragment,
|
||||||
|
ReactPortal,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { Config, names, uniqueNamesGenerator } from 'unique-names-generator';
|
import { Config, names, uniqueNamesGenerator } from 'unique-names-generator';
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
@@ -34,28 +41,16 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// auto-generated profile metadata
|
// auto-generated profile metadata
|
||||||
const metadata = useMemo(
|
const metadata: any = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
display_name: name,
|
display_name: name,
|
||||||
name: name,
|
name: name,
|
||||||
username: name.toLowerCase(),
|
username: name.toLowerCase(),
|
||||||
picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89',
|
picture: 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp',
|
||||||
}),
|
}),
|
||||||
[name]
|
[name]
|
||||||
);
|
);
|
||||||
|
|
||||||
// build profile
|
|
||||||
const data = useMemo(
|
|
||||||
() => ({
|
|
||||||
pubkey: pubKey,
|
|
||||||
privkey: privKey,
|
|
||||||
npub: npub,
|
|
||||||
nsec: nsec,
|
|
||||||
metadata: metadata,
|
|
||||||
}),
|
|
||||||
[metadata, npub, nsec, privKey, pubKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
// toggle privatek key
|
// toggle privatek key
|
||||||
const showPrivateKey = () => {
|
const showPrivateKey = () => {
|
||||||
if (type === 'password') {
|
if (type === 'password') {
|
||||||
@@ -66,7 +61,8 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// create account and broadcast to all relays
|
// create account and broadcast to all relays
|
||||||
const submit = () => {
|
const submit = useCallback(async () => {
|
||||||
|
const { createAccount } = await import('@utils/bindings');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// build event
|
// build event
|
||||||
@@ -81,16 +77,16 @@ export default function Page() {
|
|||||||
event.sig = signEvent(event, privKey);
|
event.sig = signEvent(event, privKey);
|
||||||
|
|
||||||
// insert to database then broadcast
|
// insert to database then broadcast
|
||||||
createAccount(data)
|
createAccount({ pubkey: pubKey, privkey: privKey, metadata: metadata })
|
||||||
.then(() => {
|
.then((res) => {
|
||||||
pool.publish(event, relays);
|
pool.publish(event, relays);
|
||||||
router.push({
|
router.push({
|
||||||
pathname: '/onboarding/create/step-2',
|
pathname: '/onboarding/create/step-2',
|
||||||
query: { id: pubKey, privkey: privKey },
|
query: { id: res.id, pubkey: res.pubkey, privkey: res.privkey },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
};
|
}, [pool, pubKey, privKey, metadata, relays, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full grid-rows-5">
|
<div className="grid h-full w-full grid-rows-5">
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import BaseLayout from '@layouts/base';
|
|||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
import { UserBase } from '@components/user/base';
|
import { UserBase } from '@components/user/base';
|
||||||
|
|
||||||
import { createFollows } from '@utils/storage';
|
import { fetchMetadata } from '@utils/metadata';
|
||||||
|
import { followsTag } from '@utils/transform';
|
||||||
|
|
||||||
import { CheckCircledIcon } from '@radix-ui/react-icons';
|
import { CheckCircledIcon } from '@radix-ui/react-icons';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
ReactElement,
|
ReactElement,
|
||||||
ReactFragment,
|
ReactFragment,
|
||||||
ReactPortal,
|
ReactPortal,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
@@ -64,7 +66,7 @@ export default function Page() {
|
|||||||
const [pool, relays]: any = useContext(RelayContext);
|
const [pool, relays]: any = useContext(RelayContext);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id, privkey }: any = router.query || '';
|
const { id, pubkey, privkey }: any = router.query || '';
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [list, setList]: any = useState(initialList);
|
const [list, setList]: any = useState(initialList);
|
||||||
@@ -76,41 +78,36 @@ export default function Page() {
|
|||||||
setFollows(arr);
|
setFollows(arr);
|
||||||
};
|
};
|
||||||
|
|
||||||
// build event tags
|
|
||||||
const tags = () => {
|
|
||||||
const arr = [];
|
|
||||||
// push item to tags
|
|
||||||
follows.forEach((item) => {
|
|
||||||
arr.push(['p', item]);
|
|
||||||
});
|
|
||||||
return arr;
|
|
||||||
};
|
|
||||||
|
|
||||||
// save follows to database then broadcast
|
// save follows to database then broadcast
|
||||||
const submit = () => {
|
const submit = useCallback(async () => {
|
||||||
|
const { createPleb } = await import('@utils/bindings');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
for (const follow of follows) {
|
||||||
|
const metadata: any = await fetchMetadata(follow, pool, relays);
|
||||||
|
createPleb({
|
||||||
|
pleb_id: follow + '-lume' + id,
|
||||||
|
pubkey: follow,
|
||||||
|
kind: 0,
|
||||||
|
metadata: metadata.content,
|
||||||
|
account_id: parseInt(id),
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
// build event
|
// build event
|
||||||
const event: any = {
|
const event: any = {
|
||||||
content: '',
|
content: '',
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
kind: 3,
|
kind: 3,
|
||||||
pubkey: id,
|
pubkey: pubkey,
|
||||||
tags: tags(),
|
tags: followsTag(follows),
|
||||||
};
|
};
|
||||||
event.id = getEventHash(event);
|
event.id = getEventHash(event);
|
||||||
event.sig = signEvent(event, privkey);
|
event.sig = signEvent(event, privkey);
|
||||||
|
|
||||||
createFollows(follows, id, 0)
|
|
||||||
.then((res) => {
|
|
||||||
if (res === 'ok') {
|
|
||||||
// publish to relays
|
|
||||||
pool.publish(event, relays);
|
pool.publish(event, relays);
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
}
|
}, [follows, id, pool, pubkey, privkey, relays, router]);
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
|||||||
@@ -2,21 +2,23 @@ import BaseLayout from '@layouts/base';
|
|||||||
|
|
||||||
import { RelayContext } from '@components/relaysProvider';
|
import { RelayContext } from '@components/relaysProvider';
|
||||||
|
|
||||||
import { createAccount, createFollows } from '@utils/storage';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
import { tagsToArray } from '@utils/transform';
|
|
||||||
|
import { fetchMetadata } from '@utils/metadata';
|
||||||
import { truncate } from '@utils/truncate';
|
import { truncate } from '@utils/truncate';
|
||||||
|
|
||||||
import destr from 'destr';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
import { getPublicKey } from 'nostr-tools';
|
||||||
import {
|
import {
|
||||||
JSXElementConstructor,
|
JSXElementConstructor,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
ReactFragment,
|
ReactFragment,
|
||||||
ReactPortal,
|
ReactPortal,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
@@ -27,9 +29,40 @@ export default function Page() {
|
|||||||
const privkey: any = router.query.privkey || null;
|
const privkey: any = router.query.privkey || null;
|
||||||
const pubkey = privkey ? getPublicKey(privkey) : null;
|
const pubkey = privkey ? getPublicKey(privkey) : null;
|
||||||
|
|
||||||
const [profile, setProfile] = useState(null);
|
const [profile, setProfile] = useState({ id: null, metadata: null });
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const insertAccountToStorage = useCallback(async (pubkey, privkey, metadata) => {
|
||||||
|
const { createAccount } = await import('@utils/bindings');
|
||||||
|
createAccount({ pubkey: pubkey, privkey: privkey, metadata: metadata })
|
||||||
|
.then((res) =>
|
||||||
|
setProfile({
|
||||||
|
id: res.id,
|
||||||
|
metadata: JSON.parse(res.metadata),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const insertFollowsToStorage = useCallback(
|
||||||
|
async (tags) => {
|
||||||
|
const { createPleb } = await import('@utils/bindings');
|
||||||
|
if (profile?.id !== null) {
|
||||||
|
for (const tag of tags) {
|
||||||
|
const metadata: any = await fetchMetadata(tag[1], pool, relays);
|
||||||
|
createPleb({
|
||||||
|
pleb_id: tag[1] + '-lume' + profile.id.toString(),
|
||||||
|
pubkey: tag[1],
|
||||||
|
kind: 0,
|
||||||
|
metadata: metadata.content,
|
||||||
|
account_id: profile.id,
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pool, profile.id, relays]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = pool.subscribe(
|
const unsubscribe = pool.subscribe(
|
||||||
[
|
[
|
||||||
@@ -42,34 +75,23 @@ export default function Page() {
|
|||||||
relays,
|
relays,
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (event.kind === 0) {
|
if (event.kind === 0) {
|
||||||
const data = {
|
insertAccountToStorage(pubkey, privkey, event.content);
|
||||||
pubkey: pubkey,
|
|
||||||
privkey: privkey,
|
|
||||||
npub: nip19.npubEncode(pubkey),
|
|
||||||
nsec: nip19.nsecEncode(privkey),
|
|
||||||
metadata: event.content,
|
|
||||||
};
|
|
||||||
setProfile(destr(event.content));
|
|
||||||
createAccount(data);
|
|
||||||
} else {
|
} else {
|
||||||
if (event.tags.length > 0) {
|
if (event.tags.length > 0) {
|
||||||
createFollows(tagsToArray(event.tags), pubkey, 0);
|
insertFollowsToStorage(event.tags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
() => {
|
() => {
|
||||||
setDone(true);
|
setDone(true);
|
||||||
},
|
|
||||||
{
|
|
||||||
unsubscribeOnEose: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe;
|
unsubscribe;
|
||||||
};
|
};
|
||||||
}, [pool, privkey, pubkey, relays]);
|
}, [insertAccountToStorage, insertFollowsToStorage, pool, relays, privkey, pubkey]);
|
||||||
|
|
||||||
// submit then redirect to home
|
// submit then redirect to home
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
@@ -91,13 +113,20 @@ export default function Page() {
|
|||||||
<div className="w-full rounded-lg bg-zinc-900 p-4 shadow-input ring-1 ring-zinc-800">
|
<div className="w-full rounded-lg bg-zinc-900 p-4 shadow-input ring-1 ring-zinc-800">
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<div className="relative h-10 w-10 rounded-full">
|
<div className="relative h-10 w-10 rounded-full">
|
||||||
<Image className="inline-block rounded-full" src={profile?.picture} alt="" fill={true} />
|
<Image
|
||||||
|
className="inline-block rounded-full"
|
||||||
|
src={profile.metadata?.picture || DEFAULT_AVATAR}
|
||||||
|
alt=""
|
||||||
|
fill={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-4 py-1">
|
<div className="flex-1 space-y-4 py-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-semibold">{profile?.display_name || profile?.name}</p>
|
<p className="font-semibold">{profile.metadata?.display_name || profile.metadata?.name}</p>
|
||||||
<span className="leading-tight text-zinc-500">·</span>
|
<span className="leading-tight text-zinc-500">·</span>
|
||||||
<p className="text-zinc-500">@{profile?.username || (pubkey && truncate(pubkey, 16, ' .... '))}</p>
|
<p className="text-zinc-500">
|
||||||
|
@{profile.metadata?.username || (pubkey && truncate(pubkey, 16, ' .... '))}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ const createMyJsonStorage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const activeAccountAtom = atomWithStorage('activeAccount', {}, createMyJsonStorage());
|
export const activeAccountAtom = atomWithStorage('activeAccount', {}, createMyJsonStorage());
|
||||||
|
export const activeAccountFollowsAtom = atomWithStorage('activeAccountFollows', [], createMyJsonStorage());
|
||||||
|
export const lastLoginAtom = atomWithStorage('lastLogin', [], createMyJsonStorage());
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export const APP_VERSION = '0.2.1';
|
||||||
export const DEFAULT_AVATAR = 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp';
|
export const DEFAULT_AVATAR = 'https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp';
|
||||||
|
|||||||
@@ -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
78
src/utils/bindings.ts
Normal 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
39
src/utils/metadata.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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"`);
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,15 @@ export const tagsToArray = (arr) => {
|
|||||||
return newarr;
|
return newarr;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const followsTag = (arr) => {
|
||||||
|
const newarr = [];
|
||||||
|
// push item to tags
|
||||||
|
arr.forEach((item) => {
|
||||||
|
newarr.push(['p', item]);
|
||||||
|
});
|
||||||
|
return newarr;
|
||||||
|
};
|
||||||
|
|
||||||
export const pubkeyArray = (arr) => {
|
export const pubkeyArray = (arr) => {
|
||||||
const newarr = [];
|
const newarr = [];
|
||||||
// push item to newarr
|
// push item to newarr
|
||||||
@@ -36,3 +45,11 @@ export const getParentID = (arr, fallback) => {
|
|||||||
|
|
||||||
return parentID;
|
return parentID;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterDuplicateParentID = (arr) => {
|
||||||
|
const filteredArray = arr.filter(
|
||||||
|
(item, index) => index === arr.findIndex((other) => item.parent_id === other.parent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return filteredArray;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user