refactor newsfeed and note

This commit is contained in:
Ren Amamiya
2023-03-25 15:26:32 +07:00
parent f1965e1b43
commit f1647fd857
23 changed files with 530 additions and 444 deletions

View File

@@ -9,6 +9,9 @@ module.exports = removeImports({
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
experimental: {
scrollRestoration: true,
},
webpack: (config) => { webpack: (config) => {
config.experiments = { ...config.experiments, topLevelAwait: true }; config.experiments = { ...config.experiments, topLevelAwait: true };
return config; return config;

View File

@@ -85,7 +85,5 @@ CREATE TABLE
kind INTEGER NOT NULL DEFAULT 1, kind INTEGER NOT NULL DEFAULT 1,
tags TEXT NOT NULL, tags TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
is_circle INTEGER NOT NULL DEFAULT 0, parent_id TEXT
is_root INTEGER NOT NULL DEFAULT 0,
is_reply INTEGER NOT NULL DEFAULT 0
); );

View File

@@ -0,0 +1,105 @@
import NoteMetadata from '@components/note/metadata';
import { NoteParent } from '@components/note/parent';
import { ImagePreview } from '@components/note/preview/image';
import { VideoPreview } from '@components/note/preview/video';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import destr from 'destr';
import { useRouter } from 'next/router';
import { memo, useMemo } from 'react';
import ReactPlayer from 'react-player/lazy';
import reactStringReplace from 'react-string-replace';
import { NoteRepost } from './repost';
export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
const router = useRouter();
const content = useMemo(() => {
let parsedContent = event.content;
// get data tags
const tags = destr(event.tags);
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
if (match.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
// image url
return <ImagePreview key={match + i} url={match} />;
} else if (ReactPlayer.canPlay(match)) {
return <VideoPreview key={match + i} url={match} />;
} else {
return (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
);
}
});
// handle #-hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
// @-mentions
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else if (tags[match][0] === 'e') {
// note-mentions
return <NoteRepost key={match + i} id={tags[match][1]} />;
} else {
return;
}
});
}
return parsedContent;
}, [event.content, event.tags]);
const getParent = useMemo(() => {
if (event.parent_id !== event.id && !event.content.includes('#[0]')) {
return <NoteParent id={event.parent_id} />;
}
return;
}, [event.content, event.id, event.parent_id]);
const openThread = (e) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
router.push(`/newsfeed/${event.parent_id}`);
} else {
e.stopPropagation();
}
};
return (
<div
onClick={(e) => openThread(e)}
className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-5 px-3 hover:bg-black/20"
>
<>{getParent}</>
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} />
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1 prose-img:mt-2 prose-img:mb-0 prose-video:mt-1 prose-video:mb-0">
{content}
</div>
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={event.id}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
/>
</div>
</div>
</div>
);
});

View File

@@ -1,68 +0,0 @@
import NoteMetadata from '@components/note/content/metadata';
import NotePreview from '@components/note/content/preview';
import { UserLarge } from '@components/user/large';
import { UserMention } from '@components/user/mention';
import destr from 'destr';
import { memo, useMemo } from 'react';
import reactStringReplace from 'react-string-replace';
export const ContentExtend = memo(function ContentExtend({ data }: { data: any }) {
const content = useMemo(() => {
let parsedContent;
// get data tags
const tags = destr(data.tags);
// remove all image urls
parsedContent = data.content.replace(/(https?:\/\/.*\.(jpg|jpeg|gif|png|webp|mp4|webm)((\?.*)$|$))/gim, '');
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
));
// handle hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else {
// #TODO: handle mention other note
// console.log(tags[match]);
}
});
}
return parsedContent;
}, [data.content, data.tags]);
return (
<div className="relative z-10 flex flex-col">
<UserLarge pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1">
{content}
</div>
<NotePreview content={data.content} />
</div>
</div>
<div
onClick={(e) => e.stopPropagation()}
className="mt-5 flex items-center border-t border-b border-zinc-800 py-2"
>
<NoteMetadata
eventID={data.id}
eventPubkey={data.pubkey}
eventContent={data.content}
eventTime={data.created_at}
/>
</div>
</div>
);
});

