add thread

This commit is contained in:
Ren Amamiya
2023-06-12 13:40:40 +07:00
parent 6dab0d5e44
commit 10f2f530c7
44 changed files with 432 additions and 346 deletions

View File

@@ -59,7 +59,6 @@ export function AddBlock() {
</Menu.Items>
</Transition>
</Menu>
{imageModal && <AddImageBlock parentState={setImageModal} />}
{feedModal && <AddFeedBlock parentState={setFeedModal} />}
</>

View File

@@ -1,6 +1,6 @@
import { NoteBase } from "@app/note/components/base";
import { NoteQuoteRepost } from "@app/note/components/quoteRepost";
import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteBase } from "@app/space/components/notes/base";
import { NoteQuoteRepost } from "@app/space/components/notes/quoteRepost";
import { NoteSkeleton } from "@app/space/components/notes/skeleton";
import { getNotesByAuthor } from "@libs/storage";
import { CancelIcon } from "@shared/icons";
import { useActiveAccount } from "@stores/accounts";
@@ -73,7 +73,7 @@ export function FeedBlock({ params }: { params: any }) {
</div>
<div
ref={parentRef}
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 overflow-y-auto"
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
style={{ contain: "strict" }}
>
{!data || isLoading ? (
@@ -108,7 +108,11 @@ export function FeedBlock({ params }: { params: any }) {
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteBase key={note.event_id} event={note} />
<NoteBase
key={note.event_id}
block={params.id}
event={note}
/>
</div>
);
} else {
@@ -118,7 +122,11 @@ export function FeedBlock({ params }: { params: any }) {
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteQuoteRepost key={note.event_id} event={note} />
<NoteQuoteRepost
key={note.event_id}
block={params.id}
event={note}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { NoteBase } from "@app/note/components/base";
import { NoteQuoteRepost } from "@app/note/components/quoteRepost";
import { NoteSkeleton } from "@app/note/components/skeleton";
import { NoteBase } from "@app/space/components/notes/base";
import { NoteQuoteRepost } from "@app/space/components/notes/quoteRepost";
import { NoteSkeleton } from "@app/space/components/notes/skeleton";
import { getNotes } from "@libs/storage";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useMemo, useRef } from "react";
@@ -11,7 +11,7 @@ const TIME = Math.floor(Date.now() / 1000);
const fetcher = async ([, offset]) => getNotes(TIME, ITEM_PER_PAGE, offset);
export function FollowingBlock() {
export function FollowingBlock({ block }: { block: number }) {
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.data) return null;
if (pageIndex === 0) return ["following", 0];
@@ -56,7 +56,7 @@ export function FollowingBlock() {
</div>
<div
ref={parentRef}
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 overflow-y-auto"
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"
style={{ contain: "strict" }}
>
{!data || isLoading ? (
@@ -91,7 +91,11 @@ export function FollowingBlock() {
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteBase key={note.event_id} event={note} />
<NoteBase
key={note.event_id}
block={block}
event={note}
/>
</div>
);
} else {
@@ -101,7 +105,11 @@ export function FollowingBlock() {
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<NoteQuoteRepost key={note.event_id} event={note} />
<NoteQuoteRepost
key={note.event_id}
block={block}
event={note}
/>
</div>
);
}

View File

@@ -20,7 +20,7 @@ export function ImageBlock({ params }: { params: any }) {
<button
type="button"
onClick={() => close()}
className="inline-flex h-6 w-9 shrink items-center justify-center rounded bg-zinc-900 group-hover:bg-zinc-800"
className="inline-flex h-7 w-7 shrink items-center justify-center rounded bg-zinc-900 hover:bg-zinc-800"
>
<CancelIcon width={14} height={14} className="text-zinc-500" />
</button>

View File

@@ -0,0 +1,65 @@
import { Kind1 } from "@app/space/components/notes/kind1";
import { Kind1063 } from "@app/space/components/notes/kind1063";
import { NoteMetadata } from "@app/space/components/notes/metadata";
import { RepliesList } from "@app/space/components/notes/replies/list";
import { NoteSkeleton } from "@app/space/components/notes/skeleton";
import { NoteDefaultUser } from "@app/space/components/user/default";
import { getNoteByID } from "@libs/storage";
import { ArrowLeftIcon } from "@shared/icons";
import { useActiveAccount } from "@stores/accounts";
import { noteParser } from "@utils/parser";
import useSWR from "swr";
const fetcher = ([, id]) => getNoteByID(id);
export function ThreadBlock({ params }: { params: any }) {
const { data } = useSWR(["thread", params.content], fetcher);
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
const content = data ? noteParser(data) : null;
const close = () => {
removeBlock(params.id, false);
};
return (
<div className="shrink-0 w-[420px] border-r border-zinc-900">
<div
data-tauri-drag-region
className="h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900"
>
<button
type="button"
onClick={() => close()}
className="inline-flex h-7 w-7 shrink items-center justify-center rounded bg-zinc-900 hover:bg-zinc-800"
>
<ArrowLeftIcon width={14} height={14} className="text-zinc-500" />
</button>
<h3 className="font-semibold text-zinc-100">{params.title}</h3>
<div className="w-9 h-6" />
</div>
<div className="scrollbar-hide flex w-full h-full flex-col gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{!data ? (
<div className="px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-3 py-3 shadow-input shadow-black/20">
<NoteSkeleton />
</div>
</div>
) : (
<div className="h-min w-full px-3 py-1.5">
<div className="rounded-md bg-zinc-900 px-5 pt-5">
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-3">
{data.kind === 1 && <Kind1 content={content} />}
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
<NoteMetadata id={params.content} eventPubkey={data.pubkey} />
</div>
</div>
</div>
)}
<div className="px-3">
<RepliesList id={params.content} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Kind1 } from "@app/space/components/notes/kind1";
import { Kind1063 } from "@app/space/components/notes/kind1063";
import { NoteMetadata } from "@app/space/components/notes/metadata";
import { NoteParent } from "@app/space/components/notes/parent";
import { NoteWrapper } from "@app/space/components/notes/wrapper";
import { NoteDefaultUser } from "@app/space/components/user/default";
import { noteParser } from "@utils/parser";
import { isTagsIncludeID } from "@utils/transform";
import { useMemo } from "react";
export function NoteBase({ block, event }: { block: number; event: any }) {
const content = useMemo(() => noteParser(event), [event]);
const checkParentID = isTagsIncludeID(event.parent_id, event.tags);
const threadID = event.parent_id ? event.parent_id : event.event_id;
return (
<NoteWrapper
thread={threadID}
block={block}
className="h-min w-full px-3 py-1.5"
>
<div className="rounded-md bg-zinc-900 px-5 pt-5">
{event.parent_id &&
(event.parent_id !== event.event_id || checkParentID) ? (
<NoteParent id={event.parent_id} />
) : (
<></>
)}
<div className="flex flex-col">
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
<div className="-mt-5 pl-[49px]">
{event.kind === 1 && <Kind1 content={content} />}
{event.kind === 1063 && <Kind1063 metadata={event.tags} />}
<NoteMetadata id={event.event_id} eventPubkey={event.pubkey} />
</div>
</div>
</div>
</NoteWrapper>
);
}

View File

@@ -0,0 +1,50 @@
import { LinkPreview } from "./preview/link";
import { MentionNote } from "@app/space/components/notes/mentions/note";
import { MentionUser } from "@app/space/components/notes/mentions/user";
import { ImagePreview } from "@app/space/components/notes/preview/image";
import { VideoPreview } from "@app/space/components/notes/preview/video";
import { truncateContent } from "@utils/transform";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function Kind1({
content,
truncate = false,
}: { content: any; truncate?: boolean }) {
return (
<>
<ReactMarkdown
remarkPlugins={[[remarkGfm]]}
linkTarget="_blank"
className="markdown"
components={{
em: ({ ...props }) => <MentionUser {...props} />,
}}
>
{truncate ? truncateContent(content.parsed, 120) : content.parsed}
</ReactMarkdown>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))
) : (
<></>
)}
</>
);
}

View File

@@ -0,0 +1,21 @@
import { Image } from "@shared/image";
function isImage(url: string) {
return /\.(jpg|jpeg|gif|png|webp|avif)$/.test(url);
}
export function Kind1063({ metadata }: { metadata: string[] }) {
const url = metadata[0][1];
return (
<div className="mt-3">
{isImage(url) && (
<Image
src={url}
alt="image"
className="h-auto w-full rounded-lg object-cover"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { Kind1 } from "@app/space/components/notes/kind1";
import { Kind1063 } from "@app/space/components/notes/kind1063";
import { NoteSkeleton } from "@app/space/components/notes/skeleton";
import { NoteWrapper } from "@app/space/components/notes/wrapper";
import { NoteQuoteUser } from "@app/space/components/user/quote";
import { useEvent } from "@utils/hooks/useEvent";
import { noteParser } from "@utils/parser";
import { memo } from "react";
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const data = useEvent(id);
const kind1 = data?.kind === 1 ? noteParser(data) : null;
const kind1063 = data?.kind === 1063 ? data.tags : null;
return (
<NoteWrapper
href={`/app/note?id=${id}`}
className="mt-3 rounded-lg border border-zinc-800 px-3 py-3"
>
{data ? (
<>
<NoteQuoteUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-2">
{kind1 && <Kind1 content={kind1} truncate={true} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
{!kind1 && !kind1063 && (
<div className="flex flex-col gap-2">
<div className="px-2 py-2 inline-flex flex-col gap-1 bg-zinc-800 rounded-md">
<span className="text-zinc-500 text-sm font-medium leading-none">
Kind: {data.kind}
</span>
<p className="text-fuchsia-500 text-sm leading-none">
Lume isn't fully support this kind in newsfeed
</p>
</div>
<div className="markdown">
<p>{data.content}</p>
</div>
</div>
)}
</div>
</>
) : (
<NoteSkeleton />
)}
</NoteWrapper>
);
});

View File

@@ -0,0 +1,20 @@
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
const hexRegex = /[0-9A-Fa-f]{6}/g;
export function MentionUser(props: { children: any[] }) {
const pubkey = props.children[0].match(hexRegex) ? props.children[0] : null;
if (!pubkey) {
return null;
}
const { user } = useProfile(pubkey);
return (
<span className="text-fuchsia-500">
@{user?.name || user?.displayName || shortenKey(pubkey)}
</span>
);
}

View File

@@ -0,0 +1,113 @@
import { NoteReply } from "@app/space/components/notes/metadata/reply";
import { NoteRepost } from "@app/space/components/notes/metadata/repost";
import { NoteZap } from "@app/space/components/notes/metadata/zap";
import { createReplyNote } from "@libs/storage";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { LoaderIcon, ReplyIcon, RepostIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { decode } from "light-bolt11-decoder";
import { useContext } from "react";
import useSWR from "swr";
const fetcher = async ([, ndk, id]) => {
let replies = 0;
let reposts = 0;
let zap = 0;
const filter: NDKFilter = {
"#e": [id],
kinds: [1, 6, 9735],
};
const events = await ndk.fetchEvents(filter);
events.forEach((event: NDKEvent) => {
switch (event.kind) {
case 1:
replies += 1;
createReplyNote(
event.id,
event.pubkey,
event.kind,
event.tags,
event.content,
event.created_at,
id,
);
break;
case 6:
reposts += 1;
break;
case 9735: {
const bolt11 = event.tags.find((tag) => tag[0] === "bolt11")[1];
if (bolt11) {
const decoded = decode(bolt11);
const amount = decoded.sections.find(
(item) => item.name === "amount",
);
const sats = amount.value / 1000;
zap += sats;
}
break;
}
default:
break;
}
});
return { replies, reposts, zap };
};
export function NoteMetadata({
id,
eventPubkey,
}: {
id: string;
eventPubkey: string;
}) {
const ndk = useContext(RelayContext);
const { data } = useSWR(["note-metadata", ndk, id], fetcher);
return (
<div className="inline-flex items-center gap-2 w-full h-12 mt-4">
{!data ? (
<>
<div className="w-20 group inline-flex items-center gap-1.5">
<ReplyIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-green-400"
/>
<LoaderIcon
width={16}
height={16}
className="animate-spin text-black dark:text-white"
/>
</div>
<div className="w-20 group inline-flex items-center gap-1.5">
<RepostIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-green-400"
/>
<LoaderIcon
width={16}
height={16}
className="animate-spin text-black dark:text-white"
/>
</div>
<div className="ml-auto">
<div className="w-10 h-4 bg-zinc-800 rounded animate-pulse" />
</div>
</>
) : (
<>
<NoteReply id={id} replies={data.replies} />
<NoteRepost id={id} pubkey={eventPubkey} reposts={data.reposts} />
<div className="ml-auto">
<NoteZap zaps={data.zap} />
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { Dialog, Transition } from "@headlessui/react";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { ReplyIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { dateToUnix } from "@utils/date";
import { compactNumber } from "@utils/number";
import { Fragment, useContext, useState } from "react";
export function NoteReply({ id, replies }: { id: string; replies: number }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const [count, setCount] = useState(replies);
const [isOpen, setIsOpen] = useState(false);
const [value, setValue] = useState("");
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const submitEvent = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 1;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [["e", id]];
// publish event
event.publish();
// close modal
setIsOpen(false);
// increment replies
setCount(count + 1);
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="w-20 group inline-flex items-center gap-1.5"
>
<ReplyIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-green-400"
/>
<span className="text-base leading-none text-zinc-400 group-hover:text-white">
{compactNumber.format(count)}
</span>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
</Transition.Child>
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900 p-3">
{/* root note */}
{/* comment form */}
<div className="flex gap-2">
<div>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-md border border-white/10">
<Image
src={account?.image}
alt="user's avatar"
className="h-11 w-11 rounded-md object-cover"
/>
</div>
</div>
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-blue-500 before:opacity-0 before:ring-2 before:ring-blue-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-blue-500/100 dark:focus-within:after:shadow-blue-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Send your comment"
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-base shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<div className="flex items-center gap-2 pl-2" />
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-base font-medium shadow-md shadow-fuchsia-900/50 hover:bg-fuchsia-600"
>
<span className="text-white drop-shadow">Send</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -0,0 +1,59 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { RepostIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { dateToUnix } from "@utils/date";
import { compactNumber } from "@utils/number";
import { useContext, useState } from "react";
export function NoteRepost({
id,
pubkey,
reposts,
}: { id: string; pubkey: string; reposts: number }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const [count, setCount] = useState(reposts);
const submitEvent = (e: any) => {
e.stopPropagation();
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = "";
event.kind = 6;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [
["e", id],
["p", pubkey],
];
// publish event
event.publish();
// update state
setCount(count + 1);
};
return (
<button
type="button"
onClick={(e) => submitEvent(e)}
className="w-20 group inline-flex items-center gap-1.5"
>
<RepostIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-blue-400"
/>
<span className="text-base leading-none text-zinc-400 group-hover:text-white">
{compactNumber.format(count)}
</span>
</button>
);
}

View File

@@ -0,0 +1,14 @@
import { compactNumber } from "@utils/number";
import { useState } from "react";
export function NoteZap({ zaps }: { zaps: number }) {
const [count, setCount] = useState(zaps);
return (
<button type="button" className="group inline-flex items-center gap-1.5">
<span className="text-base leading-none text-zinc-400 group-hover:text-white">
{compactNumber.format(count)} sats zapped
</span>
</button>
);
}

View File

@@ -0,0 +1,51 @@
import { Kind1 } from "@app/space/components/notes/kind1";
import { Kind1063 } from "@app/space/components/notes/kind1063";
import { NoteMetadata } from "@app/space/components/notes/metadata";
import { NoteSkeleton } from "@app/space/components/notes/skeleton";
import { NoteDefaultUser } from "@app/space/components/user/default";
import { useEvent } from "@utils/hooks/useEvent";
import { noteParser } from "@utils/parser";
import { memo } from "react";
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
const data = useEvent(id);
const kind1 = data?.kind === 1 ? noteParser(data) : null;
const kind1063 = data?.kind === 1063 ? data.tags : null;
return (
<div className="relative overflow-hidden flex flex-col pb-6">
<div className="absolute left-[18px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-5 pl-[49px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
{!kind1 && !kind1063 && (
<div className="flex flex-col gap-2">
<div className="px-2 py-2 inline-flex flex-col gap-1 bg-zinc-800 rounded-md">
<span className="text-zinc-500 text-sm font-medium leading-none">
Kind: {data.kind}
</span>
<p className="text-fuchsia-500 text-sm leading-none">
Lume isn't fully support this kind in newsfeed
</p>
</div>
<div className="markdown">
<p>{data.content}</p>
</div>
</div>
)}
<NoteMetadata
id={data.event_id || data.id}
eventPubkey={data.pubkey}
/>
</div>
</>
) : (
<NoteSkeleton />
)}
</div>
);
});

View File

@@ -0,0 +1,19 @@
import { Image } from "@shared/image";
export function ImagePreview({ urls }: { urls: string[] }) {
return (
<div className="mt-3 overflow-hidden">
<div className="flex flex-col gap-2">
{urls.map((url) => (
<div key={url} className="min-w-0 grow-0 shrink-0 basis-full">
<Image
src={url}
alt="image"
className="h-auto w-full rounded-lg object-cover"
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
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 ? (
<div className="flex flex-col">
<div className="w-full h-16 bg-zinc-700 animate-pulse" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="w-2/3 h-3 rounded bg-zinc-700 animate-pulse" />
<div className="w-3/4 h-3 rounded bg-zinc-700 animate-pulse" />
<div className="mt-2.5 w-1/3 h-2 rounded bg-zinc-700 animate-pulse" />
</div>
</div>
) : (
<a
className="flex flex-col"
href={urls[0]}
target="_blank"
rel="noreferrer"
>
{data["og:image"] && (
<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

@@ -0,0 +1,15 @@
import { MediaOutlet, MediaPlayer } from "@vidstack/react";
export function VideoPreview({ urls }: { urls: string[] }) {
return (
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="relative mt-2 flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
>
<MediaPlayer src={urls[0]} poster="" controls>
<MediaOutlet />
</MediaPlayer>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { RootNote } from "@app/space/components/notes/rootNote";
import { NoteWrapper } from "@app/space/components/notes/wrapper";
import { NoteRepostUser } from "@app/space/components/user/repost";
import { getQuoteID } from "@utils/transform";
export function NoteQuoteRepost({
block,
event,
}: { block: number; event: any }) {
const rootID = getQuoteID(event.tags);
return (
<NoteWrapper
thread={rootID}
block={block}
className="h-min w-full px-3 py-1.5"
>
<div className="rounded-md bg-zinc-900">
<div className="relative px-5 pb-5 pt-5">
<div className="absolute left-[35px] top-[20px] h-[70px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
</div>
<RootNote id={rootID} fallback={event.content} />
</div>
</NoteWrapper>
);
}

View File

@@ -0,0 +1,62 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { dateToUnix } from "@utils/date";
import { useContext, useState } from "react";
export function NoteReplyForm({ id }: { id: string }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const [value, setValue] = useState("");
const submitEvent = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 1;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [["e", id]];
// publish event
event.publish();
// reset form
setValue("");
};
return (
<div className="flex gap-2.5 py-4">
<div className="relative h-24 w-full flex-1 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
<div>
<textarea
name="content"
onChange={(e) => setValue(e.target.value)}
placeholder="Reply to this thread..."
className="relative h-24 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-base shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-zinc-500"
spellCheck={false}
/>
</div>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700" />
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => submitEvent()}
disabled={value.length === 0 ? true : false}
className="inline-flex h-8 w-16 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-base font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
>
Reply
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Kind1 } from "@app/space/components/notes/kind1";
import { NoteMetadata } from "@app/space/components/notes/metadata";
import { NoteReplyUser } from "@app/space/components/user/reply";
import { noteParser } from "@utils/parser";
export function Reply({ data }: { data: any }) {
const content = noteParser(data);
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-3 pt-5 mb-3 rounded-md bg-zinc-900">
<div className="flex flex-col">
<NoteReplyUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[20px] pl-[47px]">
<Kind1 content={content} />
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Reply } from "@app/space/components/notes/replies/item";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { useContext } from "react";
import useSWR from "swr";
const fetcher = async ([, ndk, id]) => {
const filter: NDKFilter = {
"#e": [id],
kinds: [1],
};
const events = await ndk.fetchEvents(filter);
return [...events];
};
export function RepliesList({ id }: { id: string }) {
const ndk = useContext(RelayContext);
const { data } = useSWR(["note-replies", ndk, id], fetcher);
return (
<div className="mt-5">
<div className="mb-2">
<h5 className="text-lg font-semibold text-zinc-300">Replies</h5>
</div>
<div className="flex flex-col">
{!data ? (
<div className="flex gap-2 px-3 py-4">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col justify-center gap-1">
<div className="flex items-baseline gap-2 text-base">
<div className="h-2.5 w-20 animate-pulse rounded-sm bg-zinc-800" />
</div>
<div className="h-4 w-44 animate-pulse rounded-sm bg-zinc-800" />
</div>
</div>
) : (
data.map((event: NDKEvent) => <Reply key={event.id} data={event} />)
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { Kind1 } from "@app/space/components/notes/kind1";
import { Kind1063 } from "@app/space/components/notes/kind1063";
import { NoteMetadata } from "@app/space/components/notes/metadata";
import { NoteSkeleton } from "@app/space/components/notes/skeleton";
import { NoteDefaultUser } from "@app/space/components/user/default";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { RelayContext } from "@shared/relayProvider";
import { noteParser } from "@utils/parser";
import { memo, useContext } from "react";
import useSWRSubscription from "swr/subscription";
import { navigate } from "vite-plugin-ssr/client/router";
function isJSON(str: string) {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}
export const RootNote = memo(function RootNote({
id,
fallback,
}: { id: string; fallback?: any }) {
const ndk = useContext(RelayContext);
const parseFallback = isJSON(fallback) ? JSON.parse(fallback) : null;
const { data, error } = useSWRSubscription(
parseFallback ? null : id,
(key, { next }) => {
const sub = ndk.subscribe({
ids: [key],
});
sub.addListener("event", (event: NDKEvent) => {
next(null, event);
});
return () => {
sub.stop();
};
},
);
const openNote = (e) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
navigate(`/app/note?id=${id}`);
} else {
e.stopPropagation();
}
};
const kind1 = !error && data?.kind === 1 ? noteParser(data) : null;
const kind1063 = !error && data?.kind === 1063 ? data.tags : null;
if (parseFallback) {
const contentFallback = noteParser(parseFallback);
return (
<div
onClick={(e) => openNote(e)}
onKeyDown={(e) => openNote(e)}
className="flex flex-col px-5"
>
<NoteDefaultUser
pubkey={parseFallback.pubkey}
time={parseFallback.created_at}
/>
<div className="-mt-5 pl-[49px]">
<Kind1 content={contentFallback} />
<NoteMetadata
id={parseFallback.id}
eventPubkey={parseFallback.pubkey}
/>
</div>
</div>
);
}
return (
<div
onClick={(e) => openNote(e)}
onKeyDown={(e) => openNote(e)}
className="flex flex-col px-5"
>
{data ? (
<>
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-5 pl-[49px]">
{kind1 && <Kind1 content={kind1} />}
{kind1063 && <Kind1063 metadata={kind1063} />}
{!kind1 && !kind1063 && (
<div className="flex flex-col gap-2">
<div className="px-2 py-2 inline-flex flex-col gap-1 bg-zinc-800 rounded-md">
<span className="text-zinc-500 text-sm font-medium leading-none">
Kind: {data.kind}
</span>
<p className="text-fuchsia-500 text-sm leading-none">
Lume isn't fully support this kind in newsfeed
</p>
</div>
<div className="markdown">
<p>{data.content}</p>
</div>
</div>
)}
<NoteMetadata id={data.id} eventPubkey={data.pubkey} />
</div>
</>
) : (
<NoteSkeleton />
)}
</div>
);
});

View File

@@ -0,0 +1,19 @@
export function NoteSkeleton() {
return (
<div className="flex h-min flex-col pb-3">
<div className="flex items-start gap-3">
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
<div className="flex flex-col gap-0.5">
<div className="h-4 w-20 rounded bg-zinc-700" />
</div>
</div>
<div className="-mt-5 animate-pulse pl-[49px]">
<div className="flex flex-col gap-1">
<div className="h-4 w-full rounded-sm bg-zinc-700" />
<div className="h-3 w-2/3 rounded-sm bg-zinc-700" />
<div className="h-3 w-1/2 rounded-sm bg-zinc-700" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useActiveAccount } from "@stores/accounts";
export function NoteWrapper({
children,
thread,
block,
className,
}: {
children: React.ReactNode;
thread: string;
block: number;
className: string;
}) {
const addTempBlock = useActiveAccount((state: any) => state.addTempBlock);
const openThread = (event: any, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
addTempBlock(block, 2, "Thread", thread);
} else {
event.stopPropagation();
}
};
return (
<div
onClick={(e) => openThread(e, thread)}
onKeyDown={(e) => openThread(e, thread)}
className={className}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { Popover, Transition } from "@headlessui/react";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from "react";
dayjs.extend(relativeTime);
export function NoteDefaultUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return (
<Popover className="relative flex items-start gap-3">
<Popover.Button className="h-11 w-11 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={user?.image || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 object-cover"
/>
</Popover.Button>
<div className="flex flex-wrap items-baseline gap-1">
<h5 className="max-w-[10rem] text-base font-semibold leading-none truncate">
{user?.nip05 || user?.name || shortenKey(pubkey)}
</h5>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time), true)}
</span>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
>
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
<Image
src={user?.image || DEFAULT_AVATAR}
alt={pubkey}
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="inline-flex w-2/3 flex-col gap-0.5">
<h5 className="text-base font-semibold leading-none">
{user?.displayName || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5>
<span className="truncate text-base leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div>
<p className="line-clamp-3 text-base leading-tight text-white">
{user?.about}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<a
href={`/app/user?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-base font-medium"
>
View full profile
</a>
<a
href={`/app/chat?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-base font-medium"
>
Message
</a>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
}

View File

@@ -0,0 +1,41 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export function NoteQuoteUser({
pubkey,
time,
}: {
pubkey: string;
time: number;
}) {
const { user } = useProfile(pubkey);
return (
<div className="group flex items-center gap-2">
<div className="relative h-6 w-6 shrink-0 rounded">
<Image
src={user?.image || DEFAULT_AVATAR}
alt={pubkey}
className="h-6 w-6 rounded object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-base">
<span className="max-w-[10rem] truncate font-semibold leading-none text-white">
{user?.nip05 || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time), true)}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export function NoteReplyUser({
pubkey,
time,
}: {
pubkey: string;
time: number;
}) {
const { user } = useProfile(pubkey);
return (
<div className="group flex items-start gap-2.5">
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={user?.image || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-base">
<span className="max-w-[10rem] truncate font-semibold leading-none text-white">
{user?.nip05 || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time), true)}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { Popover, Transition } from "@headlessui/react";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from "react";
dayjs.extend(relativeTime);
export function NoteRepostUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
const { user } = useProfile(pubkey);
return (
<Popover className="relative flex items-start gap-3">
<Popover.Button className="h-11 w-11 shrink-0 overflow-hidden rounded-md bg-zinc-900">
<Image
src={user?.image || DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</Popover.Button>
<div className="flex flex-wrap items-baseline gap-1">
<h5 className="max-w-[10rem] text-base font-semibold leading-none truncate">
{user?.nip05 || user?.name || shortenKey(pubkey)}
</h5>
<span className="font-semibold leading-none text-fuchsia-500">
{" "}
reposted
</span>
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time), true)}
</span>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 top-8 z-10 mt-3 w-screen max-w-sm px-4 sm:px-0 lg:max-w-3xl">
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="w-full max-w-xs overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 shadow-input ring-1 ring-black ring-opacity-5"
>
<div className="flex items-start gap-2.5 border-b border-zinc-800 px-3 py-3">
<Image
src={user?.image || DEFAULT_AVATAR}
alt={pubkey}
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="inline-flex w-2/3 flex-col gap-0.5">
<h5 className="text-base font-semibold leading-none">
{user?.displayName || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
</h5>
<span className="truncate text-base leading-none text-zinc-500">
{user?.nip05 || shortenKey(pubkey)}
</span>
</div>
<div>
<p className="line-clamp-3 text-base leading-tight text-white">
{user?.about}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<a
href={`/app/user?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-base font-medium"
>
View full profile
</a>
<a
href={`/app/chat?pubkey=${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-zinc-800 text-base font-medium"
>
Message
</a>
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
);
}

View File

@@ -2,6 +2,7 @@ import { AddBlock } from "@app/space/components/add";
import { FeedBlock } from "@app/space/components/blocks/feed";
import { FollowingBlock } from "@app/space/components/blocks/following";
import { ImageBlock } from "@app/space/components/blocks/image";
import { ThreadBlock } from "@app/space/components/blocks/thread";
import { useActiveAccount } from "@stores/accounts";
import { useEffect } from "react";
@@ -16,16 +17,23 @@ export function Page() {
return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<FollowingBlock />
{blocks
? blocks.map((block: any) =>
block.kind === 0 ? (
<ImageBlock key={block.id} params={block} />
) : (
<FeedBlock key={block.id} params={block} />
),
)
: null}
<FollowingBlock block={1} />
{!blocks ? (
<p>Loading...</p>
) : (
blocks.map((block: any) => {
switch (block.kind) {
case 0:
return <ImageBlock key={block.id} params={block} />;
case 1:
return <FeedBlock key={block.id} params={block} />;
case 2:
return <ThreadBlock key={block.id} params={block} />;
default:
break;
}
})
)}
<div className="shrink-0 w-[90px]">
<div className="w-full h-full inline-flex items-center justify-center">
<AddBlock />