Merge pull request #24 from reyamir/feat/channels

Initial support for public chat channels (NIP-28)
This commit is contained in:
Ren Amamiya 2023-04-12 16:48:51 +07:00 committed by GitHub
commit 1f73be3a06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1454 additions and 612 deletions

View File

@ -15,12 +15,15 @@
"dependencies": {
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-collapsible": "^1.0.2",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.5",
"@rehooks/local-storage": "^2.4.4",
"@supabase/supabase-js": "^2.15.0",
"@tauri-apps/api": "^1.2.0",
"dayjs": "^1.11.7",

View File

@ -3,12 +3,15 @@ lockfileVersion: 5.4
specifiers:
'@emoji-mart/data': ^1.1.2
'@emoji-mart/react': ^1.1.1
'@radix-ui/react-alert-dialog': ^1.0.3
'@radix-ui/react-collapsible': ^1.0.2
'@radix-ui/react-dialog': ^1.0.3
'@radix-ui/react-dropdown-menu': ^2.0.4
'@radix-ui/react-icons': ^1.3.0
'@radix-ui/react-popover': ^1.0.5
'@radix-ui/react-tabs': ^1.0.3
'@radix-ui/react-tooltip': ^1.0.5
'@rehooks/local-storage': ^2.4.4
'@supabase/supabase-js': ^2.15.0
'@tailwindcss/typography': ^0.5.9
'@tauri-apps/api': ^1.2.0
@ -54,12 +57,15 @@ specifiers:
dependencies:
'@emoji-mart/data': 1.1.2
'@emoji-mart/react': 1.1.1_kyrnz3vmphzqyjjk2ivrm6bcsu
'@radix-ui/react-alert-dialog': 1.0.3_zn3vyfk3tbnwebg5ldvieekjaq
'@radix-ui/react-collapsible': 1.0.2_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-dialog': 1.0.3_zn3vyfk3tbnwebg5ldvieekjaq
'@radix-ui/react-dropdown-menu': 2.0.4_zn3vyfk3tbnwebg5ldvieekjaq
'@radix-ui/react-icons': 1.3.0_react@18.2.0
'@radix-ui/react-popover': 1.0.5_zn3vyfk3tbnwebg5ldvieekjaq
'@radix-ui/react-tabs': 1.0.3_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-tooltip': 1.0.5_zn3vyfk3tbnwebg5ldvieekjaq
'@rehooks/local-storage': 2.4.4_react@18.2.0
'@supabase/supabase-js': 2.15.0
'@tauri-apps/api': 1.2.0
dayjs: 1.11.7
@ -559,6 +565,26 @@ packages:
'@babel/runtime': 7.21.0
dev: false
/@radix-ui/react-alert-dialog/1.0.3_zn3vyfk3tbnwebg5ldvieekjaq:
resolution:
{ integrity: sha512-QXFy7+bhGi0u+paF2QbJeSCHZs4gLMJIPm6sajUamyW0fro6g1CaSGc5zmc4QmK2NlSGUrq8m+UsUqJYtzvXow== }
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0_react@18.2.0
'@radix-ui/react-context': 1.0.0_react@18.2.0
'@radix-ui/react-dialog': 1.0.3_zn3vyfk3tbnwebg5ldvieekjaq
'@radix-ui/react-primitive': 1.0.2_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-slot': 1.0.1_react@18.2.0
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
transitivePeerDependencies:
- '@types/react'
dev: false
/@radix-ui/react-arrow/1.0.2_biqbaboplfbrettd7655fr4n2y:
resolution:
{ integrity: sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA== }
@ -926,6 +952,32 @@ packages:
react-dom: 18.2.0_react@18.2.0
dev: false
/@radix-ui/react-tooltip/1.0.5_zn3vyfk3tbnwebg5ldvieekjaq:
resolution:
{ integrity: sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w== }
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0_react@18.2.0
'@radix-ui/react-context': 1.0.0_react@18.2.0
'@radix-ui/react-dismissable-layer': 1.0.3_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-id': 1.0.0_react@18.2.0
'@radix-ui/react-popper': 1.1.1_zn3vyfk3tbnwebg5ldvieekjaq
'@radix-ui/react-portal': 1.0.2_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-presence': 1.0.0_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-primitive': 1.0.2_biqbaboplfbrettd7655fr4n2y
'@radix-ui/react-slot': 1.0.1_react@18.2.0
'@radix-ui/react-use-controllable-state': 1.0.0_react@18.2.0
'@radix-ui/react-visually-hidden': 1.0.2_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
transitivePeerDependencies:
- '@types/react'
dev: false
/@radix-ui/react-use-callback-ref/1.0.0_react@18.2.0:
resolution:
{ integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg== }
@ -990,6 +1042,19 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-visually-hidden/1.0.2_biqbaboplfbrettd7655fr4n2y:
resolution:
{ integrity: sha512-qirnJxtYn73HEk1rXL12/mXnu2rwsNHDID10th2JGtdK25T9wX+mxRmGt7iPSahw512GbZOc0syZX1nLQGoEOg== }
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.21.0
'@radix-ui/react-primitive': 1.0.2_biqbaboplfbrettd7655fr4n2y
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
/@radix-ui/rect/1.0.0:
resolution:
{ integrity: sha512-d0O68AYy/9oeEy1DdC07bz1/ZXX+DqCskRd3i4JzLSTXwefzaepQrKjXC7aNM8lTHjFLDO0pDgaEiQ7jEk+HVg== }
@ -997,6 +1062,15 @@ packages:
'@babel/runtime': 7.21.0
dev: false
/@rehooks/local-storage/2.4.4_react@18.2.0:
resolution:
{ integrity: sha512-zE+kfOkG59n/1UTxdmbwktIosclr67Nlbf2MzUJ9mNtCSypVscNHeD1qT6JCSo5Pjj8DO893IKWNLJqKKzDL/Q== }
peerDependencies:
react: '>=16.8.0'
dependencies:
react: 18.2.0
dev: false
/@rushstack/eslint-patch/1.2.0:
resolution:
{ integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg== }

View File

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Channel" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"eventId" TEXT NOT NULL,
"content" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Channel_eventId_key" ON "Channel"("eventId");
-- CreateIndex
CREATE INDEX "Channel_eventId_idx" ON "Channel"("eventId");

View File

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Chat" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pubkey" TEXT NOT NULL,
"createdAt" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Chat_pubkey_key" ON "Chat"("pubkey");
-- CreateIndex
CREATE INDEX "Chat_pubkey_idx" ON "Chat"("pubkey");

View File

@ -0,0 +1,35 @@
/*
Warnings:
- Added the required column `accountId` to the `Chat` table without a default value. This is not possible if the table is not empty.
- Added the required column `accountId` to the `Channel` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Chat" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pubkey" TEXT NOT NULL,
"createdAt" INTEGER NOT NULL,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Chat_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Chat" ("createdAt", "id", "pubkey") SELECT "createdAt", "id", "pubkey" FROM "Chat";
DROP TABLE "Chat";
ALTER TABLE "new_Chat" RENAME TO "Chat";
CREATE UNIQUE INDEX "Chat_pubkey_key" ON "Chat"("pubkey");
CREATE INDEX "Chat_pubkey_idx" ON "Chat"("pubkey");
CREATE TABLE "new_Channel" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"eventId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Channel_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Channel" ("content", "eventId", "id") SELECT "content", "eventId", "id" FROM "Channel";
DROP TABLE "Channel";
ALTER TABLE "new_Channel" RENAME TO "Channel";
CREATE UNIQUE INDEX "Channel_eventId_key" ON "Channel"("eventId");
CREATE INDEX "Channel_eventId_idx" ON "Channel"("eventId");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Channel" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"eventId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT false,
"accountId" INTEGER NOT NULL,
CONSTRAINT "Channel_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Channel" ("accountId", "content", "eventId", "id") SELECT "accountId", "content", "eventId", "id" FROM "Channel";
DROP TABLE "Channel";
ALTER TABLE "new_Channel" RENAME TO "Channel";
CREATE UNIQUE INDEX "Channel_eventId_key" ON "Channel"("eventId");
CREATE INDEX "Channel_eventId_idx" ON "Channel"("eventId");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -21,6 +21,8 @@ model Account {
plebs Pleb[]
messages Message[]
notes Note[]
chats Chat[]
channels Channel[]
@@index([pubkey])
}
@ -66,6 +68,29 @@ model Message {
@@index([pubkey, createdAt])
}
model Chat {
id Int @id @default(autoincrement())
pubkey String @unique
createdAt Int
Account Account @relation(fields: [accountId], references: [id])
accountId Int
@@index([pubkey])
}
model Channel {
id Int @id @default(autoincrement())
eventId String @unique
content String
active Boolean @default(false)
Account Account @relation(fields: [accountId], references: [id])
accountId Int
@@index([eventId])
}
model Relay {
id Int @id @default(autoincrement())
url String

View File

@ -35,6 +35,7 @@ struct CreateAccountData {
#[derive(Deserialize, Type)]
struct GetPlebData {
account_id: i32,
kind: i32,
}
#[derive(Deserialize, Type)]
@ -81,6 +82,42 @@ struct GetLatestNoteData {
date: i32,
}
#[derive(Deserialize, Type)]
struct CreateChatData {
pubkey: String,
created_at: i32,
account_id: i32,
}
#[derive(Deserialize, Type)]
struct GetChatData {
account_id: i32,
}
#[derive(Deserialize, Type)]
struct CreateChannelData {
event_id: String,
content: String,
account_id: i32,
}
#[derive(Deserialize, Type)]
struct GetChannelData {
limit: i32,
offset: i32,
}
#[derive(Deserialize, Type)]
struct GetActiveChannelData {
active: bool,
}
#[derive(Deserialize, Type)]
struct UpdateChannelData {
event_id: String,
active: bool,
}
#[tauri::command]
#[specta::specta]
async fn get_accounts(db: DbState<'_>) -> Result<Vec<account::Data>, ()> {
@ -105,7 +142,10 @@ async fn create_account(db: DbState<'_>, data: CreateAccountData) -> Result<acco
#[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)])
.find_many(vec![
pleb::account_id::equals(data.account_id),
pleb::kind::equals(data.kind),
])
.exec()
.await
.map_err(|_| ())
@ -215,6 +255,92 @@ async fn count_total_notes(db: DbState<'_>) -> Result<i64, ()> {
db.note().count(vec![]).exec().await.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn create_channel(db: DbState<'_>, data: CreateChannelData) -> Result<channel::Data, ()> {
db.channel()
.upsert(
channel::event_id::equals(data.event_id.clone()),
channel::create(
data.event_id,
data.content,
account::id::equals(data.account_id),
vec![],
),
vec![],
)
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn update_channel(db: DbState<'_>, data: UpdateChannelData) -> Result<channel::Data, ()> {
db.channel()
.update(
channel::event_id::equals(data.event_id), // Unique filter
vec![channel::active::set(data.active)], // Vec of updates
)
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_channels(db: DbState<'_>, data: GetChannelData) -> Result<Vec<channel::Data>, ()> {
db.channel()
.find_many(vec![])
.take(data.limit.into())
.skip(data.offset.into())
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_active_channels(
db: DbState<'_>,
data: GetActiveChannelData,
) -> Result<Vec<channel::Data>, ()> {
db.channel()
.find_many(vec![channel::active::equals(data.active)])
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn create_chat(db: DbState<'_>, data: CreateChatData) -> Result<chat::Data, ()> {
db.chat()
.upsert(
chat::pubkey::equals(data.pubkey.clone()),
chat::create(
data.pubkey,
data.created_at,
account::id::equals(data.account_id),
vec![],
),
vec![],
)
.exec()
.await
.map_err(|_| ())
}
#[tauri::command]
#[specta::specta]
async fn get_chats(db: DbState<'_>, data: GetChatData) -> Result<Vec<chat::Data>, ()> {
db.chat()
.find_many(vec![chat::account_id::equals(data.account_id)])
.exec()
.await
.map_err(|_| ())
}
#[tokio::main]
async fn main() {
let db = PrismaClient::_builder().build().await.unwrap();
@ -230,7 +356,13 @@ async fn main() {
create_note,
get_notes,
get_latest_notes,
get_note_by_id
get_note_by_id,
create_channel,
update_channel,
get_channels,
get_active_channels,
create_chat,
get_chats
],
"../src/utils/bindings.ts",
)
@ -272,7 +404,13 @@ async fn main() {
get_notes,
get_latest_notes,
get_note_by_id,
count_total_notes
count_total_notes,
create_channel,
update_channel,
get_channels,
get_active_channels,
create_chat,
get_chats
])
.manage(Arc::new(db))
.run(tauri::generate_context!())

View File

@ -2,8 +2,6 @@
@tailwind components;
@tailwind utilities;
@import './assets/editor.css';
/* Fixed next/image bug, source: https://nextjs.org/docs/api-reference/next/image */
@supports (font: -apple-system-body) and (-webkit-appearance: none) {
img[loading='lazy'] {

View File

@ -1,326 +0,0 @@
.w-md-editor-bar {
position: absolute;
cursor: s-resize;
right: 4px;
bottom: 4px;
margin-top: -11px;
margin-right: 0;
width: 14px;
z-index: 3;
height: 10px;
border-radius: 0 0 3px 0;
-webkit-user-select: none;
user-select: none;
}
.w-md-editor-bar svg {
display: block;
margin: 0 auto;
}
.w-md-editor-aree {
overflow: auto;
border-radius: 5px;
}
.w-md-editor-text {
min-height: 100%;
position: relative;
text-align: left;
white-space: pre-wrap;
word-break: keep-all;
overflow-wrap: break-word;
box-sizing: border-box;
margin: 0;
-webkit-font-variant-ligatures: common-ligatures;
font-variant-ligatures: common-ligatures;
@apply p-4;
}
.w-md-editor-text-pre,
.w-md-editor-text-input,
.w-md-editor-text > .w-md-editor-text-pre {
margin: 0;
border: 0;
background: none;
box-sizing: inherit;
display: inherit;
font-family: inherit;
font-size: inherit;
font-style: inherit;
-webkit-font-variant-ligatures: inherit;
font-variant-ligatures: inherit;
font-weight: inherit;
letter-spacing: inherit;
line-height: inherit;
tab-size: inherit;
text-indent: inherit;
text-rendering: inherit;
text-transform: inherit;
white-space: inherit;
overflow-wrap: inherit;
word-break: inherit;
word-break: normal;
padding: 0;
}
.w-md-editor-text-pre > code,
.w-md-editor-text-input > code,
.w-md-editor-text > .w-md-editor-text-pre > code {
font-family: inherit;
}
.w-md-editor-text-pre {
position: relative;
margin: 0px !important;
pointer-events: none;
background-color: transparent !important;
}
.w-md-editor-text-pre > code {
padding: 0 !important;
}
.w-md-editor-text-input {
position: absolute;
top: 0px;
left: 0px;
height: 100%;
width: 100%;
resize: none;
color: inherit;
overflow: hidden;
outline: 0;
padding: inherit;
-webkit-font-smoothing: antialiased;
-webkit-text-fill-color: transparent;
@apply placeholder:text-zinc-500;
}
.w-md-editor-text-input:empty {
-webkit-text-fill-color: inherit !important;
}
.w-md-editor-text-pre,
.w-md-editor-text-input {
word-wrap: pre;
word-break: break-word;
white-space: pre-wrap;
}
/**
* Hack to apply on some CSS on IE10 and IE11
*/
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
/**
* IE doesn't support '-webkit-text-fill-color'
* So we use 'color: transparent' to make the text transparent on IE
* Unlike other browsers, it doesn't affect caret color in IE
*/
.w-md-editor-text-input {
color: transparent !important;
}
.w-md-editor-text-input::selection {
background-color: #accef7 !important;
color: transparent !important;
}
}
.w-md-editor-text-pre .punctuation {
color: var(--color-prettylights-syntax-comment) !important;
}
.w-md-editor-text-pre .token.url,
.w-md-editor-text-pre .token.content {
color: var(--color-prettylights-syntax-constant) !important;
}
.w-md-editor-text-pre .token.title.important {
color: var(--color-prettylights-syntax-markup-bold);
}
.w-md-editor-text-pre .token.code-block .function {
color: var(--color-prettylights-syntax-entity);
}
.w-md-editor-text-pre .token.bold {
font-weight: unset !important;
}
.w-md-editor-text-pre .token.title {
line-height: unset !important;
font-size: unset !important;
font-weight: unset !important;
}
.w-md-editor-text-pre .token.code.keyword {
color: var(--color-prettylights-syntax-constant) !important;
}
.w-md-editor-text-pre .token.strike,
.w-md-editor-text-pre .token.strike .content {
color: var(--color-prettylights-syntax-markup-deleted-text) !important;
}
.w-md-editor-toolbar-child {
position: absolute;
border-radius: 3px;
box-shadow: 0 0 0 1px var(--color-border-default), 0 0 0 var(--color-border-default),
0 1px 1px var(--color-border-default);
background-color: var(--color-canvas-default);
z-index: 1;
display: none;
}
.w-md-editor-toolbar-child.active {
display: block;
}
.w-md-editor-toolbar-child .w-md-editor-toolbar {
border-bottom: 0;
padding: 3px;
border-radius: 3px;
}
.w-md-editor-toolbar-child .w-md-editor-toolbar ul > li {
display: block;
}
.w-md-editor-toolbar-child .w-md-editor-toolbar ul > li button:not(.cta-btn) {
width: -webkit-fill-available;
height: initial;
box-sizing: border-box;
padding: 3px 4px 2px 4px;
margin: 0;
}
.w-md-editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
flex-wrap: wrap;
}
.w-md-editor-toolbar.bottom {
border-bottom: 0px;
border-top: 1px solid var(--color-border-default);
border-radius: 0 0 3px 3px;
}
.w-md-editor-toolbar ul,
.w-md-editor-toolbar li {
margin: 0;
padding: 0;
list-style: none;
line-height: initial;
}
.w-md-editor-toolbar li {
display: inline-block;
font-size: 14px;
}
.w-md-editor-toolbar li + li {
margin: 0;
}
.w-md-editor-toolbar li > button:not(.cta-btn) {
border: none;
height: 20px;
line-height: 14px;
background: none;
text-transform: none;
font-weight: normal;
overflow: visible;
outline: none;
cursor: pointer;
transition: all 0.3s;
white-space: nowrap;
@apply rounded py-1 px-2 text-zinc-500;
}
.w-md-editor-toolbar li > button:not(.cta-btn):hover,
.w-md-editor-toolbar li > button:not(.cta-btn):focus {
@apply bg-zinc-700 text-zinc-100;
}
.w-md-editor-toolbar li > button:not(.cta-btn):active {
background-color: var(--color-neutral-muted);
color: var(--color-danger-fg);
}
.w-md-editor-toolbar li > button:not(.cta-btn):disabled {
color: var(--color-border-default);
cursor: not-allowed;
}
.w-md-editor-toolbar li > button:not(.cta-btn):disabled:hover {
background-color: transparent;
color: var(--color-border-default);
}
.w-md-editor-toolbar li.active > button:not(.cta-btn) {
color: var(--color-accent-fg);
background-color: var(--color-neutral-muted);
}
.w-md-editor-toolbar-divider {
height: 14px;
width: 1px;
margin: -3px 3px 0 3px !important;
vertical-align: middle;
background-color: var(--color-border-default);
}
.w-md-editor {
text-align: left;
border-radius: 3px;
padding-bottom: 1px;
position: relative;
display: flex;
flex-direction: column-reverse;
@apply gap-3;
}
.w-md-editor.w-md-editor-rtl {
direction: rtl !important;
text-align: right !important;
}
.w-md-editor.w-md-editor-rtl .w-md-editor-preview {
right: unset !important;
left: 0;
text-align: right !important;
box-shadow: inset -1px 0 0 0 var(--color-border-default);
}
.w-md-editor.w-md-editor-rtl .w-md-editor-text {
text-align: right !important;
}
.w-md-editor-toolbar {
@apply h-10 shrink-0;
}
.w-md-editor-content {
@apply relative h-full overflow-auto rounded-lg border-[0.5px] border-white/30 bg-zinc-800 shadow-inner;
}
.w-md-editor .copied {
display: none !important;
}
.w-md-editor-input {
width: 50%;
height: 100%;
}
.w-md-editor-text-pre > code {
word-break: break-word !important;
white-space: pre-wrap !important;
}
.w-md-editor-preview {
width: 50%;
box-sizing: border-box;
box-shadow: inset 1px 0 0 0 var(--color-border-default);
position: absolute;
padding: 10px 20px;
overflow: auto;
top: 0;
right: 0;
bottom: 0;
border-radius: 0 0 5px 0;
display: flex;
flex-direction: column;
}
.w-md-editor-preview .anchor {
display: none;
}
.w-md-editor-preview .contains-task-list {
list-style: none;
}
.w-md-editor-show-preview .w-md-editor-input {
width: 0%;
overflow: hidden;
background-color: var(--color-canvas-default);
}
.w-md-editor-show-preview .w-md-editor-preview {
width: 100%;
box-shadow: inset 0 0 0 0;
}
.w-md-editor-show-edit .w-md-editor-input {
width: 100%;
}
.w-md-editor-show-edit .w-md-editor-preview {
width: 0%;
padding: 0;
}
.w-md-editor-fullscreen {
overflow: hidden;
position: fixed;
z-index: 99999;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100% !important;
}
.w-md-editor-fullscreen .w-md-editor-content {
height: 100%;
}

18
src/assets/icons/hide.tsx Normal file
View File

@ -0,0 +1,18 @@
export default function HideIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
);
}