View File

@@ -1,65 +0,0 @@
import NoteMetadata from '@components/note/content/metadata';
import NotePreview from '@components/note/content/preview';
import { MentionNote } from '@components/note/mention';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import destr from 'destr';
import { memo, useMemo } from 'react';
import reactStringReplace from 'react-string-replace';
export const Content = memo(function Content({ data }: { data: any }) {
const content = useMemo(() => {
let parsedContent;
// get data tags
const tags = destr(data.tags);
// remove all image urls
parsedContent = data.content.replace(/(https?:\/\/.*\.(jpg|jpeg|gif|png|webp|mp4|webm)((\?.*)$|$))/gim, '');
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
));
// handle hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else if (tags[match][0] === 'e') {
return <MentionNote key={match + i} id={tags[match][1]} />;
}
});
}
return parsedContent;
}, [data.content, data.tags]);
return (
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1">
{content}
</div>
<NotePreview content={data.content} />
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={data.id}
eventPubkey={data.pubkey}
eventContent={data.content}
eventTime={data.created_at}
/>
</div>
</div>
);
});

View File

@@ -1,43 +0,0 @@
import { ImagePreview } from '@components/note/preview/image';
import { VideoPreview } from '@components/note/preview/video';
import { useEffect, useState } from 'react';
import ReactPlayer from 'react-player';
export default function NotePreview({ content }: { content: string }) {
const [video, setVideo] = useState(null);
const [images, setImages] = useState([]);
useEffect(() => {
const urls = content.match(
/((http|ftp|https):\/\/)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)?/gi
);
if (urls !== null && urls.length > 0) {
urls.forEach((url) => {
// make sure url alway have http://
if (!/^https?:\/\//i.test(url)) {
url = 'http://' + url;
}
// parse url with new URL();
const parseURL = new URL(url, 'https://uselume.xyz');
// parse image url
if (parseURL.pathname.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
// add image to preview
setImages((images) => [...images, parseURL.href]);
} else if (ReactPlayer.canPlay(parseURL.href)) {
// add video to preview
setVideo(parseURL.href);
}
});
}
}, [content]);
if (video) {
return <VideoPreview data={video} />;
} else if (images.length > 0) {
return <ImagePreview data={images} />;
} else {
return <></>;
}
}

View File

@@ -0,0 +1,78 @@
import NoteMetadata from '@components/note/metadata';
import { ImagePreview } from '@components/note/preview/image';
import { VideoPreview } from '@components/note/preview/video';
import { UserLarge } from '@components/user/large';
import { UserMention } from '@components/user/mention';
import destr from 'destr';
import { memo, useMemo } from 'react';
import ReactPlayer from 'react-player/lazy';
import reactStringReplace from 'react-string-replace';
export const NoteExtend = memo(function NoteExtend({ event }: { event: any }) {
const content = useMemo(() => {
let parsedContent = event.content;
// get data tags
const tags = destr(event.tags);
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
if (match.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
// image url
return <ImagePreview key={match + i} url={match} />;
} else if (ReactPlayer.canPlay(match)) {
return <VideoPreview key={match + i} url={match} />;
} else {
return (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
);
}
});
// handle #-hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
// @-mentions
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else if (tags[match][0] === 'e') {
// note-mentions
return <p key={match + i}>note-{tags[match][1]}</p>;
} else {
return;
}
});
}
return parsedContent;
}, [event.content, event.tags]);
return (
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3">
<div className="relative z-10 flex flex-col">
<UserLarge pubkey={event.pubkey} time={event.created_at} />
<div className="mt-2">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1 prose-img:mt-2 prose-img:mb-0 prose-video:mt-1 prose-video:mb-0">
{content}
</div>
</div>
</div>
<div className="mt-5 flex items-center border-t border-b border-zinc-800 py-2">
<NoteMetadata
eventID={event.id}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
/>
</div>
</div>
</div>
);
});

View File

