Move the event parser and dedup functions to Rust (#206)

* feat: improve js parser

* feat: move parser and dedup to rust

* fix: parser

* fix: get event function

* feat: improve parser performance (#207)

* feat: improve parser performance

* feat: add test for video parsing

* feat: finish new parser

---------

Co-authored-by: XIAO YU <xyzmhx@gmail.com>
This commit is contained in:
雨宮蓮 2024-06-12 08:27:53 +07:00 committed by GitHub
parent 1c20512ecc
commit 71be59b2e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 610 additions and 250 deletions

5
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

11
.idea/lume.iml Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src-tauri/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/src-tauri/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lume.iml" filepath="$PROJECT_DIR$/.idea/lume.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,4 +1,4 @@
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils";
import { cn } from "@lume/utils";
import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag";
@ -21,54 +21,42 @@ export function NoteContent({
className?: string;
}) {
const event = useNoteContext();
const data = useMemo(() => {
const { content, images, videos } = parser(event.content);
const words = content.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
let richContent: ReactNode[] | string = content;
const content = useMemo(() => {
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />;
});
// Get parsed meta
const { content, hashtags, events, mentions } = event.meta;
// Define rich content
let richContent: ReactNode[] | string = content;
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />;
});
}
for (const event of events) {
if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => (
<MentionNote key={event + index} eventId={event} />
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
}
if (events.length) {
for (const event of events) {
if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => (
<MentionNote key={event + index} eventId={event} />
));
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
for (const user of mentions) {
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
}
if (mentions.length) {
for (const user of mentions) {
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
}
@ -81,7 +69,7 @@ export function NoteContent({
href={match}
target="_blank"
rel="noreferrer"
className="line-clamp-1 text-blue-500 hover:text-blue-600"
className="text-blue-500 line-clamp-1 hover:text-blue-600"
>
{match}
</a>
@ -92,25 +80,26 @@ export function NoteContent({
<div key={nanoid()} className="h-3" />
));
return { content: richContent, images, videos };
return richContent;
} catch (e) {
return { content, images, videos };
console.log("[parser]: ", e);
return event.content;
}
}, []);
}, [event.content]);
return (
<div className="flex flex-col gap-2">
<div
className={cn(
"select-text text-[15px] text-pretty content-break overflow-hidden",
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
"select-text text-pretty content-break overflow-hidden",
event.content.length > 420 ? "max-h-[250px] gradient-mask-b-0" : "",
className,
)}
>
{data.content}
{content}
</div>
{data.images.length ? <Images urls={data.images} /> : null}
{data.videos.length ? <Videos urls={data.videos} /> : null}
{event.meta?.images.length ? <Images urls={event.meta.images} /> : null}
{event.meta?.videos.length ? <Videos urls={event.meta.videos} /> : null}
</div>
);
}

View File

@ -1,13 +1,4 @@
import type { Settings } from "@lume/types";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
cn,
} from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { cn } from "@lume/utils";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
@ -19,135 +10,85 @@ import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContentLarge({
compact = true,
className,
}: {
compact?: boolean;
className?: string;
}) {
const { settings }: { settings: Settings } = useRouteContext({
strict: false,
});
const event = useNoteContext();
const content = useMemo(() => {
const text = event.content.trim();
const words = text.split(/( |\n)/);
// @ts-ignore, kaboom !!!
let parsedContent: ReactNode[] = compact
? text.replace(/\n\s*\n/g, "\n")
: text;
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
// Get parsed meta
const { images, videos, hashtags, events, mentions } = event.meta;
if (events.length) {
for (const event of events) {
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
// Define rich content
let richContent: ReactNode[] | string = event.content;
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/gi,
(match, i) => {
try {
const url = new URL(match);
const ext = url.pathname.split(".")[1];
if (!settings.enhancedPrivacy) {
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
}
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
} catch {
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
}
},
);
if (compact) {
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
<div key={nanoid()} className="h-1.5" />
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, () => (
<Hashtag key={nanoid()} tag={hashtag} />
));
}
parsedContent = reactStringReplace(
parsedContent,
/[\r]?\n[\r]?\n/g,
(_, index) => <br key={event.id + "_br_" + index} />,
for (const event of events) {
richContent = reactStringReplace(richContent, event, (match, i) => (
<MentionNote key={match + i} eventId={event} />
));
}
for (const mention of mentions) {
richContent = reactStringReplace(richContent, mention, (match, i) => (
<MentionUser key={match + i} pubkey={mention} />
));
}
for (const image of images) {
richContent = reactStringReplace(richContent, image, (match, i) => (
<ImagePreview key={match + i} url={match} />
));
}
for (const video of videos) {
richContent = reactStringReplace(richContent, video, (match, i) => (
<VideoPreview key={match + i} url={match} />
));
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
(match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-500 line-clamp-1 hover:text-blue-600"
>
{match}
</a>
),
);
return parsedContent;
richContent = reactStringReplace(richContent, /(\r\n|\r|\n)+/g, () => (
<div key={nanoid()} className="h-3" />
));
return richContent;
} catch (e) {
return text;
console.log("[parser]: ", e);
return event.content;
}
}, []);
}, [event.content]);
return (
<div className={cn("select-text", className)}>
<div className="text-[15px] text-pretty content-break leading-normal">
{content}
</div>
<div
className={cn(
"select-text leading-normal text-pretty content-break",
className,
)}
>
{content}
</div>
);
}

