Compare commits

..

60 Commits

Author SHA1 Message Date
Ren Amamiya
740b7588bc Merge pull request #127 from luminous-devs/hotfix/2.2.1
v2.2.1
2023-12-06 08:09:44 +07:00
24c2ed4eb2 update 2023-12-06 08:08:11 +07:00
4006c0010e polish 2023-12-05 15:31:45 +07:00
7decf264d7 polish nsecbunker 2023-12-05 14:25:44 +07:00
482b218f74 improve error handling for useevent hook 2023-12-05 09:42:08 +07:00
e06b760e41 bump version 2023-12-04 14:14:50 +07:00
7efc35f622 replace media chrome with vidstack 2023-12-04 14:02:07 +07:00
8795923443 update 2023-12-04 13:36:16 +07:00
4093821fd0 clean up 2023-12-04 12:47:29 +07:00
b19637bdb7 remove misconfigure in react query 2023-12-04 12:17:16 +07:00
21e758ec13 update user component 2023-12-04 11:49:52 +07:00
48ab123850 improve relay screen 2023-12-04 09:26:19 +07:00
a401070031 update dependencies 2023-12-04 08:46:42 +07:00
e5e4109e79 bump version 2023-12-03 08:38:32 +07:00
Ren Amamiya
d62c814f33 Merge pull request #125 from luminous-devs/next
Lume v2.2.0
2023-12-03 08:37:37 +07:00
2a92b7c202 polish 2023-12-03 08:34:44 +07:00
255dcb43fe improve relay form 2023-12-02 17:53:45 +07:00
a528b646e3 add finish step to tutorial 2023-12-02 08:33:23 +07:00
fc35745c0d wip: tutorial 2023-12-01 15:45:43 +07:00
9ddf3471ce fix nsecbunker 2023-12-01 08:23:46 +07:00
8355ad6863 auto connect user relays 2023-11-30 20:15:54 +07:00
217ac490b1 fix outbox 2023-11-30 19:22:00 +07:00
092cf49227 improve relay connection 2023-11-30 18:19:24 +07:00
5318f6c4cb clean up 2023-11-30 17:24:07 +07:00
80f675cb54 improve notification and performance 2023-11-30 16:02:28 +07:00
6f68c2762b add prefetch data 2023-11-30 10:35:08 +07:00
f4390b29e2 revamp onboarding and launching process 2023-11-30 09:38:58 +07:00
00e4f9d357 clean up dependencies 2023-11-28 15:36:57 +07:00
d28d183620 Merge branch 'main' into next 2023-11-28 09:07:14 +07:00
3c6c9c86d1 2.1.7 2023-11-28 09:00:46 +07:00
bcd079c88e update dependencies 2023-11-28 08:34:21 +07:00
Ren Amamiya
d989d6ffad Merge pull request #123 from luminous-devs/feat/v2.1.6
Feat/v2.1.6
2023-11-27 12:01:25 +07:00
5229458746 bump version 2023-11-27 09:56:43 +07:00
2bfa1db816 update 2023-11-27 09:48:51 +07:00
8439428ce1 fix crash on settings screen 2023-11-26 15:01:13 +07:00
34dceef4a3 fix mention popup 2023-11-26 07:48:28 +07:00
Ren Amamiya
619bfb8dff Merge pull request #122 from luminous-devs/v2.1.4
v2.1.4
2023-11-26 07:22:13 +07:00
7759851541 clean up 2023-11-26 07:21:24 +07:00
9112c1c24a improve connection 2023-11-25 17:56:45 +07:00
24b21a9451 update 2023-11-25 16:03:05 +07:00
31a53b9c48 add @ suggestion popup 2023-11-25 15:41:18 +07:00
dc229f40cb fix new article layout 2023-11-25 11:07:31 +07:00
54ad1e6e1d fix new post layout 2023-11-25 09:22:15 +07:00
Ren Amamiya
065ccbbea4 Merge pull request #121 from luminous-devs/fix/nsecbunker
Fix stuck issue for connect with nsecbunker
2023-11-24 13:53:26 +07:00
74738c36cd disable blockUntilReady 2023-11-23 15:12:46 +07:00
Ren Amamiya
2fdf437789 Merge pull request #120 from luminous-devs/fix/logout
Fix logout function and other issues
2023-11-23 08:54:24 +07:00
731c72535c bump version 2023-11-23 08:52:47 +07:00
628102087e fix total account count function 2023-11-23 08:52:04 +07:00
536ea30ed2 fix logout function 2023-11-23 08:49:05 +07:00
8ee38cdb42 temp disable single-instance plugin 2023-11-22 17:27:09 +07:00
Ren Amamiya
a896300f23 Merge pull request #118 from luminous-devs/v2.1.2
v2.1.2
2023-11-22 16:18:02 +07:00
d3cf1200ba bump version 2023-11-22 16:13:06 +07:00
b5ac3df090 fix package 2023-11-22 16:10:20 +07:00
3b40dd6903 update dependencies 2023-11-22 15:27:19 +07:00
Ren Amamiya
efba6b20ea Merge pull request #117 from luminous-devs/feat/optional-updater
Make auto update is optional
2023-11-22 10:34:49 +07:00
Ren Amamiya
05fb56e5fc Merge pull request #116 from vivganes/patch-1
Little grammar corrections
2023-11-22 10:32:47 +07:00
Vivek Ganesan
59d9646e9f Little grammar corrections 2023-11-22 08:43:31 +05:30
b73d84fccb update storage provider 2023-11-22 09:05:10 +07:00
1929ceb72d add toast message 2023-11-22 08:31:58 +07:00
a1d22c1daf make auto update is optional 2023-11-22 08:30:43 +07:00
115 changed files with 3763 additions and 3139 deletions

View File

@@ -11,7 +11,6 @@
"^@app/(.*)$",
"^@libs/(.*)$",
"^@shared/(.*)$",
"^@stores/(.*)$",
"^@utils/(.*)$",
"^[./]"
],

View File

@@ -4,7 +4,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume</title>
</head>
<body class="relative cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen text-neutral-950 dark:text-neutral-50">
<body
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-neutral-950 antialiased dark:text-neutral-50"
>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>

View File

@@ -2,7 +2,7 @@
"name": "lume",
"description": "the communication app",
"private": true,
"version": "2.1.1",
"version": "2.2.1",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -18,10 +18,11 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@evilmartians/harmony": "^1.1.0",
"@getalby/sdk": "^2.6.0",
"@nostr-dev-kit/ndk": "^2.0.5",
"@evilmartians/harmony": "^1.2.0",
"@getalby/sdk": "^2.7.0",
"@nostr-dev-kit/ndk": "^2.2.0",
"@nostr-fetch/adapter-ndk": "^0.13.1",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -32,7 +33,8 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.8.4",
"@tanstack/react-query": "^5.12.2",
"@tanstack/react-query-devtools": "^5.12.2",
"@tauri-apps/api": "2.0.0-alpha.11",
"@tauri-apps/cli": "2.0.0-alpha.17",
"@tauri-apps/plugin-autostart": "2.0.0-alpha.3",
@@ -47,27 +49,26 @@
"@tauri-apps/plugin-sql": "2.0.0-alpha.3",
"@tauri-apps/plugin-updater": "2.0.0-alpha.3",
"@tauri-apps/plugin-upload": "2.0.0-alpha.3",
"@tiptap/extension-character-count": "^2.1.12",
"@tiptap/extension-document": "^2.1.12",
"@tiptap/extension-image": "^2.1.12",
"@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-paragraph": "^2.1.12",
"@tiptap/extension-placeholder": "^2.1.12",
"@tiptap/extension-text": "^2.1.12",
"@tiptap/pm": "^2.1.12",
"@tiptap/react": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"@tiptap/extension-character-count": "^2.1.13",
"@tiptap/extension-document": "^2.1.13",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-paragraph": "^2.1.13",
"@tiptap/extension-placeholder": "^2.1.13",
"@tiptap/extension-text": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.1.13",
"@vidstack/react": "^1.8.3",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.5",
"framer-motion": "^10.16.12",
"html-to-text": "^9.0.5",
"idb-keyval": "^6.2.1",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.3",
"lru-cache": "^10.1.0",
"markdown-to-jsx": "^7.3.2",
"media-chrome": "^1.5.3",
"minidenticons": "^4.2.0",
"nanoid": "^5.0.3",
"nanoid": "^5.0.4",
"nostr-fetch": "^0.13.1",
"nostr-tools": "^1.17.0",
"qrcode.react": "^3.1.0",
@@ -77,48 +78,48 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-hotkeys-hook": "^4.4.1",
"react-router-dom": "^6.19.0",
"react-router-dom": "^6.20.1",
"react-string-replace": "^1.1.1",
"reactflow": "^11.10.1",
"sonner": "^1.2.0",
"tailwind-scrollbar": "^3.0.5",
"sonner": "^1.2.4",
"tauri-controls": "github:reyamir/tauri-controls",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.4",
"virtua": "^0.16.4",
"zustand": "^4.4.6"
"tiptap-markdown": "^0.8.8",
"virtua": "^0.17.4",
"zustand": "^4.4.7"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/html-to-text": "^9.0.4",
"@types/node": "^20.9.2",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/youtube-player": "^5.5.10",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"@types/node": "^20.10.3",
"@types/react": "^18.2.41",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16",
"clsx": "^2.0.0",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^15.1.0",
"postcss": "^8.4.31",
"lint-staged": "^15.2.0",
"postcss": "^8.4.32",
"prettier": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.7",
"prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0",
"tailwind-scrollbar": "^3.0.5",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"typescript": "^5.3.2",
"vite": "^4.5.0",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.1"
}
}

1929
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

415
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "lume"
version = "2.1.1"
version = "2.2.0"
description = "the communication app"
authors = ["Ren Amamiya"]
license = "GPL-3.0"
@@ -28,12 +28,11 @@ tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", bra
tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-theme = { git = "https://github.com/reyamir/tauri-plugin-theme", branch = "tauri-v2" }
tauri-plugin-theme = { git = "https://github.com/wyhaya/tauri-plugin-theme" }
tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v2", features = [
"sqlite",
] }

View File

@@ -0,0 +1,5 @@
ALTER TABLE accounts DROP COLUMN follows;
ALTER TABLE accounts DROP COLUMN circles;
ALTER TABLE accounts DROP COLUMN last_login_at;
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS relays;

View File

@@ -115,7 +115,6 @@ fn main() {
.plugin(tauri_plugin_updater::Builder::new().build())?;
Ok(())
})
.plugin(ThemePlugin::init(ctx.config_mut()))
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
@@ -133,10 +132,17 @@ fn main() {
sql: include_str!("../migrations/20231028083224_add_ndk_cache_table.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20231130105202,
description: "clean up table",
sql: include_str!("../migrations/20231130105202_clean_up_table.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
)
.plugin(ThemePlugin::init(ctx.config_mut()))
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())

View File

@@ -9,7 +9,7 @@
},
"package": {
"productName": "Lume",
"version": "2.1.1"
"version": "2.2.1"
},
"plugins": {
"fs": {

View File

@@ -1,4 +1,8 @@
@import 'reactflow/dist/style.css';
/* @import 'reactflow/dist/style.css'; */
/* Vidstack */
@import '@vidstack/react/player/styles/default/theme.css';
@import '@vidstack/react/player/styles/default/layouts/video.css';
@tailwind base;
@tailwind components;
@@ -41,28 +45,8 @@ input::-ms-clear {
background-clip: padding-box;
}
.player {
--brand-color: #f5f5f5;
--focus-color: #4e9cf6;
--audio-brand: var(--brand-color);
--audio-focus-ring-color: var(--focus-color);
--audio-border-radius: 2px;
--video-brand: var(--brand-color);
--video-focus-ring-color: var(--focus-color);
--video-border-radius: 8px;
width: 100%;
}
.player[data-view-type='video'] {
aspect-ratio: 16 /9;
}
.ProseMirror p.is-empty::before {
@apply text-neutral-600 dark:text-neutral-400;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
@apply text-neutral-600 dark:text-neutral-400 float-left h-0 pointer-events-none content-[attr(data-placeholder)];
}
.ProseMirror img.ProseMirror-selectednode {

View File

@@ -3,7 +3,6 @@ import { fetch } from '@tauri-apps/plugin-http';
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
import { ReactFlowProvider } from 'reactflow';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error';
import { ExploreScreen } from '@app/explore';
@@ -197,37 +196,52 @@ export default function App() {
},
{
path: 'onboarding',
element: <OnboardingScreen />,
errorElement: <ErrorScreen />,
children: [
{
path: '',
async lazy() {
const { OnboardingListScreen } = await import(
'@app/auth/onboarding/list'
);
return { Component: OnboardingListScreen };
},
},
{
path: 'enrich',
async lazy() {
const { OnboardEnrichScreen } = await import(
'@app/auth/onboarding/enrich'
);
return { Component: OnboardEnrichScreen };
},
},
{
path: 'hashtag',
async lazy() {
const { OnboardHashtagScreen } = await import(
'@app/auth/onboarding/hashtag'
);
return { Component: OnboardHashtagScreen };
},
},
],
async lazy() {
const { OnboardingScreen } = await import('@app/auth/onboarding');
return { Component: OnboardingScreen };
},
},
{
path: 'follow',
async lazy() {
const { FollowScreen } = await import('@app/auth/follow');
return { Component: FollowScreen };
},
},
{
path: 'finish',
async lazy() {
const { FinishScreen } = await import('@app/auth/finish');
return { Component: FinishScreen };
},
},
{
path: 'tutorials/note',
async lazy() {
const { TutorialNoteScreen } = await import('@app/auth/tutorials/note');
return { Component: TutorialNoteScreen };
},
},
{
path: 'tutorials/widget',
async lazy() {
const { TutorialWidgetScreen } = await import('@app/auth/tutorials/widget');
return { Component: TutorialWidgetScreen };
},
},
{
path: 'tutorials/posting',
async lazy() {
const { TutorialPostingScreen } = await import('@app/auth/tutorials/posting');
return { Component: TutorialPostingScreen };
},
},
{
path: 'tutorials/finish',
async lazy() {
const { TutorialFinishScreen } = await import('@app/auth/tutorials/finish');
return { Component: TutorialFinishScreen };
},
},
],
},
@@ -294,7 +308,7 @@ export default function App() {
router={router}
fallbackElement={
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
<LoaderIcon className="h-6 w-6 animate-spin" />
</div>
}
future={{ v7_startTransition: true }}

View File

@@ -1,50 +0,0 @@
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function AllowNotification() {
const [notification, setNotification] = useOnboarding((state) => [
state.notification,
state.toggleNotification,
]);
const allow = async () => {
let permissionGranted = await isPermissionGranted();
if (!permissionGranted) {
const permission = await requestPermission();
permissionGranted = permission === 'granted';
}
if (permissionGranted) {
setNotification();
}
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Allow notification</h5>
<p className="text-sm">
By allowing Lume to send notifications in your OS settings, you will receive
notification messages when someone interacts with you or your content.
</p>
</div>
{notification ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={allow}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Allow
</button>
)}
</div>
</div>
);
}

View File

@@ -1,100 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { LRUCache } from 'lru-cache';
import { useState } from 'react';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function Circle() {
const { db } = useStorage();
const { ndk } = useNDK();
const [circle, setCircle] = useOnboarding((state) => [
state.circle,
state.toggleCircle,
]);
const [loading, setLoading] = useState(false);
const enableLinks = async () => {
setLoading(true);
const users = ndk.getUser({ pubkey: db.account.pubkey });
const follows = await users.follows();
if (follows.size === 0) {
setLoading(false);
return toast('You need to follow at least 1 account');
}
const lru = new LRUCache<string, string, void>({ max: 300 });
const followsAsArr = [];
// add user's follows to lru
follows.forEach((user) => {
lru.set(user.pubkey, user.pubkey);
followsAsArr.push(user.pubkey);
});
// get follows from follows
const events = await ndk.fetchEvents({
kinds: [NDKKind.Contacts],
authors: followsAsArr,
limit: 300,
});
events.forEach((event: NDKEvent) => {
event.tags.forEach((tag) => {
if (tag[0] === 'p') lru.set(tag[1], tag[1]);
});
});
// get lru values
const circleList = [...lru.values()] as string[];
// update db
await db.updateAccount('follows', JSON.stringify(followsAsArr));
await db.updateAccount('circles', JSON.stringify(circleList));
db.account.follows = followsAsArr;
db.account.circles = circleList;
// clear lru
lru.clear();
// done
await db.createSetting('circles', '1');
setCircle();
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Enable Circle</h5>
<p className="text-sm">
Beside newsfeed from your follows, you will see more content from all people
that followed by your follows.
</p>
</div>
{circle ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={enableLinks}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Enable'}
</button>
)}
</div>
</div>
);
}