18
src/assets/icons/mute.tsx Normal file
View File

@ -0,0 +1,18 @@
export default function MuteIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
);
}

View File

@ -0,0 +1,18 @@
export default function ReplyIcon({ className }: { className: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z"
/>
</svg>
);
}

View File

@ -4,7 +4,7 @@ const AppActions = dynamic(() => import('@components/appHeader/actions'), {
ssr: false,
});
const NoteConnector = dynamic(() => import('@components/note/connector'), {
const EventCollector = dynamic(() => import('@components/eventCollector'), {
ssr: false,
});
@ -15,7 +15,7 @@ export default function AppHeader() {
<div data-tauri-drag-region className="flex h-full w-full items-center justify-between">
<div className="flex h-full items-center divide-x divide-zinc-900 px-4 pt-px"></div>
<div>
<NoteConnector />
<EventCollector />
</div>
</div>
</div>

View File

@ -0,0 +1,59 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
export const BrowseChannelItem = ({ data }: { data: any }) => {
const router = useRouter();
const channel = JSON.parse(data.content);
const openChannel = useCallback(
(id: string) => {
router.push({
pathname: '/channels/[id]',
query: { id: id },
});
},
[router]
);
const joinChannel = useCallback(
async (id: string) => {
const { updateChannel } = await import('@utils/bindings');
updateChannel({ event_id: id, active: true })
.then(() => openChannel(id))
.catch(console.error);
},
[openChannel]
);
return (
<div
onClick={() => openChannel(data.eventId)}
className="group relative flex items-center gap-2 border-b border-zinc-800 px-3 py-2.5 hover:bg-black/20"
>
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md border border-white/10">
<ImageWithFallback
src={channel.picture || DEFAULT_AVATAR}
alt={data.id}
fill={true}
className="rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-200">{channel.name}</span>
<span className="text-sm leading-tight text-zinc-400">{channel.about}</span>
</div>
<div className="absolute right-2 top-1/2 hidden -translate-y-1/2 transform group-hover:inline-flex">
<button
onClick={() => joinChannel(data.eventId)}
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"
>
Join
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,41 @@
import { ChannelListItem } from '@components/channels/channelListItem';
import { CreateChannelModal } from '@components/channels/createChannelModal';
import { GlobeIcon } from '@radix-ui/react-icons';
import Link from 'next/link';
import { useEffect, useState } from 'react';
export default function ChannelList() {
const [list, setList] = useState([]);
useEffect(() => {
const fetchChannels = async () => {
const { getActiveChannels } = await import('@utils/bindings');
return await getActiveChannels({ active: true });
};
fetchChannels()
.then((res) => setList(res))
.catch(console.error);
}, []);
return (
<div className="flex flex-col gap-px">
<Link
href="/channels"
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">
<GlobeIcon className="h-3 w-3 text-zinc-500" />
</div>
<div>
<h5 className="text-sm font-medium text-zinc-500 group-hover:text-zinc-400">Browse channels</h5>
</div>
</Link>
{list.map((item) => (
<ChannelListItem key={item.id} data={item} />
))}
<CreateChannelModal />
</div>
);
}

View File

@ -0,0 +1,36 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useRouter } from 'next/router';
export const ChannelListItem = ({ data }: { data: any }) => {
const router = useRouter();
const channel = JSON.parse(data.content);
const openChannel = (id: string) => {
router.push({
pathname: '/channels/[id]',
query: { id: id },
});
};
return (
<div
onClick={() => openChannel(data.eventId)}
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-0 overflow-hidden rounded">
<ImageWithFallback
src={channel?.picture || DEFAULT_AVATAR}
alt={data.eventId}
fill={true}
className="rounded object-cover"
/>
</div>
<div>
<h5 className="truncate text-sm font-medium text-zinc-400">{channel.name}</h5>
</div>
</div>
);
};

View File

@ -0,0 +1,137 @@
import { RelayContext } from '@components/relaysProvider';
import { dateToUnix } from '@utils/getDate';
import * as Dialog from '@radix-ui/react-dialog';
import { Cross1Icon, PlusIcon } from '@radix-ui/react-icons';
import useLocalStorage from '@rehooks/local-storage';
import { getEventHash, signEvent } from 'nostr-tools';
import { useCallback, useContext, useState } from 'react';
import { useForm } from 'react-hook-form';
export const CreateChannelModal = () => {
const [pool, relays]: any = useContext(RelayContext);
const [open, setOpen] = useState(false);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const {
register,
handleSubmit,
reset,
formState: { isDirty, isValid },
} = useForm();
const insertChannelToDB = useCallback(async (id, data, account) => {
const { createChannel } = await import('@utils/bindings');
return await createChannel({ event_id: id, content: data, account_id: account });
}, []);
const onSubmit = (data) => {
const event: any = {
content: JSON.stringify(data),
created_at: dateToUnix(),
kind: 40,
pubkey: activeAccount.pubkey,
tags: [],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish channel
pool.publish(event, relays);
// save to database
insertChannelToDB(event.id, data, activeAccount.id);
// close modal
setOpen(false);
// reset form
reset();
};
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<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 channel</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-min w-full max-w-xl flex-col rounded-lg shadow-modal">
<div className="sticky left-0 top-0 flex h-12 w-full shrink-0 items-center justify-between rounded-t-lg bg-zinc-950 px-3">
<div className="flex w-full items-center justify-between">
<h5 className="font-medium leading-none text-zinc-500"># Create channel</h5>
<Dialog.Close asChild>
<button
autoFocus={false}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<Cross1Icon className="h-3 w-3 text-zinc-300" />
</button>
</Dialog.Close>
</div>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto rounded-b-lg bg-zinc-950 px-3 pb-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4 rounded-lg border border-white/20 bg-zinc-900 p-4"
>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-300">
Channel name *
</label>
<div className="relative 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">
<input
type={'text'}
{...register('name', { required: true })}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 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>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-300">Picture</label>
<div className="relative 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">
<input
type={'text'}
{...register('picture')}
spellCheck={false}
className="relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 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>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-300">About</label>
<div className="relative h-20 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">
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg border border-black/5 px-3 py-2 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>
<div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="h-11 w-full transform rounded-lg bg-fuchsia-500 font-medium text-white active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
>
Create
</button>
</div>
</form>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@ -0,0 +1,84 @@
import { RelayContext } from '@components/relaysProvider';
import { dateToUnix } from '@utils/getDate';
import HideIcon from '@assets/icons/hide';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import * as Tooltip from '@radix-ui/react-tooltip';
import useLocalStorage from '@rehooks/local-storage';
import { getEventHash, signEvent } from 'nostr-tools';
import { useCallback, useContext } from 'react';
export const HideMessageButton = ({ id }: { id: string }) => {
const [pool, relays]: any = useContext(RelayContext);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const hideMessage = useCallback(() => {
const event: any = {
content: '',
created_at: dateToUnix(),
kind: 43,
pubkey: activeAccount.pubkey,
tags: [['e', id]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, relays);
}, [id, activeAccount.privkey, activeAccount.pubkey, pool, relays]);
return (
<AlertDialog.Root>
<Tooltip.Provider>
<Tooltip.Root>
<AlertDialog.Trigger asChild>
<Tooltip.Trigger asChild>
<button className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-800">
<HideIcon className="h-4 w-4 text-zinc-400" />
</button>
</Tooltip.Trigger>
</AlertDialog.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded-md bg-zinc-800 px-4 py-2 text-sm leading-none text-zinc-100 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"
sideOffset={4}
>
Hide this message
<Tooltip.Arrow className="fill-zinc-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-sm data-[state=open]:animate-overlayShow" />
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-md bg-zinc-900 p-6 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] ring-1 ring-zinc-800 focus:outline-none data-[state=open]:animate-contentShow">
<AlertDialog.Title className="m-0 font-medium text-zinc-100">Are you absolutely sure?</AlertDialog.Title>
<AlertDialog.Description className="mb-5 mt-4 text-zinc-400">
This action cannot be undone. This will permanently hide this message and you will never see this again
</AlertDialog.Description>
<div className="flex justify-end gap-4">
<AlertDialog.Cancel asChild>
<button
autoFocus={false}
className="inline-flex h-9 items-center justify-center rounded px-4 font-medium leading-none text-zinc-200 outline-none hover:bg-zinc-900 focus:shadow-[0_0_0_2px]"
>
Cancel
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
autoFocus={false}
onClick={() => hideMessage()}
className="inline-flex h-9 items-center justify-center rounded bg-red-500 px-4 font-medium leading-none text-white outline-none hover:bg-red-600 focus:shadow-[0_0_0_2px] focus:shadow-red-700"
>
Yes, hide this message
</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
};

View File

@ -0,0 +1,39 @@
import ChannelMessageItem from '@components/channels/messages/item';
import { useCallback, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
export const ChannelMessages = ({ data }: { data: any }) => {
const virtuosoRef = useRef(null);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={data[index]} />;
},
[data]
);
const computeItemKey = useCallback(
(index: string | number) => {
return data[index].id;
},
[data]
);
return (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
/>
</div>
);
};

View File

@ -0,0 +1,32 @@
import { HideMessageButton } from '@components/channels/messages/hideMessageButton';
import { MuteButton } from '@components/channels/messages/muteButton';
import { ReplyButton } from '@components/channels/messages/replyButton';
import { MessageUser } from '@components/chats/messageUser';
import { memo } from 'react';
const ChannelMessageItem = ({ data }: { data: any }) => {
return (
<div className="group relative 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">
{data.content}
</div>
</div>
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-7 items-center justify-center gap-1 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<ReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<HideMessageButton id={data.id} />
<MuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
};
export default memo(ChannelMessageItem);

View File

@ -0,0 +1,85 @@
import { RelayContext } from '@components/relaysProvider';
import { dateToUnix } from '@utils/getDate';
import MuteIcon from '@assets/icons/mute';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import * as Tooltip from '@radix-ui/react-tooltip';
import useLocalStorage from '@rehooks/local-storage';
import { getEventHash, signEvent } from 'nostr-tools';
import { useCallback, useContext } from 'react';
export const MuteButton = ({ pubkey }: { pubkey: string }) => {
const [pool, relays]: any = useContext(RelayContext);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const muteUser = useCallback(() => {
const event: any = {
content: '',
created_at: dateToUnix(),
kind: 44,
pubkey: activeAccount.pubkey,
tags: [['p', pubkey]],
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, relays);
}, [pubkey, activeAccount.privkey, activeAccount.pubkey, pool, relays]);
return (
<AlertDialog.Root>
<Tooltip.Provider>
<Tooltip.Root>
<AlertDialog.Trigger asChild>
<Tooltip.Trigger asChild>
<button className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-800">
<MuteIcon className="h-4 w-4 text-zinc-400" />
</button>
</Tooltip.Trigger>
</AlertDialog.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded-md bg-zinc-800 px-4 py-2 text-sm leading-none text-zinc-100 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"
sideOffset={4}
>
Mute user
<Tooltip.Arrow className="fill-zinc-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-sm data-[state=open]:animate-overlayShow" />
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-md bg-zinc-900 p-6 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] ring-1 ring-zinc-800 focus:outline-none data-[state=open]:animate-contentShow">
<AlertDialog.Title className="m-0 font-medium text-zinc-100">Are you absolutely sure?</AlertDialog.Title>
<AlertDialog.Description className="mb-5 mt-4 text-zinc-400">
This action cannot be undone. This will permanently mute this user and you will never receive message from
this user
</AlertDialog.Description>
<div className="flex justify-end gap-4">
<AlertDialog.Cancel asChild>
<button
autoFocus={false}
className="inline-flex h-9 items-center justify-center rounded px-4 font-medium leading-none text-zinc-200 outline-none hover:bg-zinc-900 focus:shadow-[0_0_0_2px]"
>
Cancel
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
autoFocus={false}
onClick={() => muteUser()}
className="inline-flex h-9 items-center justify-center rounded bg-red-500 px-4 font-medium leading-none text-white outline-none hover:bg-red-600 focus:shadow-[0_0_0_2px] focus:shadow-red-700"
>
Yes, mute this user
</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
};

View File

@ -0,0 +1,38 @@
import { channelReplyAtom } from '@stores/channel';
import ReplyIcon from '@assets/icons/reply';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useSetAtom } from 'jotai';
export const ReplyButton = ({ id, pubkey, content }: { id: string; pubkey: string; content: string }) => {
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
const createReply = () => {
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
};
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={() => createReply()}
className="inline-flex h-6 w-6 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyIcon className="h-4 w-4 text-zinc-400" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="select-none rounded-md bg-zinc-800 px-4 py-2 text-sm leading-none text-zinc-100 shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"
sideOffset={4}
>
Reply
<Tooltip.Arrow className="fill-zinc-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
};

View File

@ -1,23 +1,19 @@
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 useLocalStorage from '@rehooks/local-storage';
import { useRouter } from 'next/router';
import { useContext, useEffect, useState } from 'react';
import { 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 [list, setList] = useState([]);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const profile = activeAccount.metadata ? JSON.parse(activeAccount.metadata) : null;
const openSelfChat = () => {
router.push({
@ -27,26 +23,15 @@ export default function ChatList() {
};
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;
const fetchChats = async () => {
const { getChats } = await import('@utils/bindings');
return await getChats({ account_id: activeAccount.id });
};
}, [pool, relays, activeAccount.pubkey]);
fetchChats()
.then((res) => setList(res))
.catch(console.error);
}, [activeAccount.id]);
return (
<div className="flex flex-col gap-px">
@ -56,7 +41,7 @@ export default function ChatList() {
>
<div className="relative h-5 w-5 shrink overflow-hidden rounded bg-white">
<ImageWithFallback
src={accountProfile.picture || DEFAULT_AVATAR}
src={profile?.picture || DEFAULT_AVATAR}
alt={activeAccount.pubkey}
fill={true}
className="rounded object-cover"
@ -64,12 +49,12 @@ export default function ChatList() {
</div>
<div>
<h5 className="text-sm font-medium text-zinc-400">
{accountProfile.display_name || accountProfile.name} <span className="text-zinc-500">(you)</span>
{profile?.display_name || profile?.name} <span className="text-zinc-500">(you)</span>
</h5>
</div>
</div>
{[...list].map((item: string, index) => (
<ChatListItem key={index} pubkey={item} />
{list.map((item) => (
<ChatListItem key={item.id} pubkey={item.pubkey} />
))}
<ChatModal />
</div>

View File

@ -1,19 +1,17 @@
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 useLocalStorage from '@rehooks/local-storage';
import { useCallback, useEffect, useState } from 'react';
export const ChatModal = () => {
const [plebs, setPlebs] = useState([]);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const fetchPlebsByAccount = useCallback(async (id) => {
const { getPlebs } = await import('@utils/bindings');
return await getPlebs({ account_id: id });
return await getPlebs({ account_id: id, kind: 0 });
}, []);
useEffect(() => {
@ -42,7 +40,10 @@ export const ChatModal = () => {
<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">
<button
autoFocus={false}
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>

View File

@ -1,14 +1,15 @@
import MessageListItem from '@components/chats/messageListItem';
import useLocalStorage from '@rehooks/local-storage';
import { useCallback, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
export const MessageList = ({ data }: { data: any }) => {
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const virtuosoRef = useRef(null);
const itemContent: any = useCallback(
(index: string | number) => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
return (
<MessageListItem
data={data[index]}
@ -17,7 +18,7 @@ export const MessageList = ({ data }: { data: any }) => {
/>
);
},
[data]
[activeAccount.privkey, activeAccount.pubkey, data]
);
const computeItemKey = useCallback(

View File

@ -1,31 +1,32 @@
import { RelayContext } from '@components/relaysProvider';
import { lastLoginAtom } from '@stores/account';
import { hasNewerNoteAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate';
import { getParentID, pubkeyArray } from '@utils/transform';
import useLocalStorage, { writeStorage } from '@rehooks/local-storage';
import { TauriEvent } from '@tauri-apps/api/event';
import { appWindow, getCurrent } from '@tauri-apps/api/window';
import { useSetAtom } from 'jotai';
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
export default function NoteConnector() {
export default function EventCollector() {
const [pool, relays]: any = useContext(RelayContext);
const setLastLoginAtom = useSetAtom(lastLoginAtom);
const [isOnline] = useState(true);
const setHasNewerNote = useSetAtom(hasNewerNoteAtom);
const [isOnline] = useState(true);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const [follows] = useLocalStorage('activeAccountFollows', []);
const now = useRef(new Date());
const unsubscribe = useRef(null);
const subscribe = useCallback(async () => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const follows = JSON.parse(localStorage.getItem('activeAccountFollows'));
const { createChat } = await import('@utils/bindings');
const { createChannel } = await import('@utils/bindings');
unsubscribe.current = pool.subscribe(
[
@ -34,43 +35,61 @@ export default function NoteConnector() {
authors: pubkeyArray(follows),
since: dateToUnix(now.current),
},
{
kinds: [4],
'#p': [activeAccount.pubkey],
since: 0,
},
{
kinds: [40],
since: 0,
},
],
relays,
(event) => {
const parentID = getParentID(event.tags, event.id);
// insert event to local database
createNote({
event_id: event.id,
pubkey: event.pubkey,
kind: event.kind,
tags: JSON.stringify(event.tags),
content: event.content,
parent_id: parentID,
parent_comment_id: '',
created_at: event.created_at,
account_id: activeAccount.id,
})
.then(() =>
// notify user reload to get newer note
setHasNewerNote(true)
)
.catch(console.error);
},
10000
if (event.kind === 1) {
const parentID = getParentID(event.tags, event.id);
// insert event to local database
createNote({
event_id: event.id,
pubkey: event.pubkey,
kind: event.kind,
tags: JSON.stringify(event.tags),
content: event.content,
parent_id: parentID,
parent_comment_id: '',
created_at: event.created_at,
account_id: activeAccount.id,
})
.then(() =>
// notify user reload to get newer note
setHasNewerNote(true)
)
.catch(console.error);
} else if (event.kind === 4) {
if (event.pubkey !== activeAccount.pubkey) {
createChat({ pubkey: event.pubkey, created_at: event.created_at, account_id: activeAccount.id });
}
} else if (event.kind === 40) {
createChannel({ event_id: event.id, content: event.content, account_id: activeAccount.id });
} else {
console.error;
}
}
);
}, [pool, relays, setHasNewerNote]);
}, [activeAccount.id, activeAccount.pubkey, follows, pool, relays, setHasNewerNote]);
useEffect(() => {
subscribe();
getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, () => {
setLastLoginAtom(now.current);
writeStorage('lastLogin', now.current);
appWindow.close();
});
return () => {
unsubscribe.current;
};
}, [setHasNewerNote, setLastLoginAtom, subscribe]);
}, [setHasNewerNote, subscribe]);
return (
<div className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 hover:bg-zinc-900">

View File

@ -6,6 +6,7 @@ import { noteContentAtom } from '@stores/note';
import { dateToUnix } from '@utils/getDate';
import useLocalStorage from '@rehooks/local-storage';
import { useAtom } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { getEventHash, signEvent } from 'nostr-tools';
@ -17,9 +18,9 @@ export default function FormBase() {
const [value, setValue] = useAtom(noteContentAtom);
const resetValue = useResetAtom(noteContentAtom);
const submitEvent = () => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const submitEvent = () => {
const event: any = {
content: value,
created_at: dateToUnix(),

View File

@ -0,0 +1,130 @@
import ImagePicker from '@components/form/imagePicker';
import { RelayContext } from '@components/relaysProvider';
import { UserMini } from '@components/user/mini';
import { channelReplyAtom } from '@stores/channel';
import { dateToUnix } from '@utils/getDate';
import { Cross1Icon } from '@radix-ui/react-icons';
import useLocalStorage from '@rehooks/local-storage';
import { useAtomValue } from 'jotai';
import { useResetAtom } from 'jotai/utils';
import { getEventHash, signEvent } from 'nostr-tools';
import { useCallback, useContext, useState } from 'react';
export default function FormChannelMessage({ eventId }: { eventId: string | string[] }) {
const [pool, relays]: any = useContext(RelayContext);
const [value, setValue] = useState('');
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const channelReply = useAtomValue(channelReplyAtom);
const resetChannelReply = useResetAtom(channelReplyAtom);
const submitEvent = useCallback(() => {
let tags;
if (channelReply.id !== null) {
tags = [
['e', eventId, '', 'root'],
['e', channelReply.id, '', 'reply'],
['p', channelReply.pubkey, ''],
];
} else {
tags = [['e', eventId, '', 'root']];
}
const event: any = {
content: value,
created_at: dateToUnix(),
kind: 42,
pubkey: activeAccount.pubkey,
tags: tags,
};
event.id = getEventHash(event);
event.sig = signEvent(event, activeAccount.privkey);
// publish note
pool.publish(event, relays);
// reset state
setValue('');
// reset channel reply
resetChannelReply();
}, [
value,
channelReply.id,
channelReply.pubkey,
activeAccount.pubkey,
activeAccount.privkey,
eventId,
resetChannelReply,
pool,
relays,
]);
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitEvent();
}
};
const stopReply = () => {
resetChannelReply();
};
return (
<div
className={`relative ${
channelReply.id ? 'h-36' : '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`}
>
{channelReply.id && (
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserMini pubkey={channelReply.pubkey} />
<div className="-mt-3.5 pl-[32px]">
<div className="text-xs text-zinc-200">{channelReply.content}</div>
</div>
</div>
<button
onClick={() => stopReply()}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
>
<Cross1Icon className="h-3 w-3 text-zinc-100" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
placeholder="Message"
className={`relative ${
channelReply.id ? 'h-36 pt-16' : 'h-24 pt-3'
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-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 className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker />
<div className="flex items-center gap-2 pl-2"></div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Send
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -3,12 +3,15 @@ import { RelayContext } from '@components/relaysProvider';
import { dateToUnix } from '@utils/getDate';
import useLocalStorage from '@rehooks/local-storage';
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 [activeAccount]: any = useLocalStorage('activeAccount', {});
const encryptMessage = useCallback(
async (privkey: string) => {
@ -18,7 +21,6 @@ export default function FormChat({ receiverPubkey }: { receiverPubkey: string })
);
const submitEvent = useCallback(() => {
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
encryptMessage(activeAccount.privkey)
.then((encryptedContent) => {
const event: any = {
@ -36,7 +38,7 @@ export default function FormChat({ receiverPubkey }: { receiverPubkey: string })
setValue('');
})
.catch(console.error);
}, [encryptMessage, receiverPubkey, pool, relays]);
}, [encryptMessage, activeAccount.privkey, activeAccount.pubkey, receiverPubkey, pool, relays]);
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {

View File

@ -1,22 +1,19 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { dateToUnix } from '@utils/getDate';
import destr from 'destr';
import { useAtomValue } from 'jotai';
import useLocalStorage from '@rehooks/local-storage';
import { getEventHash, signEvent } from 'nostr-tools';
import { useContext, useState } from 'react';
export default function FormComment({ eventID }: { eventID: any }) {
const [pool, relays]: any = useContext(RelayContext);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const [value, setValue] = useState('');
const profile = destr(activeAccount.metadata);
const profile = JSON.parse(activeAccount.metadata);
const submitEvent = () => {
const event: any = {

View File

@ -29,20 +29,19 @@ export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }
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);
const metadata: any = await fetchMetadata(tag[1]);
createPleb({
pleb_id: tag[1] + '-lume' + activeAccount.id.toString(),
pleb_id: tag[1] + '-lume' + user.id.toString(),
pubkey: tag[1],
kind: 0,
metadata: metadata.content,
account_id: activeAccount.id,
account_id: user.id,
}).catch(console.error);
}
},
[pool, relays]
[user.id]
);
useEffect(() => {
@ -56,7 +55,7 @@ export const ActiveAccount = memo(function ActiveAccount({ user }: { user: any }
relays,
(event: any) => {
if (event.tags.length > 0) {
insertFollowsToStorage(event.tags);
//insertFollowsToStorage(event.tags);
}
},
20000,

View File

@ -6,21 +6,24 @@ import { APP_VERSION } from '@stores/constants';
import LumeSymbol from '@assets/icons/Lume';
import { PlusIcon } from '@radix-ui/react-icons';
import useLocalStorage from '@rehooks/local-storage';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
export default function MultiAccounts() {
const [users, setUsers] = useState([]);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
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 renderAccount = useCallback(
(user: { pubkey: string }) => {
if (user.pubkey === activeAccount.pubkey) {
return <ActiveAccount key={user.pubkey} user={user} />;
} else {
return <InactiveAccount key={user.pubkey} user={user} />;
}
},
[activeAccount.pubkey]
);
const fetchAccounts = useCallback(async () => {
const { getAccounts } = await import('@utils/bindings');

View File

@ -1,3 +1,5 @@
import ChannelList from '@components/channels/channelList';
import * as Collapsible from '@radix-ui/react-collapsible';
import { TriangleUpIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
@ -18,7 +20,9 @@ export default function Channels() {
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-zinc-600">Channels</h3>
</Collapsible.Trigger>
<Collapsible.Content></Collapsible.Content>
<Collapsible.Content>
<ChannelList />
</Collapsible.Content>
</div>
</Collapsible.Root>
);

View File

@ -2,16 +2,13 @@ import { ImageWithFallback } from '@components/imageWithFallback';
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { activeAccountAtom } from '@stores/account';
import { dateToUnix } from '@utils/getDate';
import CommentIcon from '@assets/icons/comment';
import * as Dialog from '@radix-ui/react-dialog';
import { SizeIcon } from '@radix-ui/react-icons';
import destr from 'destr';
import { useAtomValue } from 'jotai';
import useLocalStorage from '@rehooks/local-storage';
import { useRouter } from 'next/router';
import { getEventHash, signEvent } from 'nostr-tools';
import { memo, useContext, useState } from 'react';
@ -26,7 +23,7 @@ export const NoteComment = memo(function NoteComment({
count: number;
eventID: string;
eventPubkey: string;
eventTime: string;
eventTime: number;
eventContent: any;
}) {
const router = useRouter();
@ -35,8 +32,8 @@ export const NoteComment = memo(function NoteComment({
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
const activeAccount: any = useAtomValue(activeAccountAtom);
const profile = destr(activeAccount.metadata);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const profile = activeAccount.metadata ? JSON.parse(activeAccount.metadata) : null;
const openThread = () => {
router.push(`/newsfeed/${eventID}`);
@ -68,8 +65,8 @@ export const NoteComment = memo(function NoteComment({
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm data-[state=open]:animate-overlayShow" />
<Dialog.Content className="fixed inset-0 overflow-y-auto">
<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 w-full max-w-2xl rounded-lg bg-zinc-900 p-4 text-zinc-100 ring-1 ring-zinc-800">
{/* root note */}
@ -90,7 +87,7 @@ export const NoteComment = memo(function NoteComment({
<div>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<ImageWithFallback
src={profile.picture}
src={profile?.picture}
alt="user's avatar"
fill={true}
className="rounded-md object-cover"

View File

@ -1,13 +1,11 @@
import { RelayContext } from '@components/relaysProvider';
import { activeAccountAtom } from '@stores/account';
import { dateToUnix } from '@utils/getDate';
import LikeIcon from '@assets/icons/like';
import LikedIcon from '@assets/icons/liked';
import { useAtomValue } from 'jotai';
import useLocalStorage from '@rehooks/local-storage';
import { getEventHash, signEvent } from 'nostr-tools';
import { memo, useContext, useEffect, useState } from 'react';
@ -22,8 +20,7 @@ export const NoteReaction = memo(function NoteReaction({
}) {
const [pool, relays]: any = useContext(RelayContext);
const activeAccount: any = useAtomValue(activeAccountAtom);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const [isReact, setIsReact] = useState(false);
const [like, setLike] = useState(0);

View File

@ -26,7 +26,7 @@ export default function NoteMetadata({
{
'#e': [eventID],
since: parseInt(eventTime),
kinds: [1, 7],
kinds: [7],
limit: 50,
},
],
@ -48,11 +48,7 @@ export default function NoteMetadata({
break;
}
},
1000,
undefined,
{
unsubscribeOnEose: true,
}
1000
);
return () => {

View File

@ -8,6 +8,7 @@ import { UserMention } from '@components/user/mention';
import { getParentID } from '@utils/transform';
import useLocalStorage from '@rehooks/local-storage';
import destr from 'destr';
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import reactStringReplace from 'react-string-replace';
@ -15,12 +16,13 @@ import reactStringReplace from 'react-string-replace';
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
const [pool, relays]: any = useContext(RelayContext);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const [event, setEvent] = useState(null);
const unsubscribe = useRef(null);
const fetchEvent = useCallback(async () => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
unsubscribe.current = pool.subscribe(
[
@ -54,7 +56,7 @@ export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
unsubscribeOnEose: true,
}
);
}, [id, pool, relays]);
}, [activeAccount.id, id, pool, relays]);
const checkNoteExist = useCallback(async () => {
const { getNoteById } = await import('@utils/bindings');

View File

@ -4,6 +4,7 @@ import { UserMention } from '@components/user/mention';
import { getParentID } from '@utils/transform';
import useLocalStorage from '@rehooks/local-storage';
import destr from 'destr';
import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import reactStringReplace from 'react-string-replace';
@ -11,12 +12,13 @@ import reactStringReplace from 'react-string-replace';
export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
const [pool, relays]: any = useContext(RelayContext);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const [event, setEvent] = useState(null);
const unsubscribe = useRef(null);
const fetchEvent = useCallback(async () => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
unsubscribe.current = pool.subscribe(
[
@ -50,7 +52,7 @@ export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
unsubscribeOnEose: true,
}
);
}, [id, pool, relays]);
}, [activeAccount.id, id, pool, relays]);
const checkNoteExist = useCallback(async () => {
const { getNoteById } = await import('@utils/bindings');

View File

@ -6,7 +6,7 @@ export const RelayContext = createContext({});
const relays = [
'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net',
'wss://nostr.bongbong.com',
//'wss://nostr.bongbong.com',
'wss://nostr.zebedee.cloud',
'wss://nostr.fmt.wiz.biz',
'wss://relay.snort.social',

View File

@ -0,0 +1,26 @@
import { ImageWithFallback } from '@components/imageWithFallback';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useMetadata } from '@utils/metadata';
import { truncate } from '@utils/truncate';
export const UserMini = ({ pubkey }: { pubkey: string }) => {
const profile = useMetadata(pubkey);
return (
<div className="group flex items-start gap-1">
<div className="relative h-7 w-7 shrink overflow-hidden rounded border border-white/10">
<ImageWithFallback
src={profile?.picture || DEFAULT_AVATAR}
alt={pubkey}
fill={true}
className="rounded object-cover"
/>
</div>
<span className="text-xs font-medium leading-none text-zinc-500">
Replying to {profile?.name || truncate(pubkey, 16, ' .... ')}
</span>
</div>
);
};

View File

@ -1,51 +0,0 @@
import ActiveLink from '@components/activeLink';
import AccountColumn from '@components/columns/account';
import { useLocalStorage } from '@rehooks/local-storage';
export default function UserLayout({ children }: { children: React.ReactNode }) {
const [currentUser]: any = useLocalStorage('current-user');
return (
<div className="flex h-full w-full flex-row">
<div className="relative h-full w-[70px] shrink-0 border-r border-zinc-900">
<div data-tauri-drag-region className="absolute top-0 left-0 h-12 w-full" />
<AccountColumn />
</div>
<div className="grid grow grid-cols-4">
<div className="col-span-1">
<div className="flex h-full flex-col flex-wrap justify-between overflow-hidden px-2 pt-3 pb-4">
{/* main */}
<div className="flex flex-col gap-4">
{/* menu */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-2">
<h3 className="text-sm font-bold text-zinc-400">Menu</h3>
</div>
<div className="flex flex-col gap-1 text-zinc-500">
<ActiveLink
href={`/profile/${currentUser.id}`}
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
className="flex h-10 items-center gap-1 rounded-lg px-2.5 text-sm font-medium hover:bg-zinc-900"
>
<span>Personal Page</span>
</ActiveLink>
<ActiveLink
href={`/profile/update?pubkey=${currentUser.id}`}
activeClassName="ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
className="flex h-10 items-center gap-1 rounded-lg px-2.5 text-sm font-medium hover:bg-zinc-900"
>
<span>Update Profile</span>
</ActiveLink>
</div>
</div>
</div>
</div>
</div>
<div className="col-span-3 m-3 ml-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
<div className="h-full w-full rounded-lg">{children}</div>
</div>
</div>
</div>
);
}

101
src/pages/channels/[id].tsx Normal file
View File

@ -0,0 +1,101 @@
import BaseLayout from '@layouts/base';
import WithSidebarLayout from '@layouts/withSidebar';
import { ChannelMessages } from '@components/channels/messages/index';
import FormChannelMessage from '@components/form/channelMessage';
import { RelayContext } from '@components/relaysProvider';
import { channelReplyAtom } from '@stores/channel';
import useLocalStorage from '@rehooks/local-storage';
import { useResetAtom } from 'jotai/utils';
import { useRouter } from 'next/router';
import {
JSXElementConstructor,
ReactElement,
ReactFragment,
ReactPortal,
useContext,
useEffect,
useRef,
useState,
} from 'react';
export default function Page() {
const [pool, relays]: any = useContext(RelayContext);
const router = useRouter();
const id: string | string[] = router.query.id || null;
const [messages, setMessages] = useState([]);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const resetChannelReply = useResetAtom(channelReplyAtom);
const muted = useRef(new Set());
const hided = useRef(new Set());
useEffect(() => {
// reset channel reply
resetChannelReply();
// subscribe event
const unsubscribe = pool.subscribe(
[
{
authors: [activeAccount.pubkey],
kinds: [43, 44],
since: 0,
},
{
'#e': [id],
kinds: [42],
since: 0,
},
],
relays,
(event: any) => {
if (event.kind === 44) {
muted.current = muted.current.add(event.tags[0][1]);
} else if (event.kind === 43) {
hided.current = hided.current.add(event.tags[0][1]);
} else {
if (muted.current.has(event.pubkey)) {
console.log('muted');
} else if (hided.current.has(event.id)) {
console.log('hided');
} else {
setMessages((messages) => [event, ...messages]);
}
}
}
);
return () => {
unsubscribe;
};
}, [id, pool, relays, activeAccount.pubkey, resetChannelReply]);
return (
<div className="flex h-full w-full flex-col justify-between">
<ChannelMessages data={messages.sort((a, b) => a.created_at - b.created_at)} />
<div className="shrink-0 p-3">
<FormChannelMessage eventId={id} />
</div>
</div>
);
}
Page.getLayout = function getLayout(
page:
| string
| number
| boolean
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
| ReactFragment
| ReactPortal
) {
return (
<BaseLayout>
<WithSidebarLayout>{page}</WithSidebarLayout>
</BaseLayout>
);
};

View File

@ -0,0 +1,45 @@
import BaseLayout from '@layouts/base';
import WithSidebarLayout from '@layouts/withSidebar';
import { BrowseChannelItem } from '@components/channels/browseChannelItem';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal, useEffect, useState } from 'react';
export default function Page() {
const [list, setList] = useState([]);
useEffect(() => {
const fetchChannels = async () => {
const { getChannels } = await import('@utils/bindings');
return await getChannels({ limit: 100, offset: 0 });
};
fetchChannels()
.then((res) => setList(res))
.catch(console.error);
}, []);
return (
<div className="h-full w-full overflow-y-auto">
{list.map((channel) => (
<BrowseChannelItem key={channel.id} data={channel} />
))}
</div>
);
}
Page.getLayout = function getLayout(
page:
| string
| number
| boolean
| ReactElement<unknown, string | JSXElementConstructor<unknown>>
| ReactFragment
| ReactPortal
) {
return (
<BaseLayout>
<WithSidebarLayout>{page}</WithSidebarLayout>
</BaseLayout>
);
};

View File

@ -5,9 +5,7 @@ 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 useLocalStorage from '@rehooks/local-storage';
import { useRouter } from 'next/router';
import {
JSXElementConstructor,
@ -25,7 +23,7 @@ export default function Page() {
const router = useRouter();
const pubkey: any = router.query.pubkey || null;
const activeAccount: any = useAtomValue(activeAccountAtom);
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const [messages, setMessages] = useState([]);
useEffect(() => {

View File

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

View File

@ -7,6 +7,7 @@ import { getParentID, pubkeyArray } from '@utils/transform';
import LumeSymbol from '@assets/icons/Lume';
import { useLocalStorage } from '@rehooks/local-storage';
import { invoke } from '@tauri-apps/api/tauri';
import { useRouter } from 'next/router';
import {
@ -30,11 +31,13 @@ export default function Page() {
const [eose, setEose] = useState(false);
const [lastLogin] = useLocalStorage('lastLogin', '');
const [activeAccount]: any = useLocalStorage('activeAccount', {});
const [follows] = useLocalStorage('activeAccountFollows', []);
const fetchData = useCallback(
async (since: Date) => {
const { createNote } = await import('@utils/bindings');
const activeAccount = JSON.parse(localStorage.getItem('activeAccount'));
const follows = JSON.parse(localStorage.getItem('activeAccountFollows'));
unsubscribe.current = pool.subscribe(
[
@ -67,20 +70,19 @@ export default function Page() {
}
);
},
[pool, relays]
[activeAccount.id, follows, pool, relays]
);
const isNoteExist = useCallback(async () => {
invoke('count_total_notes').then((res: number) => {
if (res > 0) {
const lastLogin = JSON.parse(localStorage.getItem('lastLogin'));
const parseDate = new Date(lastLogin);
fetchData(parseDate);
} else {
fetchData(hoursAgo(24, now.current));
}
});
}, [fetchData]);
}, [fetchData, lastLogin]);
useEffect(() => {
if (eose === false) {

View File

@ -1,53 +1,10 @@
import BaseLayout from '@layouts/base';
import WithSidebarLayout from '@layouts/withSidebar';
import FormComment from '@components/form/comment';
import { NoteComment } from '@components/note/comment';
import { NoteExtend } from '@components/note/extend';
import { RelayContext } from '@components/relaysProvider';
import { useRouter } from 'next/router';
import {
JSXElementConstructor,
ReactElement,
ReactFragment,
ReactPortal,
useContext,
useEffect,
useState,
} from 'react';
import { JSXElementConstructor, ReactElement, ReactFragment, ReactPortal } from 'react';
export default function Page() {
const [pool, relays]: any = useContext(RelayContext);
const router = useRouter();
const id = router.query.id || null;
const [rootEvent, setRootEvent] = useState(null);
const [comments, setComments] = useState([]);
useEffect(() => {
/*getNoteByID(id)
.then((res) => {
setRootEvent(res);
getAllCommentNotes(id).then((res: any) => setComments(res));
})
.catch(console.error);*/
}, [id, pool, relays]);
return (
<div className="scrollbar-hide flex h-full flex-col gap-2 overflow-y-auto py-3">
<div className="flex h-min min-h-min w-full select-text flex-col px-3">
{rootEvent && <NoteExtend event={rootEvent} />}
</div>
<div>
<FormComment eventID={id} />
</div>
<div className="flex flex-col">
{comments.length > 0 && comments.map((comment) => <NoteComment key={comment.id} event={comment} />)}
</div>
</div>
);
return <div className="scrollbar-hide flex h-full flex-col gap-2 overflow-y-auto py-3"></div>;
}
Page.getLayout = function getLayout(

View File

@ -84,7 +84,7 @@ export default function Page() {
setLoading(true);
for (const follow of follows) {
const metadata: any = await fetchMetadata(follow, pool, relays);
const metadata: any = await fetchMetadata(follow);
createPleb({
pleb_id: follow + '-lume' + id,
pubkey: follow,

View File

@ -49,7 +49,7 @@ export default function Page() {
const { createPleb } = await import('@utils/bindings');
if (profile?.id !== null) {
for (const tag of tags) {
const metadata: any = await fetchMetadata(tag[1], pool, relays);
const metadata: any = await fetchMetadata(tag[1]);
createPleb({
pleb_id: tag[1] + '-lume' + profile.id.toString(),
pubkey: tag[1],
@ -60,7 +60,7 @@ export default function Page() {
}
}
},
[pool, profile.id, relays]
[profile.id]
);
useEffect(() => {

View File

@ -1,14 +0,0 @@
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
const createMyJsonStorage = () => {
const storage = createJSONStorage(() => localStorage);
const getItem = (key) => {
const value = storage.getItem(key);
return value;
};
return { ...storage, getItem };
};
export const activeAccountAtom = atomWithStorage('activeAccount', {}, createMyJsonStorage());
export const activeAccountFollowsAtom = atomWithStorage('activeAccountFollows', [], createMyJsonStorage());
export const lastLoginAtom = atomWithStorage('lastLogin', [], createMyJsonStorage());

4
src/stores/channel.tsx Normal file
View File

@ -0,0 +1,4 @@
import { atomWithReset } from 'jotai/utils';
// channel reply id
export const channelReplyAtom = atomWithReset({ id: null, pubkey: null, content: null });

View File

@ -44,20 +44,30 @@ 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 function createChannel(data: CreateChannelData) {
return invoke<Channel>('create_channel', { data });
}
export function updateChannel(data: UpdateChannelData) {
return invoke<Channel>('update_channel', { data });
}
export function getChannels(data: GetChannelData) {
return invoke<Channel[]>('get_channels', { data });
}
export function getActiveChannels(data: GetActiveChannelData) {
return invoke<Channel[]>('get_active_channels', { data });
}
export function createChat(data: CreateChatData) {
return invoke<Chat>('create_chat', { data });
}
export function getChats(data: GetChatData) {
return invoke<Chat[]>('get_chats', { data });
}
export type Note = {
id: number;
eventId: string;
@ -70,9 +80,31 @@ export type Note = {
createdAt: number;
accountId: number;
};
export type CreateChannelData = { event_id: string; content: string; account_id: number };
export type CreatePlebData = { pleb_id: string; pubkey: string; kind: number; metadata: string; account_id: number };
export type Chat = { id: number; pubkey: 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 GetChannelData = { limit: number; offset: number };
export type GetLatestNoteData = { date: number };
export type GetPlebData = { account_id: number; kind: number };
export type CreateAccountData = { pubkey: string; privkey: string; metadata: string };
export type GetPlebPubkeyData = { pubkey: string };
export type Channel = { id: number; eventId: string; content: string; active: boolean; accountId: number };
export type GetChatData = { account_id: number };
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 UpdateChannelData = { event_id: string; active: boolean };
export type Pleb = { id: number; plebId: string; pubkey: string; kind: number; metadata: string; accountId: number };
export type CreateChatData = { pubkey: string; created_at: number; account_id: number };
export type GetNoteData = { date: number; limit: number; offset: number };
export type GetActiveChannelData = { active: boolean };
export type GetNoteByIdData = { event_id: string };

View File

@ -1,35 +1,52 @@
import { RelayContext } from '@components/relaysProvider';
import { fetch } from '@tauri-apps/api/http';
import { useCallback, useEffect, useState } from 'react';
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 fetchMetadata = async (pubkey: string) => {
const result = await fetch(`https://rbr.bio/${pubkey}/metadata.json`, {
method: 'GET',
timeout: 5,
});
return await result.data;
};
export const useMetadata = (pubkey) => {
const [pool, relays]: any = useContext(RelayContext);
const [profile, setProfile] = useState(null);
/*
const insertPlebToDB = useCallback(async (account, pubkey, metadata) => {
const { createPleb } = await import('@utils/bindings');
return await createPleb({
pleb_id: pubkey + '-lume' + account.toString(),
pubkey: pubkey,
kind: 1,
metadata: metadata,
account_id: account,
}).catch(console.error);
}, []);
*/
const getCachedMetadata = useCallback(async () => {
const { getPlebByPubkey } = await import('@utils/bindings');
getPlebByPubkey({ pubkey: pubkey })
.then((res) => {
if (res) {
const metadata = JSON.parse(res.metadata);
// update state
setProfile(metadata);
} else {
fetchMetadata(pubkey, pool, relays).then((res: any) => {
fetchMetadata(pubkey).then((res: any) => {
if (res.content) {
const metadata = JSON.parse(res.content);
// update state
setProfile(metadata);
// save to database
// insertPlebToDB(activeAccount.id, pubkey, metadata);
}
});
}
})
.catch(console.error);
}, [pool, relays, pubkey]);
}, [pubkey]);
useEffect(() => {
getCachedMetadata().catch(console.error);