add link preview

This commit is contained in:
Ren Amamiya 2023-06-04 17:20:47 +07:00
parent be6de2344e
commit c4bc67410c
11 changed files with 154 additions and 81 deletions

View File

@ -22,7 +22,7 @@
"http": { "http": {
"all": true, "all": true,
"request": true, "request": true,
"scope": ["https://rbr.bio/*", "https://void.cat/*", "https://metadata.lume.nu/*"] "scope": ["https://void.cat/*", "https://skrape.dev/*"]
}, },
"fs": { "fs": {
"all": false, "all": false,

View File

@ -1,3 +1,4 @@
import { LinkPreview } from "./preview/link";
import { MentionNote } from "@app/note/components/mentions/note"; import { MentionNote } from "@app/note/components/mentions/note";
import { MentionUser } from "@app/note/components/mentions/user"; import { MentionUser } from "@app/note/components/mentions/user";
import { ImagePreview } from "@app/note/components/preview/image"; import { ImagePreview } from "@app/note/components/preview/image";
@ -32,6 +33,11 @@ export function Kind1({
) : ( ) : (
<></> <></>
)} )}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? ( {Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => ( content.notes.map((note: string) => (
<MentionNote key={note} id={note} /> <MentionNote key={note} id={note} />

View File

@ -3,43 +3,15 @@ import { Kind1063 } from "@app/note/components/kind1063";
import { NoteSkeleton } from "@app/note/components/skeleton"; import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteQuoteUser } from "@app/note/components/user/quote"; import { NoteQuoteUser } from "@app/note/components/user/quote";
import { NoteWrapper } from "@app/note/components/wrapper"; import { NoteWrapper } from "@app/note/components/wrapper";
import { RelayContext } from "@shared/relayProvider"; import { useEvent } from "@utils/hooks/useEvent";
import { READONLY_RELAYS } from "@stores/constants";
import { noteParser } from "@utils/parser"; import { noteParser } from "@utils/parser";
import { memo, useContext } from "react"; import { memo } from "react";
import useSWRSubscription from "swr/subscription";
export const MentionNote = memo(function MentionNote({ id }: { id: string }) { export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const pool: any = useContext(RelayContext); const data = useEvent(id);
const { data, error } = useSWRSubscription( const kind1 = data?.kind === 1 ? noteParser(data) : null;
id ? id : null, const kind1063 = data?.kind === 1063 ? data.tags : null;
(key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
},
);
return () => {
unsubscribe();
};
},
);
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
return ( return (
<NoteWrapper <NoteWrapper

View File

@ -3,43 +3,15 @@ import { Kind1063 } from "@app/note/components/kind1063";
import { NoteMetadata } from "@app/note/components/metadata"; import { NoteMetadata } from "@app/note/components/metadata";
import { NoteSkeleton } from "@app/note/components/skeleton"; import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteDefaultUser } from "@app/note/components/user/default"; import { NoteDefaultUser } from "@app/note/components/user/default";
import { RelayContext } from "@shared/relayProvider"; import { useEvent } from "@utils/hooks/useEvent";
import { READONLY_RELAYS } from "@stores/constants";
import { noteParser } from "@utils/parser"; import { noteParser } from "@utils/parser";
import { memo, useContext } from "react"; import { memo } from "react";
import useSWRSubscription from "swr/subscription";
export const NoteParent = memo(function NoteParent({ id }: { id: string }) { export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
const pool: any = useContext(RelayContext); const data = useEvent(id);
const { data, error } = useSWRSubscription( const kind1 = data?.kind === 1 ? noteParser(data) : null;
id ? id : null, const kind1063 = data?.kind === 1063 ? data.tags : null;
(key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
},
);
return () => {
unsubscribe();
};
},
);
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
return ( return (
<div className="relative overflow-hidden flex flex-col pb-6"> <div className="relative overflow-hidden flex flex-col pb-6">

View File

@ -1,12 +1,9 @@
import { Image } from "@shared/image"; import { Image } from "@shared/image";
import useEmblaCarousel from "embla-carousel-react";
export function ImagePreview({ urls }: { urls: string[] }) { export function ImagePreview({ urls }: { urls: string[] }) {
const [emblaRef] = useEmblaCarousel();
return ( return (
<div ref={emblaRef} className="mt-3 overflow-hidden"> <div className="mt-3 overflow-hidden">
<div className="flex"> <div className="flex flex-col gap-2">
{urls.map((url) => ( {urls.map((url) => (
<div key={url} className="mr-2 min-w-0 grow-0 shrink-0 basis-full"> <div key={url} className="mr-2 min-w-0 grow-0 shrink-0 basis-full">
<Image <Image

View File

@ -0,0 +1,39 @@
import { Image } from "@shared/image";
import { useOpenGraph } from "@utils/hooks/useOpenGraph";
export function LinkPreview({ urls }: { urls: string[] }) {
const domain = new URL(urls[0]);
const { data, error, isLoading } = useOpenGraph(urls[0]);
return (
<div className="mt-3 overflow-hidden rounded-lg bg-zinc-800">
{error && <p>failed to load</p>}
{isLoading && !data ? (
<p>Loading...</p>
) : (
<a href={urls[0]} className="flex flex-col">
<Image
src={data["og:image"]}
alt={urls[0]}
className="w-full h-auto border-t-lg object-cover"
/>
<div className="flex flex-col gap-2 px-3 py-3">
<h5 className="leading-none font-medium text-zinc-200">
{data["og:title"]}
</h5>
{data["og:description"] ? (
<p className="leading-none text-sm text-zinc-400 line-clamp-3">
{data["og:description"]}
</p>
) : (
<></>
)}
<span className="mt-2.5 leading-none text-sm text-zinc-500">
{domain.hostname}
</span>
</div>
</a>
)}
</div>
);
}

View File

@ -15,7 +15,7 @@ button {
} }
.markdown { .markdown {
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:m-0 prose-p:leading-tight prose-a:text-[15px] prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 hover:prose-a:text-fuchsia-600 prose-ol:mb-1 prose-ul:mb-1 prose-li:text-[15px] prose-li:leading-tight prose-blockquote:m-0 prose-hr:mx-0 prose-hr:my-2; @apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:m-0 prose-p:leading-tight prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-500 hover:prose-a:text-fuchsia-600 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-blockquote:m-0 prose-hr:mx-0 prose-hr:my-2;
} }
/* For Webkit-based browsers (Chrome, Safari and Opera) */ /* For Webkit-based browsers (Chrome, Safari and Opera) */

View File

@ -5,6 +5,8 @@ export const DEFAULT_AVATAR = "https://void.cat/d/KmypFh2fBdYCEvyJrPiN89.webp";
export const DEFAULT_CHANNEL_BANNER = export const DEFAULT_CHANNEL_BANNER =
"https://bafybeiacwit7hjmdefqggxqtgh6ht5dhth7ndptwn2msl5kpkodudsr7py.ipfs.w3s.link/banner-1.jpg"; "https://bafybeiacwit7hjmdefqggxqtgh6ht5dhth7ndptwn2msl5kpkodudsr7py.ipfs.w3s.link/banner-1.jpg";
export const OPENGRAPH_KEY = "9EJG4SY-19Q4M5J-H8R29C9-091XPCC";
// read-only relay list // read-only relay list
export const READONLY_RELAYS = [ export const READONLY_RELAYS = [
"wss://welcome.nostr.wine", "wss://welcome.nostr.wine",

View File

@ -0,0 +1,57 @@
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { READONLY_RELAYS } from "@stores/constants";
import { createNote, getNoteByID } from "@utils/storage";
import { getParentID } from "@utils/transform";
import { useContext } from "react";
import useSWR from "swr";
import useSWRSubscription from "swr/subscription";
const fetcher = ([, id]) => getNoteByID(id);
export function useEvent(id: string) {
const pool: any = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const { data: cache } = useSWR(["event", id], fetcher);
const { data: newest } = useSWRSubscription(
!cache ? id : null,
(key, { next }) => {
const unsubscribe = pool.subscribe(
[
{
ids: [key],
},
],
READONLY_RELAYS,
(event: any) => {
const parentID = getParentID(event.tags, event.id);
// insert event to local database
createNote(
event.id,
account.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
parentID,
);
// update state
next(null, event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
},
);
return () => {
unsubscribe();
};
},
);
return cache ? cache : newest;
}

View File

@ -0,0 +1,28 @@
import { OPENGRAPH_KEY } from "@stores/constants";
import { fetch } from "@tauri-apps/api/http";
import useSWR from "swr";
const fetcher = async (url: string) => {
const result = await fetch(url, {
method: "GET",
timeout: 20,
});
if (result.ok) {
return result.data;
} else {
return null;
}
};
export function useOpenGraph(url: string) {
const { data, error, isLoading } = useSWR(
`https://skrape.dev/api/opengraph/?url=${url}&key=${OPENGRAPH_KEY}`,
fetcher,
);
return {
data: data,
error: error,
isLoading: isLoading,
};
}

View File

@ -13,12 +13,14 @@ export function noteParser(event: Event) {
notes: string[]; notes: string[];
images: string[]; images: string[];
videos: string[]; videos: string[];
links: string[];
} = { } = {
original: event.content, original: event.content,
parsed: event.content, parsed: event.content,
notes: [], notes: [],
images: [], images: [],
videos: [], videos: [],
links: [],
}; };
// handle media // handle media
@ -36,10 +38,15 @@ export function noteParser(event: Event) {
content.videos.push(url); content.videos.push(url);
// remove url from original content // remove url from original content
content.parsed = content.parsed.replace(url, ""); content.parsed = content.parsed.replace(url, "");
} else {
// push to store
content.links.push(url);
// remove url from original content
content.parsed = content.parsed.replace(url, "");
} }
}); });
// map hashtag to em // map hashtag to link
content.original.match(/#(\w+)(?!:\/\/)/g)?.forEach((item) => { content.original.match(/#(\w+)(?!:\/\/)/g)?.forEach((item) => {
content.parsed = content.parsed.replace(item, `[${item}](/search/${item})`); content.parsed = content.parsed.replace(item, `[${item}](/search/${item})`);
}); });
@ -47,13 +54,6 @@ export function noteParser(event: Event) {
// handle nostr mention // handle nostr mention
references.forEach((item) => { references.forEach((item) => {
const profile = item.profile; const profile = item.profile;
const event = item.event;
if (event) {
content.notes.push(event.id);
content.parsed = content.parsed.replace(item.text, "");
}
if (profile) { if (profile) {
content.parsed = content.parsed.replace(item.text, `*${profile.pubkey}*`); content.parsed = content.parsed.replace(item.text, `*${profile.pubkey}*`);
} }