@@ -1,48 +0,0 @@
import { RootNote } from '@components/note/root';
import destr from 'destr';
import { useRouter } from 'next/router';
import { memo, useCallback, useRef } from 'react';
export const Note = memo(function Note({ event }: { event: any }) {
const router = useRouter();
const tags = destr(event.tags);
const rootEventID = useRef(null);
const fetchRootEvent = useCallback(() => {
if (tags.length > 0) {
if (tags[0][0] === 'e' || tags[0][2] === 'root') {
rootEventID.current = tags[0][1];
return <RootNote id={tags[0][1]} />;
} else {
tags.every((tag) => {
if (tag[0] === 'e' && tag[2] === 'root') {
rootEventID.current = tag[1];
return <RootNote id={tag[1]} />;
}
return <></>;
});
}
} else {
return <></>;
}
}, [tags]);
const openThread = (e) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
router.push(`/newsfeed/${rootEventID.current || event.id}`);
} else {
e.stopPropagation();
}
};
return (
<div
onClick={(e) => openThread(e)}
className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-5 px-3 hover:bg-black/20"
>
<p>{event.content}</p>
</div>
);
});

View File

@@ -1,84 +0,0 @@
import { Content } from '@components/note/content';
import { RelayContext } from '@components/relaysProvider';
import { relaysAtom } from '@stores/relays';
import { createCacheNote, getNoteByID } from '@utils/storage';
import { useAtomValue } from 'jotai';
import { memo, useCallback, useContext, useEffect, useState } from 'react';
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const relays = useAtomValue(relaysAtom);
const [event, setEvent] = useState(null);
const fetchEvent = useCallback(() => {
pool.subscribe(
[
{
ids: [id],
kinds: [1],
},
],
relays,
(event: any) => {
// update state
setEvent(event);
// insert to database
createCacheNote(event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
}, [id, pool, relays]);
useEffect(() => {
getNoteByID(id).then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
});
}, [fetchEvent, id]);
if (event) {
return (
<div className="relative border border-zinc-900 p-3">
<Content data={event} />
</div>
);
} else {
return (
<div className="relative z-10 flex h-min animate-pulse select-text flex-col pb-5">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
<span className="text-zinc-500">·</span>
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
<div className="h-3 w-3 rounded-full bg-zinc-700" />
</div>
</div>
</div>
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8">
<div className="h-4 w-12 rounded bg-zinc-700" />
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
);
}
});

View File

