add user page
This commit is contained in:
@@ -13,7 +13,7 @@ export function ChatsListItem({ data }: { data: any }) {
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
||||
<div>
|
||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-base font-medium" />
|
||||
<div className="h-2.5 w-2/3 animate-pulse truncate rounded bg-zinc-800 text-base font-medium" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -41,7 +41,10 @@ export function ChatsListItem({ data }: { data: any }) {
|
||||
<div className="w-full inline-flex items-center justify-between">
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<h5 className="max-w-[9rem] truncate font-medium text-zinc-200">
|
||||
{user?.nip05 || user?.displayName || shortenKey(data.sender_pubkey)}
|
||||
{user?.nip05 ||
|
||||
user?.displayName ||
|
||||
user?.name ||
|
||||
shortenKey(data.sender_pubkey)}
|
||||
</h5>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -112,7 +112,7 @@ export function NewMessageModal() {
|
||||
/>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<h3 className="leading-none max-w-[15rem] line-clamp-1 font-medium text-zinc-100">
|
||||
{pleb.display_name || pleb.name}
|
||||
{pleb.displayName || pleb.name}
|
||||
</h3>
|
||||
<span className="leading-none max-w-[10rem] line-clamp-1 text-sm text-zinc-400">
|
||||
{pleb.nip05 ||
|
||||
|
||||
@@ -83,7 +83,7 @@ export function FollowingBlock({ block }: { block: number }) {
|
||||
|
||||
return (
|
||||
<div className="shrink-0 relative w-[400px] border-r border-zinc-900">
|
||||
<TitleBar title="Circle" />
|
||||
<TitleBar title="Your Circle" />
|
||||
{hasNewNote && (
|
||||
<div className="z-50 absolute top-12 left-1/2 transform -translate-x-1/2">
|
||||
<button
|
||||
|
||||
20
src/app/user/components/feed.tsx
Normal file
20
src/app/user/components/feed.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { getNotesByPubkey } from "@libs/storage";
|
||||
import { Note } from "@shared/notes/note";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LumeEvent } from "@utils/types";
|
||||
|
||||
export function UserFeed({ pubkey }: { pubkey: string }) {
|
||||
const { status, data } = useQuery(["user-feed", pubkey], async () => {
|
||||
return await getNotesByPubkey(pubkey);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[400px] px-2">
|
||||
{status === "loading" ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
data.map((note: LumeEvent) => <Note key={note.event_id} event={note} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/app/user/components/metadata.tsx
Normal file
55
src/app/user/components/metadata.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { compactNumber } from "@utils/number";
|
||||
|
||||
export function UserMetadata({ pubkey }: { pubkey: string }) {
|
||||
const { status, data } = useQuery(["user-metadata", pubkey], async () => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
return await res.json();
|
||||
});
|
||||
|
||||
if (status === "loading") {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center gap-10">
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{data.stats[pubkey].followers_pubkey_count ?? 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">Followers</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{data.stats[pubkey].pub_following_pubkey_count ?? 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">Following</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{data.stats[pubkey].zaps_received
|
||||
? compactNumber.format(
|
||||
data.stats[pubkey].zaps_received.msats / 1000,
|
||||
)
|
||||
: 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">
|
||||
Zaps received
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{data.stats[pubkey].zaps_sent
|
||||
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
|
||||
: 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">Zaps sent</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,25 @@
|
||||
import { usePublish } from "@libs/ndk";
|
||||
import { UserFeed } from "@app/user/components/feed";
|
||||
import { UserMetadata } from "@app/user/components/metadata";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { ThreadsIcon, ZapIcon } from "@shared/icons";
|
||||
import { Image } from "@shared/image";
|
||||
import { DEFAULT_AVATAR } from "@stores/constants";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useFollows } from "@utils/hooks/useFollows";
|
||||
import { useProfile } from "@utils/hooks/useProfile";
|
||||
import { compactNumber } from "@utils/number";
|
||||
import { useSocial } from "@utils/hooks/useSocial";
|
||||
import { shortenKey } from "@utils/shortenKey";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
export function UserScreen() {
|
||||
const publish = usePublish();
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const { pubkey } = useParams();
|
||||
const { user } = useProfile(pubkey);
|
||||
const { status: followsStatus, follows } = useFollows();
|
||||
const {
|
||||
status: userStatsStatus,
|
||||
data: userStats,
|
||||
error,
|
||||
} = useQuery(["user", pubkey], async () => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
return await res.json();
|
||||
});
|
||||
const { status, userFollows, follow, unfollow } = useSocial();
|
||||
|
||||
const follow = (pubkey: string) => {
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const followUser = (pubkey: string) => {
|
||||
try {
|
||||
const followsAsSet = new Set(follows);
|
||||
followsAsSet.add(pubkey);
|
||||
|
||||
const tags = [];
|
||||
followsAsSet.forEach((item) => {
|
||||
tags.push(["p", item]);
|
||||
});
|
||||
|
||||
// publish event
|
||||
publish({ content: "", kind: 3, tags: tags });
|
||||
follow(pubkey);
|
||||
|
||||
// update state
|
||||
setFollowed(true);
|
||||
@@ -50,18 +28,9 @@ export function UserScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const unfollow = (pubkey: string) => {
|
||||
const unfollowUser = (pubkey: string) => {
|
||||
try {
|
||||
const followsAsSet = new Set(follows);
|
||||
followsAsSet.delete(pubkey);
|
||||
|
||||
const tags = [];
|
||||
followsAsSet.forEach((item) => {
|
||||
tags.push(["p", item]);
|
||||
});
|
||||
|
||||
// publish event
|
||||
publish({ content: "", kind: 3, tags: tags });
|
||||
unfollow(pubkey);
|
||||
|
||||
// update state
|
||||
setFollowed(false);
|
||||
@@ -71,15 +40,15 @@ export function UserScreen() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (followsStatus === "success" && follows) {
|
||||
if (follows.includes(pubkey)) {
|
||||
if (status === "success" && userFollows) {
|
||||
if (userFollows.includes(pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
}
|
||||
}, [followsStatus]);
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-11 w-full flex items-center px-3 border-b border-zinc-900"
|
||||
@@ -92,107 +61,97 @@ export function UserScreen() {
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-5 -mt-7">
|
||||
<div>
|
||||
<div className="w-full -mt-7">
|
||||
<div className="px-5">
|
||||
<Image
|
||||
src={user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="w-14 h-14 rounded-md ring-2 ring-black"
|
||||
/>
|
||||
<div className="flex-1 flex flex-col gap-2 mt-4">
|
||||
<h5 className="font-semibold leading-none">
|
||||
{user?.displayName || user?.name || "No name"}
|
||||
</h5>
|
||||
<span className="max-w-[15rem] text-sm truncate leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
<p className="mt-1 line-clamp-3 break-words leading-tight text-zinc-100">
|
||||
{user?.about}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{error && <p>Failed to fetch user stats</p>}
|
||||
{userStatsStatus === "loading" ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="w-full flex items-center gap-10">
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{userStats.stats[pubkey].followers_pubkey_count ?? 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">
|
||||
Followers
|
||||
<div className="flex-1 flex flex-col gap-4 mt-2">
|
||||
<div className="flex items-center gap-16">
|
||||
<div className="inline-flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold text-lg leading-none">
|
||||
{user?.displayName || user?.name || "No name"}
|
||||
</h5>
|
||||
<span className="max-w-[15rem] text-sm truncate leading-none text-zinc-500">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{userStats.stats[pubkey].pub_following_pubkey_count ?? 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">
|
||||
Following
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{userStats.stats[pubkey].zaps_received
|
||||
? compactNumber.format(
|
||||
userStats.stats[pubkey].zaps_received.msats / 1000,
|
||||
)
|
||||
: 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">
|
||||
Zaps received
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="leading-none font-semibold text-zinc-100">
|
||||
{userStats.stats[pubkey].zaps_sent
|
||||
? compactNumber.format(
|
||||
userStats.stats[pubkey].zaps_sent.msats / 1000,
|
||||
)
|
||||
: 0}
|
||||
</span>
|
||||
<span className="leading-none text-sm text-zinc-400">
|
||||
Zaps sent
|
||||
</span>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{status === "loading" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Loading...
|
||||
</button>
|
||||
) : followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unfollowUser(pubkey)}
|
||||
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => followUser(pubkey)}
|
||||
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/app/chat/${pubkey}`}
|
||||
className="inline-flex w-36 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-10 h-10 items-center justify-center rounded-md bg-zinc-900 group hover:bg-orange-500 text-sm font-medium"
|
||||
>
|
||||
<ZapIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
{followsStatus === "loading" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-44 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Loading...
|
||||
</button>
|
||||
) : followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unfollow(pubkey)}
|
||||
className="inline-flex w-44 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => follow(pubkey)}
|
||||
className="inline-flex w-44 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/app/chat/${pubkey}`}
|
||||
className="inline-flex w-44 h-10 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500 text-sm font-medium"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
<div className="flex flex-col gap-8">
|
||||
<p className="mt-2 max-w-[500px] break-words select-text text-zinc-100">
|
||||
{user?.about}
|
||||
</p>
|
||||
<UserMetadata pubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 w-full border-t border-zinc-900">
|
||||
<Tab.Group>
|
||||
<Tab.List className="px-5 mb-2">
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
selected
|
||||
? "text-fuchsia-500 border-fuchsia-500"
|
||||
: "text-zinc-200 border-transparent"
|
||||
} font-medium inline-flex items-center gap-2 h-10 border-t`}
|
||||
>
|
||||
<ThreadsIcon className="w-4 h-4" />
|
||||
Posts
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<UserFeed pubkey={pubkey} />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -157,26 +157,13 @@ export async function getNotes(time: number, limit: number, offset: number) {
|
||||
}
|
||||
|
||||
// get all notes by pubkey
|
||||
export async function getNotesByPubkey(
|
||||
pubkey: string,
|
||||
time: number,
|
||||
limit: number,
|
||||
offset: number,
|
||||
) {
|
||||
export async function getNotesByPubkey(pubkey: string) {
|
||||
const db = await connect();
|
||||
const totalNotes = await countTotalNotes();
|
||||
const nextCursor = offset + limit;
|
||||
|
||||
const notes: any = { data: null, nextCursor: 0 };
|
||||
const query: any = await db.select(
|
||||
`SELECT * FROM notes WHERE created_at <= "${time}" AND pubkey == "${pubkey}" AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC LIMIT "${limit}" OFFSET "${offset}";`,
|
||||
const res: any = await db.select(
|
||||
`SELECT * FROM notes WHERE pubkey == "${pubkey}" AND kind IN (1, 6, 1063) GROUP BY parent_id ORDER BY created_at DESC;`,
|
||||
);
|
||||
|
||||
notes["data"] = query;
|
||||
notes["nextCursor"] =
|
||||
Math.round(totalNotes / nextCursor) > 1 ? nextCursor : undefined;
|
||||
|
||||
return notes;
|
||||
return res;
|
||||
}
|
||||
|
||||
// get all notes by authors
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import App from "./app";
|
||||
import { RelayProvider } from "@shared/relayProvider";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -20,6 +19,5 @@ root.render(
|
||||
<RelayProvider>
|
||||
<App />
|
||||
</RelayProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -61,15 +61,16 @@ const ImagePreview = ({
|
||||
|
||||
export function Post() {
|
||||
const publish = usePublish();
|
||||
const [repost, toggle] = useComposer((state) => [
|
||||
state.repost,
|
||||
state.toggleModal,
|
||||
]);
|
||||
const editor = useMemo(
|
||||
() => withReact(withImages(withHistory(createEditor()))),
|
||||
[],
|
||||
);
|
||||
|
||||
const [repost, reply, toggle] = useComposer((state) => [
|
||||
state.repost,
|
||||
state.reply,
|
||||
state.toggleModal,
|
||||
]);
|
||||
const [content, setContent] = useState<Node[]>([
|
||||
{
|
||||
children: [
|
||||
@@ -84,6 +85,18 @@ export function Post() {
|
||||
return nodes.map((n) => Node.string(n)).join("\n");
|
||||
}, []);
|
||||
|
||||
const getRef = () => {
|
||||
if (repost.id) {
|
||||
return repost.id;
|
||||
} else if (reply.id) {
|
||||
return reply.id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const refID = getRef();
|
||||
|
||||
const submit = () => {
|
||||
let tags: string[][] = [];
|
||||
let kind: number;
|
||||
@@ -94,6 +107,20 @@ export function Post() {
|
||||
["e", repost.id, FULL_RELAYS[0], "root"],
|
||||
["p", repost.pubkey],
|
||||
];
|
||||
} else if (reply.id && reply.pubkey) {
|
||||
kind = 1;
|
||||
if (reply.root && reply.root !== reply.id) {
|
||||
tags = [
|
||||
["e", reply.id, FULL_RELAYS[0], "root"],
|
||||
["e", reply.root, FULL_RELAYS[0], "reply"],
|
||||
["p", reply.pubkey],
|
||||
];
|
||||
} else {
|
||||
tags = [
|
||||
["e", reply.id, FULL_RELAYS[0], "root"],
|
||||
["p", reply.pubkey],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
kind = 1;
|
||||
tags = [];
|
||||
@@ -130,14 +157,14 @@ export function Post() {
|
||||
<div className="w-full">
|
||||
<Editable
|
||||
autoFocus
|
||||
placeholder="What's on your mind?"
|
||||
placeholder={
|
||||
refID ? "Share your thoughts on it" : "What's on your mind?"
|
||||
}
|
||||
spellCheck="false"
|
||||
className={`${
|
||||
repost.id ? "!min-h-42" : "!min-h-[86px]"
|
||||
} markdown`}
|
||||
className={`${refID ? "!min-h-42" : "!min-h-[86px]"} markdown`}
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
{repost.id && <MentionNote id={repost.id} />}
|
||||
{refID && <MentionNote id={refID} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
|
||||
@@ -2,11 +2,22 @@ import { MentionNote } from "@shared/notes/mentions/note";
|
||||
import { ImagePreview } from "@shared/notes/preview/image";
|
||||
import { LinkPreview } from "@shared/notes/preview/link";
|
||||
import { VideoPreview } from "@shared/notes/preview/video";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Kind1({
|
||||
content,
|
||||
truncate = false,
|
||||
}: { content: any; truncate?: boolean }) {
|
||||
}: {
|
||||
content: {
|
||||
original: string;
|
||||
parsed: ReactNode[];
|
||||
notes: string[];
|
||||
images: string[];
|
||||
videos: string[];
|
||||
links: string[];
|
||||
};
|
||||
truncate?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -16,28 +27,15 @@ export function Kind1({
|
||||
>
|
||||
{content.parsed}
|
||||
</div>
|
||||
{Array.isArray(content.images) && content.images.length ? (
|
||||
{content.images.length > 0 && (
|
||||
<ImagePreview urls={content.images} truncate={truncate} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{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.videos.length > 0 && <VideoPreview urls={content.videos} />}
|
||||
{content.links.length > 0 && <LinkPreview urls={content.links} />}
|
||||
{content.notes.length > 0 &&
|
||||
content.notes.map((note: string) => (
|
||||
<MentionNote key={note} id={note} />
|
||||
))
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,8 @@ import { useEvent } from "@utils/hooks/useEvent";
|
||||
import { memo } from "react";
|
||||
|
||||
export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const kind1 = data?.kind === 1 ? data.content : null;
|
||||
const kind1063 = data?.kind === 1063 ? data.tags : null;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
@@ -37,17 +33,19 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
<div
|
||||
onClick={(e) => openThread(e, id)}
|
||||
onKeyDown={(e) => openThread(e, id)}
|
||||
className="mt-3 rounded-lg bg-zinc-800 border-t border-zinc-700/50 px-3 py-3"
|
||||
className="mt-3 rounded-lg bg-zinc-800/50 border-t border-zinc-700/50 px-3 py-3"
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
) : status === "success" ? (
|
||||
<>
|
||||
<User pubkey={data.pubkey} time={data.created_at} size="small" />
|
||||
<div className="mt-2">
|
||||
{kind1 && <Kind1 content={kind1} truncate={true} />}
|
||||
{kind1063 && <Kind1063 metadata={kind1063} />}
|
||||
{!kind1 && !kind1063 && (
|
||||
{data.kind === 1 && (
|
||||
<Kind1 content={data.content} truncate={true} />
|
||||
)}
|
||||
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
|
||||
{data.kind !== 1 && data.kind !== 1063 && (
|
||||
<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">
|
||||
@@ -64,6 +62,8 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>Failed to fetch event</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,12 +11,12 @@ import { useContext } from "react";
|
||||
|
||||
export function NoteMetadata({
|
||||
id,
|
||||
rootID,
|
||||
eventPubkey,
|
||||
currentBlock,
|
||||
}: {
|
||||
id: string;
|
||||
rootID?: string;
|
||||
eventPubkey: string;
|
||||
currentBlock?: number;
|
||||
}) {
|
||||
const ndk = useContext(RelayContext);
|
||||
const { status, data } = useQuery(["note-metadata", id], async () => {
|
||||
@@ -112,8 +112,9 @@ export function NoteMetadata({
|
||||
<>
|
||||
<NoteReply
|
||||
id={id}
|
||||
rootID={rootID}
|
||||
pubkey={eventPubkey}
|
||||
replies={data.replies}
|
||||
currentBlock={currentBlock}
|
||||
/>
|
||||
<NoteRepost id={id} pubkey={eventPubkey} reposts={data.reposts} />
|
||||
<NoteZap zaps={data.zap} />
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
import { createBlock } from "@libs/storage";
|
||||
import { ReplyIcon } from "@shared/icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useComposer } from "@stores/composer";
|
||||
import { compactNumber } from "@utils/number";
|
||||
|
||||
export function NoteReply({
|
||||
id,
|
||||
rootID,
|
||||
pubkey,
|
||||
replies,
|
||||
}: { id: string; replies: number; currentBlock?: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const block = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
return createBlock(data.kind, data.title, data.content);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["blocks"] });
|
||||
},
|
||||
});
|
||||
|
||||
const openThread = (event: any, thread: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
block.mutate({ kind: 2, title: "Thread", content: thread });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
}: { id: string; rootID?: string; pubkey: string; replies: number }) {
|
||||
const setReply = useComposer((state) => state.setReply);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => openThread(e, id)}
|
||||
onClick={() => setReply(id, rootID, pubkey)}
|
||||
className="w-20 group inline-flex items-center gap-1.5"
|
||||
>
|
||||
<ReplyIcon
|
||||
|
||||
@@ -7,7 +7,7 @@ export function NoteRepost({
|
||||
pubkey,
|
||||
reposts,
|
||||
}: { id: string; pubkey: string; reposts: number }) {
|
||||
const setRepost = useComposer((state: any) => state.setRepost);
|
||||
const setRepost = useComposer((state) => state.setRepost);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -18,7 +18,7 @@ export function Note({ event, block }: Note) {
|
||||
|
||||
const renderParent = useMemo(() => {
|
||||
if (!isRepost && event.parent_id && event.parent_id !== event.event_id) {
|
||||
return <NoteParent id={event.parent_id} currentBlock={block} />;
|
||||
return <NoteParent id={event.parent_id} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function Note({ event, block }: Note) {
|
||||
|
||||
const renderRepost = useMemo(() => {
|
||||
if (isRepost) {
|
||||
return <Repost event={event} currentBlock={block} />;
|
||||
return <Repost event={event} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -71,13 +71,13 @@ export function Note({ event, block }: Note) {
|
||||
time={event.created_at}
|
||||
repost={isRepost}
|
||||
/>
|
||||
<div className="relative -mt-6 pl-[49px]">
|
||||
<div className="-mt-6 pl-[49px]">
|
||||
{renderContent}
|
||||
{!isRepost && (
|
||||
<NoteMetadata
|
||||
id={event.event_id}
|
||||
rootID={event.parent_id}
|
||||
eventPubkey={event.pubkey}
|
||||
currentBlock={block || 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,21 +5,18 @@ import { NoteSkeleton } from "@shared/notes/skeleton";
|
||||
import { User } from "@shared/user";
|
||||
import { useEvent } from "@utils/hooks/useEvent";
|
||||
|
||||
export function NoteParent({
|
||||
id,
|
||||
currentBlock,
|
||||
}: { id: string; currentBlock: number }) {
|
||||
export function NoteParent({ id }: { id: string }) {
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden flex flex-col pb-6">
|
||||
<div className="relative 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" />
|
||||
{status === "loading" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
) : status === "success" ? (
|
||||
<>
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="z-10 relative -mt-6 pl-[49px]">
|
||||
<div className="-mt-6 pl-[49px]">
|
||||
{data.kind === 1 && <Kind1 content={data.content} />}
|
||||
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
|
||||
{data.kind !== 1 && data.kind !== 1063 && (
|
||||
@@ -40,10 +37,11 @@ export function NoteParent({
|
||||
<NoteMetadata
|
||||
id={data.event_id || data.id}
|
||||
eventPubkey={data.pubkey}
|
||||
currentBlock={currentBlock}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>Failed to fetch event</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
className="flex flex-col rounded-lg border border-zinc-800/50 hover:border-fuchsia-900"
|
||||
className="flex flex-col rounded-lg border border-zinc-800/50"
|
||||
href={urls[0]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
|
||||
@@ -7,10 +7,7 @@ import { useEvent } from "@utils/hooks/useEvent";
|
||||
import { getRepostID } from "@utils/transform";
|
||||
import { LumeEvent } from "@utils/types";
|
||||
|
||||
export function Repost({
|
||||
event,
|
||||
currentBlock,
|
||||
}: { event: LumeEvent; currentBlock?: number }) {
|
||||
export function Repost({ event }: { event: LumeEvent }) {
|
||||
const repostID = getRepostID(event.tags);
|
||||
const { status, data } = useEvent(repostID);
|
||||
|
||||
@@ -19,10 +16,10 @@ export function Repost({
|
||||
<div className="absolute left-[18px] -top-10 h-[50px] w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600" />
|
||||
{status === "loading" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
) : status === "success" ? (
|
||||
<>
|
||||
<User pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="z-10 relative -mt-6 pl-[49px]">
|
||||
<div className="-mt-6 pl-[49px]">
|
||||
{data.kind === 1 && <Kind1 content={data.content} />}
|
||||
{data.kind === 1063 && <Kind1063 metadata={data.tags} />}
|
||||
{data.kind !== 1 && data.kind !== 1063 && (
|
||||
@@ -43,10 +40,11 @@ export function Repost({
|
||||
<NoteMetadata
|
||||
id={data.event_id || data.id}
|
||||
eventPubkey={data.pubkey}
|
||||
currentBlock={currentBlock}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>Failed to fetch event</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,12 +20,31 @@ export function User({
|
||||
repost?: boolean;
|
||||
isChat?: boolean;
|
||||
}) {
|
||||
const { user } = useProfile(pubkey);
|
||||
const { status, user } = useProfile(pubkey);
|
||||
const createdAt = formatCreatedAt(time, isChat);
|
||||
|
||||
const avatarWidth = size === "small" ? "w-6" : "w-11";
|
||||
const avatarHeight = size === "small" ? "h-6" : "h-11";
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex gap-3 ${
|
||||
size === "small" ? "items-center" : "items-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`${avatarWidth} ${avatarHeight} ${
|
||||
size === "small" ? "rounded" : "rounded-lg"
|
||||
} relative z-10 bg-zinc-800 animate-pulse shrink-0 overflow-hidden`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-baseline gap-1">
|
||||
<div className="w-36 h-3.5 rounded bg-zinc-800 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
className={`relative flex gap-3 ${
|
||||
@@ -47,10 +66,10 @@ export function User({
|
||||
<div className="flex flex-wrap items-baseline gap-1">
|
||||
<h5
|
||||
className={`text-zinc-100 font-semibold leading-none truncate ${
|
||||
size === "small" ? "max-w-[7rem]" : "max-w-[10rem]"
|
||||
size === "small" ? "max-w-[8rem]" : "max-w-[15rem]"
|
||||
}`}
|
||||
>
|
||||
{user?.nip05 || user?.name || shortenKey(pubkey)}
|
||||
{user?.nip05 || user?.name || user?.displayName || shortenKey(pubkey)}
|
||||
</h5>
|
||||
{repost && (
|
||||
<span className="font-semibold leading-none text-fuchsia-500">
|
||||
@@ -70,8 +89,8 @@ export function User({
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute left-0 top-10 z-50 mt-3">
|
||||
<div className="w-full max-w-xs overflow-hidden rounded-md border border-zinc-800/50 bg-zinc-900/90 backdrop-blur-lg">
|
||||
<Popover.Panel className="absolute z-50 top-10 mt-3">
|
||||
<div className="w-[250px] overflow-hidden rounded-md border border-zinc-800/50 bg-zinc-900/90 backdrop-blur-lg">
|
||||
<div className="flex gap-2.5 border-b border-zinc-800 px-3 py-3">
|
||||
<Image
|
||||
src={user?.image}
|
||||
@@ -81,7 +100,7 @@ export function User({
|
||||
/>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<h5 className="font-semibold leading-none">
|
||||
<h5 className="font-semibold text-sm leading-none">
|
||||
{user?.displayName || user?.name || (
|
||||
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
|
||||
)}
|
||||
@@ -91,7 +110,7 @@ export function User({
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-3 break-words text-sm leading-tight text-zinc-100">
|
||||
<p className="line-clamp-3 break-words leading-tight text-zinc-100">
|
||||
{user?.about}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,27 +2,39 @@ import { create } from "zustand";
|
||||
|
||||
interface ComposerState {
|
||||
open: boolean;
|
||||
reply: null;
|
||||
reply: { id: string; root: string; pubkey: string };
|
||||
repost: { id: string; pubkey: string };
|
||||
toggleModal: (status: boolean) => void;
|
||||
setReply: (id: string, root: string, pubkey: string) => void;
|
||||
setRepost: (id: string, pubkey: string) => void;
|
||||
clearReply: () => void;
|
||||
clearRepost: () => void;
|
||||
}
|
||||
|
||||
export const useComposer = create<ComposerState>((set) => ({
|
||||
open: false,
|
||||
reply: null,
|
||||
reply: { id: null, root: null, pubkey: null },
|
||||
repost: { id: null, pubkey: null },
|
||||
toggleModal: (status: boolean) => {
|
||||
set({ open: status });
|
||||
if (!status) {
|
||||
set({ repost: { id: null, pubkey: null } });
|
||||
set({ reply: { id: null, root: null, pubkey: null } });
|
||||
}
|
||||
},
|
||||
setReply: (id: string, root: string, pubkey: string) => {
|
||||
set({ reply: { id: id, root: root, pubkey: pubkey } });
|
||||
set({ repost: { id: null, pubkey: null } });
|
||||
set({ open: true });
|
||||
},
|
||||
setRepost: (id: string, pubkey: string) => {
|
||||
set({ repost: { id: id, pubkey: pubkey } });
|
||||
set({ reply: { id: null, root: null, pubkey: null } });
|
||||
set({ open: true });
|
||||
},
|
||||
clearReply: () => {
|
||||
set({ reply: { id: null, root: null, pubkey: null } });
|
||||
},
|
||||
clearRepost: () => {
|
||||
set({ repost: { id: null, pubkey: null } });
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@ export function useEvent(id: string) {
|
||||
async () => {
|
||||
const result = await getNoteByID(id);
|
||||
if (result) {
|
||||
result["content"] = parser(result);
|
||||
if (result.kind === 1 || result.kind === 1063) {
|
||||
result["content"] = parser(result);
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
const event = await ndk.fetchEvent(id);
|
||||
@@ -24,8 +26,10 @@ export function useEvent(id: string) {
|
||||
event.created_at,
|
||||
);
|
||||
event["event_id"] = event.id;
|
||||
// @ts-ignore
|
||||
event["content"] = parser(event);
|
||||
if (event.kind === 1 || event.kind === 1063) {
|
||||
// @ts-ignore
|
||||
event["content"] = parser(event);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useAccount } from "./useAccount";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { nip02ToArray } from "@utils/transform";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useFollows() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const { account } = useAccount();
|
||||
const { status, data: follows } = useQuery(
|
||||
["follows", account.pubkey],
|
||||
async () => {
|
||||
const res = await ndk.fetchEvents({
|
||||
kinds: [3],
|
||||
authors: [account.pubkey],
|
||||
});
|
||||
const latest = [...res].slice(-1)[0];
|
||||
const list = nip02ToArray(latest.tags);
|
||||
return list;
|
||||
},
|
||||
{
|
||||
enabled: account ? true : false,
|
||||
},
|
||||
);
|
||||
|
||||
return { status, follows };
|
||||
}
|
||||
@@ -23,11 +23,13 @@ export function useProfile(id: string) {
|
||||
const current = Math.floor(Date.now() / 1000);
|
||||
const result = await getPleb(npub);
|
||||
|
||||
if (result && result.created_at + 86400 > current) {
|
||||
if (result && parseInt(result.created_at) + 86400 >= current) {
|
||||
console.log("cache", result);
|
||||
return result;
|
||||
} else {
|
||||
const user = ndk.getUser({ npub });
|
||||
await user.fetchProfile();
|
||||
console.log("new", user);
|
||||
await createPleb(id, user.profile);
|
||||
|
||||
return user.profile;
|
||||
|
||||
65
src/utils/hooks/useSocial.tsx
Normal file
65
src/utils/hooks/useSocial.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useAccount } from "./useAccount";
|
||||
import { usePublish } from "@libs/ndk";
|
||||
import { RelayContext } from "@shared/relayProvider";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { nip02ToArray } from "@utils/transform";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useSocial() {
|
||||
const ndk = useContext(RelayContext);
|
||||
const queryClient = useQueryClient();
|
||||
const publish = usePublish();
|
||||
|
||||
const { account } = useAccount();
|
||||
const { status, data: userFollows } = useQuery(
|
||||
["userFollows", account.pubkey],
|
||||
async () => {
|
||||
const res = await ndk.fetchEvents({
|
||||
kinds: [3],
|
||||
authors: [account.pubkey],
|
||||
});
|
||||
const latest = [...res].slice(-1)[0];
|
||||
const list = nip02ToArray(latest.tags);
|
||||
return list;
|
||||
},
|
||||
{
|
||||
enabled: account ? true : false,
|
||||
},
|
||||
);
|
||||
|
||||
const unfollow = (pubkey: string) => {
|
||||
const followsAsSet = new Set(userFollows);
|
||||
followsAsSet.delete(pubkey);
|
||||
|
||||
const tags = [];
|
||||
followsAsSet.forEach((item) => {
|
||||
tags.push(["p", item]);
|
||||
});
|
||||
|
||||
// publish event
|
||||
publish({ content: "", kind: 3, tags: tags });
|
||||
// invalid cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["userFollows", account.pubkey],
|
||||
});
|
||||
};
|
||||
|
||||
const follow = (pubkey: string) => {
|
||||
const followsAsSet = new Set(userFollows);
|
||||
followsAsSet.add(pubkey);
|
||||
|
||||
const tags = [];
|
||||
followsAsSet.forEach((item) => {
|
||||
tags.push(["p", item]);
|
||||
});
|
||||
|
||||
// publish event
|
||||
publish({ content: "", kind: 3, tags: tags });
|
||||
// invalid cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["userFollows", account.pubkey],
|
||||
});
|
||||
};
|
||||
|
||||
return { status, userFollows, follow, unfollow };
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { MentionUser } from "@shared/notes/mentions/user";
|
||||
import destr from "destr";
|
||||
import getUrls from "get-urls";
|
||||
import { parseReferences } from "nostr-tools";
|
||||
import { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import reactStringReplace from "react-string-replace";
|
||||
|
||||
function isJsonString(str) {
|
||||
function isJsonString(str: string) {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
} catch (e) {
|
||||
@@ -24,7 +25,7 @@ export function parser(event: any) {
|
||||
|
||||
const content: {
|
||||
original: string;
|
||||
parsed: any;
|
||||
parsed: ReactNode[];
|
||||
notes: string[];
|
||||
images: string[];
|
||||
videos: string[];
|
||||
@@ -39,9 +40,11 @@ export function parser(event: any) {
|
||||
};
|
||||
|
||||
// remove unnecessary whitespaces
|
||||
// @ts-ignore
|
||||
content.parsed = content.parsed.replace(/\s{2,}/g, " ");
|
||||
|
||||
// remove unnecessary linebreak
|
||||
// @ts-ignore
|
||||
content.parsed = content.parsed.replace(/(\r\n|\r|\n){2,}/g, "$1\n");
|
||||
|
||||
// parse urls
|
||||
|
||||
Reference in New Issue
Block a user