feat: add new account management

This commit is contained in:
2024-02-08 17:17:45 +07:00
parent d7bbda6e7b
commit 17052aeeaa
35 changed files with 1140 additions and 1484 deletions

View File

@@ -6,6 +6,7 @@
"dependencies": {
"@getalby/sdk": "^3.2.3",
"@lume/icons": "workspace:^",
"@lume/storage": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",

View File

@@ -4,8 +4,8 @@ import { invoke } from "@tauri-apps/api/core";
export class Ark {
public account: CurrentAccount;
constructor(account: CurrentAccount) {
this.account = account;
constructor() {
this.account = null;
}
public async event_to_bech32(id: string, relays: string[]) {
@@ -67,12 +67,12 @@ export class Ark {
};
}
public async get_metadata(id: string) {
public async get_profile(id: string) {
try {
const cmd: Metadata = await invoke("get_metadata", { id });
const cmd: Metadata = await invoke("get_profile", { id });
return cmd;
} catch (e) {
console.error("failed to get metadata", id);
console.error("failed to get profile", id);
}
}

View File

@@ -3,7 +3,6 @@ import { cn, editorAtom, editorValueAtom } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useSetAtom } from "jotai";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";

View File

@@ -31,7 +31,7 @@ export function UserAbout({ className }: { className?: string }) {
return (
<div className={cn("select-text break-p", className)}>
{user.about?.trim() || user.bio?.trim() || "No bio"}
{user.profile.about?.trim() || "No bio"}
</div>
);
}

View File

@@ -46,7 +46,7 @@ export function UserAvatar({ className }: { className?: string }) {
/>
) : (
<Avatar.Image
src={user.image}
src={user.profile.picture}
alt={user.pubkey}
loading="eager"
decoding="async"

View File

@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
);
}
if (user && !user.banner) {
if (user && !user.profile.banner) {
return (
<div
className={cn("bg-gradient-to-b from-sky-400 to-sky-200", className)}
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
return (
<img
src={user.banner}
src={user.profile.banner}
alt="banner"
loading="lazy"
decoding="async"

View File

@@ -17,7 +17,7 @@ export function UserName({ className }: { className?: string }) {
return (
<div className={cn("max-w-[12rem] truncate", className)}>
{user.displayName || user.name || "Anon"}
{user.profile.display_name || user.profile.name || "Anon"}
</div>
);
}

View File

@@ -1,26 +1,17 @@
import { VerifiedIcon } from "@lume/icons";
import { cn, displayNpub } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useArk } from "../../hooks/useArk";
import { useUserContext } from "./provider";
export function UserNip05({
pubkey,
className,
}: { pubkey: string; className?: string }) {
const ark = useArk();
export function UserNip05({ className }: { className?: string }) {
const user = useUserContext();
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.nip05],
queryKey: ["nip05", user?.profile.nip05],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
if (!user) return false;
if (!user.nip05) return false;
return ark.validateNIP05({
pubkey,
nip05: user.nip05,
signal,
});
if (!user.profile.nip05) return false;
return false;
},
enabled: !!user,
});
@@ -39,11 +30,11 @@ export function UserNip05({
return (
<div className="inline-flex items-center gap-1">
<p className={cn("text-sm", className)}>
{!user?.nip05
? displayNpub(pubkey, 16)
: user?.nip05?.startsWith("_@")
? user?.nip05?.replace("_@", "")
: user?.nip05}
{!user?.profile.nip05
? displayNpub(user.pubkey, 16)
: user?.profile.nip05?.startsWith("_@")
? user?.profile.nip05?.replace("_@", "")
: user?.profile.nip05}
</p>
{!isLoading && verified ? (
<VerifiedIcon className="size-4 text-teal-500" />

View File

@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { ReactNode, createContext, useContext } from "react";
import { useArk } from "../../hooks/useArk";
const UserContext = createContext<Metadata>(null);
const UserContext = createContext<{ pubkey: string; profile: Metadata }>(null);
export function UserProvider({
pubkey,
@@ -11,12 +11,12 @@ export function UserProvider({
embed,
}: { pubkey: string; children: ReactNode; embed?: string }) {
const ark = useArk();
const { data: user } = useQuery({
const { data: profile } = useQuery({
queryKey: ["user", pubkey],
queryFn: async () => {
if (embed) return JSON.parse(embed) as Metadata;
const profile = await ark.get_metadata(pubkey);
const profile = await ark.get_profile(pubkey);
if (!profile)
throw new Error(
@@ -32,7 +32,11 @@ export function UserProvider({
retry: 2,
});
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
return (
<UserContext.Provider value={{ pubkey, profile }}>
{children}
</UserContext.Provider>
);
}
export function useUserContext() {

View File

@@ -1,4 +0,0 @@
import { createContext } from "react";
import { type Ark } from "./ark";
export const LumeContext = createContext<Ark>(undefined);

View File

@@ -1,8 +1,8 @@
import { useContext } from "react";
import { LumeContext } from "../context";
import { ArkContext } from "../provider";
export const useArk = () => {
const context = useContext(LumeContext);
const context = useContext(ArkContext);
if (context === undefined) {
throw new Error("Please import Ark Provider to use useArk() hook");
}

View File

@@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useArk } from "./useArk";
export function useProfile(pubkey: string) {
@@ -10,7 +10,7 @@ export function useProfile(pubkey: string) {
} = useQuery({
queryKey: ["user", pubkey],
queryFn: async () => {
const profile = await ark.get_metadata(pubkey);
const profile = await ark.get_profile(pubkey);
if (!profile)
throw new Error(
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`,

View File

@@ -1,6 +1,4 @@
import { NDKKind, NDKTag } from "@nostr-dev-kit/ndk";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { normalizeRelayUrl } from "nostr-fetch";
import { useArk } from "./useArk";
export function useRelaylist() {

View File

@@ -1,5 +1,4 @@
export * from "./ark";
export * from "./context";
export * from "./provider";
export * from "./hooks/useEvent";
export * from "./hooks/useArk";

View File

@@ -1,18 +1,9 @@
import { PropsWithChildren, useEffect, useState } from "react";
import { PropsWithChildren, createContext, useMemo } from "react";
import { Ark } from "./ark";
import { LumeContext } from "./context";
export const LumeProvider = ({ children }: PropsWithChildren<object>) => {
const [ark, setArk] = useState<Ark>(undefined);
export const ArkContext = createContext<Ark>(undefined);
useEffect(() => {
async function setupArk() {
const _ark = new Ark();
setArk(_ark);
}
if (!ark) setupArk();
}, []);
return <LumeContext.Provider value={ark}>{children}</LumeContext.Provider>;
export const ArkProvider = ({ children }: PropsWithChildren<object>) => {
const ark = useMemo(() => new Ark(), []);
return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>;
};

View File

@@ -8,7 +8,7 @@
"access": "public"
},
"dependencies": {
"nostr-tools": "~1.17.0",
"@tauri-apps/plugin-store": "2.0.0-beta.0",
"react": "^18.2.0"
},
"devDependencies": {

View File

@@ -1,18 +1,15 @@
import { locale, platform } from "@tauri-apps/plugin-os";
import Database from "@tauri-apps/plugin-sql";
import { Store } from "@tauri-apps/plugin-store";
import { PropsWithChildren, createContext, useContext } from "react";
import { LumeStorage } from "./storage";
const StorageContext = createContext<LumeStorage>(null);
const sqliteAdapter = await Database.load("sqlite:lume_v3.db");
const store = new Store("lume.data");
const platformName = await platform();
const osLocale = await locale();
const db = new LumeStorage(sqliteAdapter, platformName, osLocale);
await db.init();
if (db.settings.depot) await db.launchDepot();
const db = new LumeStorage(store, platformName, osLocale);
export const StorageProvider = ({ children }: PropsWithChildren<object>) => {
return (

View File

@@ -1,458 +1,41 @@
import {
Account,
IColumn,
Interests,
NDKCacheEvent,
NDKCacheEventTag,
NDKCacheUser,
NDKCacheUserProfile,
} from "@lume/types";
import { invoke } from "@tauri-apps/api/core";
import { resolve, appConfigDir, resolveResource } from "@tauri-apps/api/path";
import { VITE_FLATPAK_RESOURCE } from "@lume/utils";
import { Platform } from "@tauri-apps/plugin-os";
import { Child, Command } from "@tauri-apps/plugin-shell";
import Database from "@tauri-apps/plugin-sql";
import { nip19 } from "nostr-tools";
import { Store } from "@tauri-apps/plugin-store";
export class LumeStorage {
#db: Database;
#depot: Child;
#store: Store;
readonly platform: Platform;
readonly locale: string;
public currentUser: Account;
public interests: Interests;
public nwc: string;
public settings: {
autoupdate: boolean;
nsecbunker: boolean;
media: boolean;
hashtag: boolean;
depot: boolean;
tunnelUrl: string;
lowPower: boolean;
translation: boolean;
translateApiKey: string;
instantZap: boolean;
defaultZapAmount: number;
};
constructor(db: Database, platform: Platform, locale: string) {
this.#db = db;
constructor(store: Store, platform: Platform, locale: string) {
this.#store = store;
this.locale = locale;
this.platform = platform;
this.interests = null;
this.nwc = null;
this.settings = {
autoupdate: false,
nsecbunker: false,
media: true,
hashtag: true,
depot: false,
tunnelUrl: "",
lowPower: false,
translation: false,
translateApiKey: "",
instantZap: false,
defaultZapAmount: 21,
};
}
public async init() {
const settings = await this.getAllSettings();
const account = await this.getActiveAccount();
if (account) {
this.currentUser = account;
this.interests = await this.getInterests();
}
for (const item of settings) {
if (item.value.length > 10) {
this.settings[item.key] = item.value;
} else {
this.settings[item.key] = !!parseInt(item.value);
}
}
}
async #keyring_save(key: string, value: string) {
return await invoke("secure_save", { key, value });
}
async #keyring_load(key: string) {
try {
const value: string = await invoke("secure_load", { key });
if (!value) return null;
return value;
} catch {
return null;
}
}
async #keyring_remove(key: string) {
return await invoke("secure_remove", { key });
}
public async launchDepot() {
const configPath =
VITE_FLATPAK_RESOURCE !== null
? await resolve("/", VITE_FLATPAK_RESOURCE)
: await resolveResource("resources/config.toml");
const dataPath = await appConfigDir();
const command = Command.sidecar("bin/depot", [
"-c",
configPath,
"-d",
dataPath,
]);
this.#depot = await command.spawn();
}
public checkDepot() {
if (this.#depot) return true;
return false;
}
public async stopDepot() {
if (this.#depot) return this.#depot.kill();
}
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) return null;
if (typeof results[0].profile === "string")
results[0].profile = JSON.parse(results[0].profile);
return results[0];
}
public async getCacheEvent(id: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
"SELECT * FROM ndk_events WHERE id = $1 ORDER BY id DESC LIMIT 1;",
[id],
);
if (!results.length) return null;
return results[0];
}
public async getCacheEvents(ids: string[]) {
const idsArr = `'${ids.join("','")}'`;
const results: Array<NDKCacheEvent> = await this.#db.select(
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`,
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByPubkey(pubkey: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
"SELECT * FROM ndk_events WHERE pubkey = $1 ORDER BY id;",
[pubkey],
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByKind(kind: number) {
const results: Array<NDKCacheEvent> = await this.#db.select(
"SELECT * FROM ndk_events WHERE kind = $1 ORDER BY id;",
[kind],
);
if (!results.length) return [];
return results;
}
public async getCacheEventsByKindAndAuthor(kind: number, pubkey: string) {
const results: Array<NDKCacheEvent> = await this.#db.select(
"SELECT * FROM ndk_events WHERE kind = $1 AND pubkey = $2 ORDER BY id;",
[kind, pubkey],
);
if (!results.length) return [];
return results;
}
public async getCacheEventTagsByTagValue(tagValue: string) {
const results: Array<NDKCacheEventTag> = await this.#db.select(
"SELECT * FROM ndk_eventtags WHERE tagValue = $1 ORDER BY id;",
[tagValue],
);
if (!results.length) return [];
return results;
}
public async setCacheEvent({
id,
pubkey,
content,
kind,
createdAt,
relay,
event,
}: NDKCacheEvent) {
return await this.#db.execute(
"INSERT OR IGNORE INTO ndk_events (id, pubkey, content, kind, createdAt, relay, event) VALUES ($1, $2, $3, $4, $5, $6, $7);",
[id, pubkey, content, kind, createdAt, relay, event],
);
}
public async setCacheEventTag({
id,
eventId,
tag,
value,
tagValue,
}: NDKCacheEventTag) {
return await this.#db.execute(
"INSERT OR IGNORE INTO ndk_eventtags (id, eventId, tag, value, tagValue) VALUES ($1, $2, $3, $4, $5);",
[id, eventId, tag, value, tagValue],
);
}
public async setCacheProfiles(profiles: Array<NDKCacheUser>) {
return await Promise.all(
profiles.map(
async (profile) =>
await this.#db.execute(
"INSERT OR IGNORE INTO ndk_users (pubkey, profile, createdAt) VALUES ($1, $2, $3);",
[profile.pubkey, profile.profile, profile.createdAt],
),
),
);
}
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) => ({
npub: nip19.npubEncode(item.pubkey),
...JSON.parse(item.profile as string),
}));
return users;
}
public async checkAccount() {
const result: Array<{ total: string }> = await this.#db.select(
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;',
);
return parseInt(result[0].total);
}
public async getActiveAccount() {
const results: Array<Account> = await this.#db.select(
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;',
);
if (results.length) {
this.currentUser = results[0];
return results[0];
}
return null;
}
public async createAccount({
pubkey,
privkey,
}: {
pubkey: string;
privkey?: string;
}) {
const existAccounts: Array<Account> = await this.#db.select(
"SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;",
[pubkey],
);
if (existAccounts.length) {
await this.#db.execute(
"UPDATE accounts SET is_active = '1' WHERE pubkey = $1;",
[pubkey],
);
} else {
await this.#db.execute(
"INSERT OR IGNORE INTO accounts (pubkey, is_active) VALUES ($1, $2);",
[pubkey, 1],
);
if (privkey) await this.#keyring_save(pubkey, privkey);
}
const account = await this.getActiveAccount();
return account;
}
/**
* Save private key to OS secure storage
* @deprecated this method will be remove in the next update
*/
public async createPrivkey(name: string, privkey: string) {
return await this.#keyring_save(name, privkey);
}
/**
* Load private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async loadPrivkey(name: string) {
return await this.#keyring_load(name);
}
/**
* Remove private key from OS secure storage
* @deprecated this method will be remove in the next update
*/
public async removePrivkey(name: string) {
return await this.#keyring_remove(name);
}
public async updateAccount(column: string, value: string) {
const insert = await this.#db.execute(
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
[value, this.currentUser.id],
);
if (insert) {
const account = await this.getActiveAccount();
return account;
}
}
public async getColumns() {
if (!this.currentUser) return [];
const columns: Array<IColumn> = await this.#db.select(
"SELECT * FROM columns WHERE account_id = $1 ORDER BY created_at DESC;",
[this.currentUser.id],
);
return columns;
}
public async createColumn(
kind: number,
title: string,
content: string | string[],
) {
const insert = await this.#db.execute(
"INSERT INTO columns (account_id, kind, title, content) VALUES ($1, $2, $3, $4);",
[this.currentUser.id, kind, title, content],
);
if (insert) {
const columns: Array<IColumn> = await this.#db.select(
"SELECT * FROM columns WHERE id = $1 ORDER BY id DESC LIMIT 1;",
[insert.lastInsertId],
);
if (!columns.length) console.error("get created widget failed");
return columns[0];
}
}
public async updateColumn(id: number, title: string, content: string) {
return await this.#db.execute(
"UPDATE columns SET title = $1, content = $2 WHERE id = $3;",
[title, content, id],
);
}
public async removeColumn(id: number) {
const res = await this.#db.execute("DELETE FROM columns WHERE id = $1;", [
id,
]);
if (res) return id;
}
public async createSetting(key: string, value: string | undefined) {
const currentSetting = await this.checkSettingValue(key);
if (!currentSetting) {
if (key !== "translateApiKey" && key !== "tunnelUrl")
this.settings[key] === !!parseInt(value);
return await this.#db.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);",
[key, value],
);
}
return await this.#db.execute(
"UPDATE settings SET value = $1 WHERE key = $2;",
[value, key],
);
}
public async getAllSettings() {
const results: { key: string; value: string }[] = await this.#db.select(
"SELECT * FROM settings ORDER BY id DESC;",
);
if (results.length < 1) return [];
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) return "0";
return results[0].value;
}
public async getInterests() {
const results: { key: string; value: string }[] = await this.#db.select(
"SELECT * FROM settings WHERE key = 'interests' ORDER BY id DESC LIMIT 1;",
);
if (!results.length) return null;
if (!results[0].value.length) return null;
return JSON.parse(results[0].value) as Interests;
}
public async clearCache() {
await this.#db.execute("DELETE FROM ndk_events;");
await this.#db.execute("DELETE FROM ndk_eventtags;");
await this.#db.execute("DELETE FROM ndk_users;");
}
public async clearProfileCache(pubkey: string) {
await this.#db.execute("DELETE FROM ndk_users WHERE pubkey = $1;", [
pubkey,
]);
}
public async logout() {
await this.createSetting("nsecbunker", "0");
await this.#db.execute(
"UPDATE accounts SET is_active = '0' WHERE id = $1;",
[this.currentUser.id],
);
this.currentUser = null;
this.nwc = null;
public async createSetting(key: string, value: string | boolean) {
this.settings[key] = value;
await this.#store.set(this.settings[key], { value });
}
}

