Make Lume Faster (#208)

* chore: fix some lint issues

* feat: refactor contact list

* feat: refactor relay hint

* feat: add missing commands

* feat: use new cache layer for react query

* feat: refactor column

* feat: improve relay hint

* fix: replace break with continue in parser

* refactor: publish function

* feat: add reply command

* feat: improve editor

* fix: quote

* chore: update deps

* refactor: note component

* feat: improve repost

* feat: improve cache

* fix: backup screen

* refactor: column manager
This commit is contained in:
雨宮蓮
2024-06-17 13:52:06 +07:00
committed by GitHub
parent 7c99ed39e4
commit 843895d876
79 changed files with 1738 additions and 1975 deletions

View File

@@ -125,3 +125,4 @@ export * from "./src/key";
export * from "./src/remote";
export * from "./src/nsfw";
export * from "./src/visit";
export * from "./src/pow";

View File

@@ -1,19 +1,9 @@
export function PlusSquareIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 15v-3m0 0V9m0 3H9m3 0h3m-3 9c-2.796 0-4.193 0-5.296-.457a6 6 0 01-3.247-3.247C3 16.194 3 14.796 3 12c0-2.796 0-4.193.457-5.296a6 6 0 013.247-3.247C7.807 3 9.204 3 12 3c2.796 0 4.194 0 5.296.457a6 6 0 013.247 3.247C21 7.807 21 9.204 21 12c0 2.796 0 4.194-.457 5.296a6 6 0 01-3.247 3.247C16.194 21 14.796 21 12 21z"
fill="currentColor"
d="m4.842 20.032.34-.668-.34.668Zm-.874-.874.668-.34-.668.34Zm16.064 0-.668-.34.668.34Zm-.874.874-.34-.668.34.668Zm.874-15.19-.668.34.668-.34Zm-.874-.874-.34.668.34-.668Zm-15.19.874-.668-.34.668.34Zm.874-.874-.34-.668.34.668ZM15.25 12.75a.75.75 0 0 0 0-1.5v1.5Zm-6.493-1.5a.75.75 0 0 0 0 1.5v-1.5Zm2.493 3.993a.75.75 0 0 0 1.5 0h-1.5Zm1.5-6.485a.75.75 0 0 0-1.5 0h1.5ZM19.5 6.95v10.1H21V6.95h-1.5ZM17.05 19.5H6.95V21h10.1v-1.5ZM4.5 17.05V6.95H3v10.1h1.5ZM6.95 4.5h10.1V3H6.95v1.5Zm0 15c-.572 0-.957 0-1.253-.025-.287-.023-.424-.065-.514-.111L4.502 20.7c.337.172.693.24 1.073.27.371.03.827.03 1.375.03v-1.5ZM3 17.05c0 .548 0 1.003.03 1.375.03.38.098.736.27 1.073l1.336-.68c-.046-.091-.088-.228-.111-.516-.024-.295-.025-.68-.025-1.252H3Zm2.183 2.314a1.25 1.25 0 0 1-.547-.547l-1.336.681A2.75 2.75 0 0 0 4.502 20.7l.68-1.336ZM19.5 17.05c0 .572 0 .957-.025 1.252-.023.288-.065.425-.111.515l1.336.681c.172-.337.24-.693.27-1.073.03-.372.03-.827.03-1.375h-1.5ZM17.05 21c.548 0 1.003 0 1.375-.03.38-.03.736-.098 1.073-.27l-.68-1.336c-.091.046-.228.088-.516.111-.295.024-.68.025-1.252.025V21Zm2.314-2.183a1.25 1.25 0 0 1-.547.547l.681 1.336a2.751 2.751 0 0 0 1.202-1.2l-1.336-.681ZM21 6.95c0-.548 0-1.004-.03-1.375-.03-.38-.098-.736-.27-1.073l-1.336.68c.046.091.088.228.111.515.024.296.025.68.025 1.253H21ZM17.05 4.5c.572 0 .957 0 1.252.025.288.023.425.065.515.111l.681-1.336c-.337-.172-.693-.24-1.073-.27C18.053 3 17.598 3 17.05 3v1.5Zm3.65.002A2.75 2.75 0 0 0 19.5 3.3l-.681 1.336c.235.12.426.311.546.547l1.336-.681ZM4.5 6.95c0-.572 0-.957.025-1.253.023-.287.065-.424.111-.514L3.3 4.502c-.172.337-.24.693-.27 1.073C3 5.946 3 6.402 3 6.95h1.5ZM6.95 3c-.548 0-1.004 0-1.375.03-.38.03-.736.098-1.073.27l.68 1.336c.091-.046.228-.088.515-.111.296-.024.68-.025 1.253-.025V3ZM4.636 5.183a1.25 1.25 0 0 1 .547-.547L4.502 3.3A2.75 2.75 0 0 0 3.3 4.502l1.336.68ZM15.25 11.25H8.757v1.5h6.493v-1.5Zm-2.5 3.993V8.758h-1.5v6.485h1.5Z"
/>
</svg>
);