View File

@@ -1,47 +0,0 @@
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function OutboxModel() {
const { db } = useStorage();
const [outbox, setOutbox] = useOnboarding((state) => [
state.outbox,
state.toggleOutbox,
]);
const enableOutbox = async () => {
await db.createSetting('outbox', '1');
setOutbox();
};
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between gap-2">
<div>
<h5 className="font-semibold">Enable Outbox (experiment)</h5>
<p className="text-sm">
When you request information about a user, Lume will automatically query the
user&apos;s outbox relays and subsequent queries will favour using those
relays for queries with that user&apos;s pubkey.
</p>
</div>
{outbox ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<button
type="button"
onClick={enableOutbox}
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Enable
</button>
)}
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { Link } from 'react-router-dom';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function FavoriteHashtag() {
const hashtag = useOnboarding((state) => state.hashtag);
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between">
<div>
<h5 className="font-semibold">Favorite topic</h5>
<p className="text-sm">
By adding favorite topic, Lume will display all contents related to this topic
for you
</p>
</div>
{hashtag ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<Link
to="/auth/onboarding/hashtag"
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Add
</Link>
)}
</div>
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function FollowList() {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery({
queryKey: ['follows'],
queryFn: async () => {
const user = ndk.getUser({ pubkey: db.account.pubkey });
const follows = await user.follows();
const followsAsArr = [];
follows.forEach((user) => {
followsAsArr.push(user.pubkey);
});
// update db
await db.updateAccount('follows', JSON.stringify(followsAsArr));
await db.updateAccount('circles', JSON.stringify(followsAsArr));
db.account.follows = followsAsArr;
db.account.circles = followsAsArr;
return followsAsArr;
},
refetchOnWindowFocus: false,
});
return (
<div className="relative rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<h5 className="font-semibold">Your follows</h5>
<div className="mt-2 flex w-full items-center justify-center">
{status === 'pending' ? (
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
) : (
<div className="isolate flex -space-x-2">
{data.slice(0, 16).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{data.length > 16 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-200 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-800">
<span className="text-xs font-medium">+{data.length}</span>
</div>
) : null}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { Link } from 'react-router-dom';
import { CheckCircleIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
export function SuggestFollow() {
const enrich = useOnboarding((state) => state.enrich);
return (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="flex items-start justify-between">
<div>
<h5 className="font-semibold">Enrich your network</h5>
<p className="text-sm">
Follow more people to stay up to date with everything happening around the
world.
</p>
</div>
{enrich ? (
<div className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-teal-500 text-white">
<CheckCircleIcon className="h-4 w-4" />
</div>
) : (
<Link
to="/auth/onboarding/enrich"
className="mt-1 inline-flex h-9 w-24 shrink-0 items-center justify-center rounded-lg bg-neutral-200 font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Check
</Link>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { NDKEvent, NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { downloadDir } from '@tauri-apps/api/path';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { message, save } from '@tauri-apps/plugin-dialog';
import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { motion } from 'framer-motion';
import { minidenticon } from 'minidenticons';
@@ -15,7 +15,7 @@ import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { ArrowLeftIcon, InfoIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function CreateAccountScreen() {
@@ -45,8 +45,6 @@ export function CreateAccountScreen() {
const onSubmit = async (data: { name: string; about: string }) => {
try {
if (!ndk.signer) return navigate('/new/privkey');
setLoading(true);
const profile = {
@@ -69,7 +67,6 @@ export function CreateAccountScreen() {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.kind = NDKKind.Metadata;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = userPubkey;
event.tags = [];
@@ -78,6 +75,16 @@ export function CreateAccountScreen() {
if (publish) {
await db.createAccount(userNpub, userPubkey);
await db.secureSave(userPubkey, userPrivkey);
const relayListEvent = new NDKEvent(ndk);
relayListEvent.kind = NDKKind.RelayList;
relayListEvent.tags = [...ndk.pool.relays.values()].map((item) => [
'r',
item.url,
]);
await relayListEvent.publish();
setKeys({
npub: userNpub,
nsec: userNsec,
@@ -90,7 +97,7 @@ export function CreateAccountScreen() {
setLoading(false);
}
} catch (e) {
return toast(e);
return toast.error(e);
}
};
@@ -115,7 +122,7 @@ export function CreateAccountScreen() {
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
return toast.error(e);
}
};
@@ -125,33 +132,33 @@ export function CreateAccountScreen() {
{!keys ? (
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
className="group inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 group-hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-200 dark:group-hover:bg-neutral-700">
<ArrowLeftIcon className="h-4 w-4" />
</div>
Back
</button>
) : null}
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
<h1 className="text-center text-2xl font-semibold">
Let&apos;s set up your account.
</h1>
<div className="flex flex-col gap-3">
{!keys ? (
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} />
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="font-semibold">Avatar</span>
<div className="relative flex h-36 w-full items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
<div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-900">
{picture.length > 0 ? (
<img
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-xl"
className="h-14 w-14 rounded-xl object-cover"
/>
) : (
<img
@@ -160,9 +167,7 @@ export function CreateAccountScreen() {
className="h-14 w-14 rounded-xl bg-black dark:bg-white"
/>
)}
<div className="absolute bottom-2 right-2">
<AvatarUploader setPicture={setPicture} />
</div>
<AvatarUploader setPicture={setPicture} />
</div>
</div>
<div className="flex flex-col gap-1">
@@ -176,7 +181,7 @@ export function CreateAccountScreen() {
minLength: 1,
})}
spellCheck={false}
className="h-11 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<div className="flex flex-col gap-1">
@@ -186,20 +191,29 @@ export function CreateAccountScreen() {
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-neutral-200 px-3 py-2 !outline-none placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-5 w-4 animate-spin" />
) : (
'Create and Continue'
)}
</button>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<InfoIcon className="h-8 w-8" />
<p>
There are many more settings you can configure from the
&quot;Settings&quot; screen. Be sure to visit it later.
</p>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Create and Continue'
)}
</button>
</div>
</div>
</form>
</div>
@@ -211,7 +225,7 @@ export function CreateAccountScreen() {
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<User pubkey={keys.pubkey} variant="simple" />
</motion.div>
@@ -221,7 +235,7 @@ export function CreateAccountScreen() {
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<div className="flex flex-col gap-1.5">
<h5 className="font-semibold">Backup account</h5>
@@ -229,7 +243,7 @@ export function CreateAccountScreen() {
<p className="mb-2 select-text text-sm text-neutral-800 dark:text-neutral-200">
Your private key is your password. If you lose this key, you will
lose access to your account! Copy it and keep it in a safe place.{' '}
<span className="text-red-600">
<span className="text-red-500">
There is no way to reset your private key.
</span>
</p>
@@ -249,13 +263,13 @@ export function CreateAccountScreen() {
value={
keys.nsec.substring(0, 10) + '**************************'
}
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
<button
type="button"
onClick={copyNsec}
className="rounded-md bg-neutral-300 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
className="rounded-md bg-neutral-200 px-2 py-1 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-700 dark:hover:bg-neutral-600"
>
Copy
</button>
@@ -269,7 +283,7 @@ export function CreateAccountScreen() {
<input
readOnly
value={keys.npub}
className="h-11 rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
</div>
</div>
@@ -277,7 +291,7 @@ export function CreateAccountScreen() {
<button
type="button"
onClick={() => download()}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
className="mt-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Download account keys
</button>
@@ -293,9 +307,9 @@ export function CreateAccountScreen() {
opacity: 1,
y: 0,
}}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
type="button"
onClick={() => navigate('/auth/onboarding', { state: { newuser: true } })}
onClick={() => navigate('/auth/onboarding')}
>
Finish
</motion.button>

34
src/app/auth/finish.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { Link } from 'react-router-dom';
export function FinishScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl font-light">
Yo, you&apos;re ready to use <span className="font-bold">Lume</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/auth/tutorials/note"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Start tutorial
</Link>
<Link
to="/"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Skip
</Link>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
You need to restart app to make changes in previous step take effect or you
can continue with Lume default settings
</p>
</div>
</div>
</div>
);
}

276
src/app/auth/follow.tsx Normal file
View File

@@ -0,0 +1,276 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import * as Accordion from '@radix-ui/react-accordion';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import {
ArrowLeftIcon,
ArrowRightIcon,
CancelIcon,
ChevronDownIcon,
LoaderIcon,
PlusIcon,
} from '@shared/icons';
import { User } from '@shared/user';
const POPULAR_USERS = [
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6',
'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
'npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s',
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8',
'npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a',
'npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc',
'npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza',
'npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424',
'npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac',
'npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv',
'npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk',
];
const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445'];
export function FollowScreen() {
const { ndk } = useNDK();
const { db } = useStorage();
const { status, data } = useQuery({
queryKey: ['trending-profiles-widget'],
queryFn: async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
},
});
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
const navigate = useNavigate();
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const submit = async () => {
try {
setLoading(true);
if (!follows.length) return navigate('/auth/finish');
const event = new NDKEvent(ndk);
event.kind = NDKKind.Contacts;
event.tags = follows.map((item) => {
if (item.startsWith('npub')) return ['p', nip19.decode(item).data as string];
return ['p', item];
});
const publish = await event.publish();
if (publish) {
db.account.contacts = follows.map((item) => {
if (item.startsWith('npub')) return nip19.decode(item).data as string;
return item;
});
return navigate('/auth/finish');
}
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-semibold">Dive into the nostrverse</h1>
<h2 className="text-neutral-700 dark:text-neutral-300">
Try following some users that interest you
<br />
to build up your timeline.
</h2>
</div>
<Accordion.Root type="single" defaultValue="recommended" collapsible>
<Accordion.Item value="recommended" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Popular users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{POPULAR_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="trending" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Trending users
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data?.profiles.map(
(item: { pubkey: string; profile: { content: string } }) => (
<div
key={item.pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(item.pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(item.pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
)
)
)}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="lume" className="mb-3 overflow-hidden rounded-xl">
<Accordion.Trigger className="flex h-12 w-full items-center justify-between rounded-t-xl bg-neutral-100 px-3 font-medium dark:bg-neutral-900">
Lume team
<ChevronDownIcon className="h-4 w-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex h-[420px] w-full flex-col gap-3 overflow-y-auto rounded-b-xl bg-neutral-50 p-3 dark:bg-neutral-950">
{LUME_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-100 bg-white dark:border-neutral-900 dark:bg-black"
>
<div className="p-3">
<User pubkey={pubkey} variant="large" />
</div>
<div className="border-t border-neutral-100 px-3 py-4 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={twMerge(
'inline-flex h-9 w-full items-center justify-center gap-1 rounded-lg font-medium text-white',
follows.includes(pubkey)
? 'bg-red-500 hover:bg-red-600'
: 'bg-blue-500 hover:bg-blue-600'
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="h-4 w-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</div>
<div className="absolute bottom-3 right-3 flex w-full items-center justify-end gap-2">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-3 font-semibold hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-blue-800"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
<button
type="button"
onClick={submit}
disabled={loading}
className="inline-flex h-11 w-max items-center justify-center gap-2 rounded-lg bg-blue-500 px-3 font-semibold text-white hover:bg-blue-600"
>
Continue
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<ArrowRightIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { readText } from '@tauri-apps/plugin-clipboard-manager';
import { open } from '@tauri-apps/plugin-shell';
import { motion } from 'framer-motion';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
@@ -10,21 +11,22 @@ import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon } from '@shared/icons';
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
export function ImportAccountScreen() {
const navigate = useNavigate();
const { db } = useStorage();
const { ndk } = useNDK();
const [npub, setNpub] = useState<string>('');
const [nsec, setNsec] = useState<string>('');
const [pubkey, setPubkey] = useState<undefined | string>(undefined);
const [loading, setLoading] = useState(false);
const [created, setCreated] = useState({ ok: false, remote: false });
const [savedPrivkey, setSavedPrivkey] = useState(false);
const navigate = useNavigate();
const submitNpub = async () => {
if (npub.length < 6) return toast.error('You must enter valid npub');
if (!npub.startsWith('npub1')) return toast.error('npub must be starts with npub1');
@@ -44,9 +46,19 @@ export function ImportAccountScreen() {
try {
const pubkey = nip19.decode(npub.split('#')[0]).data as string;
const localSigner = NDKPrivateKeySigner.generate();
await db.secureSave(pubkey + '-bunker', localSigner.privateKey);
const remoteSigner = new NDKNip46Signer(ndk, npub, localSigner);
await db.createSetting('nsecbunker', '1');
await db.secureSave(`${pubkey}-nsecbunker`, localSigner.privateKey);
// open nsecbunker web app in default browser
await open('https://app.nsecbunker.com/keys');
const bunker = new NDK({
explicitRelayUrls: ['wss://relay.nsecbunker.com', 'wss://nostr.vulpem.com'],
});
bunker.connect();
const remoteSigner = new NDKNip46Signer(bunker, npub, localSigner);
await remoteSigner.blockUntilReady();
ndk.signer = remoteSigner;
@@ -65,12 +77,25 @@ export function ImportAccountScreen() {
const createAccount = async () => {
try {
await db.createAccount(npub, pubkey);
setCreated((prev) => ({ ...prev, ok: true }));
setLoading(true);
if (created.remote) navigate('/auth/onboarding', { state: { newuser: false } });
// add account to db
await db.createAccount(npub, pubkey);
// get account metadata
const user = ndk.getUser({ pubkey });
if (user) {
db.account.contacts = [...(await user.follows())].map((user) => user.pubkey);
db.account.relayList = await user.relayList();
}
setCreated((prev) => ({ ...prev, ok: true }));
setLoading(false);
if (created.remote) navigate('/auth/onboarding');
} catch (e) {
return toast(`Create account failed: ${e}`);
setLoading(false);
return toast.error(e);
}
};
@@ -111,17 +136,16 @@ export function ImportAccountScreen() {
) : null}
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Import your account.
</h1>
<h1 className="text-center text-2xl font-semibold">Import your account.</h1>
<div className="flex flex-col gap-3">
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
<div className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex flex-col gap-1.5">
<label htmlFor="npub" className="font-semibold">
Enter your public key:
</label>
<div className="flex w-full flex-col gap-2">
<input
readOnly={!!pubkey}
name="npub"
type="text"
value={npub}
@@ -131,26 +155,32 @@ export function ImportAccountScreen() {
autoCorrect="off"
autoCapitalize="off"
placeholder="npub1"
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
{!pubkey ? (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={submitNpub}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
className="h-11 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
<button
type="button"
onClick={connectNsecBunker}
className="h-9 w-full shrink-0 rounded-lg bg-neutral-200 font-semibold text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
className="h-11 w-full shrink-0 rounded-lg bg-neutral-200 font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
Continue with nsecBunker
</button>
</div>
) : null}
{npub.indexOf('#') > -1 ? (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
You&apos;re using nsecbunker token, keep in mind it only can redeem
one-time, you need to login again in the next launch
</p>
) : null}
</div>
</div>
</div>
@@ -161,16 +191,16 @@ export function ImportAccountScreen() {
opacity: 1,
y: 0,
}}
className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200"
className="rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950"
>
<h5 className="mb-1.5 font-semibold">Account found</h5>
<div className="flex w-full flex-col gap-2">
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-100 px-4 py-3 dark:bg-neutral-900">
<User pubkey={pubkey} variant="simple" />
<button
type="button"
onClick={changeAccount}
className="h-8 w-20 shrink-0 rounded-lg bg-neutral-300 text-sm font-medium text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-600"
className="h-8 w-max shrink-0 rounded-lg bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
Change
</button>
@@ -179,9 +209,13 @@ export function ImportAccountScreen() {
<button
type="button"
onClick={createAccount}
className="h-9 w-full shrink-0 rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
'Continue'
)}
</button>
) : null}
</div>
@@ -214,7 +248,7 @@ export function ImportAccountScreen() {
autoCorrect="off"
autoCapitalize="off"
placeholder="nsec1"
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-800 dark:placeholder:text-neutral-400"
className="h-11 w-full rounded-lg border-transparent bg-neutral-200 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
{nsec.length < 5 ? (
<div className="absolute right-0 top-0 inline-flex h-11 items-center justify-center px-2">
@@ -259,8 +293,8 @@ export function ImportAccountScreen() {
{db.platform === 'macos'
? 'Apple Keychain (macOS)'
: db.platform === 'windows'
? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'}
? 'Credential Manager (Windows)'
: 'Secret Service (Linux)'}
</b>
, it will be secured by your OS
</p>
@@ -281,11 +315,9 @@ export function ImportAccountScreen() {
opacity: 1,
y: 0,
}}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
type="button"
onClick={() =>
navigate('/auth/onboarding', { state: { newuser: false } })
}
onClick={() => navigate('/auth/onboarding')}
>
Continue
</motion.button>

148
src/app/auth/onboarding.tsx Normal file
View File

@@ -0,0 +1,148 @@
import * as Switch from '@radix-ui/react-switch';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { InfoIcon } from '@shared/icons';
export function OnboardingScreen() {
const { db } = useStorage();
const navigate = useNavigate();
const [settings, setSettings] = useState({
autoupdate: false,
outbox: false,
notification: false,
});
const next = () => {
if (!db.account.contacts.length) return navigate('/auth/follow');
return navigate('/auth/finish');
};
const toggleOutbox = async () => {
await db.createSetting('outbox', String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleAutoupdate = async () => {
await db.createSetting('autoupdate', String(+!settings.autoupdate));
db.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await db.getAllSettings();
if (!data) return;
data.forEach((item) => {
if (item.key === 'autoupdate')
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === 'outbox')
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
});
}
loadSettings();
}, []);
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-light text-neutral-900 dark:text-neutral-100">
You&apos;re almost ready to use Lume.
</h1>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Let&apos;s start personalizing your experience.
</h2>
</div>
<div className="flex flex-col gap-3">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Auto check for update on Login</h3>
<p className="text-sm">
Keep Lume up to date with latest version, always have new features and bug
free.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.notification}
onClick={() => toggleNofitication()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Push notification</h3>
<p className="text-sm">
Enabling push notifications will allow you to receive notifications from
Lume directly on your device.
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div>
<h3 className="font-semibold">Use Gossip model (recommended)</h3>
<p className="text-sm">
Automatically discover relays to connect based on the preferences of each
author.
</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-lg bg-blue-100 p-3 text-sm text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<InfoIcon className="h-8 w-8" />
<p>
There are many more settings you can configure from the &quot;Settings&quot;
screen. Be sure to visit it later.
</p>
</div>
<button
type="button"
onClick={next}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Continue
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,140 +0,0 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardEnrichScreen() {
const { ndk } = useNDK();
const { db } = useStorage();
const { status, data } = useQuery({
queryKey: ['trending-profiles-widget'],
queryFn: async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
}
return res.json();
},
});
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
const navigate = useNavigate();
const setEnrich = useOnboarding((state) => state.toggleEnrich);
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const submit = async () => {
try {
setLoading(true);
const tags = arrayToNIP02(follows);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.created_at = Math.floor(Date.now() / 1000);
event.tags = tags;
const publish = await event.publish();
// redirect to next step
if (publish) {
db.account.follows = follows;
await db.updateAccount('follows', JSON.stringify(follows));
await db.updateAccount('circles', JSON.stringify(follows));
setEnrich();
navigate(-1);
} else {
setLoading(false);
}
} catch (e) {
setLoading(false);
toast(e);
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
</div>
Back
</button>
</div>
<div className="mx-auto mb-8 w-full max-w-md px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Enrich your network
</h1>
</div>
<div className="flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4 scrollbar-none">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
) : (
data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="relative h-[300px] shrink-0 grow-0 basis-[250px] overflow-hidden rounded-lg bg-neutral-200 px-4 py-4 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
{follows.includes(item.pubkey) && (
<div className="absolute right-2 top-2">
<CheckCircleIcon className="h-5 w-5 text-teal-400" />
</div>
)}
</button>
))
)}
</div>
<div className="mx-auto mt-8 w-full max-w-md px-3">
<button
type="button"
onClick={submit}
disabled={loading || follows.length === 0}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>It might take a bit, please patient...</span>
</>
) : (
<span>Follow {follows.length} accounts & Continue</span>
)}
</button>
</div>
</div>
);
}

View File

@@ -1,93 +0,0 @@
import { message } from '@tauri-apps/plugin-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { TOPICS, WIDGET_KIND } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useWidget } from '@utils/hooks/useWidget';
export function OnboardHashtagScreen() {
const [loading, setLoading] = useState(false);
const [topic, setTopic] = useState(null);
const navigate = useNavigate();
const setHashtag = useOnboarding((state) => state.toggleHashtag);
const { addWidget } = useWidget();
const submit = async () => {
try {
setLoading(true);
setHashtag();
addWidget.mutate({
kind: WIDGET_KIND.topic,
title: topic.title,
content: JSON.stringify(topic.content),
});
navigate(-1);
} catch (e) {
setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
</div>
Back
</button>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Choose your favorite topic
</h1>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-3">
{TOPICS.map((item) => (
<button
key={item.title}
type="button"
onClick={() => setTopic(item)}
className="inline-flex h-14 items-center justify-between rounded-xl bg-neutral-100 px-4 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
<p className="font-medium">{item.title}</p>
{topic && topic.title === item.title && (
<div>
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
</div>
)}
</button>
))}
</div>
<button
type="button"
onClick={submit}
disabled={loading || !topic}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>Adding...</span>
</>
) : (
<span>Add & Continue</span>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { Outlet } from 'react-router-dom';
export function OnboardingScreen() {
return <Outlet />;
}

View File

@@ -1,58 +0,0 @@
import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AllowNotification } from '@app/auth/components/features/allowNotification';
import { Circle } from '@app/auth/components/features/enableCircle';
import { OutboxModel } from '@app/auth/components/features/enableOutbox';
import { FavoriteHashtag } from '@app/auth/components/features/favoriteHashtag';
import { FollowList } from '@app/auth/components/features/followList';
import { SuggestFollow } from '@app/auth/components/features/suggestFollow';
import { LoaderIcon } from '@shared/icons';
export function OnboardingListScreen() {
const { state } = useLocation();
const { newuser }: { newuser: boolean } = state;
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const completed = () => {
setLoading(true);
const timeout = setTimeout(() => setLoading(false), 200);
clearTimeout(timeout);
navigate('/');
};
return (
<div className="relative flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<h1 className="text-2xl font-light text-neutral-900 dark:text-neutral-100">
You&apos;re almost ready to use Lume.
</h1>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Let&apos;s start personalizing your experience.
</h2>
</div>
<div className="flex flex-col gap-3">
{newuser ? <SuggestFollow /> : <FollowList />}
<FavoriteHashtag />
<Circle />
<OutboxModel />
<AllowNotification />
<button
type="button"
onClick={completed}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : ' Continue'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,160 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
export function OnboardRelaysScreen() {
const navigate = useNavigate();
const toggleRelays = useOnboarding((state) => state.toggleRelays);
const [loading, setLoading] = useState(false);
const [relays, setRelays] = useState(new Set<string>());
const { db } = useStorage();
const { ndk } = useNDK();
const { getAllRelaysByUsers } = useNostr();
const { status, data } = useQuery({
queryKey: ['relays'],
queryFn: async () => {
return await getAllRelaysByUsers();
},
refetchOnWindowFocus: false,
});
const toggleRelay = (relay: string) => {
if (relays.has(relay)) {
setRelays((prev) => {
prev.delete(relay);
return new Set(prev);
});
} else {
setRelays((prev) => new Set(prev.add(relay)));
}
};
const submit = async () => {
try {
setLoading(true);
for (const relay of relays) {
await db.createRelay(relay);
}
const tags = Array.from(relays).map((relay) => ['r', relay.replace(/\/+$/, '')]);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = 10002;
event.created_at = Math.floor(Date.now() / 1000);
event.tags = tags;
await event.publish();
toggleRelays();
navigate(-1);
} catch (e) {
setLoading(false);
toast.error(e);
}
};
return (
<div className="relative flex h-full w-full flex-col justify-center">
<div className="absolute left-[8px] top-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium"
>
<div className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
<ArrowLeftIcon className="h-5 w-5" />
</div>
Back
</button>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
Relay discovery
</h1>
<div className="flex flex-col gap-4">
<div className="flex h-[420px] w-full flex-col overflow-y-auto rounded-xl bg-neutral-100 dark:bg-neutral-900">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
</div>
) : data.size === 0 ? (
<div className="flex h-full w-full items-center justify-center px-6">
<p className="text-center text-neutral-300 dark:text-neutral-600">
Lume couldn&apos;t find any relays from your follows.
<br />
You can skip this step and use default relays instead.
</p>
</div>
) : (
[...data].map(([key, value]) => (
<button
key={key}
type="button"
onClick={() => toggleRelay(key)}
className="inline-flex transform items-start justify-between px-4 py-2 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-2">
<div className="pt-1.5">
{relays.has(key) ? (
<CheckCircleIcon className="h-4 w-4 text-teal-500" />
) : (
<CheckCircleIcon className="h-4 w-4 text-neutral-300 dark:text-neutral-700" />
)}
</div>
<p className="max-w-[15rem] truncate">{key.replace(/\/+$/, '')}</p>
</div>
<div className="inline-flex items-center gap-2">
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
Used by
</span>
<div className="isolate flex -space-x-2">
{value.slice(0, 3).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{value.length > 3 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">+{value.length}</span>
</div>
) : null}
</div>
</div>
</div>
</button>
))
)}
</div>
<button
type="button"
onClick={() => submit()}
disabled={loading}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<LoaderIcon className="h-4 w-4 animate-spin" />
<span>Adding...</span>
</>
) : (
<span>Add {relays.size} relays & Continue</span>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { Link } from 'react-router-dom';
export function TutorialFinishScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl font-light">
Yo, you&apos;ve understood basic features 🎉
</h1>
</div>
<div className="flex flex-col gap-2">
<Link
to="/"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Start using
</Link>
<Link
to="https://nostr.how/"
target="_blank"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Learn more about Nostr
</Link>
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
If you&apos;ve trouble when user Lume, you can report the issue{' '}
<a
href="github.com/luminous-devs/lume"
target="_blank"
className="text-blue-500 !underline"
>
here
</a>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
import { TextNote } from '@shared/notes';
export function TutorialNoteScreen() {
const { ndk } = useNDK();
const exampleEvent = new NDKEvent(ndk, {
id: 'a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821',
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
created_at: 1701355223,
kind: 1,
tags: [],
content: 'good morning nostr, stay humble and stack sats 🫡',
sig: '9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae',
});
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<EditIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
What is a <span className="font-bold">Note?</span>
</h1>
</div>
<div className="flex flex-col gap-2">
<p className="px-3">
Posts on Nostr based Social Network client are usually called
&apos;Notes.&apos; Notes are arranged chronologically on the timeline and are
updated in real-time.
</p>
<p className="px-3 font-semibold">Here is one example:</p>
<TextNote event={exampleEvent} className="pointer-events-none my-2" />
<p className="px-3 font-semibold">Here are how you can interact with a note:</p>
<div className="flex flex-col gap-2 px-3">
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReplyIcon className="h-5 w-5 text-blue-500" />
</div>
<p>
Reply - Click on this button to reply to a note. It&apos;s also possible
to reply to replies, continuing the conversation like a thread.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ReactionIcon className="h-5 w-5 text-red-500" />
</div>
<p>Reaction - You can add reactions to the Note to express your concern.</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<RepostIcon className="h-5 w-5 text-teal-500" />
</div>
<p>
Repost - You can share that note to your own timeline. You can also quote
them with your comments.
</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<ZapIcon className="h-5 w-5 text-orange-500" />
</div>
<p>Zap - You can send tip in Bitcoin to that note owner with zero-fees</p>
</div>
</div>
<div className="mt-5 flex gap-2 px-3">
<Link
to="/auth/finish"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Back
</Link>
<Link
to="/auth/tutorials/widget"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export function TutorialPostingScreen() {
return <div></div>;
}

View File

@@ -0,0 +1,64 @@
import { Link } from 'react-router-dom';
import { BellIcon, HomeIcon, PlusIcon } from '@shared/icons';
export function TutorialWidgetScreen() {
return (
<div className="flex h-full w-full select-text items-center justify-center">
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
<div className="flex flex-col items-center gap-3">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<h1 className="text-2xl font-light">
The concept of <span className="font-bold">Widgets</span>
</h1>
</div>
<div className="flex flex-col gap-2 px-3">
<p>
Lume provides multiple widgets based on usage. You always can control what you
need to show on your Home.
</p>
<p className="font-semibold">Default widgets:</p>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<HomeIcon className="h-5 w-5" />
</div>
<p>Newsfeed - You can view notes from accounts you follow.</p>
</div>
<div className="inline-flex gap-3">
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<BellIcon className="h-5 w-5" />
</div>
<p>Notification - You can view all notifications related to your account.</p>
</div>
<p>
If you want to add more widget, you can click to this button on Home Screen.
</p>
<div className="flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
<button
type="button"
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-5 flex gap-2">
<Link
to="/auth/tutorials/note"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Back
</Link>
<Link
to="/auth/tutorials/finish"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Continue
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -7,19 +7,19 @@ export function WelcomeScreen() {
<div className="text-center">
<img src="/icon.png" alt="Lume's logo" className="mx-auto mb-1 h-auto w-16" />
<h1 className="text-2xl">
Welcome to <span className="font-semibold">Lume</span>
Welcome to <span className="font-bold">Lume</span>
</h1>
</div>
<div className="flex flex-col gap-2 px-8">
<Link
to="/auth/create"
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
Create new account
</Link>
<Link
to="/auth/import"
className="inline-flex h-10 w-full items-center justify-center rounded-lg font-medium text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900"
className="inline-flex h-11 w-full items-center justify-center rounded-lg font-medium text-neutral-900 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-900"
>
Log in
</Link>

View File

@@ -54,7 +54,7 @@ export function ChatForm({ receiverPubkey }: { receiverPubkey: string }) {
autoCorrect="off"
autoCapitalize="off"
placeholder="Message..."
className="h-10 flex-1 resize-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:outline-none dark:text-neutral-100 dark:placeholder:text-neutral-300"
className="h-10 flex-1 resize-none border-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:border-none focus:shadow-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-300"
/>
<button
type="button"

View File

@@ -12,14 +12,14 @@ import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export const ChatListItem = memo(function ChatListItem({ event }: { event: NDKEvent }) {
const { status, user } = useProfile(event.pubkey);
const { isLoading, user } = useProfile(event.pubkey);
const decryptedContent = useDecryptMessage(event);
const createdAt = formatCreatedAt(event.created_at, true);
const svgURI =
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50));
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-2.5 rounded-md px-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-400 dark:bg-neutral-600" />

View File

@@ -57,7 +57,7 @@ export function ErrorScreen() {
Sorry, an unexpected error has occurred.
</h1>
<h3 className="text-3xl font-semibold leading-snug text-white">
Don&apos;t be panic, your account is safe.
Don&apos;t panic, your account is safe.
<br />
Here are what things you can do:
</h3>
@@ -65,7 +65,7 @@ export function ErrorScreen() {
<div className="flex w-full flex-col gap-3">
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
<div className="text-xl font-semibold text-white">
1. Try close and re-open app
1. Try to close and re-open the app
</div>
<button
type="button"
@@ -112,21 +112,36 @@ export function ErrorScreen() {
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-1.5">
<div className="text-xl font-semibold text-white">
4. Use other Nostr client
4. Use another Nostr client
</div>
<div className="select-text text-lg font-medium text-blue-300">
<p>
While waiting Lume&apos;s Devs release the bug fixes, you always can use
other Nostr client with your account:
While waiting for Lume&apos;s Devs to release the bug fixes, you always
can use other Nostr clients with your account:
</p>
<div className="mt-2 flex flex-col gap-1 text-white">
<a href="https://snort.social" className="hover:!underline">
<a
className="hover:!underline"
href="https://snort.social"
target="_blank"
rel="noreferrer"
>
snort.social
</a>
<a href="https://primal.net" className="hover:!underline">
<a
className="hover:!underline"
href="https://primal.net"
target="_blank"
rel="noreferrer"
>
primal.net
</a>
<a href="https://nostrudel.ninja" className="hover:!underline">
<a
className="hover:!underline"
href="https://nostrudel.ninja"
target="_blank"
rel="noreferrer"
>
nostrudel.ninja
</a>
</div>

View File

@@ -3,9 +3,9 @@ import { memo } from 'react';
import { useProfile } from '@utils/hooks/useProfile';
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
const { isLoading, user } = useProfile(pubkey);
if (status === 'pending') {
if (isLoading) {
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
}

View File

@@ -23,7 +23,7 @@ export const UserWithDrawer = memo(function UserWithDrawer({
}) {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, user } = useProfile(pubkey);
const { isLoading, user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
@@ -67,7 +67,7 @@ export const UserWithDrawer = memo(function UserWithDrawer({
};
useEffect(() => {
if (db.account.follows.includes(pubkey)) {
if (db.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);
@@ -82,7 +82,7 @@ export const UserWithDrawer = memo(function UserWithDrawer({
<Dialog.Portal>
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] animate-slideRightAndFade items-center justify-center px-4 pb-4 pt-16 transition-all">
<div className="h-full w-full overflow-y-auto rounded-lg border border-neutral-300 bg-neutral-200 py-3 dark:border-neutral-700 dark:bg-neutral-800">
{status === 'pending' ? (
{isLoading ? (
<div>
<p>Loading...</p>
</div>

View File

@@ -28,7 +28,7 @@ export function ExploreScreen() {
const { getContactsByPubkey } = useNostr();
const { project } = useReactFlow();
const defaultContacts = useMemo(() => getMultipleRandom(db.account.follows, 10), []);
const defaultContacts = useMemo(() => getMultipleRandom(db.account.contacts, 10), []);
const reactFlowWrapper = useRef(null);
const connectingNodeId = useRef(null);

View File

@@ -21,8 +21,7 @@ import {
WidgetList,
} from '@shared/widgets';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { Widget } from '@utils/types';
export function HomeScreen() {
@@ -35,18 +34,18 @@ export function HomeScreen() {
queryFn: async () => {
const dbWidgets = await db.getWidgets();
const defaultWidgets = [
{
id: '9998',
title: 'Notification',
content: '',
kind: WIDGET_KIND.notification,
},
{
id: '9999',
title: 'Newsfeed',
content: '',
kind: WIDGET_KIND.newsfeed,
},
{
id: '9998',
title: 'Notification',
content: '',
kind: WIDGET_KIND.notification,
},
];
return [...defaultWidgets, ...dbWidgets];

View File

@@ -4,7 +4,7 @@ import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { useMemo, useState } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge';
@@ -27,12 +27,14 @@ import {
export function NewArticleScreen() {
const { ndk } = useNDK();
const [height, setHeight] = useState(0);
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState('');
const [summary, setSummary] = useState({ open: false, content: '' });
const [cover, setCover] = useState('');
const navigate = useNavigate();
const containerRef = useRef(null);
const ident = useMemo(() => String(Date.now()), []);
const editor = useEditor({
extensions: [
@@ -113,123 +115,133 @@ export function NewArticleScreen() {
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
return (
<div className="flex h-full flex-col justify-between">
<div className="flex flex-col gap-4">
{cover ? (
<img
src={cover}
alt="post cover"
className="h-72 w-full rounded-lg object-cover"
/>
) : null}
<div className="group flex justify-between gap-2">
<input
name="title"
className="h-9 flex-1 border-none bg-transparent text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="Untitled"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div
className={twMerge(
'inline-flex shrink-0 gap-2 group-hover:inline-flex',
title.length > 0 ? '' : 'hidden'
)}
>
<ArticleCoverUploader setCover={setCover} />
<button
type="button"
onClick={() => setSummary((prev) => ({ ...prev, open: !prev.open }))}
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
<div className="flex flex-1 flex-col justify-between">
<div className="flex-1 overflow-y-auto">
<div
className="flex flex-col gap-4"
ref={containerRef}
style={{ height: `${height}px` }}
>
{cover ? (
<img
src={cover}
alt="post cover"
className="h-72 w-full rounded-lg object-cover"
/>
) : null}
<div className="group flex justify-between gap-2">
<input
name="title"
className="h-9 flex-1 border-none bg-transparent px-0 text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 focus:border-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="Untitled"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div
className={twMerge(
'inline-flex shrink-0 gap-2 group-hover:inline-flex',
title.length > 0 ? '' : 'hidden'
)}
>
<ThreadsIcon className="h-4 w-4" />
Add summary
</button>
</div>
</div>
{summary.open ? (
<div className="flex gap-3">
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="flex-1">
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
<ArticleCoverUploader setCover={setCover} />
<button
type="button"
onClick={() => setSummary((prev) => ({ ...prev, open: !prev.open }))}
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
>
<ThreadsIcon className="h-4 w-4" />
Add summary
</button>
</div>
</div>
) : null}
<div>
{editor && (
<FloatingMenu
{summary.open ? (
<div className="flex gap-3">
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="flex-1">
<textarea
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
placeholder="A brief summary of your article"
value={summary.content}
onChange={(e) =>
setSummary((prev) => ({ ...prev, content: e.target.value }))
}
/>
</div>
</div>
) : null}
<div>
{editor && (
<FloatingMenu
editor={editor}
tippyOptions={{ duration: 100 }}
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 1 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 2 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 3 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('bold') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('italic') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor}
tippyOptions={{ duration: 100 }}
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 1 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading1Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 2 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading2Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('heading', { level: 3 })
? 'bg-white shadow dark:bg-black'
: ''
)}
>
<Heading3Icon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('bold') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<BoldIcon className="h-5 w-5" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={twMerge(
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
editor.isActive('italic') ? 'bg-white shadow dark:bg-black' : ''
)}
>
<ItalicIcon className="h-5 w-5" />
</button>
</FloatingMenu>
)}
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div>
</div>
</div>
<div>

View File

@@ -2,3 +2,4 @@ export * from './articleCoverUploader';
export * from './mediaUploader';
export * from './mentionPopup';
export * from './mentionPopupItem';
export * from './mentionList';

View File

@@ -0,0 +1,104 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { Ref, forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { NDKCacheUserProfile } from '@utils/types';
type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean;
};
const List = (
props: {
items: NDKCacheUserProfile[];
command: (arg0: { id: string }) => void;
},
ref: Ref<unknown>
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item.pubkey });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[200px] flex-col overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 shadow-lg shadow-neutral-500/20 dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-neutral-300/50">
{props.items.length ? (
props.items.map((item, index) => (
<button
key={index}
onClick={() => selectItem(index)}
className={twMerge(
'inline-flex h-11 items-center gap-2 rounded-md px-2',
index === selectedIndex ? 'bg-neutral-100 dark:bg-neutral-900' : ''
)}
>
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={150}>
<img
src={
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(item.name, 90, 50))
}
alt={item.name}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[150px] truncate text-sm font-medium">{item.name}</h5>
</button>
))
) : (
<div className="text-center text-sm font-medium">No result</div>
)}
</div>
);
};
export const MentionList = forwardRef<MentionListRef>(List);

View File

@@ -32,8 +32,8 @@ export function MentionPopup({ editor }: { editor: Editor }) {
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
>
<div className="flex flex-col gap-1 py-1">
{db.account.follows.length > 0 ? (
db.account.follows.map((item) => (
{db.account.contacts.length > 0 ? (
db.account.contacts.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}>
<MentionPopupItem pubkey={item} />
</button>

View File

@@ -1,10 +1,18 @@
import * as Avatar from '@radix-ui/react-avatar';
import { minidenticon } from 'minidenticons';
import { useMemo } from 'react';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
const { status, user } = useProfile(pubkey, embed);
const { isLoading, user } = useProfile(pubkey, embed);
const svgURI = useMemo(
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
[pubkey]
);
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-2.5 px-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
@@ -18,14 +26,25 @@ export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: st
return (
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
<img
src={user.picture || user.image}
alt={pubkey}
className="shirnk-0 h-8 w-8 rounded-md object-cover"
/>
<Avatar.Root className="shirnk-0 h-8 w-8">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-neutral-900 dark:text-neutral-100">
{user.display_name || user.displayName || user.name}
{user?.display_name || user?.displayName || user?.name}
</h5>
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
{displayNpub(pubkey, 16)}

View File

@@ -1,11 +1,13 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import CharacterCount from '@tiptap/extension-character-count';
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { useEffect, useState } from 'react';
import { nip19 } from 'nostr-tools';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
@@ -16,18 +18,21 @@ import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, LoaderIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { useSuggestion } from '@utils/hooks/useSuggestion';
import { useWidget } from '@utils/hooks/useWidget';
export function NewPostScreen() {
const { ndk } = useNDK();
const { addWidget } = useWidget();
const { suggestion } = useSuggestion();
const [loading, setLoading] = useState(false);
const [height, setHeight] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const containerRef = useRef(null);
const editor = useEditor({
extensions: [
StarterKit.configure(),
@@ -39,6 +44,14 @@ export function NewPostScreen() {
},
}),
CharacterCount.configure(),
Mention.configure({
suggestion,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderLabel({ options, node }) {
const npub = nip19.npubEncode(node.attrs.id);
return `nostr:${npub}`;
},
}),
],
content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
editorProps: {
@@ -115,34 +128,40 @@ export function NewPostScreen() {
}
};
useLayoutEffect(() => {
setHeight(containerRef.current.clientHeight);
}, []);
useEffect(() => {
if (editor) editor.commands.focus('end');
}, [editor]);
return (
<div className="flex h-full flex-col justify-between">
<div>
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{searchParams.get('replyTo') && (
<div className="relative max-w-lg">
<MentionNote id={searchParams.get('replyTo')} editing />
<button
type="button"
onClick={() => setSearchParams({})}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
>
<CancelIcon className="h-5 w-5" />
</button>
</div>
)}
<div className="flex flex-1 flex-col gap-4">
<div className="flex-1 overflow-y-auto">
<div ref={containerRef} style={{ height: `${height}px` }}>
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{searchParams.get('replyTo') && (
<div className="relative max-w-lg">
<MentionNote id={searchParams.get('replyTo')} editing />
<button
type="button"
onClick={() => setSearchParams({})}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
>
<CancelIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
<div className="inline-flex h-16 w-full items-center justify-between border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-950">
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} characters
</span>

View File

@@ -62,7 +62,7 @@ export function NewPrivkeyScreen() {
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="h-11 w-full rounded-lg bg-neutral-100 px-3 py-2 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
/>
<div className="mt-2 flex flex-col gap-2">
<button

View File

@@ -4,6 +4,7 @@ import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { ArrowLeftIcon, CheckCircleIcon, ShareIcon } from '@shared/icons';
import { NoteReplyForm } from '@shared/notes';
@@ -40,14 +41,18 @@ export function ArticleNoteScreen() {
}, [data]);
const share = async () => {
await writeText(
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
try {
await writeText(
'https://njump.me/' +
nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
} catch (e) {
toast.error(e);
}
};
return (

View File

@@ -4,6 +4,7 @@ import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/types/nip19';
import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
import {
@@ -30,14 +31,18 @@ export function TextNoteScreen() {
const [isCopy, setIsCopy] = useState(false);
const share = async () => {
await writeText(
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
try {
await writeText(
'https://njump.me/' +
nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
);
// update state
setIsCopy(true);
// reset state after 2 sec
setTimeout(() => setIsCopy(false), 2000);
} catch (e) {
toast.error(e);
}
};
const scrollToReply = () => {

View File

@@ -43,21 +43,19 @@ export function NWCForm({ setWalletConnectURL }) {
return (
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="flex flex-col gap-1.5">
<textarea
name="walletConnectURL"
value={uri}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={false}
onChange={(e) => setUri(e.target.value)}
placeholder="nostr+walletconnect://"
className="h-40 w-full resize-none rounded-lg bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
</div>
<textarea
name="walletConnectURL"
value={uri}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={false}
onChange={(e) => setUri(e.target.value)}
placeholder="nostr+walletconnect://"
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Connect'}
</button>

View File

@@ -11,7 +11,7 @@ export function NWCScreen() {
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
const remove = async () => {
await db.secureRemove('nwc');
await db.secureRemove(`${db.account.pubkey}-nwc`);
setWalletConnectURL(null);
};
@@ -41,16 +41,16 @@ export function NWCScreen() {
<CheckCircleIcon className="h-4 w-4" />
<div>You&apos;re using nostr wallet connect</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<textarea
readOnly
value={walletConnectURL.substring(0, 120) + '****'}
className="relative h-40 w-full resize-none rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => remove()}
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
>
Remove connection
</button>

View File

@@ -1,5 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useCallback } from 'react';
import { VList } from 'virtua';
@@ -15,7 +16,7 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
queryFn: async () => {
const url = 'wss://' + relayUrl;
const events = await fetcher.fetchLatestEvents(
[url],
[normalizeRelayUrl(url)],
{
kinds: [NDKKind.Text, NDKKind.Repost],
},
@@ -24,6 +25,8 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
return events as unknown as NDKEvent[];
},
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
const renderItem = useCallback(
@@ -41,19 +44,18 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
);
return (
<div className="h-full">
<VList className="mx-auto w-full max-w-[500px] scrollbar-none">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
</div>
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
{status === 'pending' ? (
<div className="flex h-full w-full items-center justify-center">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/80">Loading newsfeed...</p>
</div>
) : (
data.map((item) => renderItem(item))
)}
</VList>
</div>
</div>
) : (
data.map((item) => renderItem(item))
)}
<div className="h-20" />
</VList>
);
}

View File

@@ -1,69 +1,62 @@
import { useQueryClient } from '@tanstack/react-query';
import { NDKRelayUrl } from '@nostr-dev-kit/ndk';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import { toast } from 'sonner';
import { PlusIcon } from '@shared/icons';
import { useRelay } from '@utils/hooks/useRelay';
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
export function RelayForm() {
const { db } = useStorage();
const queryClient = useQueryClient();
const { connectRelay } = useRelay();
const [relay, setRelay] = useState<{
url: NDKRelayUrl;
purpose: 'read' | 'write' | undefined;
}>({ url: '', purpose: undefined });
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const createRelay = async () => {
if (url.length < 1) return setError('Please enter relay url');
const create = () => {
if (relay.url.length < 1) return toast.info('Please enter relay url');
try {
const relay = new URL(url.replace(/\s/g, ''));
const relayUrl = new URL(relay.url.replace(/\s/g, ''));
if (
domainRegex.test(relay.host) &&
(relay.protocol === 'wss:' || relay.protocol === 'ws:')
domainRegex.test(relayUrl.host) &&
(relayUrl.protocol === 'wss:' || relayUrl.protocol === 'ws:')
) {
const res = await db.createRelay(url);
if (!res) return setError("You're already using this relay");
queryClient.invalidateQueries({
queryKey: ['user-relay'],
});
setError('');
setUrl('');
connectRelay.mutate(normalizeRelayUrl(relay.url));
setRelay({ url: '', purpose: undefined });
} else {
return setError(
return toast.error(
'URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again'
);
}
} catch {
return setError('Relay URL is not valid. Please check again');
return toast.error('Relay URL is not valid. Please check again');
}
};
return (
<div className="flex flex-col gap-1">
<div className="flex h-10 items-center justify-between rounded-lg bg-neutral-200 pr-1.5 dark:bg-neutral-800">
<div className="flex gap-2">
<input
className="h-full w-full bg-transparent pl-3 pr-1.5 text-neutral-900 placeholder:text-neutral-600 focus:outline-none dark:text-neutral-100 dark:placeholder:text-neutral-400"
type="url"
className="h-11 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
placeholder="wss://"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
value={url}
onChange={(e) => setUrl(e.target.value)}
value={relay.url}
onChange={(e) => setRelay((prev) => ({ ...prev, url: e.target.value }))}
/>
<button
type="button"
onClick={() => createRelay()}
className="inline-flex h-6 w-6 items-center justify-center rounded bg-blue-500 text-white hover:bg-blue-600"
onClick={() => create()}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
>
<PlusIcon className="h-4 w-4" />
<PlusIcon className="h-5 w-5" />
</button>
</div>
<span className="text-sm text-red-400">{error}</span>
</div>
);
}

View File

@@ -1,22 +1,16 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { message } from '@tauri-apps/plugin-dialog';
import { normalizeRelayUrl } from 'nostr-fetch';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { VList } from 'virtua';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon, PlusIcon, ShareIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr';
import { useRelay } from '@utils/hooks/useRelay';
export function RelayList() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { getAllRelaysByUsers } = useNostr();
const { db } = useStorage();
const { connectRelay } = useRelay();
const { status, data } = useQuery({
queryKey: ['relays'],
queryFn: async () => {
@@ -28,21 +22,13 @@ export function RelayList() {
staleTime: Infinity,
});
const navigate = useNavigate();
const inspectRelay = (relayUrl: string) => {
const url = new URL(relayUrl);
navigate(`/relays/${url.hostname}`);
};
const connectRelay = async (relayUrl: string) => {
const url = normalizeRelayUrl(relayUrl);
const res = await db.createRelay(url);
if (!res) await message("You're aldready connected to this relay");
queryClient.invalidateQueries({
queryKey: ['user-relay'],
});
};
return (
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
{status === 'pending' ? (
@@ -55,9 +41,7 @@ export function RelayList() {
) : (
<VList className="h-full">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
All relays
</h3>
<h3 className="font-semibold">Relay discovery</h3>
</div>
{[...data].map(([key, value]) => (
<div
@@ -76,7 +60,7 @@ export function RelayList() {
</button>
<button
type="button"
onClick={() => connectRelay(key)}
onClick={() => connectRelay.mutate(key)}
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<PlusIcon className="h-3 w-3" />

View File

@@ -1,71 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RelayForm } from '@app/relays/components/relayForm';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon } from '@shared/icons';
export function UserRelay() {
const queryClient = useQueryClient();
const { relayUrls } = useNDK();
const { db } = useStorage();
const { status, data } = useQuery({
queryKey: ['user-relay'],
queryFn: async () => {
return await db.getExplicitRelayUrls();
},
refetchOnWindowFocus: false,
});
const removeRelay = async (relayUrl: string) => {
await db.removeRelay(relayUrl);
queryClient.invalidateQueries({
queryKey: ['user-relay'],
});
};
return (
<div className="mt-3 px-3">
{status === 'pending' ? (
<p>Loading...</p>
) : (
<div className="flex flex-col gap-2">
{data.map((item) => (
<div
key={item}
className="group flex h-10 items-center justify-between rounded-lg bg-neutral-100 pl-3 pr-1.5 dark:bg-neutral-900"
>
<div className="inline-flex items-center gap-2.5">
{relayUrls.includes(item) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500"></span>
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item}
</p>
</div>
<button
type="button"
onClick={() => removeRelay(item)}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
))}
<RelayForm />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { NDKKind, NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { RelayForm } from '@app/relays/components/relayForm';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, RefreshIcon } from '@shared/icons';
import { useRelay } from '@utils/hooks/useRelay';
export function UserRelayList() {
const { db } = useStorage();
const { ndk } = useNDK();
const { removeRelay } = useRelay();
const { status, data, refetch } = useQuery({
queryKey: ['relays', db.account.pubkey],
queryFn: async () => {
const event = await ndk.fetchEvent(
{
kinds: [NDKKind.RelayList],
authors: [db.account.pubkey],
},
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
);
if (!event) return [];
return event.tags;
},
refetchOnWindowFocus: false,
});
const currentRelays = new Set([...ndk.pool.relays.values()].map((item) => item.url));
return (
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold">Connected relays</h3>
<button
type="button"
onClick={() => refetch()}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<RefreshIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex flex-col gap-2 px-3">
{status === 'pending' ? (
<p>Loading...</p>
) : !data.length ? (
<div className="flex h-20 w-full items-center justify-center rounded-xl bg-neutral-50 dark:bg-neutral-950">
<p className="text-sm font-medium">You not have personal relay list yet</p>
</div>
) : (
data.map((item) => (
<div
key={item[1]}
className="group flex h-11 items-center justify-between rounded-lg bg-neutral-100 px-3 dark:bg-neutral-900"
>
<div className="inline-flex items-baseline gap-2">
{currentRelays.has(item[1]) ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-teal-500"></span>
</span>
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
)}
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item[1]}
</p>
</div>
<div className="inline-flex items-center gap-2">
{item[2]?.length ? (
<div className="inline-flex h-6 w-max items-center justify-center rounded bg-neutral-200 px-2 text-xs font-medium capitalize dark:bg-neutral-800">
{item[2]}
</div>
) : null}
<button
type="button"
onClick={() => removeRelay.mutate(item[1])}
className="hidden h-6 w-6 items-center justify-center rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<CancelIcon className="h-4 w-4 text-neutral-900 dark:text-neutral-100" />
</button>
</div>
</div>
))
)}
<RelayForm />
</div>
</div>
);
}

View File

@@ -1,18 +1,11 @@
import { RelayList } from '@app/relays/components/relayList';
import { UserRelay } from '@app/relays/components/userRelay';
import { UserRelayList } from '@app/relays/components/userRelayList';
export function RelaysScreen() {
return (
<div className="grid h-full w-full grid-cols-3">
<RelayList />
<div className="col-span-1">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100">
Connected relays
</h3>
</div>
<UserRelay />
</div>
<UserRelayList />
</div>
);
}

View File

@@ -1,9 +1,24 @@
import { getVersion } from '@tauri-apps/api/app';
import { relaunch } from '@tauri-apps/plugin-process';
import { Update, check } from '@tauri-apps/plugin-updater';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
export function AboutScreen() {
const [version, setVersion] = useState('');
const [newUpdate, setNewUpdate] = useState<Update>(null);
const checkUpdate = async () => {
const update = await check();
if (!update) toast.info('There is no update available');
setNewUpdate(update);
};
const installUpdate = async () => {
await newUpdate.downloadAndInstall();
await relaunch();
};
useEffect(() => {
async function loadVersion() {
@@ -20,19 +35,38 @@ export function AboutScreen() {
<img src="/icon.png" alt="Lume's logo" className="w-16 shrink-0" />
<div>
<h1 className="text-xl font-semibold">Lume</h1>
<p className="text-neutral-700 dark:text-neutral-300">Version {version}</p>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Version {version}
</p>
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
{!newUpdate ? (
<button
type="button"
onClick={() => checkUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Check for update
</button>
) : (
<button
type="button"
onClick={() => installUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Install {newUpdate.version}
</button>
)}
<Link
to="https://lume.nu"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Website
</Link>
<Link
to="https://github.com/luminous-devs/lume/issues"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Report a issue
</Link>

View File

@@ -39,7 +39,7 @@ export function BackupSettingScreen() {
readOnly
type={showPassword ? 'text' : 'password'}
value={nip19.nsecEncode(privkey)}
className="relative h-11 w-full resize-none rounded-lg bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
className="relative h-11 w-full resize-none rounded-lg border-none bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"

View File

@@ -11,7 +11,7 @@ import { displayNpub } from '@utils/shortenKey';
export function ProfileCard() {
const { db } = useStorage();
const { status, user } = useProfile(db.account.pubkey);
const { isLoading, user } = useProfile(db.account.pubkey);
const svgURI =
'data:image/svg+xml;utf8,' +
@@ -19,7 +19,7 @@ export function ProfileCard() {
return (
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === 'pending' ? (
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
@@ -57,7 +57,7 @@ export function ProfileCard() {
{user?.display_name || user?.name}
</h3>
<p className="text-lg text-neutral-700 dark:text-neutral-300">
{user.nip05 || displayNpub(db.account.pubkey, 16)}
{user?.nip05 || displayNpub(db.account.pubkey, 16)}
</p>
</div>
</div>

View File

@@ -184,12 +184,9 @@ export function EditProfileScreen() {
</label>
<input
type={'text'}
{...register('display_name', {
required: true,
minLength: 4,
})}
{...register('display_name')}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
@@ -201,12 +198,9 @@ export function EditProfileScreen() {
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
})}
{...register('name')}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
@@ -218,12 +212,9 @@ export function EditProfileScreen() {
</label>
<div className="relative">
<input
{...register('nip05', {
required: true,
minLength: 4,
})}
{...register('nip05')}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
{nip05.verified ? (
@@ -256,7 +247,7 @@ export function EditProfileScreen() {
type={'text'}
{...register('website', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
@@ -270,7 +261,7 @@ export function EditProfileScreen() {
type={'text'}
{...register('lud16', { required: false })}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div className="flex flex-col gap-1">
@@ -283,14 +274,14 @@ export function EditProfileScreen() {
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
className="relative h-20 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className="mx-auto inline-flex h-9 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
className="mx-auto inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />

View File

@@ -13,6 +13,7 @@ import { DarkIcon, LightIcon, SystemModeIcon } from '@shared/icons';
export function GeneralSettingScreen() {
const { db } = useStorage();
const [settings, setSettings] = useState({
autoupdate: false,
autolaunch: false,
outbox: false,
media: true,
@@ -59,6 +60,13 @@ export function GeneralSettingScreen() {
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
};
const toggleAutoupdate = async () => {
await db.createSetting('autoupdate', String(+!settings.autoupdate));
db.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
if (settings.notification) return;
@@ -82,6 +90,12 @@ export function GeneralSettingScreen() {
if (!data) return;
data.forEach((item) => {
if (item.key === 'autoupdate')
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === 'outbox')
setSettings((prev) => ({
...prev,
@@ -99,12 +113,6 @@ export function GeneralSettingScreen() {
...prev,
hashtag: !!parseInt(item.value),
}));
if (item.key === 'notification')
setSettings((prev) => ({
...prev,
notification: !!parseInt(item.value),
}));
});
}
@@ -114,6 +122,19 @@ export function GeneralSettingScreen() {
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Updater</div>
<div className="text-sm">Auto download new update at Login</div>
</div>
<Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Startup</div>

View File

@@ -21,54 +21,58 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false);
const navigate = useNavigate();
const navigate = useNavigate();
const svgURI =
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50));
const follow = async (pubkey: string) => {
const follow = async () => {
try {
if (!ndk.signer) return navigate('/new/privkey');
setFollowed(true);
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows();
const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
if (add) {
setFollowed(true);
} else {
toast('You already follow this user');
if (!add) {
toast.success('You already follow this user');
setFollowed(false);
}
} catch (error) {
console.log(error);
} catch (e) {
toast.error(e);
setFollowed(false);
}
};
const unfollow = async (pubkey: string) => {
const unfollow = async () => {
try {
if (!ndk.signer) return navigate('/new/privkey');
setFollowed(false);
const user = ndk.getUser({ pubkey: db.account.pubkey });
const contacts = await user.follows();
contacts.delete(new NDKUser({ pubkey: pubkey }));
let list: string[][];
contacts.forEach((el) => list.push(['p', el.pubkey, el.relayUrls?.[0] || '', '']));
const list = [...contacts].map((item) => [
'p',
item.pubkey,
item.relayUrls?.[0] || '',
'',
]);
const event = new NDKEvent(ndk);
event.content = '';
event.kind = NDKKind.Contacts;
event.tags = list;
const publishedRelays = await event.publish();
if (publishedRelays) {
setFollowed(false);
}
} catch (error) {
console.log(error);
await event.publish();
} catch (e) {
toast.error(e);
}
};
useEffect(() => {
if (db.account.follows.includes(pubkey)) {
if (db.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);
@@ -78,9 +82,9 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
return (
<>
<div className="h-56 w-full overflow-hidden rounded-tl-lg">
{user.banner ? (
{user?.banner ? (
<img
src={user.banner}
src={user?.banner}
alt="user banner"
className="h-full w-full rounded-tl-lg object-cover"
/>
@@ -96,7 +100,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="h-14 w-14 rounded-lg bg-white ring-2 ring-neutral-100 dark:ring-neutral-900"
className="h-14 w-14 rounded-lg bg-white object-cover ring-2 ring-neutral-100 dark:ring-neutral-900"
/>
<Avatar.Fallback delayMs={300}>
<img
@@ -110,12 +114,12 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
<div className="flex flex-col items-center gap-1">
<div className="inline-flex flex-col items-center">
<h5 className="text-center text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{user.name || user.display_name || user.displayName || 'No name'}
{user?.name || user?.display_name || user?.displayName || 'No name'}
</h5>
{user.nip05 ? (
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
nip05={user.nip05}
className="text-neutral-600 dark:text-neutral-400"
/>
) : (
@@ -125,7 +129,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
)}
</div>
<div className="flex flex-col gap-6">
{user.about || user.bio ? (
{user?.about || user?.bio ? (
<p className="mt-2 max-w-[500px] select-text break-words text-center text-neutral-900 dark:text-neutral-100">
{user.about || user.bio}
</p>
@@ -139,23 +143,23 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
{followed ? (
<button
type="button"
onClick={() => unfollow(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
onClick={unfollow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Unfollow
</button>
) : (
<button
type="button"
onClick={() => follow(pubkey)}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
onClick={follow}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Follow
</button>
)}
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
>
Message
</Link>

View File

@@ -1,141 +1,205 @@
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import NDK, {
NDKEvent,
NDKKind,
NDKNip46Signer,
NDKPrivateKeySigner,
} from '@nostr-dev-kit/ndk';
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { message } from '@tauri-apps/plugin-dialog';
import { fetch } from '@tauri-apps/plugin-http';
import { NostrFetcher } from 'nostr-fetch';
import { useEffect, useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { ask } from '@tauri-apps/plugin-dialog';
import { relaunch } from '@tauri-apps/plugin-process';
import { NostrFetcher, normalizeRelayUrlSet } from 'nostr-fetch';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import NDKCacheAdapterTauri from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider';
import { FETCH_LIMIT } from '@utils/constants';
export const NDKInstance = () => {
const { db } = useStorage();
const queryClient = useQueryClient();
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [fetcher, setFetcher] = useState<NostrFetcher | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
const { db } = useStorage();
const fetcher = useMemo(
() => (ndk ? NostrFetcher.withCustomPool(ndkAdapter(ndk)) : null),
[ndk]
);
async function getSigner(nsecbunker?: boolean) {
if (!db.account) return;
// TODO: fully support NIP-11
async function getExplicitRelays() {
try {
// get relays
const relays = await db.getExplicitRelayUrls();
const onlineRelays = new Set(relays);
// NIP-46 Signer
if (nsecbunker) {
const localSignerPrivkey = await db.secureLoad(`${db.account.pubkey}-nsecbunker`);
if (!localSignerPrivkey) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
const bunker = new NDK({
explicitRelayUrls: ['wss://relay.nsecbunker.com', 'wss://nostr.vulpem.com'],
});
bunker.connect();
for (const relay of relays) {
try {
const url = new URL(relay);
const res = await fetch(`https://${url.hostname}`, {
method: 'GET',
headers: {
Accept: 'application/nostr+json',
},
signal: controller.signal,
});
const remoteSigner = new NDKNip46Signer(bunker, db.account.id, localSigner);
await remoteSigner.blockUntilReady();
if (!res.ok) {
toast.warning(`${relay} is not working, skipping...`);
onlineRelays.delete(relay);
}
toast.success(`Connected to ${relay}`);
} catch {
toast.warning(`${relay} is not working, skipping...`);
onlineRelays.delete(relay);
} finally {
clearTimeout(timeoutId);
}
return remoteSigner;
}
// return all online relays
return [...onlineRelays];
} catch (e) {
console.error(e);
}
}
async function getSigner(instance: NDK) {
if (!db.account) return null;
const localSignerPrivkey = await db.secureLoad(db.account.pubkey + '-bunker');
const userPrivkey = await db.secureLoad(db.account.pubkey);
// NIP-46 Signer
if (localSignerPrivkey) {
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
const remoteSigner = new NDKNip46Signer(instance, db.account.id, localSigner);
await remoteSigner.blockUntilReady();
return remoteSigner;
}
// Privkey Signer
if (userPrivkey) {
// Privkey Signer
const userPrivkey = await db.secureLoad(db.account.pubkey);
if (!userPrivkey) return null;
return new NDKPrivateKeySigner(userPrivkey);
} catch (e) {
console.log(e);
if (e === 'Token already redeemed') {
toast.info(
'nsecbunker token already redeemed. You need to re-login with another token.'
);
await db.secureRemove(`${db.account.pubkey}-nsecbunker`);
await db.accountLogout();
}
return null;
}
}
async function initNDK() {
const outboxSetting = await db.getSettingValue('outbox');
const explicitRelayUrls = await getExplicitRelays();
const bunkerSetting = await db.getSettingValue('nsecbunker');
const tauriAdapter = new NDKCacheAdapterTauri(db);
const instance = new NDK({
explicitRelayUrls,
cacheAdapter: tauriAdapter,
outboxRelayUrls: ['wss://purplepag.es'],
enableOutboxModel: outboxSetting === '1',
});
const bunker = !!parseInt(bunkerSetting);
const outbox = !!parseInt(outboxSetting);
const explicitRelayUrls = normalizeRelayUrlSet([
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://nostr.mutinywallet.com',
]);
// #TODO: user should config outbox relays
const outboxRelayUrls = normalizeRelayUrlSet(['wss://purplepag.es']);
// #TODO: user should config blacklist relays
const blacklistRelayUrls = normalizeRelayUrlSet(['wss://brb.io']);
try {
// connect
await instance.connect(2000);
const tauriAdapter = new NDKCacheAdapterTauri(db);
const instance = new NDK({
explicitRelayUrls,
outboxRelayUrls,
blacklistRelayUrls,
enableOutboxModel: outbox,
autoConnectUserRelays: true,
autoFetchUserMutelist: true,
cacheAdapter: tauriAdapter,
// clientName: 'Lume',
// clientNip89: '',
});
// add signer
const signer = await getSigner(instance);
instance.signer = signer;
// add signer if exist
const signer = await getSigner(bunker);
if (signer) instance.signer = signer;
// connect
await instance.connect();
const _fetcher = NostrFetcher.withCustomPool(ndkAdapter(instance));
// update account's metadata
if (db.account) {
const circleSetting = await db.getSettingValue('circles');
const user = instance.getUser({ pubkey: db.account.pubkey });
const follows = await user.follows();
const relayList = await user.relayList();
instance.activeUser = user;
const followsAsArr = [];
follows.forEach((user) => {
followsAsArr.push(user.pubkey);
const contacts = await user.follows(undefined /* outbox */);
db.account.contacts = [...contacts].map((user) => user.pubkey);
// prefetch newsfeed
await queryClient.prefetchInfiniteQuery({
queryKey: ['newsfeed'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const rootIds = new Set();
const dedupQueue = new Set();
const events = await _fetcher.fetchLatestEvents(
explicitRelayUrls,
{
kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.contacts,
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
});
ndkEvents.forEach((event) => {
const tags = event.tags.filter((el) => el[0] === 'e');
if (tags && tags.length > 0) {
const rootId = tags.filter((el) => el[3] === 'root')[1] ?? tags[0][1];
if (rootIds.has(rootId)) return dedupQueue.add(event.id);
rootIds.add(rootId);
}
});
return ndkEvents
.filter((event) => !dedupQueue.has(event.id))
.sort((a, b) => b.created_at - a.created_at);
},
});
// update user's follows
await db.updateAccount('follows', JSON.stringify(followsAsArr));
if (circleSetting !== '1')
await db.updateAccount('circles', JSON.stringify(followsAsArr));
// prefetch notification
await queryClient.prefetchInfiniteQuery({
queryKey: ['notification'],
initialPageParam: 0,
queryFn: async ({
signal,
pageParam,
}: {
signal: AbortSignal;
pageParam: number;
}) => {
const events = await _fetcher.fetchLatestEvents(
explicitRelayUrls,
{
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
'#p': [db.account.pubkey],
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }
);
// update user's relay list
if (relayList) {
for (const relay of relayList.relays) {
await db.createRelay(relay);
}
}
const ndkEvents = events.map((event) => {
return new NDKEvent(ndk, event);
});
return ndkEvents.sort((a, b) => b.created_at - a.created_at);
},
});
}
} catch (error) {
await message(`NDK instance init failed: ${error}`, {
setNDK(instance);
setFetcher(_fetcher);
setRelayUrls(explicitRelayUrls);
} catch (e) {
console.error(e);
const yes = await ask(e, {
title: 'Lume',
type: 'error',
okLabel: 'Yes',
});
if (yes) relaunch();
}
setNDK(instance);
setRelayUrls(explicitRelayUrls);
}
useEffect(() => {
@@ -144,7 +208,7 @@ export const NDKInstance = () => {
return {
ndk,
relayUrls,
fetcher,
relayUrls,
};
};

View File

@@ -8,7 +8,7 @@ import { NDKInstance } from '@libs/ndk/instance';
import { LoaderIcon } from '@shared/icons';
import { QUOTES } from '@stores/constants';
import { QUOTES } from '@utils/constants';
interface NDKContext {
ndk: undefined | NDK;

View File

@@ -3,8 +3,6 @@ import { invoke } from '@tauri-apps/api/primitives';
import { Platform } from '@tauri-apps/plugin-os';
import Database from '@tauri-apps/plugin-sql';
import { FULL_RELAYS } from '@stores/constants';
import { rawEvent } from '@utils/transform';
import type {
Account,
@@ -12,6 +10,7 @@ import type {
NDKCacheEvent,
NDKCacheEventTag,
NDKCacheUser,
NDKCacheUserProfile,
Relays,
Widget,
} from '@utils/types';
@@ -20,13 +19,18 @@ export class LumeStorage {
public db: Database;
public account: Account | null;
public platform: Platform | null;
public settings: { outbox: boolean; media: boolean; hashtag: boolean };
public settings: {
autoupdate: boolean;
outbox: boolean;
media: boolean;
hashtag: boolean;
};
constructor(sqlite: Database, platform: Platform) {
this.db = sqlite;
this.account = null;
this.platform = platform;
this.settings = { outbox: false, media: true, hashtag: true };
this.settings = { autoupdate: false, outbox: false, media: true, hashtag: true };
}
public async secureSave(key: string, value: string) {
@@ -47,13 +51,27 @@ export class LumeStorage {
return await invoke('secure_remove', { key });
}
public async getAllCacheUsers() {
const results: Array<NDKCacheUser> = await this.db.select(
'SELECT * FROM ndk_users ORDER BY createdAt DESC;'
);
if (!results.length) return [];
const users: NDKCacheUserProfile[] = results.map((item) => ({
pubkey: item.pubkey,
...JSON.parse(item.profile as string),
}));
return users;
}
public async getCacheUser(pubkey: string) {
const results: Array<NDKCacheUser> = await this.db.select(
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',
[pubkey]
);
if (results.length < 1) return null;
if (!results.length) return null;
if (typeof results[0].profile === 'string')
results[0].profile = JSON.parse(results[0].profile);
@@ -67,7 +85,7 @@ export class LumeStorage {
[id]
);
if (results.length < 1) return null;
if (!results.length) return null;
return results[0];
}
@@ -78,7 +96,7 @@ export class LumeStorage {
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`
);
if (results.length < 1) return [];
if (!results.length) return [];
return results;
}
@@ -88,7 +106,7 @@ export class LumeStorage {
[pubkey]
);
if (results.length < 1) return [];
if (!results.length) return [];
return results;
}
@@ -98,7 +116,7 @@ export class LumeStorage {
[kind]
);
if (results.length < 1) return [];
if (!results.length) return [];
return results;
}
@@ -108,7 +126,7 @@ export class LumeStorage {
[kind, pubkey]
);
if (results.length < 1) return [];
if (!results.length) return [];
return results;
}
@@ -118,7 +136,7 @@ export class LumeStorage {
[tagValue]
);
if (results.length < 1) return [];
if (!results.length) return [];
return results;
}
@@ -158,7 +176,7 @@ export class LumeStorage {
public async checkAccount() {
const result: Array<{ total: string }> = await this.db.select(
'SELECT COUNT(*) AS "total" FROM accounts;'
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
return parseInt(result[0].total);
}
@@ -168,20 +186,9 @@ export class LumeStorage {
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
);
if (results.length > 0) {
const account = results[0];
if (typeof account.follows === 'string')
account.follows = JSON.parse(account.follows) ?? [];
if (typeof account.circles === 'string')
account.circles = JSON.parse(account.circles) ?? [];
if (typeof account.last_login_at === 'string')
account.last_login_at = parseInt(account.last_login_at);
this.account = account;
return account;
if (results.length) {
this.account = results[0];
this.account.contacts = [];
} else {
console.log('no active account, please create new account');
return null;
@@ -194,7 +201,7 @@ export class LumeStorage {
[pubkey]
);
if (existAccounts.length > 0) {
if (existAccounts.length) {
await this.db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [
pubkey,
]);
@@ -205,8 +212,7 @@ export class LumeStorage {
);
}
const account = await this.getActiveAccount();
return account;
return await this.getActiveAccount();
}
public async updateAccount(column: string, value: string) {
@@ -221,15 +227,6 @@ export class LumeStorage {
}
}
public async updateLastLogin() {
const now = Math.floor(Date.now() / 1000);
this.account.last_login_at = now;
return await this.db.execute(
'UPDATE accounts SET last_login_at = $1 WHERE id = $2;',
[now, this.account.id]
);
}
public async getWidgets() {
const widgets: Array<Widget> = await this.db.select(
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',
@@ -401,24 +398,13 @@ export class LumeStorage {
return results.length < 1;
}
public async getExplicitRelayUrls() {
if (!this.account) return FULL_RELAYS;
const result: Relays[] = await this.db.select(
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
);
if (!result || !result.length) return FULL_RELAYS;
return result.map((el) => el.relay);
}
public async createRelay(relay: string, purpose?: string) {
const existRelays: Relays[] = await this.db.select(
'SELECT * FROM relays WHERE relay = $1 AND account_id = $2 ORDER BY id DESC LIMIT 1;',
[relay, this.account.id]
);
if (existRelays.length > 0) return false;
if (existRelays.length) return;
return await this.db.execute(
'INSERT OR IGNORE INTO relays (account_id, relay, purpose) VALUES ($1, $2, $3);',
@@ -430,8 +416,15 @@ export class LumeStorage {
return await this.db.execute(`DELETE FROM relays WHERE relay = "${relay}";`);
}
public async createSetting(key: string, value: string) {
const currentSetting = await this.getSettingValue(key);
public async createSetting(key: string, value: string | undefined) {
if (value) {
return await this.db.execute(
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
[key, value]
);
}
const currentSetting = await this.checkSettingValue(key);
if (!currentSetting)
return await this.db.execute(
@@ -455,12 +448,21 @@ export class LumeStorage {
return results;
}
public async checkSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.db.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (!results.length) return false;
return results[0].value;
}
public async getSettingValue(key: string) {
const results: { key: string; value: string }[] = await this.db.select(
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
[key]
);
if (results.length < 1) return null;
if (!results.length) return '0';
return results[0].value;
}
@@ -475,9 +477,7 @@ export class LumeStorage {
await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
this.account.id,
]);
this.account = null;
return true;
}
public async close() {

View File

@@ -10,7 +10,7 @@ import { LumeStorage } from '@libs/storage/instance';
import { LoaderIcon } from '@shared/icons';
import { QUOTES } from '@stores/constants';
import { QUOTES } from '@utils/constants';
interface StorageContext {
db: LumeStorage;
@@ -20,7 +20,7 @@ const StorageContext = createContext<StorageContext>({
db: undefined,
});
const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const StorageInstance = () => {
const [db, setDB] = useState<LumeStorage>(undefined);
const [isNewVersion, setIsNewVersion] = useState(false);
@@ -33,6 +33,8 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
const settings = await lumeStorage.getAllSettings();
let autoUpdater = false;
if (settings) {
settings.forEach((item) => {
if (item.key === 'outbox') lumeStorage.settings.outbox = !!parseInt(item.value);
@@ -41,16 +43,23 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
if (item.key === 'hashtag')
lumeStorage.settings.hashtag = !!parseInt(item.value);
if (item.key === 'autoupdate') {
if (parseInt(item.value)) autoUpdater = true;
}
});
}
// check update
const update = await check();
if (update) {
setIsNewVersion(true);
if (autoUpdater) {
// check update
const update = await check();
// install new version
if (update) {
setIsNewVersion(true);
await update.downloadAndInstall();
await relaunch();
await update.downloadAndInstall();
await relaunch();
}
}
setDB(lumeStorage);
@@ -66,6 +75,12 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
if (!db) initLumeStorage();
}, []);
return { db, isNewVersion };
};
const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const { db, isNewVersion } = StorageInstance();
if (!db)
return (
<div
@@ -93,7 +108,7 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
<div className="absolute bottom-5 right-5 inline-flex items-center gap-2.5">
<LoaderIcon className="h-6 w-6 animate-spin text-blue-500" />
<p className="font-semibold">
{isNewVersion ? 'Found a new version, updating' : 'Checking for updates...'}
{isNewVersion ? 'Found a new version, updating...' : 'Starting...'}
</p>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
@@ -10,7 +11,7 @@ import App from './app';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // 10 seconds
},
},
});
@@ -20,7 +21,8 @@ const root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<Toaster position="top-center" closeButton theme="system" />
<ReactQueryDevtools initialIsOpen={false} />
<Toaster position="top-center" theme="system" closeButton />
<StorageProvider>
<NDKProvider>
<App />

View File

@@ -11,18 +11,12 @@ import { useProfile } from '@utils/hooks/useProfile';
export function ActiveAccount() {
const { db } = useStorage();
const { status, user } = useProfile(db.account.pubkey);
const { user } = useProfile(db.account.pubkey);
const svgURI =
'data:image/svg+xml;utf8,' +
encodeURIComponent(minidenticon(db.account.pubkey, 90, 50));
if (status === 'pending') {
return (
<div className="aspect-square h-auto w-full animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
);
}
return (
<div className="flex flex-col gap-1 rounded-lg bg-neutral-100 p-1 ring-1 ring-transparent hover:bg-neutral-200 hover:ring-blue-500 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Link to="/settings/" className="relative inline-block">
@@ -33,7 +27,7 @@ export function ActiveAccount() {
loading="lazy"
decoding="async"
style={{ contentVisibility: 'auto' }}
className="aspect-square h-auto w-full rounded-md"
className="aspect-square h-auto w-full rounded-md object-cover"
/>
<Avatar.Fallback delayMs={150}>
<img

View File

@@ -1,5 +1,7 @@
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
@@ -9,17 +11,27 @@ export function Logout() {
const { ndk } = useNDK();
const navigate = useNavigate();
const queryClient = useQueryClient();
const logout = async () => {
ndk.signer = null;
try {
ndk.signer = null;
// remove account
await db.accountLogout();
await db.secureRemove(db.account.pubkey);
await db.secureRemove(db.account.pubkey + '-bunker');
// remove private key
await db.secureRemove(db.account.pubkey);
await db.secureRemove(`${db.account.pubkey}-nsecbunker`);
// redirect to welcome screen
navigate('/auth/welcome');
// logout
await db.accountLogout();
// clear cache
queryClient.clear();
// redirect to welcome screen
navigate('/auth/welcome');
} catch (e) {
toast.error(e);
}
};
return (
@@ -33,7 +45,7 @@ export function Logout() {
</button>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-2xl dark:bg-white/50" />
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-md rounded-xl bg-neutral-100 dark:bg-neutral-900">
<div className="flex flex-col gap-1 border-b border-white/5 px-5 py-4">
@@ -54,13 +66,15 @@ export function Logout() {
Cancel
</button>
</AlertDialog.Cancel>
<button
type="button"
onClick={() => logout()}
className="inline-flex h-9 items-center justify-center rounded-lg bg-red-500 px-4 text-sm font-medium text-white outline-none hover:bg-red-600"
>
Logout
</button>
<AlertDialog.Action asChild>
<button
type="button"
onClick={() => logout()}
className="inline-flex h-9 items-center justify-center rounded-lg bg-red-500 px-4 text-sm font-medium text-white outline-none hover:bg-red-600"
>
Logout
</button>
</AlertDialog.Action>
</div>
</div>
</AlertDialog.Content>

View File

@@ -1,8 +1,8 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Link } from 'react-router-dom';
import { Logout } from '@shared/accounts/logout';
import { HorizontalDotsIcon } from '@shared/icons';
import { Logout } from '@shared/logout';
export function AccountMoreActions() {
return (

View File

@@ -1,7 +1,7 @@
import { message } from '@tauri-apps/plugin-dialog';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
@@ -37,14 +37,9 @@ export function AvatarUploader({
<button
type="button"
onClick={() => uploadAvatar()}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-100 px-1.5 py-1 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
className="inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<PlusIcon className="h-4 w-4" />
)}
Change avatar
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Change avatar'}
</button>
);
}

View File

@@ -7,15 +7,17 @@ export function AuthLayout() {
const { db } = useStorage();
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
<div className="flex h-screen w-screen flex-col">
{db.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9" />
)}
<div className="flex h-full min-h-0 w-full">
<Outlet />
<ScrollRestoration />
<div className="h-full w-full px-2.5 pb-2.5 pt-1">
<div className="flex h-full min-h-0 w-full rounded-lg bg-white p-3 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<Outlet />
<ScrollRestoration />
</div>
</div>
</div>
);

View File

@@ -15,65 +15,62 @@ export function NewLayout() {
{db.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<div data-tauri-drag-region className="h-9" />
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div data-tauri-drag-region className="h-6" />
<div className="flex h-full min-h-0 w-full">
<div className="container mx-auto grid grid-cols-8 px-4">
<div className="col-span-1">
<Link
to="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
</div>
<div className="relative col-span-6 flex flex-col">
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
{location.pathname !== '/new/privkey' ? (
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<NavLink
to="/new/"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Post
</NavLink>
<NavLink
to="/new/article"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Article
</NavLink>
<NavLink
to="/new/file"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
File Sharing
</NavLink>
</div>
) : null}
</div>
<div className="h-full min-h-0 w-full">
<Outlet />
</div>
</div>
<div className="col-span-1" />
<div data-tauri-drag-region className="h-4 shrink-0" />
<div className="container mx-auto grid flex-1 grid-cols-8 px-4">
<div className="col-span-1">
<Link
to="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
</div>
<div className="col-span-6 flex flex-col">
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
{location.pathname !== '/new/privkey' ? (
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<NavLink
to="/new/"
end
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Post
</NavLink>
<NavLink
to="/new/article"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Article
</NavLink>
<NavLink
to="/new/file"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
File Sharing
</NavLink>
</div>
) : null}
</div>
<Outlet />
</div>
<div className="col-span-1" />
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { Link, NavLink, Outlet, ScrollRestoration } from 'react-router-dom';
import { NavLink, Outlet, ScrollRestoration, useNavigate } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { WindowTitlebar } from 'tauri-controls';
@@ -15,6 +15,7 @@ import {
export function SettingsLayout() {
const { db } = useStorage();
const navigate = useNavigate();
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
@@ -26,16 +27,17 @@ export function SettingsLayout() {
<div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto pb-10">
<div className="flex h-20 w-full items-center justify-between border-b border-neutral-200 px-2 pb-2 dark:border-neutral-900">
<div>
<Link
to="/"
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
</button>
</div>
<div className="flex items-center gap-0.5">
<NavLink
to="/settings"
to="/settings/"
end
className={({ isActive }) =>
twMerge(

View File

@@ -39,7 +39,6 @@ export const NIP05 = memo(function NIP05({
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data: NIP05 = await res.json();
if (data.names) {
if (data.names[localPath] !== pubkey) return false;
return true;

View File

@@ -7,8 +7,7 @@ import { NoteReaction } from '@shared/notes/actions/reaction';
import { NoteRepost } from '@shared/notes/actions/repost';
import { NoteZap } from '@shared/notes/actions/zap';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
export function NoteActions({

View File

@@ -10,17 +10,20 @@ import CurrencyInput from 'react-currency-input-field';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, ZapIcon } from '@shared/icons';
import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification';
import { compactNumber } from '@utils/number';
import { displayNpub } from '@utils/shortenKey';
export function NoteZap({ event }: { event: NDKEvent }) {
const nwc = useRef(null);
const navigate = useNavigate();
const { db } = useStorage();
const { ndk } = useNDK();
const { user } = useProfile(event.pubkey);
@@ -84,7 +87,9 @@ export function NoteZap({ event }: { event: NDKEvent }) {
useEffect(() => {
async function getWalletConnectURL() {
const uri: string = await invoke('secure_load', { key: 'nwc' });
const uri: string = await invoke('secure_load', {
key: `${db.account.pubkey}-nwc`,
});
if (uri) setWalletConnectURL(uri);
}
@@ -115,7 +120,8 @@ export function NoteZap({ event }: { event: NDKEvent }) {
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center font-semibold">
Send tip to {user?.name || user?.display_name || user?.displayName}
Send tip to{' '}
{user?.name || user?.displayName || displayNpub(event.pubkey, 16)}
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
<CancelIcon className="h-4 w-4" />
@@ -135,7 +141,7 @@ export function NoteZap({ event }: { event: NDKEvent }) {
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(value)}
className="w-full flex-1 bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none dark:text-neutral-400"
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
sats
@@ -189,28 +195,28 @@ export function NoteZap({ event }: { event: NDKEvent }) {
autoCorrect="off"
autoCapitalize="off"
placeholder="Enter message (optional)"
className="w-full resize-none rounded-lg bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 dark:bg-neutral-900 dark:text-neutral-400"
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-400"
/>
<div className="flex flex-col gap-2">
{walletConnectURL ? (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
{isCompleted ? (
<p>Successfully tipped</p>
<p className="leading-tight">Successfully zapped</p>
) : isLoading ? (
<span className="flex flex-col">
<p>Waiting for approval</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
<p className="leading-tight">Waiting for approval</p>
<p className="text-xs leading-tight text-neutral-100">
Go to your wallet and approve payment request
</p>
</span>
) : (
<span className="flex flex-col">
<p>Send tip</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
<p className="leading-tight">Send zap</p>
<p className="text-xs leading-tight text-neutral-100">
You&apos;re using nostr wallet connect
</p>
</span>
@@ -220,7 +226,7 @@ export function NoteZap({ event }: { event: NDKEvent }) {
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
>
Create Lightning invoice
</button>
@@ -234,7 +240,7 @@ export function NoteZap({ event }: { event: NDKEvent }) {
<QRCodeSVG value={invoice} size={256} />
</div>
<div className="flex flex-col items-center gap-1">
<h3 className="text-lg font-medium">Scan to pay</h3>
<h3 className="text-lg font-medium">Scan to zap</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
You must use Bitcoin wallet which support Lightning
<br />

View File

@@ -4,12 +4,22 @@ import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function ChildNote({ id, isRoot }: { id: string; isRoot?: boolean }) {
const { status, data } = useEvent(id);
const { isFetching, isError, data } = useEvent(id);
if (status === 'pending' || !data) {
if (isFetching) {
return <NoteSkeleton />;
}
if (isError) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event
</div>
</div>
);
}
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">

View File

@@ -1,14 +1,11 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { downloadDir } from '@tauri-apps/api/path';
import { download } from '@tauri-apps/plugin-upload';
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import {
MediaControlBar,
MediaController,
MediaFullscreenButton,
MediaMuteButton,
MediaPlayButton,
MediaTimeRange,
} from 'media-chrome/dist/react';
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
import { memo } from 'react';
import { Link } from 'react-router-dom';
@@ -32,7 +29,7 @@ export function FileNote({ event }: { event: NDKEvent }) {
switch (type) {
case 'image':
return (
<div key={url} className="group relative">
<div className="group relative">
<img
src={url}
alt={url}
@@ -52,18 +49,15 @@ export function FileNote({ event }: { event: NDKEvent }) {
);
case 'video':
return (
<MediaController
key={url}
className="aspect-video w-full overflow-hidden rounded-lg"
<MediaPlayer
src={url}
className="w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<video slot="media" src={url} preload="metadata" muted />
<MediaControlBar>
<MediaPlayButton></MediaPlayButton>
<MediaTimeRange></MediaTimeRange>
<MediaMuteButton></MediaMuteButton>
<MediaFullscreenButton></MediaFullscreenButton>
</MediaControlBar>
</MediaController>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
default:
return (

View File

@@ -1,14 +1,11 @@
import { NDKTag } from '@nostr-dev-kit/ndk';
import { downloadDir } from '@tauri-apps/api/path';
import { download } from '@tauri-apps/plugin-upload';
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import {
MediaControlBar,
MediaController,
MediaFullscreenButton,
MediaMuteButton,
MediaPlayButton,
MediaTimeRange,
} from 'media-chrome/dist/react';
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
import { memo } from 'react';
import { Link } from 'react-router-dom';
@@ -50,18 +47,15 @@ export function FileKind({ tags }: { tags: NDKTag[] }) {
if (type === 'video') {
return (
<MediaController
key={url}
className="aspect-video w-full overflow-hidden rounded-lg"
<MediaPlayer
src={url}
className="w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<video slot="media" src={url} preload="metadata" muted />
<MediaControlBar>
<MediaPlayButton></MediaPlayButton>
<MediaTimeRange></MediaTimeRange>
<MediaMuteButton></MediaMuteButton>
<MediaFullscreenButton></MediaFullscreenButton>
</MediaControlBar>
</MediaController>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
}

View File

@@ -7,14 +7,14 @@ export function TextKind({ content, textmode }: { content: string; textmode?: bo
if (textmode) {
return (
<div className="break-p line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
<div className="line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{parsedContent}
</div>
);
}
return (
<div className={'min-w-0 px-3'}>
<div className="min-w-0 px-3">
<div className="break-p select-text leading-normal text-neutral-900 dark:text-neutral-100">
{parsedContent}
</div>

View File

@@ -1,5 +1,4 @@
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
export function Hashtag({ tag }: { tag: string }) {

View File

@@ -9,8 +9,7 @@ import {
} from '@shared/notes';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { useEvent } from '@utils/hooks/useEvent';
import { useWidget } from '@utils/hooks/useWidget';
@@ -21,7 +20,7 @@ export const MentionNote = memo(function MentionNote({
id: string;
editing?: boolean;
}) {
const { status, data } = useEvent(id);
const { isFetching, isError, data } = useEvent(id);
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
@@ -37,7 +36,7 @@ export const MentionNote = memo(function MentionNote({
}
};
if (status === 'pending') {
if (isFetching) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
<NoteSkeleton />
@@ -45,6 +44,14 @@ export const MentionNote = memo(function MentionNote({
);
}
if (isError) {
return (
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
Failed to fetch event
</div>
);
}
return (
<div className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="mt-3 px-3">

View File

@@ -1,7 +1,6 @@
import { memo } from 'react';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { useWidget } from '@utils/hooks/useWidget';
@@ -21,12 +20,7 @@ export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: strin
}
className="break-words text-blue-500 hover:text-blue-600"
>
{'@' +
(user?.name ||
user?.display_name ||
user?.displayName ||
user?.username ||
'unknown')}
{'@' + (user?.name || user?.displayName || user?.username || 'unknown')}
</button>
);
});

View File

@@ -1,109 +1,157 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { ShareIcon } from '@shared/icons';
import {
MemoizedArticleKind,
MemoizedFileKind,
MemoizedTextKind,
NoteSkeleton,
} from '@shared/notes';
import { ReplyIcon, RepostIcon } from '@shared/icons';
import { ChildNote, TextKind } from '@shared/notes';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { formatCreatedAt } from '@utils/createdAt';
import { useEvent } from '@utils/hooks/useEvent';
import { useNostr } from '@utils/hooks/useNostr';
import { useWidget } from '@utils/hooks/useWidget';
export function NotifyNote({ event }: { event: NDKEvent }) {
const createdAt = formatCreatedAt(event.created_at, false);
const rootEventId = event.tags.find((el) => el[0] === 'e')?.[1];
const { status, data } = useEvent(rootEventId);
const { getEventThread } = useNostr();
const { addWidget } = useWidget();
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <MemoizedTextKind key={event.id} content={event.content} textmode />;
case NDKKind.Article:
return <MemoizedArticleKind key={event.id} id={event.id} tags={event.tags} />;
case 1063:
return <MemoizedFileKind key={event.id} tags={event.tags} />;
default:
return (
<div className="break-p line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{event.content}
</div>
);
}
};
const thread = getEventThread(event.tags);
const createdAt = formatCreatedAt(event.created_at, false);
const renderText = (kind: number) => {
switch (kind) {
case NDKKind.Text:
return 'replied';
case NDKKind.Reaction: {
return `reacted your post`;
}
case NDKKind.Repost:
return 'reposted your post';
case NDKKind.Zap:
return 'zapped your post';
default:
return 'unknown';
}
};
if (status === 'pending') {
if (event.kind === NDKKind.Reaction) {
return (
<div className="h-min w-full px-3 pb-3">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<NoteSkeleton />
<div className="mb-3 h-min w-full px-3">
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex h-10 items-center justify-between">
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-blue-100 text-xs ring-2 ring-neutral-50 dark:bg-blue-900 dark:ring-neutral-950">
{event.content === '+' ? '👍' : event.content}
</div>
<div className="flex flex-1 items-center justify-between">
<div className="inline-flex items-center gap-1.5">
<User pubkey={event.pubkey} variant="notify" />
<p className="text-neutral-700 dark:text-neutral-300">reacted</p>
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="w-full px-3">
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <ChildNote id={thread.rootEventId} /> : null}
</div>
</div>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show original post
</button>
</div>
</div>
</div>
);
}
return (
<div className="mb-3 h-min w-full px-3">
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex h-10 items-center justify-between">
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-blue-100 text-xs ring-2 ring-neutral-50 dark:bg-blue-900 dark:ring-neutral-950">
{event.kind === 7 ? (event.content === '+' ? '👍' : event.content) : '⚡️'}
</div>
<div className="flex flex-1 items-center justify-between">
<div className="inline-flex items-center gap-1.5">
<User pubkey={event.pubkey} variant="notify" />
<p className="text-neutral-900 dark:text-neutral-100">
{renderText(event.kind)}
</p>
if (event.kind === NDKKind.Repost) {
return (
<div className="mb-3 h-min w-full px-3">
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex h-10 items-center justify-between">
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-teal-500 text-xs ring-2 ring-neutral-50 dark:ring-neutral-950">
<RepostIcon className="h-4 w-4 text-white" />
</div>
<div className="flex flex-1 items-center justify-between">
<div className="inline-flex items-center gap-1.5">
<User pubkey={event.pubkey} variant="notify" />
<p className="text-neutral-700 dark:text-neutral-300">reposted</p>
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
</div>
<div className="flex gap-2">
<div className="flex-1">{data ? renderKind(data) : <p>Loading...</p>}</div>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: data.id,
})
}
className="inline-flex min-h-full w-10 shrink-0 items-center justify-center rounded-lg text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
<ShareIcon className="h-5 w-5" />
</button>
<div className="flex flex-col gap-2">
<div className="w-full px-3">
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread.rootEventId ? <ChildNote id={thread.rootEventId} /> : null}
</div>
</div>
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show original post
</button>
</div>
</div>
</div>
</div>
);
);
}
if (event.kind === NDKKind.Text) {
return (
<div className="mb-3 h-min w-full px-3">
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
<div className="flex h-10 items-center justify-between">
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-blue-500 text-xs ring-2 ring-neutral-50 dark:ring-neutral-950">
<ReplyIcon className="h-4 w-4 text-white" />
</div>
<div className="flex flex-1 items-center justify-between">
<div className="inline-flex items-center gap-1.5">
<User pubkey={event.pubkey} variant="notify" />
<p className="text-neutral-700 dark:text-neutral-300">replied</p>
</div>
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="w-full px-3">
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
{thread?.replyEventId ? (
<ChildNote id={thread?.replyEventId} />
) : thread?.rootEventId ? (
<ChildNote id={thread?.rootEventId} isRoot />
) : null}
<button
type="button"
onClick={() =>
addWidget.mutate({
kind: WIDGET_KIND.thread,
title: 'Thread',
content: thread.replyEventId
? thread.replyEventId
: thread.rootEventId,
})
}
className="self-start text-blue-500 hover:text-blue-600"
>
Show full thread
</button>
</div>
</div>
<TextKind content={event.content} textmode />
</div>
</div>
</div>
);
}
}
export const MemoizedNotifyNote = memo(NotifyNote);

View File

@@ -1,27 +1,19 @@
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import {
MediaControlBar,
MediaController,
MediaFullscreenButton,
MediaLoadingIndicator,
MediaMuteButton,
MediaPlayButton,
MediaTimeRange,
} from 'media-chrome/dist/react';
DefaultVideoLayout,
defaultLayoutIcons,
} from '@vidstack/react/player/layouts/default';
export function VideoPreview({ url }: { url: string }) {
return (
<MediaController
key={url}
className="my-2 aspect-video w-full overflow-hidden rounded-lg"
<MediaPlayer
src={url}
className="my-2 w-full overflow-hidden rounded-lg"
aspectRatio="16/9"
load="visible"
>
<video slot="media" src={url} preload="metadata" muted />
<MediaLoadingIndicator slot="centered-chrome"></MediaLoadingIndicator>
<MediaControlBar>
<MediaPlayButton></MediaPlayButton>
<MediaTimeRange></MediaTimeRange>
<MediaMuteButton></MediaMuteButton>
<MediaFullscreenButton></MediaFullscreenButton>
</MediaControlBar>
</MediaController>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
}

View File

@@ -50,7 +50,7 @@ export function NoteReplyForm({ rootEvent }: { rootEvent: NDKEvent }) {
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this post..."
className="h-28 w-full resize-none rounded-t-xl bg-neutral-100 px-5 py-4 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-400"
className="h-28 w-full resize-none rounded-t-xl border-transparent bg-neutral-100 px-5 py-4 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
spellCheck={false}
/>
<div className="inline-flex items-center justify-end gap-2 rounded-b-xl p-2">

View File

@@ -1,16 +1,16 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { memo } from 'react';
import { twMerge } from 'tailwind-merge';
import { ChildNote, NoteActions } from '@shared/notes';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { useNostr } from '@utils/hooks/useNostr';
import { useRichContent } from '@utils/hooks/useRichContent';
import { useWidget } from '@utils/hooks/useWidget';
export function TextNote({ event }: { event: NDKEvent }) {
export function TextNote({ event, className }: { event: NDKEvent; className?: string }) {
const { parsedContent } = useRichContent(event.content);
const { addWidget } = useWidget();
const { getEventThread } = useNostr();
@@ -18,7 +18,7 @@ export function TextNote({ event }: { event: NDKEvent }) {
const thread = getEventThread(event.tags);
return (
<div className="mb-3 h-min w-full px-3">
<div className={twMerge('mb-3 h-min w-full px-3', className)}>
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
{thread ? (

View File

@@ -33,13 +33,13 @@ export function TitleBar({
<div className="col-span-1 flex justify-center">
{id === '9999' ? (
<div className="isolate flex -space-x-2">
{db.account.circles
{db.account.contacts
?.slice(0, 8)
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
{db.account.circles?.length > 8 ? (
{db.account.contacts?.length > 8 ? (
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
<span className="text-[8px] font-medium">
+{db.account.circles?.length - 8}
+{db.account.contacts?.length - 8}
</span>
</div>
) : null}

View File

@@ -4,7 +4,7 @@ import { minidenticon } from 'minidenticons';
import { memo, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { RepostIcon, WorldIcon } from '@shared/icons';
import { RepostIcon } from '@shared/icons';
import { NIP05 } from '@shared/nip05';
import { MoreActions } from '@shared/notes';
@@ -40,20 +40,33 @@ export const User = memo(function User({
embedProfile?: string;
subtext?: string;
}) {
const { status, user } = useProfile(pubkey, embedProfile);
const { isLoading, user } = useProfile(pubkey, embedProfile);
const createdAt = useMemo(() => formatCreatedAt(time, variant === 'chat'), [pubkey]);
const svgURI = useMemo(
const createdAt = useMemo(() => formatCreatedAt(time, variant === 'chat'), [time]);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
[pubkey]
);
if (variant === 'mention') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<div className="h-6 w-6 shrink-0 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3.5 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div>
</div>
);
}
@@ -70,7 +83,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
@@ -78,10 +91,7 @@ export const User = memo(function User({
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
displayNpub(pubkey, 16)}
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
@@ -91,11 +101,19 @@ export const User = memo(function User({
}
if (variant === 'notify') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<div className="h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3.5 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
</div>
);
}
@@ -112,31 +130,27 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
displayNpub(pubkey, 16)}
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
</div>
);
}
if (variant === 'large') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-2.5">
<div className="h-14 w-14 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div>
<div className="flex flex-col gap-1.5">
<div className="h-3.5 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
@@ -150,44 +164,30 @@ export const User = memo(function User({
alt={pubkey}
loading="lazy"
decoding="async"
className="h-14 w-14 rounded-lg object-cover"
className="h-11 w-11 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-14 w-14 rounded-lg bg-black dark:bg-white"
className="h-11 w-11 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex h-full flex-col items-start justify-between">
<div className="flex flex-col items-start gap-1 text-start">
<p className="max-w-[15rem] truncate text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName}
</p>
<p className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500">
{user?.about || user?.bio || 'No bio'}
</p>
</div>
<div className="flex flex-col gap-2">
{user?.website ? (
<Link
to={user?.website}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-neutral-900 dark:text-neutral-100/70"
>
<WorldIcon className="h-4 w-4" />
<p className="max-w-[10rem] truncate">{user?.website}</p>
</Link>
) : null}
</div>
<div className="flex flex-col items-start text-start">
<p className="max-w-[15rem] truncate text-lg font-semibold">
{user?.name || user?.display_name || user?.displayName}
</p>
<p className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert">
{user?.about || user?.bio || 'No bio'}
</p>
</div>
</div>
);
}
if (variant === 'simple') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-2.5">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
@@ -211,7 +211,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
@@ -222,7 +222,7 @@ export const User = memo(function User({
{user?.name || user?.display_name || user?.displayName}
</h3>
<p className="max-w-[10rem] truncate text-sm text-neutral-900 dark:text-neutral-100/70">
{user?.nip05 || user?.username || displayNpub(pubkey, 16)}
{user?.nip05 || user?.username || fallbackName}
</p>
</div>
</div>
@@ -230,7 +230,7 @@ export const User = memo(function User({
}
if (variant === 'avatar') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="h-12 w-12 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
);
@@ -247,7 +247,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-12 w-12 rounded-lg bg-black dark:bg-white"
/>
@@ -257,7 +257,7 @@ export const User = memo(function User({
}
if (variant === 'miniavatar') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
);
@@ -274,7 +274,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
@@ -284,9 +284,23 @@ export const User = memo(function User({
}
if (variant === 'childnote') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black object-cover dark:bg-white"
/>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">{fallbackName} </div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
@@ -302,7 +316,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
@@ -310,10 +324,7 @@ export const User = memo(function User({
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">
{user?.display_name ||
user?.name ||
user?.displayName ||
displayNpub(pubkey, 16)}{' '}
{user?.display_name || user?.name || user?.displayName || fallbackName}{' '}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
@@ -324,7 +335,7 @@ export const User = memo(function User({
}
if (variant === 'stacked') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="inline-block h-8 w-8 animate-pulse rounded-full bg-neutral-300 ring-1 ring-neutral-200 dark:bg-neutral-700 dark:ring-neutral-800" />
);
@@ -341,7 +352,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="inline-block h-8 w-8 rounded-full bg-black ring-1 ring-neutral-200 dark:bg-white dark:ring-neutral-800"
/>
@@ -351,7 +362,7 @@ export const User = memo(function User({
}
if (variant === 'ministacked') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="inline-block h-6 w-6 animate-pulse rounded-full bg-neutral-300 ring-1 ring-white dark:ring-black" />
);
@@ -368,7 +379,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="inline-block h-6 w-6 rounded-full bg-black ring-1 ring-white dark:bg-white dark:ring-black"
/>
@@ -378,7 +389,7 @@ export const User = memo(function User({
}
if (variant === 'repost') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex gap-3">
<div className="inline-flex h-10 w-10 items-center justify-center">
@@ -404,11 +415,11 @@ export const User = memo(function User({
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
@@ -416,10 +427,7 @@ export const User = memo(function User({
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name ||
user?.display_name ||
user?.displayName ||
displayNpub(pubkey, 16)}
{user?.name || user?.display_name || user?.displayName || fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
@@ -429,13 +437,13 @@ export const User = memo(function User({
}
if (variant === 'thread') {
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-3">
<div className="flex h-16 items-center gap-3 px-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col gap-1">
<div className="h-4 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
@@ -449,11 +457,11 @@ export const User = memo(function User({
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
className="h-10 w-10 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
@@ -466,19 +474,27 @@ export const User = memo(function User({
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<span>{createdAt}</span>
<span>·</span>
<span>{displayNpub(pubkey, 16)}</span>
<span>{fallbackName}</span>
</div>
</div>
</div>
);
}
if (status === 'pending') {
if (isLoading) {
return (
<div className="flex items-center gap-3 px-3">
<div className="h-9 w-9 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="h-4 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
@@ -498,7 +514,7 @@ export const User = memo(function User({
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
@@ -507,10 +523,7 @@ export const User = memo(function User({
</HoverCard.Trigger>
<div className="flex h-6 flex-1 items-start gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name ||
user?.display_name ||
user?.displayName ||
displayNpub(pubkey, 16)}
{user?.name || user?.display_name || user?.displayName || fallbackName}
</div>
<div className="ml-auto inline-flex items-center gap-3">
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
@@ -530,11 +543,11 @@ export const User = memo(function User({
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
@@ -551,12 +564,12 @@ export const User = memo(function User({
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
nip05={user.nip05}
className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-300"
/>
) : (
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-300">
{displayNpub(pubkey, 16)}
{fallbackName}
</span>
)}
</div>

View File

@@ -61,7 +61,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
};
useEffect(() => {
if (db.account.follows.includes(pubkey)) {
if (db.account.contacts.includes(pubkey)) {
setFollowed(true);
}
}, []);

View File

@@ -12,8 +12,7 @@ import { MemoizedArticleNote } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function ArticleWidget({ widget }: { widget: Widget }) {
@@ -40,7 +39,7 @@ export function ArticleWidget({ widget }: { widget: Widget }) {
} else {
filter = {
kinds: [NDKKind.Article],
authors: db.account.circles,
authors: db.account.contacts,
};
}

View File

@@ -12,8 +12,7 @@ import { MemoizedFileNote } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function FileWidget({ widget }: { widget: Widget }) {
@@ -40,7 +39,7 @@ export function FileWidget({ widget }: { widget: Widget }) {
} else {
filter = {
kinds: [1063],
authors: db.account.circles,
authors: db.account.contacts,
};
}

View File

@@ -15,8 +15,7 @@ import {
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function GroupWidget({ widget }: { widget: Widget }) {

View File

@@ -10,8 +10,7 @@ import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
import { FETCH_LIMIT } from '@utils/constants';
import { Widget } from '@utils/types';
export function HashtagWidget({ widget }: { widget: Widget }) {

View File

@@ -16,7 +16,7 @@ import {
import { TitleBar } from '@shared/titleBar';
import { LiveUpdater, WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
import { FETCH_LIMIT } from '@utils/constants';
export function NewsfeedWidget() {
const { db } = useStorage();
@@ -39,7 +39,7 @@ export function NewsfeedWidget() {
relayUrls,
{
kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.circles,
authors: db.account.contacts,
},
FETCH_LIMIT,
{ asOf: pageParam === 0 ? undefined : pageParam, abortSignal: signal }

View File

@@ -11,8 +11,7 @@ import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { FETCH_LIMIT } from '@stores/constants';
import { FETCH_LIMIT } from '@utils/constants';
import { useNostr } from '@utils/hooks/useNostr';
import { sendNativeNotification } from '@utils/notification';
@@ -54,7 +53,6 @@ export function NotificationWidget() {
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
enabled: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,

View File

@@ -12,8 +12,7 @@ import {
} from '@shared/icons';
import { User } from '@shared/user';
import { WIDGET_KIND } from '@stores/constants';
import { WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
export function AddGroupFeeds({ currentWidgetId }: { currentWidgetId: string }) {
@@ -96,7 +95,7 @@ export function AddGroupFeeds({ currentWidgetId }: { currentWidgetId: string })
Users
</span>
<div className="flex h-[420px] flex-col overflow-y-auto rounded-xl bg-neutral-100 py-2 dark:bg-neutral-900">
{db.account.circles.map((item: string) => (
{db.account.contacts.map((item: string) => (
<button
key={item}
type="button"

View File

@@ -3,8 +3,7 @@ import { Resolver, useForm } from 'react-hook-form';
import { CancelIcon, GroupFeedsIcon, PlusIcon } from '@shared/icons';
import { HASHTAGS, WIDGET_KIND } from '@stores/constants';
import { HASHTAGS, WIDGET_KIND } from '@utils/constants';
import { useWidget } from '@utils/hooks/useWidget';
type FormValues = {

View File

@@ -30,12 +30,12 @@ export function LiveUpdater({ status }: { status: QueryStatus }) {
useEffect(() => {
let sub: NDKSubscription = undefined;
if (status === 'success' && db.account && db.account.circles.length > 0) {
if (status === 'success' && db.account && db.account?.follows?.length > 0) {
queryClient.fetchQuery({ queryKey: ['notification'] });
const filter: NDKFilter = {
kinds: [NDKKind.Text, NDKKind.Repost],
authors: db.account.circles,
authors: db.account.contacts,
since: Math.floor(Date.now() / 1000),
};

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