View File

@ -3,7 +3,7 @@ import { Note } from "@/components/note";
import { User } from "@/components/user";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { NostrEvent } from "@lume/types";
import type { NostrEvent } from "@lume/types";
import { NostrQuery } from "@lume/system";
export function RepostNote({
@ -21,12 +21,7 @@ export function RepostNote({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed: NostrEvent = JSON.parse(event.content);
return embed;
}
const id = event.tags.find((el) => el[0] === "e")?.[1];
const id = event.tags.find((el) => el[0] === "e")[1];
const repostEvent = await NostrQuery.getEvent(id);
return repostEvent;
@ -50,27 +45,27 @@ export function RepostNote({
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
Reposted by
</div>
<User.Avatar className="size-6 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<User.Avatar className="object-cover rounded-full size-6 shrink-0 ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</User.Root>
</User.Provider>
{isLoading ? (
<div className="flex h-20 items-center justify-center gap-2">
<div className="flex items-center justify-center h-20 gap-2">
<Spinner />
Loading event...
</div>
) : isError || !repostEvent ? (
<div className="flex h-20 items-center justify-center">
<div className="flex items-center justify-center h-20">
Event not found within your current relay set
</div>
) : (
<Note.Provider event={repostEvent}>
<Note.Root>
<div className="px-3 h-14 flex items-center justify-between">
<div className="flex items-center justify-between px-3 h-14">
<Note.User />
<Note.Menu />
</div>
<Note.Content className="px-3" />
<div className="mt-3 flex items-center gap-4 h-14 px-3">
<div className="flex items-center gap-4 px-3 mt-3 h-14">
<Note.Open />
<Note.Reply />
<Note.Repost />

View File

@ -11,13 +11,17 @@
"rules": {
"recommended": true,
"style": {
"noNonNullAssertion": "warn"
"noNonNullAssertion": "warn",
"noUselessElse": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
},
"complexity": {
"noStaticOnlyClass": "off"
}
}
}

View File

@ -244,7 +244,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getEvent(id: string) : Promise<Result<string, string>> {
async getEvent(id: string) : Promise<Result<RichEvent, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
} catch (e) {
@ -252,7 +252,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getReplies(id: string) : Promise<Result<string[], string>> {
async getReplies(id: string) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
} catch (e) {
@ -260,7 +260,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getEventsBy(publicKey: string, asOf: string | null) : Promise<Result<string[], string>> {
async getEventsBy(publicKey: string, asOf: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, asOf }) };
} catch (e) {
@ -268,7 +268,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getLocalEvents(pubkeys: string[], until: string | null) : Promise<Result<string[], string>> {
async getLocalEvents(pubkeys: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { pubkeys, until }) };
} catch (e) {
@ -276,7 +276,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getGlobalEvents(until: string | null) : Promise<Result<string[], string>> {
async getGlobalEvents(until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_global_events", { until }) };
} catch (e) {
@ -284,7 +284,7 @@ try {
else return { status: "error", error: e as any };
}
},
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<string[], string>> {
async getHashtagEvents(hashtags: string[], until: string | null) : Promise<Result<RichEvent[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
} catch (e) {
@ -367,7 +367,9 @@ await TAURI_INVOKE("set_badge", { count });
/** user-defined types **/
export type Account = { npub: string; nsec: string }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null }
/** tauri-specta globals **/

View File

@ -1,4 +1,4 @@
import { EventWithReplies, Kind, NostrEvent } from "@lume/types";
import type { EventWithReplies, Kind, Meta, NostrEvent } from "@lume/types";
import { commands } from "./commands";
import { generateContentTags } from "@lume/utils";
@ -11,6 +11,7 @@ export class LumeEvent {
public content: string;
public sig: string;
public relay?: string;
public meta: Meta;
#raw: NostrEvent;
constructor(event: NostrEvent) {
@ -74,9 +75,17 @@ export class LumeEvent {
const query = await commands.getReplies(id);
if (query.status === "ok") {
const events = query.data.map(
(item) => JSON.parse(item) as EventWithReplies,
);
const events = query.data.map((item) => {
const raw = JSON.parse(item.raw) as EventWithReplies;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
if (events.length > 0) {
const replies = new Set();
@ -135,7 +144,7 @@ export class LumeEvent {
const queryReply = await commands.getEvent(reply_to);
if (queryReply.status === "ok") {
const replyEvent = JSON.parse(queryReply.data) as NostrEvent;
const replyEvent = JSON.parse(queryReply.data.raw) as NostrEvent;
const relayHint =
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";

View File

@ -1,18 +1,12 @@
import type { Event, NostrEvent } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { NostrQuery } from "../query";
export function useEvent(id: string) {
const { isLoading, isError, data } = useQuery({
queryKey: ["event", id],
queryFn: async () => {
try {
const eventId: string = id
.replace("nostr:", "")
.split("'")[0]
.split(".")[0];
const cmd: string = await invoke("get_event", { id: eventId });
const event: NostrEvent = JSON.parse(cmd);
const event = await NostrQuery.getEvent(id);
return event;
} catch (e) {
throw new Error(e);

View File

@ -1,10 +1,15 @@
import { LumeColumn, Metadata, NostrEvent, Relay, Settings } from "@lume/types";
import type {
LumeColumn,
Metadata,
NostrEvent,
Relay,
Settings,
} from "@lume/types";
import { 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 { dedupEvents } from "./dedup";
import { invoke } from "@tauri-apps/api/core";
import { relaunch } from "@tauri-apps/plugin-process";
@ -98,9 +103,16 @@ export class NostrQuery {
const query = await commands.getEvent(normalize);
if (query.status === "ok") {
const event: NostrEvent = JSON.parse(query.data);
return event;
const data = query.data;
const raw = JSON.parse(data.raw) as NostrEvent;
if (data?.parsed) {
raw.meta = data.parsed;
}
return raw;
} else {
console.log("[getEvent]: ", query.error);
return null;
}
}
@ -110,8 +122,19 @@ export class NostrQuery {
const query = await commands.getEventsBy(pubkey, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
return events;
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
return data;
} else {
return [];
}
@ -122,10 +145,19 @@ export class NostrQuery {
const query = await commands.getLocalEvents(pubkeys, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
return dedup;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
return data;
} else {
return [];
}
@ -136,10 +168,19 @@ export class NostrQuery {
const query = await commands.getGlobalEvents(until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
return dedup;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
return data;
} else {
return [];
}
@ -151,10 +192,19 @@ export class NostrQuery {
const query = await commands.getHashtagEvents(nostrTags, until);
if (query.status === "ok") {
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
const dedup = dedupEvents(events);
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
return dedup;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
return data;
} else {
return [];
}
@ -311,8 +361,7 @@ export class NostrQuery {
const query = await commands.getBootstrapRelays();
if (query.status === "ok") {
let relays: Relay[] = [];
console.log(query.data);
const relays: Relay[] = [];
for (const item of query.data) {
const line = item.split(",");

View File

@ -28,6 +28,15 @@ export enum Kind {
// #TODO: Add all nostr kinds
}
export interface Meta {
content: string;
images: string[];
videos: string[];
events: string[];
mentions: string[];
hashtags: string[];
}
export interface NostrEvent {
id: string;
pubkey: string;
@ -36,6 +45,7 @@ export interface NostrEvent {
tags: string[][];
content: string;
sig: string;
meta: Meta;
}
export interface EventWithReplies extends NostrEvent {

View File

@ -1,12 +1,31 @@
import { IMAGES, VIDEOS } from "./constants";
import { Meta } from "@lume/types";
import { IMAGES, NOSTR_EVENTS, NOSTR_MENTIONS, VIDEOS } from "./constants";
import { fetch } from "@tauri-apps/plugin-http";
export function parser(content: string) {
// Get clean content
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) {
@ -16,20 +35,44 @@ export function parser(content: string) {
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 trimContent = text.trim();
return {
content: trimContent,
const meta: Meta = {
content: text.trim(),
images,
videos,
events,
mentions,
hashtags,
};
return meta;
}

13
src-tauri/Cargo.lock generated
View File

@ -2707,6 +2707,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e"
[[package]]
name = "linkify"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
dependencies = [
"memchr",
]
[[package]]
name = "linux-keyutils"
version = "0.2.4"
@ -2786,12 +2795,15 @@ name = "lume"
version = "4.0.0"
dependencies = [
"cocoa",
"futures",
"keyring",
"keyring-search",
"linkify",
"monitor",
"nostr-sdk",
"objc",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"specta",
@ -2812,6 +2824,7 @@ dependencies = [
"tauri-plugin-upload",
"tauri-specta",
"tokio",
"url",
]
[[package]]

View File

@ -17,11 +17,11 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
monitor = { git = "https://github.com/ahkohd/tauri-toolkit", branch = "v2" }
tauri = { version = "2.0.0-beta", features = [
"unstable",
"tray-icon",
"macos-private-api",
"native-tls-vendored",
"protocol-asset",
"unstable",
"tray-icon",
"macos-private-api",
"native-tls-vendored",
"protocol-asset",
] }
tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
@ -40,6 +40,10 @@ tauri-plugin-decorum = "0.1.0"
specta = "^2.0.0-rc.12"
keyring = "2"
keyring-search = "0.2.0"
reqwest = "0.12.4"
url = "2.5.0"
futures = "0.3.30"
linkify = "0.10.0"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"

View File

@ -59,6 +59,7 @@
"fs:allow-read-file",
"theme:allow-set-theme",
"theme:allow-get-theme",
"http:default",
"shell:allow-open",
{
"identifier": "http:default",

View File

@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","splash","settings","search","nwc","activity","zap-*","event-*","user-*","editor-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-set-focus","window:allow-center","window:allow-minimize","window:allow-maximize","window:allow-set-size","window:allow-set-focus","window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}}
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","panel","splash","settings","search","nwc","activity","zap-*","event-*","user-*","editor-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","os:allow-os-type","updater:default","updater:allow-check","updater:allow-download-and-install","window:allow-start-dragging","window:allow-create","window:allow-close","window:allow-set-focus","window:allow-center","window:allow-minimize","window:allow-maximize","window:allow-set-size","window:allow-set-focus","window:allow-start-dragging","decorum:allow-show-snap-overlay","clipboard-manager:allow-write-text","clipboard-manager:allow-read-text","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:allow-open","dialog:allow-ask","dialog:allow-message","process:allow-restart","fs:allow-read-file","theme:allow-set-theme","theme:allow-get-theme","http:default","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"},{"path":"$RESOURCE/resources/*"}]}],"platforms":["linux","macOS","windows"]}}

View File

@ -1,11 +1,23 @@
use crate::Nostr;
use nostr_sdk::prelude::*;
use std::{str::FromStr, time::Duration};
use futures::future::join_all;
use nostr_sdk::prelude::*;
use serde::Serialize;
use specta::Type;
use tauri::State;
use crate::Nostr;
use crate::nostr::utils::{dedup_event, Meta, parse_event};
#[derive(Debug, Serialize, Type)]
pub struct RichEvent {
pub raw: String,
pub parsed: Option<Meta>,
}
#[tauri::command]
#[specta::specta]
pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<String, String> {
pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, String> {
let client = &state.client;
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
Ok(val) => match val {
@ -36,7 +48,14 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<String, Stri
{
Ok(events) => {
if let Some(event) = events.first() {
Ok(event.as_json())
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
@ -50,7 +69,7 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<String, Stri
#[tauri::command]
#[specta::specta]
pub async fn get_replies(id: &str, state: State<'_, Nostr>) -> Result<Vec<String>, String> {
pub async fn get_replies(id: &str, state: State<'_, Nostr>) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
match EventId::from_hex(id) {
@ -58,7 +77,21 @@ pub async fn get_replies(id: &str, state: State<'_, Nostr>) -> Result<Vec<String
let filter = Filter::new().kinds(vec![Kind::TextNote]).event(event_id);
match client.get_events_of(vec![filter], None).await {
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Ok(events) => {
let futures = events.into_iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
@ -72,7 +105,7 @@ pub async fn get_events_by(
public_key: &str,
as_of: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
match PublicKey::from_str(public_key) {
@ -88,7 +121,21 @@ pub async fn get_events_by(
.until(until);
match client.get_events_of(vec![filter], None).await {
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Ok(events) => {
let futures = events.into_iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
@ -102,7 +149,7 @@ pub async fn get_local_events(
pubkeys: Vec<String>,
until: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(until).unwrap(),
@ -128,7 +175,22 @@ pub async fn get_local_events(
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
.await
{
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Ok(events) => {
let dedup = dedup_event(&events, false);
let futures = dedup.into_iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
@ -138,7 +200,7 @@ pub async fn get_local_events(
pub async fn get_global_events(
until: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(until).unwrap(),
@ -154,7 +216,22 @@ pub async fn get_global_events(
.get_events_of(vec![filter], Some(Duration::from_secs(8)))
.await
{
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Ok(events) => {
let dedup = dedup_event(&events, false);
let futures = dedup.into_iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}
@ -165,7 +242,7 @@ pub async fn get_hashtag_events(
hashtags: Vec<&str>,
until: Option<&str>,
state: State<'_, Nostr>,
) -> Result<Vec<String>, String> {
) -> Result<Vec<RichEvent>, String> {
let client = &state.client;
let as_of = match until {
Some(until) => Timestamp::from_str(until).unwrap(),
@ -178,7 +255,22 @@ pub async fn get_hashtag_events(
.hashtags(hashtags);
match client.get_events_of(vec![filter], None).await {
Ok(events) => Ok(events.into_iter().map(|ev| ev.as_json()).collect()),
Ok(events) => {
let dedup = dedup_event(&events, false);
let futures = dedup.into_iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
Some(parse_event(&ev.content).await)
} else {
None
};
RichEvent { raw, parsed }
});
let rich_events = join_all(futures).await;
Ok(rich_events)
}
Err(err) => Err(err.to_string()),
}
}

View File

@ -1,5 +1,184 @@
use nostr_sdk::Event;
use std::collections::HashSet;
use std::str::FromStr;
use linkify::LinkFinder;
use nostr_sdk::{Alphabet, Event, SingleLetterTag, Tag, TagKind};
use reqwest::Client;
use serde::Serialize;
use specta::Type;
use url::Url;
#[derive(Debug, Serialize, Type)]
pub struct Meta {
pub content: String,
pub images: Vec<String>,
pub videos: Vec<String>,
pub events: Vec<String>,
pub mentions: Vec<String>,
pub hashtags: Vec<String>,
}
const NOSTR_EVENTS: [&str; 10] = [
"@nevent1",
"@note1",
"@nostr:note1",
"@nostr:nevent1",
"nostr:note1",
"note1",
"nostr:nevent1",
"nevent1",
"Nostr:note1",
"Nostr:nevent1",
];
const NOSTR_MENTIONS: [&str; 10] = [
"@npub1",
"nostr:npub1",
"nostr:nprofile1",
"nostr:naddr1",
"npub1",
"nprofile1",
"naddr1",
"Nostr:npub1",
"Nostr:nprofile1",
"Nostr:naddr1",
];
const IMAGES: [&str; 7] = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
const VIDEOS: [&str; 5] = ["mp4", "mov", "avi", "webm", "mkv"];
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
events.iter().max_by_key(|event| event.created_at())
}
pub fn dedup_event(events: &[Event], nsfw: bool) -> Vec<Event> {
let mut seen_ids = HashSet::new();
events
.iter()
.filter(|&event| {
let e = TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E));
let e_tags: Vec<&Tag> = event.tags.iter().filter(|el| el.kind() == e).collect();
let ids: Vec<&str> = e_tags.iter().filter_map(|tag| tag.content()).collect();
let is_dup = ids.iter().any(|id| seen_ids.contains(*id));
for id in &ids {
seen_ids.insert(*id);
}
if nsfw {
let w_tags: Vec<&Tag> = event
.tags
.iter()
.filter(|el| el.kind() == TagKind::ContentWarning)
.collect();
!is_dup && w_tags.is_empty()
} else {
!is_dup
}
})
.cloned()
.collect()
}
pub async fn parse_event(content: &str) -> Meta {
let words: Vec<_> = content.split_whitespace().collect();
let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
let urls: Vec<_> = finder.links(content).collect();
let hashtags = words
.iter()
.filter(|&&word| word.starts_with('#'))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let events = words
.iter()
.filter(|&&word| NOSTR_EVENTS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mentions = words
.iter()
.filter(|&&word| NOSTR_MENTIONS.iter().any(|&el| word.starts_with(el)))
.map(|&s| s.to_string())
.collect::<Vec<_>>();
let mut images = Vec::new();
let mut videos = Vec::new();
let mut text = content.to_string();
if !urls.is_empty() {
let client = Client::new();
for url in urls {
let url_str = url.as_str();
if let Ok(parsed_url) = Url::from_str(url_str) {
if let Some(ext) = parsed_url
.path_segments()
.and_then(|segments| segments.last().and_then(|s| s.split('.').last()))
{
if IMAGES.contains(&ext) {
text = text.replace(url_str, "");
images.push(url_str.to_string());
break;
}
if VIDEOS.contains(&ext) {
text = text.replace(url_str, "");
videos.push(url_str.to_string());
break;
}
}
// Check the content type of URL via HEAD request
if let Ok(res) = client.head(url_str).send().await {
if let Some(content_type) = res.headers().get("Content-Type") {
if content_type.to_str().unwrap_or("").starts_with("image") {
text = text.replace(url_str, "");
images.push(url_str.to_string());
break;
}
}
}
}
}
}
// Clean up the resulting content string to remove extra spaces
let cleaned_text = text.trim().to_string();
Meta {
content: cleaned_text,
events,
mentions,
hashtags,
images,
videos,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_parse_event() {
let content = "Check this image: https://example.com/image.jpg #cool @npub1";
let meta = parse_event(content).await;
assert_eq!(meta.content, "Check this image: #cool @npub1");
assert_eq!(meta.images, vec!["https://example.com/image.jpg"]);
assert_eq!(meta.videos, Vec::<String>::new());
assert_eq!(meta.hashtags, vec!["#cool"]);
assert_eq!(meta.mentions, vec!["@npub1"]);
}
#[tokio::test]
async fn test_parse_video() {
let content = "Check this video: https://example.com/video.mp4 #cool @npub1";
let meta = parse_event(content).await;
assert_eq!(meta.content, "Check this video: #cool @npub1");
assert_eq!(meta.images, Vec::<String>::new());
assert_eq!(meta.videos, vec!["https://example.com/video.mp4"]);
assert_eq!(meta.hashtags, vec!["#cool"]);
assert_eq!(meta.mentions, vec!["@npub1"]);
}
}