View File

@@ -0,0 +1,19 @@
export function PowIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M19 2.75a2.25 2.25 0 1 1 0 4.5 2.25 2.25 0 0 1 0-4.5ZM5.567 18.434a6.196 6.196 0 0 1 0-8.763L9.4 5.837a1.807 1.807 0 1 1 2.556 2.556l-3.834 3.834a2.582 2.582 0 1 0 3.651 3.65l3.834-3.833a1.807 1.807 0 0 1 2.556 2.556l-3.834 3.834a6.177 6.177 0 0 1-4.382 1.815 6.177 6.177 0 0 1-4.381-1.815Z"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
d="m13.965 13.687 2.556 2.556M7.758 7.48l2.556 2.556"
/>
</svg>
);
}

View File

@@ -5,7 +5,9 @@
"main": "./src/index.ts",
"dependencies": {
"@lume/utils": "workspace:^",
"@tanstack/react-query": "^5.40.1",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/react-query": "^5.45.0",
"nostr-tools": "^2.7.0",
"react": "^18.3.1"
},
"devDependencies": {

View File

@@ -1,5 +1,5 @@
import { Metadata } from "@lume/types";
import { Result, commands } from "./commands";
import type { Metadata } from "@lume/types";
import { type Result, commands } from "./commands";
import { Window } from "@tauri-apps/api/window";
export class NostrAccount {
@@ -123,24 +123,24 @@ export class NostrAccount {
const query = await commands.getBalance();
if (query.status === "ok") {
return parseInt(query.data);
return Number.parseInt(query.data);
} else {
return 0;
}
}
static async getContactList() {
const query = await commands.getContactList();
static async isContactListEmpty() {
const query = await commands.isContactListEmpty();
if (query.status === "ok") {
return query.data;
} else {
return [];
return true;
}
}
static async follow(pubkey: string, alias?: string) {
const query = await commands.follow(pubkey, alias);
static async checkContact(pubkey: string) {
const query = await commands.checkContact(pubkey);
if (query.status === "ok") {
return query.data;
@@ -149,8 +149,8 @@ export class NostrAccount {
}
}
static async unfollow(pubkey: string) {
const query = await commands.unfollow(pubkey);
static async toggleContact(pubkey: string, alias?: string) {
const query = await commands.toggleContact(pubkey, alias);
if (query.status === "ok") {
return query.data;

View File

@@ -76,6 +76,14 @@ try {
else return { status: "error", error: e as any };
}
},
async getPrivateKey(npub: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_private_key", { npub }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async connectRemoteAccount(uri: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("connect_remote_account", { uri }) };
@@ -140,9 +148,9 @@ try {
else return { status: "error", error: e as any };
}
},
async setContactList(pubkeys: string[]) : Promise<Result<boolean, string>> {
async setContactList(publicKeys: string[]) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_contact_list", { pubkeys }) };
return { status: "ok", data: await TAURI_INVOKE("set_contact_list", { publicKeys }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -156,17 +164,25 @@ try {
else return { status: "error", error: e as any };
}
},
async follow(id: string, alias: string | null) : Promise<Result<string, string>> {
async isContactListEmpty() : Promise<Result<boolean, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("follow", { id, alias }) };
return { status: "ok", data: await TAURI_INVOKE("is_contact_list_empty") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async unfollow(id: string) : Promise<Result<string, string>> {
async checkContact(hex: string) : Promise<Result<boolean, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("unfollow", { id }) };
return { status: "ok", data: await TAURI_INVOKE("check_contact", { hex }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async toggleContact(hex: string, alias: string | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("toggle_contact", { hex, alias }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -244,6 +260,14 @@ try {
else return { status: "error", error: e as any };
}
},
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEvent(id: string) : Promise<Result<RichEvent, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
@@ -252,6 +276,14 @@ try {
else return { status: "error", error: e as any };
}
},
async getEventFrom(id: string, relayHint: string) : Promise<Result<RichEvent, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_from", { id, relayHint }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
@@ -268,9 +300,17 @@ try {
else return { status: "error", error: e as any };
}
},
async getLocalEvents(pubkeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
async getLocalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { pubkeys, until }) };
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getGroupEvents(publicKeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_group_events", { publicKeys, until }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@@ -292,9 +332,17 @@ try {
else return { status: "error", error: e as any };
}
},
async publish(content: string, tags: string[][]) : Promise<Result<string, string>> {
async publish(content: string, warning: string | null, difficulty: number | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("publish", { content, tags }) };
return { status: "ok", data: await TAURI_INVOKE("publish", { content, warning, difficulty }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async reply(content: string, to: string, root: string | null) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("reply", { content, to, root }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };

View File

@@ -1,6 +1,11 @@
import type { EventWithReplies, Kind, Meta, NostrEvent } from "@lume/types";
import { commands } from "./commands";
import { generateContentTags } from "@lume/utils";
import type {
EventTag,
EventWithReplies,
Kind,
Meta,
NostrEvent,
} from "@lume/types";
import { type Result, commands } from "./commands";
export class LumeEvent {
public id: string;
@@ -10,8 +15,8 @@ export class LumeEvent {
public tags: string[][];
public content: string;
public sig: string;
public relay?: string;
public meta: Meta;
public relay?: string;
#raw: NostrEvent;
constructor(event: NostrEvent) {
@@ -19,49 +24,52 @@ export class LumeEvent {
Object.assign(this, event);
}
get isQuote() {
return this.tags.filter((tag) => tag[0] === "q").length > 0;
}
get isConversation() {
const tags = this.tags.filter(
(tag) => tag[0] === "e" && tag[3] !== "mention",
);
return tags.length > 0;
}
get mentions() {
return this.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]);
}
static getEventThread(tags: string[][], gossip = true) {
let root: string = null;
let reply: string = null;
get repostId() {
return this.tags.find((tag) => tag[0] === "e")[1];
}
// Get all event references from tags, ignore mention
const events = tags.filter((el) => el[0] === "e" && el[3] !== "mention");
get thread() {
let root: EventTag = null;
let reply: EventTag = null;
if (gossip) {
const relays = tags
.filter((el) => el[0] === "e" && el[2]?.length)
.map((tag) => tag[2]);
// Get all event references from tags, ignore mention.
const events = this.tags.filter(
(el) => el[0] === "e" && el[3] !== "mention",
);
if (relays.length >= 1) {
for (const relay of relays) {
try {
if (relay.length) {
const url = new URL(relay);
commands
.connectRelay(url.toString())
.then(() => console.log("[relay hint]: ", url));
}
} catch (e) {
console.log("[relay hint] error: ", relay);
}
}
if (events.length === 1) {
root = { id: events[0][1], relayHint: events[0][2] };
}
if (events.length === 2) {
root = { id: events[0][1], relayHint: events[0][2] };
reply = { id: events[1][1], relayHint: events[1][2] };
}
if (events.length > 2) {
for (const tag of events) {
if (tag[3] === "root") root = { id: tag[1], relayHint: tag[2] };
if (tag[3] === "reply") reply = { id: tag[1], relayHint: tag[2] };
}
}
if (events.length === 1) {
root = events[0][1];
}
if (events.length > 1) {
root = events.find((el) => el[3] === "root")?.[1] ?? events[0][1];
reply = events.find((el) => el[3] === "reply")?.[1] ?? events[1][1];
}
// Fix some rare case when root === reply
if (root && reply && root === reply) {
// Fix some rare case when root same as reply
if (root && reply && root.id === reply.id) {
reply = null;
}
@@ -71,7 +79,17 @@ export class LumeEvent {
};
}
static async getReplies(id: string) {
get quote() {
const tag = this.tags.filter(
(tag) => tag[0] === "q" || tag[3] === "mention",
);
const id = tag[0][1];
const relayHint = tag[0][2];
return { id, relayHint };
}
public async getReplies(id: string) {
const query = await commands.getReplies(id);
if (query.status === "ok") {
@@ -99,14 +117,6 @@ export class LumeEvent {
for (const tag of tags) {
const rootIndex = events.findIndex((el) => el.id === tag[1]);
// Relay Hint
if (tag[2]?.length) {
const url = new URL(tag[2]);
commands
.connectRelay(url.toString())
.then(() => console.log("[relay hint]: ", url));
}
if (rootIndex !== -1) {
const rootEvent = events[rootIndex];
@@ -129,63 +139,8 @@ export class LumeEvent {
}
}
static async publish(
content: string,
reply_to?: string,
quote?: boolean,
nsfw?: boolean,
) {
const g = await generateContentTags(content);
const eventContent = g.content;
const eventTags = g.tags;
if (reply_to) {
const queryReply = await commands.getEvent(reply_to);
if (queryReply.status === "ok") {
const replyEvent = JSON.parse(queryReply.data.raw) as NostrEvent;
const relayHint =
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
if (quote) {
eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
eventTags.push(["q", replyEvent.id]);
} else {
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
if (rootEvent) {
eventTags.push([
"e",
rootEvent[1],
rootEvent[2] || relayHint,
"root",
]);
}
eventTags.push(["e", replyEvent.id, relayHint, "reply"]);
eventTags.push(["p", replyEvent.pubkey]);
}
}
}
if (nsfw) {
eventTags.push(["L", "content-warning"]);
eventTags.push(["l", "reason", "content-warning"]);
eventTags.push(["content-warning", "nsfw"]);
}
const query = await commands.publish(eventContent, eventTags);
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
static async zap(id: string, amount: number, message: string) {
const query = await commands.zapEvent(id, amount.toString(), message);
public async zap(amount: number, message: string) {
const query = await commands.zapEvent(this.id, amount.toString(), message);
if (query.status === "ok") {
return query.data;
@@ -223,4 +178,26 @@ export class LumeEvent {
throw new Error(query.error);
}
}
static async publish(
content: string,
warning?: string,
difficulty?: number,
reply_to?: string,
root_to?: string,
) {
let query: Result<string, string>;
if (reply_to) {
query = await commands.reply(content, reply_to, root_to);
} else {
query = await commands.publish(content, warning, difficulty);
}
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
}
}

View File

@@ -1,12 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { NostrQuery } from "../query";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
export function useEvent(id: string) {
export function useEvent(id: string, relayHint?: string) {
const { isLoading, isError, data } = useQuery({
queryKey: ["event", id],
queryFn: async () => {
try {
const event = await NostrQuery.getEvent(id);
const event = await NostrQuery.getEvent(id, relayHint);
return event;
} catch (e) {
throw new Error(e);
@@ -17,6 +18,10 @@ export function useEvent(id: string) {
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
persister: experimental_createPersister({
storage: localStorage,
maxAge: 1000 * 60 * 60 * 12, // 12 hours
}),
});
return { isLoading, isError, data };

View File

@@ -1,53 +0,0 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { commands } from "../commands";
import { dedupEvents } from "../dedup";
import { NostrEvent } from "@lume/types";
export function useInfiniteEvents(
contacts: string[],
label: string,
account: string,
nsfw?: boolean,
) {
const pubkeys = contacts;
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: [label, account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
try {
const until: string = pageParam > 0 ? pageParam.toString() : undefined;
const query = await commands.getLocalEvents(pubkeys, until);
if (query.status === "ok") {
const nostrEvents = query.data as unknown as NostrEvent[];
const events = dedupEvents(nostrEvents, nsfw);
return events;
} else {
throw new Error(query.error);
}
} catch (e) {
throw new Error(e);
}
},
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
return {
data,
isLoading,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
};
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { commands } from "../commands";
import { experimental_createPersister } from "@tanstack/query-persist-client-core";
export function useProfile(pubkey: string, embed?: string) {
const {
@@ -8,7 +9,7 @@ export function useProfile(pubkey: string, embed?: string) {
isError,
data: profile,
} = useQuery({
queryKey: ["user", pubkey],
queryKey: ["profile", pubkey],
queryFn: async () => {
try {
if (embed) return JSON.parse(embed) as Metadata;
@@ -30,6 +31,10 @@ export function useProfile(pubkey: string, embed?: string) {
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
persister: experimental_createPersister({
storage: localStorage,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
}),
});
return { isLoading, isError, profile };

View File

@@ -4,5 +4,4 @@ export * from "./query";
export * from "./window";
export * from "./commands";
export * from "./hooks/useEvent";
export * from "./hooks/useInfiniteEvents";
export * from "./hooks/useProfile";

View File

@@ -5,13 +5,15 @@ import type {
Relay,
Settings,
} from "@lume/types";
import { commands } from "./commands";
import { type Result, type RichEvent, commands } from "./commands";
import { resolveResource } from "@tauri-apps/api/path";
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { relaunch } from "@tauri-apps/plugin-process";
import { nip19 } from "nostr-tools";
import { LumeEvent } from "./event";
enum NSTORE_KEYS {
settings = "lume_user_settings",
@@ -19,6 +21,24 @@ enum NSTORE_KEYS {
}
export class NostrQuery {
static #toLumeEvents(richEvents: RichEvent[]) {
const events = richEvents.map((item) => {
const nostrEvent = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) {
nostrEvent.meta = item.parsed;
} else {
nostrEvent.meta = null;
}
const lumeEvent = new LumeEvent(nostrEvent);
return lumeEvent;
});
return events;
}
static async upload(filePath?: string) {
const allowExts = [
"png",
@@ -78,7 +98,9 @@ export class NostrQuery {
const query = await commands.getNotifications();
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const data = query.data.map((item) => JSON.parse(item) as NostrEvent);
const events = data.map((ev) => new LumeEvent(ev));
return events;
} else {
console.error(query.error);
@@ -98,9 +120,32 @@ export class NostrQuery {
}
}
static async getEvent(id: string) {
const normalize: string = id.replace("nostr:", "").replace(/[^\w\s]/gi, "");
const query = await commands.getEvent(normalize);
static async getEvent(id: string, hint?: string) {
// Validate ID
const normalizeId: string = id
.replace("nostr:", "")
.replace(/[^\w\s]/gi, "");
// Define query
let query: Result<RichEvent, string>;
let relayHint: string = hint;
if (normalizeId.startsWith("nevent1")) {
const decoded = nip19.decode(normalizeId);
if (decoded.type === "nevent") relayHint = decoded.data.relays[0];
}
// Build query
if (relayHint) {
try {
const url = new URL(relayHint);
query = await commands.getEventFrom(normalizeId, url.toString());
} catch {
query = await commands.getEvent(normalizeId);
}
} else {
query = await commands.getEvent(normalizeId);
}
if (query.status === "ok") {
const data = query.data;
@@ -110,13 +155,46 @@ export class NostrQuery {
raw.meta = data.parsed;
}
return raw;
const event = new LumeEvent(raw);
return event;
} else {
console.log("[getEvent]: ", query.error);
return null;
}
}
static async getRepostEvent(event: LumeEvent) {
try {
const embed: NostrEvent = JSON.parse(event.content);
const query = await commands.getEventMeta(embed.content);
if (query.status === "ok") {
embed.meta = query.data;
const lumeEvent = new LumeEvent(embed);
return lumeEvent;
}
} catch {
const query = await commands.getEvent(event.repostId);
if (query.status === "ok") {
const data = query.data;
const raw = JSON.parse(data.raw) as NostrEvent;
if (data?.parsed) {
raw.meta = data.parsed;
}
const event = new LumeEvent(raw);
return event;
} else {
console.log("[getRepostEvent]: ", query.error);
return null;
}
}
}
static async getUserEvents(pubkey: string, asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getEventsBy(pubkey, until);
@@ -140,9 +218,21 @@ export class NostrQuery {
}
}
static async getLocalEvents(pubkeys: string[], asOf?: number) {
static async getLocalEvents(asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getLocalEvents(pubkeys, until);
const query = await commands.getLocalEvents(until);
if (query.status === "ok") {
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
}
}
static async getGroupEvents(pubkeys: string[], asOf?: number) {
const until: string = asOf && asOf > 0 ? asOf.toString() : undefined;
const query = await commands.getGroupEvents(pubkeys, until);
if (query.status === "ok") {
const data = query.data.map((item) => {
@@ -211,8 +301,6 @@ export class NostrQuery {
}
static async verifyNip05(pubkey: string, nip05?: string) {
if (!nip05) return false;
const query = await commands.verifyNip05(pubkey, nip05);
if (query.status === "ok") {
@@ -299,7 +387,9 @@ export class NostrQuery {
return systemColumns;
}
return columns;
// Filter "open" column
// Reason: deprecated
return columns.filter((col) => col.label !== "open");
} else {
return systemColumns;
}

View File

@@ -1,8 +1,9 @@
import { NostrEvent } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import type { LumeEvent } from "./event";
import { commands } from "./commands";
export class LumeWindow {
static async openEvent(event: NostrEvent) {
static async openEvent(event: NostrEvent | LumeEvent) {
const eTags = event.tags.filter((tag) => tag[0] === "e" || tag[0] === "q");
const root: string =
eTags.find((el) => el[3] === "root")?.[1] ?? eTags[0]?.[1];
@@ -38,12 +39,18 @@ export class LumeWindow {
}
}
static async openEditor(reply_to?: string, quote = false) {
static async openEditor(reply_to?: string, quote?: string) {
let url: string;
if (reply_to) {
url = `/editor?reply_to=${reply_to}&quote=${quote}`;
} else {
url = `/editor?reply_to=${reply_to}`;
}
if (quote?.length) {
url = `/editor?quote=${quote}`;
}
if (!reply_to?.length && !quote?.length) {
url = "/editor";
}

View File

@@ -52,6 +52,11 @@ export interface EventWithReplies extends NostrEvent {
replies: Array<NostrEvent>;
}
export interface EventTag {
id: string;
relayHint: string;
}
export interface Metadata {
name?: string;
display_name?: string;

View File

@@ -25,7 +25,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
snapPointIndexes,
} = useSnapCarousel();
return (
<div className="group relative">
<div className="relative group">
<ul
ref={scrollRef}
className="relative flex overflow-auto snap-x scrollbar-none"
@@ -39,9 +39,10 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
</ul>
<div
aria-hidden
className="hidden group-hover:flex z-10 absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full justify-between items-center px-5"
className="absolute z-10 items-center justify-between hidden w-full px-5 transform -translate-x-1/2 -translate-y-1/2 group-hover:flex left-1/2 top-1/2"
>
<button
type="button"
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
@@ -51,6 +52,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
<ArrowLeftIcon className="size-6" />
</button>
<button
type="button"
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
@@ -60,7 +62,7 @@ export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
<ArrowRightIcon className="size-6" />
</button>
</div>
<div className="absolute top-3 right-3 flex justify-center bg-black mix-blend-multiply bg-opacity-20 backdrop-blur-sm h-6 w-12 items-center rounded-full text-sm font-medium text-white">
<div className="absolute flex items-center justify-center w-12 h-6 text-sm font-medium text-white bg-black rounded-full top-3 right-3 mix-blend-multiply bg-opacity-20 backdrop-blur-sm">
{activePageIndex + 1} / {pages.length}
</div>
</div>

View File

@@ -33,7 +33,7 @@ export function Spinner({
<span
aria-hidden
style={{ display: "contents", visibility: "hidden" }}
// Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
// biome-ignore lint/correctness/noConstantCondition: Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
{...{ inert: true ? "" : undefined }}
>
{children}

View File

@@ -2,16 +2,6 @@ export * from "./src/constants";
export * from "./src/delay";
export * from "./src/formater";
export * from "./src/editor";
export * from "./src/nip01";
export * from "./src/nip94";
export * from "./src/notification";
export * from "./src/cn";
export * from "./src/image";
export * from "./src/parser";
export * from "./src/groupBy";
export * from "./src/invoice";
export * from "./src/update";
// Hooks
export * from "./src/hooks/useNetworkStatus";
export * from "./src/hooks/useOpenGraph";

View File

@@ -8,7 +8,6 @@
"access": "public"
},
"dependencies": {
"@tanstack/react-query": "^5.40.1",
"bitcoin-units": "^1.0.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
@@ -17,7 +16,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"slate": "^0.103.0",
"slate-react": "^0.104.0"
"slate-react": "^0.105.0"
},
"devDependencies": {
"@lume/tsconfig": "workspace:^",

View File

@@ -1,21 +0,0 @@
export const groupBy = <T>(
array: T[],
predicate: (value: T, index: number, array: T[]) => string,
) =>
array.reduce(
(acc, value, index, array) => {
(acc[predicate(value, index, array)] ||= []).push(value);
return acc;
},
{} as { [key: string]: T[] },
);
export const groupByToMap = <T, Q>(
array: T[],
predicate: (value: T, index: number, array: T[]) => Q,
) =>
array.reduce((map, value, index, array) => {
const key = predicate(value, index, array);
map.get(key)?.push(value) ?? map.set(key, [value]);
return map;
}, new Map<Q, T[]>());

View File

@@ -1,25 +0,0 @@
import { useEffect, useState } from "react";
const getOnLineStatus = () =>
typeof navigator !== "undefined" && typeof navigator.onLine === "boolean"
? navigator.onLine
: true;
export function useNetworkStatus() {
const [status, setStatus] = useState(getOnLineStatus());
const setOnline = () => setStatus(true);
const setOffline = () => setStatus(false);
useEffect(() => {
window.addEventListener("online", setOnline);
window.addEventListener("offline", setOffline);
return () => {
window.removeEventListener("online", setOnline);
window.removeEventListener("offline", setOffline);
};
}, []);
return status;
}

View File

@@ -1,27 +0,0 @@
import type { Opengraph } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
export function useOpenGraph(url: string) {
const { isLoading, isError, data } = useQuery({
queryKey: ["opg", url],
queryFn: async () => {
try {
const res: Opengraph = await invoke("fetch_opg", { url });
return res;
} catch {
throw new Error("fetch preview failed");
}
},
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
return {
isLoading,
isError,
data,
};
}

View File

@@ -1,10 +0,0 @@
export function getImageMeta(
url: string,
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject();
img.src = url;
});
}

View File

@@ -7,10 +7,10 @@ export function decodeZapInvoice(tags?: string[][]) {
const decodedInvoice = decode(invoice);
const amountSection = decodedInvoice.sections.find(
(s: any) => s.name === "amount",
(s: { name: string }) => s.name === "amount",
);
const amount = parseInt(amountSection.value);
const amount = Number.parseInt(amountSection.value);
const displayValue = getBitcoinDisplayValues(amount);
return displayValue;

View File

@@ -1,96 +0,0 @@
import { nip19 } from "nostr-tools";
import type { EventPointer, ProfilePointer } from "nostr-tools/lib/types/nip19";
// Borrow from NDK
// https://github.com/nostr-dev-kit/ndk/blob/master/ndk/src/events/content-tagger.ts
export async function generateContentTags(content: string) {
const promises: Promise<void>[] = [];
const tags: string[][] = [];
const tagRegex = /(@|nostr:)(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]+/g;
const hashtagRegex = /#(\w+)/g;
const addTagIfNew = (t: string[]) => {
if (!tags.find((t2) => t2[0] === t[0] && t2[1] === t[1])) {
tags.push(t);
}
};
content = content.replace(tagRegex, (tag) => {
try {
const entity = tag.split(/(@|nostr:)/)[2];
const { type, data } = nip19.decode(entity);
let t: string[] | undefined;
switch (type) {
case "npub":
t = ["p", data as string];
break;
case "nprofile":
t = ["p", (data as ProfilePointer).pubkey as string];
break;
case "note":
promises.push(
new Promise(async (resolve) => {
addTagIfNew(["e", data, "", "mention"]);
resolve();
}),
);
break;
case "nevent":
promises.push(
new Promise(async (resolve) => {
let { id, relays, author } = data as EventPointer;
// If the nevent doesn't have a relay specified, try to get one
if (!relays || relays.length === 0) {
relays = [""];
}
addTagIfNew(["e", id, relays[0], "mention"]);
if (author) addTagIfNew(["p", author]);
resolve();
}),
);
break;
case "naddr":
promises.push(
new Promise(async (resolve) => {
const id = [data.kind, data.pubkey, data.identifier].join(":");
let relays = data.relays ?? [];
// If the naddr doesn't have a relay specified, try to get one
if (relays.length === 0) {
relays = [""];
}
addTagIfNew(["a", id, relays[0], "mention"]);
addTagIfNew(["p", data.pubkey]);
resolve();
}),
);
break;
default:
return tag;
}
if (t) addTagIfNew(t);
return `nostr:${entity}`;
} catch (error) {
return tag;
}
});
await Promise.all(promises);
content = content.replace(hashtagRegex, (tag, word) => {
const t: string[] = ["t", word];
if (!tags.find((t2) => t2[0] === t[0] && t2[1] === t[1])) {
tags.push(t);
}
return tag; // keep the original tag in the content
});
return { content, tags };
}

View File

@@ -1,15 +0,0 @@
export function fileType(url: string) {
if (url.match(/\.(jpg|jpeg|gif|png|webp|avif|tiff)$/)) {
return "image";
}
if (url.match(/\.(mp4|mov|webm|wmv|flv|mts|avi|ogv|mkv)$/)) {
return "video";
}
if (url.match(/\.(mp3|ogg|wav)$/)) {
return "audio";
}
return "link";
}

View File

@@ -1,16 +0,0 @@
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
export async function sendNativeNotification(content: string, title?: string) {
let permissionGranted = await isPermissionGranted();
if (!permissionGranted) {
const permission = await requestPermission();
permissionGranted = permission === "granted";
}
if (permissionGranted) {
sendNotification({ title: title || "Lume", body: content });
}
}

View File

@@ -1,78 +0,0 @@
import { Meta } from "@lume/types";
import { IMAGES, NOSTR_EVENTS, NOSTR_MENTIONS, VIDEOS } from "./constants";
import { fetch } from "@tauri-apps/plugin-http";
export async function parser(
content: string,
abortController?: AbortController,
) {
const words = content.split(/( |\n)/);
const urls = content.match(/(https?:\/\/\S+)/gi);
// Extract hashtags
const hashtags = words.filter((word) => word.startsWith("#"));
// Extract nostr events
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
// Extract nostr mentions
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
// Extract images and videos from content
const images: string[] = [];
const videos: string[] = [];
let text: string = content;
if (urls) {
for (const url of urls) {
const ext = new URL(url).pathname.split(".")[1];
if (IMAGES.includes(ext)) {
text = text.replace(url, "");
images.push(url);
break;
}
if (VIDEOS.includes(ext)) {
text = text.replace(url, "");
videos.push(url);
break;
}
if (urls.length <= 3) {
try {
const res = await fetch(url, {
method: "HEAD",
priority: "high",
signal: abortController.signal,
// proxy: settings.proxy;
});
if (res.headers.get("Content-Type").startsWith("image")) {
text = text.replace(url, "");
images.push(url);
break;
}
} catch {
break;
}
}
}
}
const meta: Meta = {
content: text.trim(),
images,
videos,
events,
mentions,
hashtags,
};
return meta;
}