@@ -16,7 +16,7 @@ import { useRouter } from 'next/router';
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from 'nostr-tools';
import { memo, useContext, useState } from 'react'; import { memo, useContext, useState } from 'react';
export const CommentsCounter = memo(function CommentsCounter({ export const NoteComment = memo(function NoteComment({
count, count,
eventID, eventID,
eventPubkey, eventPubkey,
@@ -79,7 +79,7 @@ export const CommentsCounter = memo(function CommentsCounter({
<div className="relative z-10"> <div className="relative z-10">
<UserExtend pubkey={eventPubkey} time={eventTime} /> <UserExtend pubkey={eventPubkey} time={eventTime} />
</div> </div>
<div className="-mt-4 pl-[60px]"> <div className="-mt-5 pl-[52px]">
<div className="prose prose-zinc max-w-none break-words leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1"> <div className="prose prose-zinc max-w-none break-words leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1">
{eventContent} {eventContent}
</div> </div>
@@ -88,7 +88,7 @@ export const CommentsCounter = memo(function CommentsCounter({
<div className="absolute top-0 left-[21px] h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div> <div className="absolute top-0 left-[21px] h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
</div> </div>
{/* comment form */} {/* comment form */}
<div className="flex gap-4"> <div className="flex gap-2">
<div> <div>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10"> <div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<ImageWithFallback <ImageWithFallback

View File

@@ -12,7 +12,7 @@ import { useAtom, useAtomValue } from 'jotai';
import { getEventHash, signEvent } from 'nostr-tools'; import { getEventHash, signEvent } from 'nostr-tools';
import { memo, useContext, useEffect, useState } from 'react'; import { memo, useContext, useEffect, useState } from 'react';
export const LikesCounter = memo(function LikesCounter({ export const NoteReaction = memo(function NoteReaction({
count, count,
eventID, eventID,
eventPubkey, eventPubkey,

View File

@@ -1,5 +1,5 @@
import { CommentsCounter } from '@components/note/counter/comments'; import { NoteComment } from '@components/note/meta/comment';
import { LikesCounter } from '@components/note/counter/likes'; import { NoteReaction } from '@components/note/meta/reaction';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
import { relaysAtom } from '@stores/relays'; import { relaysAtom } from '@stores/relays';
@@ -62,14 +62,14 @@ export default function NoteMetadata({
return ( return (
<div className="relative z-10 -ml-1 flex items-center gap-8"> <div className="relative z-10 -ml-1 flex items-center gap-8">
<CommentsCounter <NoteComment
count={comments} count={comments}
eventID={eventID} eventID={eventID}
eventPubkey={eventPubkey} eventPubkey={eventPubkey}
eventContent={eventContent} eventContent={eventContent}
eventTime={eventTime} eventTime={eventTime}
/> />
<LikesCounter count={likes} eventID={eventID} eventPubkey={eventPubkey} /> <NoteReaction count={likes} eventID={eventID} eventPubkey={eventPubkey} />
</div> </div>
); );
} }

View File

@@ -0,0 +1,155 @@
import NoteMetadata from '@components/note/metadata';
import { ImagePreview } from '@components/note/preview/image';
import { VideoPreview } from '@components/note/preview/video';
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import { relaysAtom } from '@stores/relays';
import { createCacheNote, getNoteByID } from '@utils/storage';
import destr from 'destr';
import { useAtomValue } from 'jotai';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import ReactPlayer from 'react-player';
import reactStringReplace from 'react-string-replace';
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const relays = useAtomValue(relaysAtom);
const [event, setEvent] = useState(null);
const fetchEvent = useCallback(() => {
pool.subscribe(
[
{
ids: [id],
kinds: [1],
},
],
relays,
(event: any) => {
// update state
setEvent(event);
// insert to database
createCacheNote(event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
}, [id, pool, relays]);
useEffect(() => {
getNoteByID(id).then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
});
}, [fetchEvent, id]);
const content = useMemo(() => {
let parsedContent = event ? event.content : null;
if (parsedContent !== null) {
// get data tags
const tags = destr(event.tags);
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
if (match.toLowerCase().match(/\.(jpg|jpeg|gif|png|webp)$/)) {
// image url
return <ImagePreview key={match + i} url={match} />;
} else if (ReactPlayer.canPlay(match)) {
return <VideoPreview key={match + i} url={match} />;
} else {
return (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
);
}
});
// handle #-hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
// @-mentions
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else if (tags[match][0] === 'e') {
// note-mentions
return <p key={match + i}>note-{tags[match][1]}</p>;
} else {
return;
}
});
}
}
return parsedContent;
}, [event]);
if (event) {
return (
<div className="relative pb-5">
<div className="absolute top-0 left-[21px] h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} />
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1 prose-img:mt-2 prose-img:mb-0 prose-video:mt-1 prose-video:mb-0">
{content}
</div>
</div>
</div>
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]">
<NoteMetadata
eventID={event.id}
eventPubkey={event.pubkey}
eventContent={event.content}
eventTime={event.created_at}
/>
</div>
</div>
</div>
);
} else {
return (
<div className="relative z-10 flex h-min animate-pulse select-text flex-col pb-5">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
<span className="text-zinc-500">·</span>
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
<div className="h-3 w-3 rounded-full bg-zinc-700" />
</div>
</div>
</div>
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8">
<div className="h-4 w-12 rounded bg-zinc-700" />
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
);
}
});

View File

@@ -1,23 +1,20 @@
import Image from 'next/image'; import Image from 'next/image';
import { memo } from 'react'; import { memo } from 'react';
export const ImagePreview = memo(function ImagePreview({ data }: { data: any }) { export const ImagePreview = memo(function ImagePreview({ url }: { url: string }) {
return ( return (
<div className="relative mt-2 flex flex-col overflow-hidden"> <div className="relative mt-3 h-full w-full rounded-lg xl:w-2/3">
{data.map((image: string, index: number) => (
<div key={index} className={`relative h-full w-full rounded-lg xl:w-2/3 ${index >= 1 ? 'mt-2' : ''}`}>
<Image <Image
placeholder="blur" src={url}
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII=" alt={url}
src={image}
alt={image}
width="0" width="0"
height="0" height="0"
sizes="100vw" sizes="100vw"
className="h-auto w-full rounded-lg border border-zinc-800 object-cover" className="h-auto w-full rounded-lg border border-zinc-800 object-cover"
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkqAcAAIUAgUW0RjgAAAAASUVORK5CYII="
priority
/> />
</div> </div>
))}
</div>
); );
}); });

View File

@@ -1,11 +1,11 @@
import { memo } from 'react'; import { memo } from 'react';
import ReactPlayer from 'react-player/lazy'; import ReactPlayer from 'react-player/lazy';
export const VideoPreview = memo(function VideoPreview({ data }: { data: string }) { export const VideoPreview = memo(function VideoPreview({ url }: { url: string }) {
return ( return (
<div onClick={(e) => e.stopPropagation()} className="relative mt-2 flex flex-col overflow-hidden rounded-lg"> <div onClick={(e) => e.stopPropagation()} className="relative mt-3 flex flex-col overflow-hidden rounded-lg">
<ReactPlayer <ReactPlayer
url={data} url={url}
controls={true} controls={true}
volume={0} volume={0}
className="aspect-video w-full xl:w-2/3" className="aspect-video w-full xl:w-2/3"

View File

@@ -0,0 +1,121 @@
import { RelayContext } from '@components/relaysProvider';
import { UserExtend } from '@components/user/extend';
import { UserMention } from '@components/user/mention';
import { relaysAtom } from '@stores/relays';
import { createCacheNote, getNoteByID } from '@utils/storage';
import destr from 'destr';
import { useAtomValue } from 'jotai';
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import reactStringReplace from 'react-string-replace';
export const NoteRepost = memo(function NoteRepost({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const relays = useAtomValue(relaysAtom);
const [event, setEvent] = useState(null);
const fetchEvent = useCallback(() => {
pool.subscribe(
[
{
ids: [id],
kinds: [1],
},
],
relays,
(event: any) => {
// update state
setEvent(event);
// insert to database
createCacheNote(event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
}, [id, pool, relays]);
useEffect(() => {
getNoteByID(id).then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
});
}, [fetchEvent, id]);
const content = useMemo(() => {
let parsedContent = event ? event.content : null;
if (parsedContent !== null) {
// get data tags
const tags = destr(event.tags);
// handle urls
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => (
<a key={match + i} href={match} target="_blank" rel="noreferrer">
{match}
</a>
));
// handle #-hashtags
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
<span key={match + i} className="cursor-pointer text-fuchsia-500">
#{match}
</span>
));
// handle mentions
if (tags.length > 0) {
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
if (tags[match][0] === 'p') {
// @-mentions
return <UserMention key={match + i} pubkey={tags[match][1]} />;
} else {
return;
}
});
}
}
return parsedContent;
}, [event]);
if (event) {
return (
<div className="relative mt-3 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
<div className="relative z-10 flex flex-col">
<UserExtend pubkey={event.pubkey} time={event.created_at} />
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-2">
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1 prose-img:mt-2 prose-img:mb-0 prose-video:mt-1 prose-video:mb-0">
{content}
</div>
</div>
</div>
</div>
</div>
);
} else {
return (
<div className="relative z-10 flex h-min animate-pulse select-text flex-col pb-5">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
<span className="text-zinc-500">·</span>
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
<div className="h-3 w-3 rounded-full bg-zinc-700" />
</div>
</div>
</div>
</div>
);
}
});

View File

@@ -1,84 +0,0 @@
import { RelayContext } from '@components/relaysProvider';
import { relaysAtom } from '@stores/relays';
import { createCacheNote, getNoteByID } from '@utils/storage';
import { useAtomValue } from 'jotai';
import { memo, useCallback, useContext, useEffect, useState } from 'react';
export const RootNote = memo(function RootNote({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const relays = useAtomValue(relaysAtom);
const [event, setEvent] = useState(null);
const fetchEvent = useCallback(() => {
pool.subscribe(
[
{
ids: [id],
kinds: [1],
},
],
relays,
(event: any) => {
// update state
setEvent(event);
// insert to database
createCacheNote(event);
},
undefined,
undefined,
{
unsubscribeOnEose: true,
}
);
}, [id, pool, relays]);
useEffect(() => {
getNoteByID(id).then((res) => {
if (res) {
setEvent(res);
} else {
fetchEvent();
}
});
}, [fetchEvent, id]);
if (event) {
return (
<div className="relative pb-5">
<div className="absolute top-0 left-[21px] h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
<p>{event.content}</p>
</div>
);
} else {
return (
<div className="relative z-10 flex h-min animate-pulse select-text flex-col pb-5">
<div className="flex items-start gap-2">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="h-4 w-16 rounded bg-zinc-700" />
<span className="text-zinc-500">·</span>
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
<div className="h-3 w-3 rounded-full bg-zinc-700" />
</div>
</div>
</div>
<div className="-mt-5 pl-[52px]">
<div className="flex flex-col gap-6">
<div className="h-16 w-full rounded bg-zinc-700" />
<div className="flex items-center gap-8">
<div className="h-4 w-12 rounded bg-zinc-700" />
<div className="h-4 w-12 rounded bg-zinc-700" />
</div>
</div>
</div>
</div>
);
}
});

View File

@@ -1,8 +1,7 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import WithSidebarLayout from '@layouts/withSidebar'; import WithSidebarLayout from '@layouts/withSidebar';
import { Content } from '@components/note/content'; import { NoteExtend } from '@components/note/extend';
import { ContentExtend } from '@components/note/content/extend';
import FormComment from '@components/note/form/comment'; import FormComment from '@components/note/form/comment';
import { RelayContext } from '@components/relaysProvider'; import { RelayContext } from '@components/relaysProvider';
@@ -11,7 +10,6 @@ import { relaysAtom } from '@stores/relays';
import { getNoteByID } from '@utils/storage'; import { getNoteByID } from '@utils/storage';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { GetStaticPaths } from 'next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { import {
JSXElementConstructor, JSXElementConstructor,
@@ -65,21 +63,13 @@ export default function Page() {
return ( return (
<div className="scrollbar-hide flex h-full flex-col gap-2 overflow-y-auto py-5"> <div className="scrollbar-hide flex h-full flex-col gap-2 overflow-y-auto py-5">
<div className="flex h-min min-h-min w-full select-text flex-col px-3"> <div className="flex h-min min-h-min w-full select-text flex-col px-3">
{rootEvent && <ContentExtend data={rootEvent} />} {rootEvent && <NoteExtend event={rootEvent} />}
</div> </div>
<div> <div>
<FormComment eventID={id} /> <FormComment eventID={id} />
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
{comments.length > 0 && {comments.length > 0 && comments.map((comment) => <p key={comment.id}>{comment.content}</p>)}
comments.map((comment) => (
<div
key={comment.id}
className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20"
>
<Content data={comment} />
</div>
))}
</div> </div>
</div> </div>
); );

View File

@@ -1,7 +1,7 @@
import BaseLayout from '@layouts/base'; import BaseLayout from '@layouts/base';
import WithSidebarLayout from '@layouts/withSidebar'; import WithSidebarLayout from '@layouts/withSidebar';
import { Note } from '@components/note'; import { NoteBase } from '@components/note/base';
import FormBasic from '@components/note/form/basic'; import FormBasic from '@components/note/form/basic';
import { hasNewerNoteAtom, notesAtom } from '@stores/note'; import { hasNewerNoteAtom, notesAtom } from '@stores/note';
@@ -52,7 +52,7 @@ export default function Page() {
<div className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${items[0].start}px)` }}> <div className="absolute top-0 left-0 w-full" style={{ transform: `translateY(${items[0].start}px)` }}>
{items.map((virtualRow) => ( {items.map((virtualRow) => (
<div key={virtualRow.key} data-index={virtualRow.index} ref={virtualizer.measureElement}> <div key={virtualRow.key} data-index={virtualRow.index} ref={virtualizer.measureElement}>
<Note event={data[virtualRow.index]} /> <NoteBase event={data[virtualRow.index]} />
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,9 +1,9 @@
import { isSSR } from '@utils/ssr'; import { isSSR } from '@utils/ssr';
import { getActiveAccount } from '@utils/storage'; import { getActiveAccount } from '@utils/storage';
import { atomWithCache } from 'jotai-cache'; import { atom } from 'jotai';
export const activeAccountAtom = atomWithCache(async () => { export const activeAccountAtom = atom(async () => {
const response = isSSR ? {} : await getActiveAccount(); const response = isSSR ? {} : await getActiveAccount();
return response; return response;
}); });

View File

@@ -1,9 +1,9 @@
import { isSSR } from '@utils/ssr'; import { isSSR } from '@utils/ssr';
import { getAllRelays } from '@utils/storage'; import { getAllRelays } from '@utils/storage';
import { atomWithCache } from 'jotai-cache'; import { atom } from 'jotai';
export const relaysAtom = atomWithCache(async () => { export const relaysAtom = atom(async () => {
const response = isSSR ? [] : await getAllRelays(); const response = isSSR ? [] : await getAllRelays();
return response; return response;
}); });

View File

@@ -1,3 +1,5 @@
import { getParentID } from '@utils/transform';
import Database from 'tauri-plugin-sql-api'; import Database from 'tauri-plugin-sql-api';
let db: null | Database = null; let db: null | Database = null;
@@ -89,7 +91,7 @@ export async function getCacheProfile(id) {
// get note by id // get note by id
export async function getAllNotes() { export async function getAllNotes() {
const db = await connect(); const db = await connect();
return await db.select(`SELECT * FROM cache_notes WHERE is_root = 0 ORDER BY created_at DESC LIMIT 1000`); return await db.select(`SELECT * FROM cache_notes GROUP BY parent_id ORDER BY created_at DESC LIMIT 1000`);
} }
// get note by id // get note by id
@@ -103,7 +105,15 @@ export async function getNoteByID(id) {
export async function createCacheNote(data) { export async function createCacheNote(data) {
const db = await connect(); const db = await connect();
return await db.execute( return await db.execute(
'INSERT OR IGNORE INTO cache_notes (id, pubkey, created_at, kind, content, tags, is_root) VALUES (?, ?, ?, ?, ?, ?, ?);', 'INSERT OR IGNORE INTO cache_notes (id, pubkey, created_at, kind, content, tags, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?);',
[data.id, data.pubkey, data.created_at, data.kind, data.content, JSON.stringify(data.tags), 0] [
data.id,
data.pubkey,
data.created_at,
data.kind,
data.content,
JSON.stringify(data.tags),
getParentID(data.tags, data.id),
]
); );
} }

View File

@@ -1,3 +1,5 @@
import destr from 'destr';
export const tagsToArray = (arr) => { export const tagsToArray = (arr) => {
const newarr = []; const newarr = [];
// push item to newarr // push item to newarr
@@ -15,3 +17,22 @@ export const pubkeyArray = (arr) => {
}); });
return newarr; return newarr;
}; };
export const getParentID = (arr, fallback) => {
const tags = destr(arr);
let parentID = fallback;
if (tags.length > 0) {
if (tags[0][0] === 'e' || tags[0][2] === 'root' || tags[0][3] === 'root') {
parentID = tags[0][1];
} else {
tags.forEach((tag) => {
if (tag[0] === 'e' && (tag[2] === 'root' || tag[3] === 'root')) {
parentID = tag[1];
}
});
}
}
return parentID;
};