mirror of
https://github.com/luminous-devs/lume.git
synced 2024-09-29 16:30:55 +00:00
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:
parent
1c20512ecc
commit
71be59b2e9
5
.idea/.gitignore
vendored
Normal file
5
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
Normal 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
11
.idea/lume.iml
Normal 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
8
.idea/modules.xml
Normal 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
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -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 { type ReactNode, useMemo } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
import { Hashtag } from "./mentions/hashtag";
|
import { Hashtag } from "./mentions/hashtag";
|
||||||
@ -21,30 +21,21 @@ export function NoteContent({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const data = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
const { content, images, videos } = parser(event.content);
|
try {
|
||||||
const words = content.split(/( |\n)/);
|
// Get parsed meta
|
||||||
const hashtags = words.filter((word) => word.startsWith("#"));
|
const { content, hashtags, events, mentions } = event.meta;
|
||||||
const events = words.filter((word) =>
|
|
||||||
NOSTR_EVENTS.some((el) => word.startsWith(el)),
|
|
||||||
);
|
|
||||||
const mentions = words.filter((word) =>
|
|
||||||
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Define rich content
|
||||||
let richContent: ReactNode[] | string = content;
|
let richContent: ReactNode[] | string = content;
|
||||||
|
|
||||||
try {
|
|
||||||
if (hashtags.length) {
|
|
||||||
for (const hashtag of hashtags) {
|
for (const hashtag of hashtags) {
|
||||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
richContent = reactStringReplace(richContent, regex, (_, index) => {
|
||||||
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
return <Hashtag key={hashtag + index} tag={hashtag} />;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length) {
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (quote) {
|
if (quote) {
|
||||||
richContent = reactStringReplace(richContent, event, (_, index) => (
|
richContent = reactStringReplace(richContent, event, (_, index) => (
|
||||||
@ -56,9 +47,7 @@ export function NoteContent({
|
|||||||
richContent = reactStringReplace(richContent, event, () => null);
|
richContent = reactStringReplace(richContent, event, () => null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (mentions.length) {
|
|
||||||
for (const user of mentions) {
|
for (const user of mentions) {
|
||||||
if (mention) {
|
if (mention) {
|
||||||
richContent = reactStringReplace(richContent, user, (_, index) => (
|
richContent = reactStringReplace(richContent, user, (_, index) => (
|
||||||
@ -70,7 +59,6 @@ export function NoteContent({
|
|||||||
richContent = reactStringReplace(richContent, user, () => null);
|
richContent = reactStringReplace(richContent, user, () => null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
richContent = reactStringReplace(
|
richContent = reactStringReplace(
|
||||||
richContent,
|
richContent,
|
||||||
@ -81,7 +69,7 @@ export function NoteContent({
|
|||||||
href={match}
|
href={match}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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}
|
{match}
|
||||||
</a>
|
</a>
|
||||||
@ -92,25 +80,26 @@ export function NoteContent({
|
|||||||
<div key={nanoid()} className="h-3" />
|
<div key={nanoid()} className="h-3" />
|
||||||
));
|
));
|
||||||
|
|
||||||
return { content: richContent, images, videos };
|
return richContent;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { content, images, videos };
|
console.log("[parser]: ", e);
|
||||||
|
return event.content;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [event.content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"select-text text-[15px] text-pretty content-break overflow-hidden",
|
"select-text text-pretty content-break overflow-hidden",
|
||||||
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
|
event.content.length > 420 ? "max-h-[250px] gradient-mask-b-0" : "",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
{data.images.length ? <Images urls={data.images} /> : null}
|
{event.meta?.images.length ? <Images urls={event.meta.images} /> : null}
|
||||||
{data.videos.length ? <Videos urls={data.videos} /> : null}
|
{event.meta?.videos.length ? <Videos urls={event.meta.videos} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,4 @@
|
|||||||
import type { Settings } from "@lume/types";
|
import { cn } from "@lume/utils";
|
||||||
import {
|
|
||||||
AUDIOS,
|
|
||||||
IMAGES,
|
|
||||||
NOSTR_EVENTS,
|
|
||||||
NOSTR_MENTIONS,
|
|
||||||
VIDEOS,
|
|
||||||
cn,
|
|
||||||
} from "@lume/utils";
|
|
||||||
import { useRouteContext } from "@tanstack/react-router";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { type ReactNode, useMemo } from "react";
|
import { type ReactNode, useMemo } from "react";
|
||||||
import reactStringReplace from "react-string-replace";
|
import reactStringReplace from "react-string-replace";
|
||||||
@ -19,135 +10,85 @@ import { VideoPreview } from "./preview/video";
|
|||||||
import { useNoteContext } from "./provider";
|
import { useNoteContext } from "./provider";
|
||||||
|
|
||||||
export function NoteContentLarge({
|
export function NoteContentLarge({
|
||||||
compact = true,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
compact?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { settings }: { settings: Settings } = useRouteContext({
|
|
||||||
strict: false,
|
|
||||||
});
|
|
||||||
const event = useNoteContext();
|
const event = useNoteContext();
|
||||||
const content = useMemo(() => {
|
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 {
|
try {
|
||||||
if (hashtags.length) {
|
// Get parsed meta
|
||||||
|
const { images, videos, hashtags, events, mentions } = event.meta;
|
||||||
|
|
||||||
|
// Define rich content
|
||||||
|
let richContent: ReactNode[] | string = event.content;
|
||||||
|
|
||||||
for (const hashtag of hashtags) {
|
for (const hashtag of hashtags) {
|
||||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||||
parsedContent = reactStringReplace(parsedContent, regex, () => {
|
richContent = reactStringReplace(richContent, regex, () => (
|
||||||
return <Hashtag key={nanoid()} tag={hashtag} />;
|
<Hashtag key={nanoid()} tag={hashtag} />
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.length) {
|
|
||||||
for (const event of events) {
|
|
||||||
parsedContent = reactStringReplace(
|
|
||||||
parsedContent,
|
|
||||||
event,
|
|
||||||
(match, i) => <MentionNote key={match + i} eventId={event} />,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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" />
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedContent = reactStringReplace(
|
for (const event of events) {
|
||||||
parsedContent,
|
richContent = reactStringReplace(richContent, event, (match, i) => (
|
||||||
/[\r]?\n[\r]?\n/g,
|
<MentionNote key={match + i} eventId={event} />
|
||||||
(_, index) => <br key={event.id + "_br_" + index} />,
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
return text;
|
console.log("[parser]: ", e);
|
||||||
|
return event.content;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [event.content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("select-text", className)}>
|
<div
|
||||||
<div className="text-[15px] text-pretty content-break leading-normal">
|
className={cn(
|
||||||
|
"select-text leading-normal text-pretty content-break",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { Note } from "@/components/note";
|
|||||||
import { User } from "@/components/user";
|
import { User } from "@/components/user";
|
||||||
import { cn } from "@lume/utils";
|
import { cn } from "@lume/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { NostrEvent } from "@lume/types";
|
import type { NostrEvent } from "@lume/types";
|
||||||
import { NostrQuery } from "@lume/system";
|
import { NostrQuery } from "@lume/system";
|
||||||
|
|
||||||
export function RepostNote({
|
export function RepostNote({
|
||||||
@ -21,12 +21,7 @@ export function RepostNote({
|
|||||||
queryKey: ["repost", event.id],
|
queryKey: ["repost", event.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
if (event.content.length > 50) {
|
const id = event.tags.find((el) => el[0] === "e")[1];
|
||||||
const embed: NostrEvent = JSON.parse(event.content);
|
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = event.tags.find((el) => el[0] === "e")?.[1];
|
|
||||||
const repostEvent = await NostrQuery.getEvent(id);
|
const repostEvent = await NostrQuery.getEvent(id);
|
||||||
|
|
||||||
return repostEvent;
|
return repostEvent;
|
||||||
@ -50,27 +45,27 @@ export function RepostNote({
|
|||||||
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
<div className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
Reposted by
|
Reposted by
|
||||||
</div>
|
</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.Root>
|
||||||
</User.Provider>
|
</User.Provider>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-20 items-center justify-center gap-2">
|
<div className="flex items-center justify-center h-20 gap-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Loading event...
|
Loading event...
|
||||||
</div>
|
</div>
|
||||||
) : isError || !repostEvent ? (
|
) : 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
|
Event not found within your current relay set
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Note.Provider event={repostEvent}>
|
<Note.Provider event={repostEvent}>
|
||||||
<Note.Root>
|
<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.User />
|
||||||
<Note.Menu />
|
<Note.Menu />
|
||||||
</div>
|
</div>
|
||||||
<Note.Content className="px-3" />
|
<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.Open />
|
||||||
<Note.Reply />
|
<Note.Reply />
|
||||||
<Note.Repost />
|
<Note.Repost />
|
||||||
|
@ -11,13 +11,17 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"style": {
|
"style": {
|
||||||
"noNonNullAssertion": "warn"
|
"noNonNullAssertion": "warn",
|
||||||
|
"noUselessElse": "off"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useExhaustiveDependencies": "off"
|
"useExhaustiveDependencies": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"noSvgWithoutTitle": "off"
|
"noSvgWithoutTitle": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noStaticOnlyClass": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -244,7 +244,7 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_event", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -252,7 +252,7 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_replies", { id }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -260,7 +260,7 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, asOf }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_events_by", { publicKey, asOf }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -268,7 +268,7 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { pubkeys, until }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_local_events", { pubkeys, until }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -276,7 +276,7 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_global_events", { until }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_global_events", { until }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -284,7 +284,7 @@ try {
|
|||||||
else return { status: "error", error: e as any };
|
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 {
|
try {
|
||||||
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
|
return { status: "ok", data: await TAURI_INVOKE("get_hashtag_events", { hashtags, until }) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -367,7 +367,9 @@ await TAURI_INVOKE("set_badge", { count });
|
|||||||
/** user-defined types **/
|
/** user-defined types **/
|
||||||
|
|
||||||
export type Account = { npub: string; nsec: string }
|
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 Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
|
||||||
|
export type RichEvent = { raw: string; parsed: Meta | null }
|
||||||
|
|
||||||
/** tauri-specta globals **/
|
/** tauri-specta globals **/
|
||||||
|
|
||||||
|
@ -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 { commands } from "./commands";
|
||||||
import { generateContentTags } from "@lume/utils";
|
import { generateContentTags } from "@lume/utils";
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ export class LumeEvent {
|
|||||||
public content: string;
|
public content: string;
|
||||||
public sig: string;
|
public sig: string;
|
||||||
public relay?: string;
|
public relay?: string;
|
||||||
|
public meta: Meta;
|
||||||
#raw: NostrEvent;
|
#raw: NostrEvent;
|
||||||
|
|
||||||
constructor(event: NostrEvent) {
|
constructor(event: NostrEvent) {
|
||||||
@ -74,9 +75,17 @@ export class LumeEvent {
|
|||||||
const query = await commands.getReplies(id);
|
const query = await commands.getReplies(id);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
const events = query.data.map(
|
const events = query.data.map((item) => {
|
||||||
(item) => JSON.parse(item) as EventWithReplies,
|
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) {
|
if (events.length > 0) {
|
||||||
const replies = new Set();
|
const replies = new Set();
|
||||||
@ -135,7 +144,7 @@ export class LumeEvent {
|
|||||||
const queryReply = await commands.getEvent(reply_to);
|
const queryReply = await commands.getEvent(reply_to);
|
||||||
|
|
||||||
if (queryReply.status === "ok") {
|
if (queryReply.status === "ok") {
|
||||||
const replyEvent = JSON.parse(queryReply.data) as NostrEvent;
|
const replyEvent = JSON.parse(queryReply.data.raw) as NostrEvent;
|
||||||
const relayHint =
|
const relayHint =
|
||||||
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
|
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
|
||||||
|
|
||||||
|
@ -1,18 +1,12 @@
|
|||||||
import type { Event, NostrEvent } from "@lume/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { NostrQuery } from "../query";
|
||||||
|
|
||||||
export function useEvent(id: string) {
|
export function useEvent(id: string) {
|
||||||
const { isLoading, isError, data } = useQuery({
|
const { isLoading, isError, data } = useQuery({
|
||||||
queryKey: ["event", id],
|
queryKey: ["event", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const eventId: string = id
|
const event = await NostrQuery.getEvent(id);
|
||||||
.replace("nostr:", "")
|
|
||||||
.split("'")[0]
|
|
||||||
.split(".")[0];
|
|
||||||
const cmd: string = await invoke("get_event", { id: eventId });
|
|
||||||
const event: NostrEvent = JSON.parse(cmd);
|
|
||||||
return event;
|
return event;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
|
@ -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 { commands } from "./commands";
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
import { resolveResource } from "@tauri-apps/api/path";
|
||||||
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
|
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
|
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { dedupEvents } from "./dedup";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
|
||||||
@ -98,9 +103,16 @@ export class NostrQuery {
|
|||||||
const query = await commands.getEvent(normalize);
|
const query = await commands.getEvent(normalize);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
const event: NostrEvent = JSON.parse(query.data);
|
const data = query.data;
|
||||||
return event;
|
const raw = JSON.parse(data.raw) as NostrEvent;
|
||||||
|
|
||||||
|
if (data?.parsed) {
|
||||||
|
raw.meta = data.parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
} else {
|
} else {
|
||||||
|
console.log("[getEvent]: ", query.error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -110,8 +122,19 @@ export class NostrQuery {
|
|||||||
const query = await commands.getEventsBy(pubkey, until);
|
const query = await commands.getEventsBy(pubkey, until);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
|
const data = query.data.map((item) => {
|
||||||
return events;
|
const raw = JSON.parse(item.raw) as NostrEvent;
|
||||||
|
|
||||||
|
if (item.parsed) {
|
||||||
|
raw.meta = item.parsed;
|
||||||
|
} else {
|
||||||
|
raw.meta = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -122,10 +145,19 @@ export class NostrQuery {
|
|||||||
const query = await commands.getLocalEvents(pubkeys, until);
|
const query = await commands.getLocalEvents(pubkeys, until);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
|
const data = query.data.map((item) => {
|
||||||
const dedup = dedupEvents(events);
|
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 {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -136,10 +168,19 @@ export class NostrQuery {
|
|||||||
const query = await commands.getGlobalEvents(until);
|
const query = await commands.getGlobalEvents(until);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
|
const data = query.data.map((item) => {
|
||||||
const dedup = dedupEvents(events);
|
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 {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -151,10 +192,19 @@ export class NostrQuery {
|
|||||||
const query = await commands.getHashtagEvents(nostrTags, until);
|
const query = await commands.getHashtagEvents(nostrTags, until);
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
const events = query.data.map((item) => JSON.parse(item) as NostrEvent);
|
const data = query.data.map((item) => {
|
||||||
const dedup = dedupEvents(events);
|
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 {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -311,8 +361,7 @@ export class NostrQuery {
|
|||||||
const query = await commands.getBootstrapRelays();
|
const query = await commands.getBootstrapRelays();
|
||||||
|
|
||||||
if (query.status === "ok") {
|
if (query.status === "ok") {
|
||||||
let relays: Relay[] = [];
|
const relays: Relay[] = [];
|
||||||
console.log(query.data);
|
|
||||||
|
|
||||||
for (const item of query.data) {
|
for (const item of query.data) {
|
||||||
const line = item.split(",");
|
const line = item.split(",");
|
||||||
|
10
packages/types/index.d.ts
vendored
10
packages/types/index.d.ts
vendored
@ -28,6 +28,15 @@ export enum Kind {
|
|||||||
// #TODO: Add all nostr kinds
|
// #TODO: Add all nostr kinds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Meta {
|
||||||
|
content: string;
|
||||||
|
images: string[];
|
||||||
|
videos: string[];
|
||||||
|
events: string[];
|
||||||
|
mentions: string[];
|
||||||
|
hashtags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface NostrEvent {
|
export interface NostrEvent {
|
||||||
id: string;
|
id: string;
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
@ -36,6 +45,7 @@ export interface NostrEvent {
|
|||||||
tags: string[][];
|
tags: string[][];
|
||||||
content: string;
|
content: string;
|
||||||
sig: string;
|
sig: string;
|
||||||
|
meta: Meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventWithReplies extends NostrEvent {
|
export interface EventWithReplies extends NostrEvent {
|
||||||
|
@ -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) {
|
export async function parser(
|
||||||
// Get clean content
|
content: string,
|
||||||
|
abortController?: AbortController,
|
||||||
|
) {
|
||||||
|
const words = content.split(/( |\n)/);
|
||||||
const urls = content.match(/(https?:\/\/\S+)/gi);
|
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
|
// Extract images and videos from content
|
||||||
const images: string[] = [];
|
const images: string[] = [];
|
||||||
const videos: string[] = [];
|
const videos: string[] = [];
|
||||||
|
|
||||||
let text: string = content;
|
let text: string = content;
|
||||||
|
|
||||||
if (urls) {
|
if (urls) {
|
||||||
@ -16,20 +35,44 @@ export function parser(content: string) {
|
|||||||
if (IMAGES.includes(ext)) {
|
if (IMAGES.includes(ext)) {
|
||||||
text = text.replace(url, "");
|
text = text.replace(url, "");
|
||||||
images.push(url);
|
images.push(url);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (VIDEOS.includes(ext)) {
|
if (VIDEOS.includes(ext)) {
|
||||||
text = text.replace(url, "");
|
text = text.replace(url, "");
|
||||||
videos.push(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();
|
const meta: Meta = {
|
||||||
|
content: text.trim(),
|
||||||
return {
|
|
||||||
content: trimContent,
|
|
||||||
images,
|
images,
|
||||||
videos,
|
videos,
|
||||||
|
events,
|
||||||
|
mentions,
|
||||||
|
hashtags,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return meta;
|
||||||
}
|
}
|
||||||
|
13
src-tauri/Cargo.lock
generated
13
src-tauri/Cargo.lock
generated
@ -2707,6 +2707,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e"
|
checksum = "dd1bc4d24ad230d21fb898d1116b1801d7adfc449d42026475862ab48b11e70e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linkify"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-keyutils"
|
name = "linux-keyutils"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@ -2786,12 +2795,15 @@ name = "lume"
|
|||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cocoa",
|
"cocoa",
|
||||||
|
"futures",
|
||||||
"keyring",
|
"keyring",
|
||||||
"keyring-search",
|
"keyring-search",
|
||||||
|
"linkify",
|
||||||
"monitor",
|
"monitor",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"objc",
|
"objc",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"specta",
|
"specta",
|
||||||
@ -2812,6 +2824,7 @@ dependencies = [
|
|||||||
"tauri-plugin-upload",
|
"tauri-plugin-upload",
|
||||||
"tauri-specta",
|
"tauri-specta",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -40,6 +40,10 @@ tauri-plugin-decorum = "0.1.0"
|
|||||||
specta = "^2.0.0-rc.12"
|
specta = "^2.0.0-rc.12"
|
||||||
keyring = "2"
|
keyring = "2"
|
||||||
keyring-search = "0.2.0"
|
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]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
cocoa = "0.25.0"
|
cocoa = "0.25.0"
|
||||||
|
@ -59,6 +59,7 @@
|
|||||||
"fs:allow-read-file",
|
"fs:allow-read-file",
|
||||||
"theme:allow-set-theme",
|
"theme:allow-set-theme",
|
||||||
"theme:allow-get-theme",
|
"theme:allow-get-theme",
|
||||||
|
"http:default",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
|
@ -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"]}}
|
@ -1,11 +1,23 @@
|
|||||||
use crate::Nostr;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use std::{str::FromStr, time::Duration};
|
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 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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 client = &state.client;
|
||||||
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
|
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
|
||||||
Ok(val) => match val {
|
Ok(val) => match val {
|
||||||
@ -36,7 +48,14 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<String, Stri
|
|||||||
{
|
{
|
||||||
Ok(events) => {
|
Ok(events) => {
|
||||||
if let Some(event) = events.first() {
|
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 {
|
} else {
|
||||||
Err("Cannot found this event with current relay list".into())
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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;
|
let client = &state.client;
|
||||||
|
|
||||||
match EventId::from_hex(id) {
|
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);
|
let filter = Filter::new().kinds(vec![Kind::TextNote]).event(event_id);
|
||||||
|
|
||||||
match client.get_events_of(vec![filter], None).await {
|
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()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,7 +105,7 @@ pub async fn get_events_by(
|
|||||||
public_key: &str,
|
public_key: &str,
|
||||||
as_of: Option<&str>,
|
as_of: Option<&str>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
|
|
||||||
match PublicKey::from_str(public_key) {
|
match PublicKey::from_str(public_key) {
|
||||||
@ -88,7 +121,21 @@ pub async fn get_events_by(
|
|||||||
.until(until);
|
.until(until);
|
||||||
|
|
||||||
match client.get_events_of(vec![filter], None).await {
|
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()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,7 +149,7 @@ pub async fn get_local_events(
|
|||||||
pubkeys: Vec<String>,
|
pubkeys: Vec<String>,
|
||||||
until: Option<&str>,
|
until: Option<&str>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let as_of = match until {
|
let as_of = match until {
|
||||||
Some(until) => Timestamp::from_str(until).unwrap(),
|
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)))
|
.get_events_of(vec![filter], Some(Duration::from_secs(10)))
|
||||||
.await
|
.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()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -138,7 +200,7 @@ pub async fn get_local_events(
|
|||||||
pub async fn get_global_events(
|
pub async fn get_global_events(
|
||||||
until: Option<&str>,
|
until: Option<&str>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let as_of = match until {
|
let as_of = match until {
|
||||||
Some(until) => Timestamp::from_str(until).unwrap(),
|
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)))
|
.get_events_of(vec![filter], Some(Duration::from_secs(8)))
|
||||||
.await
|
.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()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,7 +242,7 @@ pub async fn get_hashtag_events(
|
|||||||
hashtags: Vec<&str>,
|
hashtags: Vec<&str>,
|
||||||
until: Option<&str>,
|
until: Option<&str>,
|
||||||
state: State<'_, Nostr>,
|
state: State<'_, Nostr>,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<RichEvent>, String> {
|
||||||
let client = &state.client;
|
let client = &state.client;
|
||||||
let as_of = match until {
|
let as_of = match until {
|
||||||
Some(until) => Timestamp::from_str(until).unwrap(),
|
Some(until) => Timestamp::from_str(until).unwrap(),
|
||||||
@ -178,7 +255,22 @@ pub async fn get_hashtag_events(
|
|||||||
.hashtags(hashtags);
|
.hashtags(hashtags);
|
||||||
|
|
||||||
match client.get_events_of(vec![filter], None).await {
|
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()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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> {
|
pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
|
||||||
events.iter().max_by_key(|event| event.created_at())
|
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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user