View File

@@ -1,6 +1,7 @@
{
"name": "@lume/types",
"version": "0.0.0",
"main": "./index.d.ts",
"types": "./index.d.ts",
"private": true,
"license": "MIT",

View File

@@ -1,24 +1,21 @@
import { useStorage } from "@lume/storage";
import { cn } from "@lume/utils";
import { type Platform } from "@tauri-apps/plugin-os";
import { Outlet } from "react-router-dom";
import { Editor } from "../editor/column";
import { Navigation } from "../navigation";
import { SearchDialog } from "../search/dialog";
import { WindowTitleBar } from "../titlebar";
export function AppLayout({ platform }: { platform: Platform }) {
export function AppLayout() {
const storage = useStorage();
return (
<div
className={cn(
"flex h-screen w-screen flex-col",
platform !== "macos" ? "bg-neutral-50 dark:bg-neutral-950" : "",
storage.platform !== "macos" ? "bg-neutral-50 dark:bg-neutral-950" : "",
)}
>
{platform === "windows" ? (
<WindowTitleBar platform={platform} />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div data-tauri-drag-region className="h-9 shrink-0" />
<div className="flex w-full h-full min-h-0">
<Navigation />
<Editor />

View File

@@ -1,9 +1,7 @@
import { ArrowLeftIcon, SettingsIcon } from "@lume/icons";
import { type Platform } from "@tauri-apps/plugin-os";
import { ArrowLeftIcon } from "@lume/icons";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { WindowTitleBar } from "../titlebar";
export function AuthLayout({ platform }: { platform: Platform }) {
export function AuthLayout() {
const location = useLocation();
const navigate = useNavigate();
@@ -11,11 +9,7 @@ export function AuthLayout({ platform }: { platform: Platform }) {
return (
<div className="flex flex-col w-screen h-screen bg-black text-neutral-50">
{platform === "windows" ? (
<WindowTitleBar platform={platform} />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div data-tauri-drag-region className="h-9 shrink-0" />
<div className="relative w-full h-full">
<div className="absolute top-8 z-10 flex items-center justify-between w-full px-9">
{canGoBack ? (