don't hate me, old git is fuck up
This commit is contained in:
25
src/components/accountBar/account.tsx
Normal file
25
src/components/accountBar/account.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Image from 'next/image';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Account = memo(function Account({ user, current }: { user: any; current: string }) {
|
||||
const userData = JSON.parse(user.metadata);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-11 w-11 shrink overflow-hidden rounded-full ${
|
||||
current === user.pubkey ? 'ring-1 ring-fuchsia-500 ring-offset-4 ring-offset-black' : ''
|
||||
}`}>
|
||||
{userData?.picture !== undefined ? (
|
||||
<Image
|
||||
src={userData.picture}
|
||||
alt="user's avatar"
|
||||
fill={true}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-11 w-11 animate-pulse rounded-full bg-zinc-700" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
166
src/components/accountBar/index.tsx
Normal file
166
src/components/accountBar/index.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Account } from '@components/accountBar/account';
|
||||
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
|
||||
import LumeIcon from '@assets/icons/Lume';
|
||||
import MiniPlusIcon from '@assets/icons/MiniPlus';
|
||||
import PostIcon from '@assets/icons/Post';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { commands } from '@uiw/react-md-editor';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { dateToUnix, useNostr } from 'nostr-react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor').then((mod) => mod.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function AccountBar() {
|
||||
const { publish } = useNostr();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const $currentUser: any = useStore(currentUser);
|
||||
const pubkey = $currentUser.pubkey;
|
||||
const privkey = $currentUser.privkey;
|
||||
|
||||
const postButton = {
|
||||
name: 'post',
|
||||
keyCommand: 'post',
|
||||
buttonProps: { className: 'cta-btn', 'aria-label': 'Post a message' },
|
||||
icon: (
|
||||
<div className="relative inline-flex h-10 w-16 transform cursor-pointer overflow-hidden rounded bg-zinc-900 px-2.5 ring-zinc-500/50 ring-offset-zinc-900 will-change-transform focus:outline-none focus:ring-1 focus:ring-offset-2 active:translate-y-1">
|
||||
<span className="absolute inset-px z-10 inline-flex items-center justify-center rounded bg-zinc-900 text-zinc-200">
|
||||
Post
|
||||
</span>
|
||||
<span className="absolute inset-0 z-0 scale-x-[2.0] blur before:absolute before:inset-0 before:top-1/2 before:aspect-square before:animate-disco before:bg-gradient-conic before:from-gray-300 before:via-fuchsia-600 before:to-orange-600"></span>
|
||||
</div>
|
||||
),
|
||||
execute: (state: { text: any }) => {
|
||||
const message = state.text;
|
||||
|
||||
if (message.length > 0) {
|
||||
const event: any = {
|
||||
content: message,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: pubkey,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
publish(event);
|
||||
setValue('');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getAccounts = useCallback(async () => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
const result: any = await db.select('SELECT * FROM accounts');
|
||||
|
||||
setUsers(result);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().catch(console.error);
|
||||
}, [getAccounts]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-between px-2 pt-12 pb-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{users.map((user, index) => (
|
||||
<Account key={index} user={user} current={$currentUser.pubkey} />
|
||||
))}
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="group relative flex h-11 w-11 shrink cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 border-dashed border-zinc-600 hover:border-zinc-400">
|
||||
<MiniPlusIcon className="h-6 w-6 text-zinc-400 group-hover:text-zinc-200" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* post button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex h-11 w-11 transform items-center justify-center rounded-full bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 font-bold text-white shadow-lg active:translate-y-1">
|
||||
<PostIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{/* end post button */}
|
||||
<LumeIcon className="h-8 w-auto text-zinc-700" />
|
||||
</div>
|
||||
{/* modal */}
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={() => setIsOpen(false)}>
|
||||
<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 bg-black bg-opacity-30 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-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 w-full max-w-2xl transform overflow-hidden rounded-lg text-zinc-100 shadow-modal transition-all">
|
||||
<div className="absolute top-0 left-0 h-full w-full bg-black bg-opacity-10 backdrop-blur-md"></div>
|
||||
<div className="absolute bottom-0 left-0 h-24 w-full border-t border-white/10 bg-zinc-900"></div>
|
||||
<div className="relative z-10 px-4 pt-4 pb-2">
|
||||
<MDEditor
|
||||
value={value}
|
||||
preview={'edit'}
|
||||
height={200}
|
||||
minHeight={200}
|
||||
visibleDragbar={false}
|
||||
highlightEnable={false}
|
||||
defaultTabEnable={true}
|
||||
autoFocus={true}
|
||||
commands={[
|
||||
commands.bold,
|
||||
commands.italic,
|
||||
commands.strikethrough,
|
||||
commands.divider,
|
||||
commands.checkedListCommand,
|
||||
commands.unorderedListCommand,
|
||||
commands.orderedListCommand,
|
||||
commands.divider,
|
||||
commands.link,
|
||||
commands.image,
|
||||
]}
|
||||
extraCommands={[postButton]}
|
||||
textareaProps={{
|
||||
placeholder: "What's your thought?",
|
||||
}}
|
||||
onChange={(val) => setValue(val)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
{/* end modal */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/activeLink.tsx
Normal file
45
src/components/activeLink.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Link, { LinkProps } from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren, memo, useEffect, useState } from 'react';
|
||||
|
||||
type ActiveLinkProps = LinkProps & {
|
||||
className?: string;
|
||||
activeClassName: string;
|
||||
};
|
||||
|
||||
const ActiveLink = ({
|
||||
children,
|
||||
activeClassName,
|
||||
className,
|
||||
...props
|
||||
}: PropsWithChildren<ActiveLinkProps>) => {
|
||||
const { asPath, isReady } = useRouter();
|
||||
const [computedClassName, setComputedClassName] = useState(className);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the router fields are updated client-side
|
||||
if (isReady) {
|
||||
// Dynamic route will be matched via props.as
|
||||
// Static route will be matched via props.href
|
||||
const linkPathname = new URL((props.as || props.href) as string, location.href).pathname;
|
||||
|
||||
// Using URL().pathname to get rid of query and hash
|
||||
const activePathname = new URL(asPath, location.href).pathname;
|
||||
|
||||
const newClassName =
|
||||
linkPathname === activePathname ? `${className} ${activeClassName}`.trim() : className;
|
||||
|
||||
if (newClassName !== computedClassName) {
|
||||
setComputedClassName(newClassName);
|
||||
}
|
||||
}
|
||||
}, [asPath, isReady, props.as, props.href, activeClassName, className, computedClassName]);
|
||||
|
||||
return (
|
||||
<Link className={computedClassName} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ActiveLink);
|
||||
86
src/components/checkAccount.tsx
Normal file
86
src/components/checkAccount.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
import { follows } from '@stores/follows';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Database from 'tauri-plugin-sql-api';
|
||||
|
||||
export default function CheckAccount() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const accounts = async () => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
const result = await db.select('SELECT * FROM accounts ORDER BY id ASC LIMIT 1');
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getFollowings = async (account) => {
|
||||
const db = await Database.load('sqlite:lume.db');
|
||||
const result: any = await db.select(
|
||||
`SELECT pubkey FROM followings WHERE account = "${account.pubkey}"`
|
||||
);
|
||||
|
||||
const arr = [];
|
||||
|
||||
result.forEach((item: { pubkey: any }) => {
|
||||
arr.push(item.pubkey);
|
||||
});
|
||||
|
||||
return arr;
|
||||
};
|
||||
|
||||
accounts()
|
||||
.then((res: any) => {
|
||||
if (res.length === 0) {
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/onboarding');
|
||||
}, 1500);
|
||||
} else {
|
||||
currentUser.set(res[0]);
|
||||
|
||||
getFollowings(res[0])
|
||||
.then(async (res) => {
|
||||
follows.set(res);
|
||||
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
router.push('/feed/following');
|
||||
}, 1500);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? (
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
src/components/empty.tsx
Normal file
103
src/components/empty.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import MiniMailIcon from '@assets/icons/MiniMail';
|
||||
import MiniPlusIcon from '@assets/icons/MiniPlus';
|
||||
import RefreshIcon from '@assets/icons/Refresh';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
const sampleData = [
|
||||
{
|
||||
name: 'Dick Whitman (🌎/21M)',
|
||||
role: 'dickwhitman@nostrplebs.com',
|
||||
imageUrl: 'https://pbs.twimg.com/profile_images/1594930968325984256/TjMXaXBE_400x400.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Jack',
|
||||
role: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
|
||||
imageUrl: 'https://pbs.twimg.com/profile_images/1115644092329758721/AFjOr-K8_400x400.jpg',
|
||||
},
|
||||
{
|
||||
name: 'Sats Symbol',
|
||||
role: 'npub1mqngkfwfyv2ckv7hshck9pqucpz08tktde2jukr3hheatup2y2tqnzc32w',
|
||||
imageUrl: 'https://pbs.twimg.com/profile_images/1563388888860594177/7evrI1uB_400x400.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Empty() {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg pt-8">
|
||||
<div>
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-zinc-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 48 48"
|
||||
aria-hidden="true">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M34 40h10v-4a6 6 0 00-10.712-3.714M34 40H14m20 0v-4a9.971 9.971 0 00-.712-3.714M14 40H4v-4a6 6 0 0110.713-3.714M14 40v-4c0-1.313.253-2.566.713-3.714m0 0A10.003 10.003 0 0124 26c4.21 0 7.813 2.602 9.288 6.286M30 14a6 6 0 11-12 0 6 6 0 0112 0zm12 6a4 4 0 11-8 0 4 4 0 018 0zm-28 0a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
</svg>
|
||||
<h2 className="mt-2 text-lg font-medium text-zinc-100">
|
||||
You haven't followed anyone yet
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
You can send invite via email to your friend and lume will onboard them into nostr or
|
||||
follow some people in suggested below
|
||||
</p>
|
||||
</div>
|
||||
<form action="#" className="relative mt-6">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
className="block h-11 w-full rounded-lg border-none px-4 shadow-md ring-1 ring-white/10 placeholder:text-zinc-500 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
placeholder="Enter an email"
|
||||
/>
|
||||
<button className="absolute right-0.5 top-1/2 inline-flex h-10 -translate-y-1/2 transform items-center gap-1 rounded-md border border-zinc-600 bg-zinc-700 px-4 text-sm font-medium text-zinc-200 shadow-md">
|
||||
<MiniMailIcon className="h-4 w-4" />
|
||||
Invite
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col items-start gap-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-zinc-500">Suggestions</h3>
|
||||
<RefreshIcon className="h-4 w-4 text-zinc-600" />
|
||||
</div>
|
||||
<ul className="w-full divide-y divide-zinc-800 border-t border-b border-zinc-800">
|
||||
{sampleData.map((person, index) => (
|
||||
<li key={index} className="flex items-center justify-between space-x-3 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-3">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
className="rounded-full object-cover"
|
||||
src={person.imageUrl}
|
||||
alt={person.name}
|
||||
fill={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-zinc-200">{person.name}</p>
|
||||
<p className="w-56 truncate text-sm font-medium text-zinc-500">{person.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-zinc-700 bg-zinc-800 px-3 py-1 text-xs font-medium text-zinc-400 shadow-sm hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2">
|
||||
<MiniPlusIcon className="-ml-1 h-5 w-5" />
|
||||
<span className="text-sm font-medium text-zinc-300">Follow</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="bg-[radial-gradient(ellipse_at_bottom_right,_var(--tw-gradient-stops))] from-gray-300 via-fuchsia-600 to-orange-600 bg-clip-text text-sm font-bold text-transparent">
|
||||
Explore more →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/imageWithFallback.tsx
Normal file
37
src/components/imageWithFallback.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Avatar from 'boring-avatars';
|
||||
import Image from 'next/image';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
export const ImageWithFallback = memo(function ImageWithFallback({
|
||||
src,
|
||||
alt,
|
||||
fill,
|
||||
className,
|
||||
}: {
|
||||
src: any;
|
||||
alt: string;
|
||||
fill: boolean;
|
||||
className: string;
|
||||
}) {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={alt}
|
||||
variant="beam"
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
) : (
|
||||
<Image alt={alt} onError={setError} src={src} fill={fill} className={className} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
18
src/components/navigatorBar/incomingList.tsx
Normal file
18
src/components/navigatorBar/incomingList.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export function IncomingList({ data }: { data: any }) {
|
||||
const list: any = Array.from(new Set(data.map((item: any) => item.pubkey)));
|
||||
|
||||
if (list.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{list.map((item, index) => (
|
||||
<div key={index}>
|
||||
<p>{item}</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
66
src/components/navigatorBar/index.tsx
Normal file
66
src/components/navigatorBar/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ActiveLink from '@components/activeLink';
|
||||
|
||||
import PlusIcon from '@assets/icons/Plus';
|
||||
|
||||
export default function NavigatorBar() {
|
||||
return (
|
||||
<div className="flex h-full flex-col flex-wrap justify-between overflow-hidden px-2 pt-12 pb-4">
|
||||
{/* main */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Newsfeed */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Newsfeed</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900">
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-zinc-500">
|
||||
<ActiveLink
|
||||
href={`/feed/following`}
|
||||
activeClassName="rounded-lg ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
className="flex h-10 items-center gap-1 px-2.5 text-sm font-medium">
|
||||
<span>#</span>
|
||||
<span>following</span>
|
||||
</ActiveLink>
|
||||
<ActiveLink
|
||||
href={`/feed/global`}
|
||||
activeClassName="rounded-lg ring-1 ring-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
className="flex h-10 items-center gap-1 px-2.5 text-sm font-medium">
|
||||
<span>#</span>
|
||||
<span>global</span>
|
||||
</ActiveLink>
|
||||
</div>
|
||||
</div>
|
||||
{/* Channels
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Channels</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900">
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
*/}
|
||||
{/* Direct messages */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-sm font-bold text-zinc-400">Direct Messages</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 w-6 items-center justify-center rounded-full hover:bg-zinc-900">
|
||||
<PlusIcon className="h-4 w-4 text-zinc-400 group-hover:text-zinc-100" />
|
||||
</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/note/atoms/reaction.tsx
Normal file
83
src/components/note/atoms/reaction.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { currentUser } from '@stores/currentUser';
|
||||
|
||||
import LikeIcon from '@assets/icons/Like';
|
||||
import LikeSolidIcon from '@assets/icons/LikeSolid';
|
||||
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { dateToUnix, useNostr, useNostrEvents } from 'nostr-react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Reaction({
|
||||
eventID,
|
||||
eventPubkey,
|
||||
}: {
|
||||
eventID: string;
|
||||
eventPubkey: string;
|
||||
}) {
|
||||
const { publish } = useNostr();
|
||||
const [reaction, setReaction] = useState(0);
|
||||
const [isReact, setIsReact] = useState(false);
|
||||
|
||||
const $currentUser: any = useStore(currentUser);
|
||||
const pubkey = $currentUser.pubkey;
|
||||
const privkey = $currentUser.privkey;
|
||||
|
||||
const { onEvent } = useNostrEvents({
|
||||
filter: {
|
||||
'#e': [eventID],
|
||||
since: 0,
|
||||
kinds: [7],
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
onEvent((rawMetadata) => {
|
||||
try {
|
||||
const content = rawMetadata.content;
|
||||
if (content === '🤙' || content === '+') {
|
||||
setReaction(reaction + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err, rawMetadata);
|
||||
}
|
||||
});
|
||||
|
||||
const handleReaction = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', eventID],
|
||||
['p', eventPubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, privkey);
|
||||
|
||||
publish(event);
|
||||
|
||||
setIsReact(true);
|
||||
setReaction(reaction + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => handleReaction(e)}
|
||||
className="group flex w-16 items-center gap-1.5 text-sm text-zinc-500">
|
||||
<div className="rounded-lg p-1 group-hover:bg-zinc-800">
|
||||
{isReact ? (
|
||||
<LikeSolidIcon className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<LikeIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<span>{reaction}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
23
src/components/note/atoms/reply.tsx
Normal file
23
src/components/note/atoms/reply.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import ReplyIcon from '@assets/icons/Reply';
|
||||
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
|
||||
export default function Reply({ eventID }: { eventID: string }) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
'#e': [eventID],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button className="group flex w-16 items-center gap-1.5 text-sm text-zinc-500">
|
||||
<div className="rounded-lg p-1 group-hover:bg-zinc-800">
|
||||
<ReplyIcon />
|
||||
</div>
|
||||
<span>{events.length || 0}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
src/components/note/atoms/rootUser.tsx
Normal file
26
src/components/note/atoms/rootUser.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
|
||||
export default function RootUser({ userPubkey, action }: { userPubkey: string; action: string }) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
authors: [userPubkey],
|
||||
kinds: [0],
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
const userData: any = JSON.parse(events[0].content);
|
||||
return (
|
||||
<div className="text-zinc-400">
|
||||
<p>
|
||||
{userData?.name ? userData.name : truncate(userPubkey, 16, ' .... ')} {action}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
80
src/components/note/atoms/user.tsx
Normal file
80
src/components/note/atoms/user.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ImageWithFallback } from '@components/imageWithFallback';
|
||||
|
||||
import { truncate } from '@utils/truncate';
|
||||
|
||||
import MoreIcon from '@assets/icons/More';
|
||||
|
||||
import Avatar from 'boring-avatars';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
import { memo } from 'react';
|
||||
import Moment from 'react-moment';
|
||||
|
||||
export const User = memo(function User({ pubkey, time }: { pubkey: string; time: any }) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
authors: [pubkey],
|
||||
kinds: [0],
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
const userData: any = JSON.parse(events[0].content);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10">
|
||||
{userData?.picture ? (
|
||||
<ImageWithFallback
|
||||
src={userData.picture}
|
||||
alt={pubkey}
|
||||
fill={true}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
size={44}
|
||||
name={pubkey}
|
||||
variant="beam"
|
||||
colors={['#FEE2E2', '#FEF3C7', '#F59E0B', '#EC4899', '#D946EF', '#8B5CF6']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-bold leading-tight">
|
||||
{userData?.name ? userData.name : truncate(pubkey, 16, ' .... ')}
|
||||
</span>
|
||||
<span className="text-zinc-500">·</span>
|
||||
<Moment fromNow unix className="text-zinc-500">
|
||||
{time}
|
||||
</Moment>
|
||||
</div>
|
||||
<div>
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="relative flex animate-pulse items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full border border-white/10 bg-zinc-700"></div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full 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-16 rounded bg-zinc-700" />
|
||||
</div>
|
||||
<div>
|
||||
<MoreIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
20
src/components/note/content/ImagePreview.tsx
Normal file
20
src/components/note/content/ImagePreview.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function ImagePreview({ data }: { data: object }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-800`}>
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src={data['image']}
|
||||
alt="image preview"
|
||||
width="0"
|
||||
height="0"
|
||||
sizes="100vw"
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/note/content/index.tsx
Normal file
70
src/components/note/content/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { MarkdownPreviewProps } from '@uiw/react-markdown-preview';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
const MarkdownPreview = dynamic<MarkdownPreviewProps>(() => import('@uiw/react-markdown-preview'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Content({ data }: { data: string }) {
|
||||
const imagesRef = useRef([]);
|
||||
const videosRef = useRef([]);
|
||||
|
||||
const urls = useMemo(
|
||||
() =>
|
||||
data.match(
|
||||
/((http|ftp|https):\/\/)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)?/gi
|
||||
),
|
||||
[data]
|
||||
);
|
||||
|
||||
const extractURL = useCallback((urls: any[]) => {
|
||||
if (urls !== null && urls.length > 0) {
|
||||
urls.forEach((url: string | URL) => {
|
||||
const parseURL = new URL(url);
|
||||
const path = parseURL.pathname.toLowerCase();
|
||||
switch (path) {
|
||||
case path.match(/\.(jpg|jpeg|gif|png|webp)$/)?.input:
|
||||
imagesRef.current.push(parseURL.href);
|
||||
break;
|
||||
case path.match(
|
||||
/(http:|https:)?\/\/(www\.)?(youtube.com|youtu.be)\/(watch)?(\?v=)?(\S+)?/
|
||||
)?.input:
|
||||
videosRef.current.push(parseURL.href);
|
||||
break;
|
||||
case path.match(/\.(mp4|webm|m4v|mov|avi|mkv|flv)$/)?.input:
|
||||
videosRef.current.push(parseURL.href);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
extractURL(urls);
|
||||
}, [extractURL, urls]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<MarkdownPreview
|
||||
source={data}
|
||||
className={
|
||||
'prose prose-zinc max-w-none break-words dark:prose-invert prose-headings:mt-3 prose-headings:mb-2 prose-p:m-0 prose-p:leading-normal prose-ul:mt-2 prose-li:my-1'
|
||||
}
|
||||
linkTarget="_blank"
|
||||
disallowedElements={[
|
||||
'Table',
|
||||
'Heading ID',
|
||||
'Highlight',
|
||||
'Fenced Code Block',
|
||||
'Footnote',
|
||||
'Definition List',
|
||||
'Task List',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/note/content/preview/imageCard.tsx
Normal file
20
src/components/note/content/preview/imageCard.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function ImageCard({ data }: { data: object }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-800`}>
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
src={data['image']}
|
||||
alt="image preview"
|
||||
width="0"
|
||||
height="0"
|
||||
sizes="100vw"
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/note/content/preview/linkCard.tsx
Normal file
23
src/components/note/content/preview/linkCard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function LinkCard({ data }: { data: object }) {
|
||||
return (
|
||||
<Link
|
||||
href={data['url']}
|
||||
target={'_blank'}
|
||||
className="relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-700">
|
||||
<div className="relative aspect-video h-auto w-full">
|
||||
<Image src={data['image']} alt="image preview" fill={true} className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div>
|
||||
<h5 className="font-semibold leading-tight">{data['title']}</h5>
|
||||
<p className="text-sm text-zinc-300">{data['description']}</p>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-500">{data['url']}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
17
src/components/note/content/preview/video.tsx
Normal file
17
src/components/note/content/preview/video.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import ReactPlayer from 'react-player/lazy';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function Video({ data }: { data: object }) {
|
||||
return (
|
||||
<div className="relative mt-2 flex flex-col overflow-hidden rounded-xl border border-zinc-800">
|
||||
<ReactPlayer
|
||||
url={data['url']}
|
||||
controls={true}
|
||||
volume={0}
|
||||
className="aspect-video w-full"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/note/liked.tsx
Normal file
64
src/components/note/liked.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import RootUser from '@components/note/atoms/rootUser';
|
||||
import User from '@components/note/atoms/user';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
|
||||
import LikeSolidIcon from '@assets/icons/LikeSolid';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const Liked = memo(function Liked({
|
||||
eventUser,
|
||||
sourceID,
|
||||
}: {
|
||||
eventUser: string;
|
||||
sourceID: string;
|
||||
}) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
ids: [sourceID],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
|
||||
<div className="flex items-center gap-1 pl-8 text-sm">
|
||||
<LikeSolidIcon className="h-4 w-4 text-zinc-400" />
|
||||
<div className="ml-2">
|
||||
<RootUser userPubkey={eventUser} action={'like'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={events[0].pubkey} time={events[0].created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={events[0].content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={events[0].id} />
|
||||
<Reaction eventID={events[0].id} eventPubkey={events[0].pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Placeholder />;
|
||||
}
|
||||
});
|
||||
64
src/components/note/multi.tsx
Normal file
64
src/components/note/multi.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import User from '@components/note/atoms/user';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function Multi({ event }: { event: any }) {
|
||||
const tags = event.tags;
|
||||
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
ids: [tags[0][1]],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
return (
|
||||
<div className="relative flex h-min min-h-min w-full select-text flex-col overflow-hidden border-b border-zinc-800">
|
||||
<div className="absolute left-[45px] top-6 h-full w-[2px] bg-zinc-800"></div>
|
||||
<div className="flex flex-col bg-zinc-900 px-6 pt-6 pb-2">
|
||||
<User pubkey={events[0].pubkey} time={events[0].created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={events[0].content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={events[0].id} />
|
||||
<Reaction eventID={events[0].id} eventPubkey={events[0].pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col bg-zinc-900 px-6 pb-6">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="relative z-10 -mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={event.content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={event.id} />
|
||||
<Reaction eventID={event.id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Placeholder />;
|
||||
}
|
||||
}
|
||||
30
src/components/note/placeholder.tsx
Normal file
30
src/components/note/placeholder.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const Placeholder = memo(function Placeholder() {
|
||||
return (
|
||||
<div className="relative z-10 flex h-min animate-pulse select-text flex-col py-6 px-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-full 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-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
);
|
||||
});
|
||||
64
src/components/note/repost.tsx
Normal file
64
src/components/note/repost.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import RootUser from '@components/note/atoms/rootUser';
|
||||
import User from '@components/note/atoms/user';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
|
||||
import RepostIcon from '@assets/icons/Repost';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useNostrEvents } from 'nostr-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
export const Repost = memo(function Repost({
|
||||
eventUser,
|
||||
sourceID,
|
||||
}: {
|
||||
eventUser: string;
|
||||
sourceID: string;
|
||||
}) {
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
ids: [sourceID],
|
||||
since: 0,
|
||||
kinds: [1],
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (events !== undefined && events.length > 0) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
|
||||
<div className="flex items-center gap-1 pl-8 text-sm">
|
||||
<RepostIcon className="h-4 w-4 text-zinc-400" />
|
||||
<div className="ml-2">
|
||||
<RootUser userPubkey={eventUser} action={'repost'} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={events[0].pubkey} time={events[0].created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={events[0].content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={events[0].id} />
|
||||
<Reaction eventID={events[0].id} eventPubkey={events[0].pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <Placeholder />;
|
||||
}
|
||||
});
|
||||
36
src/components/note/single.tsx
Normal file
36
src/components/note/single.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Reaction from '@components/note/atoms/reaction';
|
||||
import Reply from '@components/note/atoms/reply';
|
||||
import { User } from '@components/note/atoms/user';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { memo } from 'react';
|
||||
|
||||
const DynamicContent = dynamic(() => import('@components/note/content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<>
|
||||
<p>Loading...</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Single = memo(function Single({ event }: { event: any }) {
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 py-6 px-6">
|
||||
<div className="flex flex-col">
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-4 pl-[60px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<DynamicContent data={event.content} />
|
||||
<div className="-ml-1 flex items-center gap-8">
|
||||
<Reply eventID={event.id} />
|
||||
<Reaction eventID={event.id} eventPubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
50
src/components/thread.tsx
Normal file
50
src/components/thread.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Liked } from '@components/note/liked';
|
||||
// import { Multi } from '@components/note/multi';
|
||||
import { Placeholder } from '@components/note/placeholder';
|
||||
import { Repost } from '@components/note/repost';
|
||||
import { Single } from '@components/note/single';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function Thread({ data }: { data: any }) {
|
||||
const ItemContent = useCallback(
|
||||
(index: string | number) => {
|
||||
const event = data[index];
|
||||
|
||||
if (event.kind === 7) {
|
||||
// type: like
|
||||
return <Liked key={index} eventUser={event.pubkey} sourceID={event.tags[0][1]} />;
|
||||
} else if (event.content === '#[0]') {
|
||||
// type: repost
|
||||
return <Repost key={index} eventUser={event.pubkey} sourceID={event.tags[0][1]} />;
|
||||
} else {
|
||||
// type: default
|
||||
return <Single key={index} event={event} />;
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
data={data}
|
||||
itemContent={ItemContent}
|
||||
components={{
|
||||
EmptyPlaceholder: () => <Placeholder />,
|
||||
ScrollSeekPlaceholder: () => <Placeholder />,
|
||||
}}
|
||||
scrollSeekConfiguration={{
|
||||
enter: (velocity) => Math.abs(velocity) > 800,
|
||||
exit: (velocity) => Math.abs(velocity) < 500,
|
||||
}}
|
||||
overscan={800}
|
||||
increaseViewportBy={1000}
|
||||
className="scrollbar-hide relative h-full w-full"
|
||||
style={{
|
||||
contain: 'strict',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user