fix the mess I started
This commit is contained in:
@@ -4,7 +4,7 @@ import Navigation from '@lume/shared/navigation';
|
||||
|
||||
export function LayoutChannel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { ChannelBlackList } from '@lume/shared/channels/channelBlackList';
|
||||
import { ChannelProfile } from '@lume/shared/channels/channelProfile';
|
||||
import { UpdateChannelModal } from '@lume/shared/channels/updateChannelModal';
|
||||
import { FormChannel } from '@lume/shared/form/channel';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { channelMessagesAtom, channelReplyAtom } from '@lume/stores/channel';
|
||||
import { FULL_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix, hoursAgo } from '@lume/utils/getDate';
|
||||
@@ -12,18 +10,20 @@ import { arrayObjToPureArr } from '@lume/utils/transform';
|
||||
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { Suspense, lazy, useContext, useRef } from 'react';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { Suspense, lazy, useRef } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
|
||||
const ChannelMessages = lazy(() => import('@lume/shared/channels/messages'));
|
||||
|
||||
let mutedList: any = [];
|
||||
let activeAccount: any = {};
|
||||
let activeMutedList: any = [];
|
||||
let activeHidedList: any = [];
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const { getBlacklist, getActiveBlacklist, getActiveAccount } = await import('@lume/utils/storage');
|
||||
const activeAccount = await getActiveAccount();
|
||||
activeAccount = await getActiveAccount();
|
||||
activeHidedList = await getActiveBlacklist(activeAccount.id, 43);
|
||||
activeMutedList = await getActiveBlacklist(activeAccount.id, 44);
|
||||
mutedList = await getBlacklist(activeAccount.id, 44);
|
||||
@@ -33,12 +33,9 @@ export function Page() {
|
||||
const pageContext = usePageContext();
|
||||
const searchParams: any = pageContext.urlParsed.search;
|
||||
|
||||
const id = searchParams.id;
|
||||
const channelID = searchParams.id;
|
||||
const channelPubkey = searchParams.pubkey;
|
||||
|
||||
const pool: any = useContext(RelayContext);
|
||||
const activeAccount: any = useContext(AccountContext);
|
||||
|
||||
const setChannelMessages = useSetAtom(channelMessagesAtom);
|
||||
const resetChannelMessages = useResetAtom(channelMessagesAtom);
|
||||
const resetChannelReply = useResetAtom(channelReplyAtom);
|
||||
@@ -47,16 +44,17 @@ export function Page() {
|
||||
const hided = arrayObjToPureArr(activeHidedList);
|
||||
const muted = arrayObjToPureArr(activeMutedList);
|
||||
|
||||
useSWRSubscription(id, () => {
|
||||
useSWRSubscription(channelID, () => {
|
||||
// reset channel reply
|
||||
resetChannelReply();
|
||||
// reset channel messages
|
||||
resetChannelMessages();
|
||||
// subscribe for new messages
|
||||
const pool = new RelayPool(FULL_RELAYS);
|
||||
const unsubscribe = pool.subscribe(
|
||||
[
|
||||
{
|
||||
'#e': [id],
|
||||
'#e': [channelID],
|
||||
kinds: [42],
|
||||
since: dateToUnix(hoursAgo(48, now.current)),
|
||||
},
|
||||
@@ -82,11 +80,11 @@ export function Page() {
|
||||
<div className="flex h-full flex-col justify-between gap-2">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<ChannelProfile id={id} pubkey={channelPubkey} />
|
||||
<ChannelProfile id={channelID} pubkey={channelPubkey} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChannelBlackList blacklist={mutedList} />
|
||||
{activeAccount.pubkey === channelPubkey && <UpdateChannelModal id={id} />}
|
||||
{activeAccount.pubkey === channelPubkey && <UpdateChannelModal id={activeAccount} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full flex-1 flex-col justify-between rounded-lg border border-zinc-800 bg-zinc-900 shadow-input shadow-black/20">
|
||||
@@ -94,7 +92,7 @@ export function Page() {
|
||||
<ChannelMessages />
|
||||
</Suspense>
|
||||
<div className="shrink-0 p-3">
|
||||
<FormChannel eventId={id} />
|
||||
<FormChannel eventId={channelID} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Navigation from '@lume/shared/navigation';
|
||||
|
||||
export function LayoutChat({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
|
||||
56
src/app/newsfeed/components/contentParser.tsx
Normal file
56
src/app/newsfeed/components/contentParser.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ImagePreview } from '@lume/app/newsfeed/components/note/preview/image';
|
||||
import { VideoPreview } from '@lume/app/newsfeed/components/note/preview/video';
|
||||
import { YoutubePreview } from '@lume/app/newsfeed/components/note/preview/youtube';
|
||||
import { NoteQuote } from '@lume/app/newsfeed/components/note/quote';
|
||||
import { NoteMentionUser } from '@lume/app/newsfeed/components/user/mention';
|
||||
|
||||
import destr from 'destr';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
|
||||
export const contentParser = (noteContent: any, noteTags: any) => {
|
||||
let parsedContent = noteContent.trim();
|
||||
|
||||
// get data tags
|
||||
const tags = destr(noteTags);
|
||||
// handle urls
|
||||
parsedContent = reactStringReplace(parsedContent, /(https?:\/\/\S+)/g, (match, i) => {
|
||||
if (match.match(/\.(jpg|jpeg|gif|png|webp)$/i)) {
|
||||
// image url
|
||||
return <ImagePreview key={match + i} url={match} size="large" />;
|
||||
} else if (match.match(/(http:|https:)?(\/\/)?(www\.)?(youtube.com|youtu.be)\/(watch|embed)?(\?v=|\/)?(\S+)?/)) {
|
||||
// youtube
|
||||
return <YoutubePreview key={match + i} url={match} />;
|
||||
} else if (match.match(/\.(mp4|webm)$/i)) {
|
||||
// video
|
||||
return <VideoPreview key={match + i} url={match} />;
|
||||
} else {
|
||||
return (
|
||||
<a key={match + i} href={match} className="cursor-pointer text-fuchsia-500" target="_blank" rel="noreferrer">
|
||||
{match}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
// handle #-hashtags
|
||||
parsedContent = reactStringReplace(parsedContent, /#(\w+)/g, (match, i) => (
|
||||
<span key={match + i} className="cursor-pointer text-fuchsia-500">
|
||||
#{match}
|
||||
</span>
|
||||
));
|
||||
// handle mentions
|
||||
if (tags && tags.length > 0) {
|
||||
parsedContent = reactStringReplace(parsedContent, /\#\[(\d+)\]/gm, (match, i) => {
|
||||
if (tags[match][0] === 'p') {
|
||||
// @-mentions
|
||||
return <NoteMentionUser key={tags[match][1] + i} pubkey={tags[match][1]} />;
|
||||
} else if (tags[match][0] === 'e') {
|
||||
// note-quotes
|
||||
return <NoteQuote key={tags[match][1] + i} id={tags[match][1]} />;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return parsedContent;
|
||||
};
|
||||
73
src/app/newsfeed/components/form.tsx
Normal file
73
src/app/newsfeed/components/form.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ImagePicker } from '@lume/shared/form/imagePicker';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { noteContentAtom } from '@lume/stores/note';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
import { useActiveAccount } from '@lume/utils/hooks/useActiveAccount';
|
||||
|
||||
import { useAtom } from 'jotai';
|
||||
import { useResetAtom } from 'jotai/utils';
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
|
||||
export default function NoteForm() {
|
||||
const { account, isLoading, isError } = useActiveAccount();
|
||||
const [value, setValue] = useAtom(noteContentAtom);
|
||||
const resetValue = useResetAtom(noteContentAtom);
|
||||
|
||||
const submitEvent = () => {
|
||||
if (!isLoading && !isError && account) {
|
||||
const pool = new RelayPool(WRITEONLY_RELAYS);
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: account.pubkey,
|
||||
tags: [],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, account.privkey);
|
||||
|
||||
// publish note
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// reset form
|
||||
resetValue();
|
||||
// send notification
|
||||
// sendNotification('Note has been published successfully');
|
||||
} else {
|
||||
console.log('Cannot publish note');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="relative h-32 w-full shrink-0 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
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="What's up?"
|
||||
className="relative h-32 w-full resize-none rounded-lg border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</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">
|
||||
<ImagePicker type="note" />
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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-sm font-medium shadow-button hover:bg-fuchsia-600 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/app/newsfeed/components/note/base.tsx
Normal file
51
src/app/newsfeed/components/note/base.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { contentParser } from '@lume/app/newsfeed/components/contentParser';
|
||||
import { NoteParent } from '@lume/app/newsfeed/components/note/parent';
|
||||
import { NoteDefaultUser } from '@lume/app/newsfeed/components/user/default';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
|
||||
export const NoteBase = memo(function NoteBase({ event }: { event: any }) {
|
||||
const content = contentParser(event.content, event.tags);
|
||||
|
||||
const parentNote = () => {
|
||||
if (event.parent_id) {
|
||||
if (event.parent_id !== event.event_id && !event.content.includes('#[0]')) {
|
||||
return <NoteParent key={event.parent_id} id={event.parent_id} />;
|
||||
}
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
const openUserPage = (e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/user?pubkey=${event.pubkey}`);
|
||||
};
|
||||
|
||||
const openThread = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/newsfeed/note?id=${event.parent_id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => openThread(e)}
|
||||
className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20"
|
||||
>
|
||||
{parentNote()}
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<div onClick={(e) => openUserPage(e)}>
|
||||
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
|
||||
</div>
|
||||
<div className="mt-1 pl-[52px]">
|
||||
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">{content}</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
24
src/app/newsfeed/components/note/comment.tsx
Normal file
24
src/app/newsfeed/components/note/comment.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { contentParser } from '@lume/app/newsfeed/components/contentParser';
|
||||
import { NoteDefaultUser } from '@lume/app/newsfeed/components/user/default';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
export const NoteComment = memo(function NoteComment({ event }: { event: any }) {
|
||||
const content = contentParser(event.content, event.tags);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20">
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<NoteDefaultUser pubkey={event.pubkey} time={event.created_at} />
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="prose prose-zinc max-w-none break-words text-[15px] leading-tight dark:prose-invert prose-p:m-0 prose-p:text-[15px] prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-img:m-0 prose-video:m-0">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
158
src/app/newsfeed/components/note/meta/comment.tsx
Normal file
158
src/app/newsfeed/components/note/meta/comment.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { UserExtend } from '@lume/shared/user/extend';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ChatLines, OpenNewWindow } from 'iconoir-react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { Fragment, useContext, useState } from 'react';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
|
||||
export const NoteComment = ({
|
||||
count,
|
||||
eventID,
|
||||
eventPubkey,
|
||||
eventContent,
|
||||
eventTime,
|
||||
}: {
|
||||
count: number;
|
||||
eventID: string;
|
||||
eventPubkey: string;
|
||||
eventTime: number;
|
||||
eventContent: any;
|
||||
}) => {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const activeAccount: any = useContext(AccountContext);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const profile = activeAccount ? JSON.parse(activeAccount.metadata) : null;
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const openThread = () => {
|
||||
navigate(`/newsfeed/note?id=${eventID}`);
|
||||
};
|
||||
|
||||
const submitEvent = () => {
|
||||
const event: any = {
|
||||
content: value,
|
||||
created_at: dateToUnix(),
|
||||
kind: 1,
|
||||
pubkey: activeAccount.pubkey,
|
||||
tags: [['e', eventID]],
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, activeAccount.privkey);
|
||||
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal()}
|
||||
className="group flex w-16 items-center gap-1 text-sm text-zinc-500"
|
||||
>
|
||||
<div className="rounded-md p-1 group-hover:bg-zinc-800">
|
||||
<ChatLines width={20} height={20} className="text-zinc-500" />
|
||||
</div>
|
||||
<span>{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 data-[state=open]:animate-overlayShow" />
|
||||
</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">
|
||||
{/* root note */}
|
||||
<div className="relative z-10 flex flex-col pb-6">
|
||||
<div className="relative z-10">
|
||||
<UserExtend pubkey={eventPubkey} time={eventTime} />
|
||||
</div>
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="prose prose-zinc max-w-none break-words leading-tight dark:prose-invert prose-headings:mb-2 prose-headings:mt-3 prose-p:m-0 prose-p:leading-tight prose-a:font-normal prose-a:text-fuchsia-500 prose-a:no-underline prose-ul:mt-2 prose-li:my-1">
|
||||
{eventContent}
|
||||
</div>
|
||||
</div>
|
||||
{/* divider */}
|
||||
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
</div>
|
||||
{/* 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">
|
||||
<img src={profile?.picture} alt="user's avatar" className="h-11 w-11 rounded-md object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-36 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-36 w-full resize-none rounded-md border border-black/5 px-3.5 py-3 text-sm shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-200 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">
|
||||
<button
|
||||
onClick={() => openThread()}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-zinc-700"
|
||||
>
|
||||
<OpenNewWindow width={16} height={16} className="text-zinc-400" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pl-2"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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-sm 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
69
src/app/newsfeed/components/note/meta/reaction.tsx
Normal file
69
src/app/newsfeed/components/note/meta/reaction.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { AccountContext } from '@lume/shared/accountProvider';
|
||||
import { RelayContext } from '@lume/shared/relaysProvider';
|
||||
import { WRITEONLY_RELAYS } from '@lume/stores/constants';
|
||||
import { dateToUnix } from '@lume/utils/getDate';
|
||||
|
||||
import { Heart } from 'iconoir-react';
|
||||
import { getEventHash, signEvent } from 'nostr-tools';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
export const NoteReaction = ({
|
||||
count,
|
||||
liked,
|
||||
eventID,
|
||||
eventPubkey,
|
||||
}: {
|
||||
count: number;
|
||||
liked: boolean;
|
||||
eventID: string;
|
||||
eventPubkey: string;
|
||||
}) => {
|
||||
const pool: any = useContext(RelayContext);
|
||||
const activeAccount: any = useContext(AccountContext);
|
||||
|
||||
const [isReact, setIsReact] = useState(false);
|
||||
const [like, setLike] = useState(0);
|
||||
|
||||
const handleLike = (e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (liked === false || isReact === false) {
|
||||
const event: any = {
|
||||
content: '+',
|
||||
kind: 7,
|
||||
tags: [
|
||||
['e', eventID],
|
||||
['p', eventPubkey],
|
||||
],
|
||||
created_at: dateToUnix(),
|
||||
pubkey: activeAccount.pubkey,
|
||||
};
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, activeAccount.privkey);
|
||||
// publish event to all relays
|
||||
pool.publish(event, WRITEONLY_RELAYS);
|
||||
// update state to change icon to filled heart
|
||||
setIsReact(true);
|
||||
// update counter
|
||||
setLike((like) => (like += 1));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsReact(liked);
|
||||
setLike(count);
|
||||
}, [count, liked]);
|
||||
|
||||
return (
|
||||
<button onClick={(e) => handleLike(e)} className="group flex w-16 items-center gap-1 text-sm text-zinc-500">
|
||||
<div className="rounded-md p-1 group-hover:bg-zinc-800">
|
||||
{isReact ? (
|
||||
<Heart width={20} height={20} className="fill-red-500 text-transparent" />
|
||||
) : (
|
||||
<Heart width={20} height={20} className="text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
<span>{like}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
5
src/app/newsfeed/components/note/metadata.tsx
Normal file
5
src/app/newsfeed/components/note/metadata.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
export const NoteMetadata = memo(function NoteMetadata() {
|
||||
return <div className="relative z-10 -ml-1 flex items-center gap-8"></div>;
|
||||
});
|
||||
83
src/app/newsfeed/components/note/parent.tsx
Normal file
83
src/app/newsfeed/components/note/parent.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { contentParser } from '@lume/app/newsfeed/components/contentParser';
|
||||
import { NoteDefaultUser } from '@lume/app/newsfeed/components/user/default';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { memo } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
|
||||
export const NoteParent = memo(function NoteParent({ id }: { id: string }) {
|
||||
const { data, error } = useSWRSubscription(
|
||||
id
|
||||
? [
|
||||
{
|
||||
ids: [id],
|
||||
kinds: [1],
|
||||
},
|
||||
]
|
||||
: null,
|
||||
(key, { next }) => {
|
||||
const pool = new RelayPool(READONLY_RELAYS);
|
||||
const unsubscribe = pool.subscribe(
|
||||
key,
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative pb-5">
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="animated-pulse">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-1 pl-[52px]">
|
||||
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
|
||||
{contentParser(data.content, data.tags)}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
27
src/app/newsfeed/components/note/placeholder.tsx
Normal file
27
src/app/newsfeed/components/note/placeholder.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
export const Placeholder = () => {
|
||||
return (
|
||||
<div className="relative z-10 flex h-min animate-pulse select-text flex-col px-3 py-5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-zinc-700" />
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="h-4 w-16 rounded bg-zinc-700" />
|
||||
<span className="text-zinc-500">·</span>
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-5 pl-[52px]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="h-16 w-full rounded bg-zinc-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
<div className="h-4 w-12 rounded bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
src/app/newsfeed/components/note/preview/image.tsx
Normal file
14
src/app/newsfeed/components/note/preview/image.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export const ImagePreview = ({ url, size }: { url: string; size: string }) => {
|
||||
return (
|
||||
<div className={`relative h-full ${size === 'large' ? 'w-4/5' : 'w-1/2'} mt-2 rounded-lg border border-zinc-800`}>
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
className="h-auto w-full rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
11
src/app/newsfeed/components/note/preview/video.tsx
Normal file
11
src/app/newsfeed/components/note/preview/video.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MediaOutlet, MediaPlayer } from '@vidstack/react';
|
||||
|
||||
export const VideoPreview = ({ url }: { url: string }) => {
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} className="relative mt-2 flex flex-col overflow-hidden rounded-lg">
|
||||
<MediaPlayer src={url} poster="" controls>
|
||||
<MediaOutlet />
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
src/app/newsfeed/components/note/preview/youtube.tsx
Normal file
16
src/app/newsfeed/components/note/preview/youtube.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import YouTube from 'react-youtube';
|
||||
|
||||
function getVideoId(url: string) {
|
||||
const regex = /(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[&#?])|$))/gm;
|
||||
return regex.exec(url)[3];
|
||||
}
|
||||
|
||||
export const YoutubePreview = ({ url }: { url: string }) => {
|
||||
const id = getVideoId(url);
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()} className="relative mt-2 flex flex-col overflow-hidden rounded-lg">
|
||||
<YouTube videoId={id} className="aspect-video xl:w-2/3" opts={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
src/app/newsfeed/components/note/quote.tsx
Normal file
57
src/app/newsfeed/components/note/quote.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { contentParser } from '@lume/app/newsfeed/components/contentParser';
|
||||
import { NoteDefaultUser } from '@lume/app/newsfeed/components/user/default';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { memo } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
|
||||
export const NoteQuote = memo(function NoteQuote({ id }: { id: string }) {
|
||||
const { data, error } = useSWRSubscription(
|
||||
id
|
||||
? [
|
||||
{
|
||||
ids: [id],
|
||||
kinds: [1],
|
||||
},
|
||||
]
|
||||
: null,
|
||||
(key, { next }) => {
|
||||
const pool = new RelayPool(READONLY_RELAYS);
|
||||
const unsubscribe = pool.subscribe(
|
||||
key,
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative mb-2 mt-3 rounded-lg border border-zinc-700 bg-zinc-800 p-2 py-3">
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="h-6 w-full animate-pulse select-text flex-col rounded bg-zinc-800"></div>
|
||||
) : (
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-1 pl-[52px]">
|
||||
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
|
||||
{contentParser(data.content, data.tags)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
19
src/app/newsfeed/components/note/quoteRepost.tsx
Normal file
19
src/app/newsfeed/components/note/quoteRepost.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RootNote } from '@lume/app/newsfeed/components/note/rootNote';
|
||||
import { NoteRepostUser } from '@lume/app/newsfeed/components/user/repost';
|
||||
import { getQuoteID } from '@lume/utils/transform';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
export const NoteQuoteRepost = memo(function NoteQuoteRepost({ event }: { event: any }) {
|
||||
const rootID = getQuoteID(event.tags);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-min min-h-min w-full select-text flex-col border-b border-zinc-800 px-3 py-5 hover:bg-black/20">
|
||||
<div className="relative z-10 flex flex-col pb-5">
|
||||
<div className="absolute left-[21px] top-0 h-full w-0.5 bg-gradient-to-t from-zinc-800 to-zinc-600"></div>
|
||||
<NoteRepostUser pubkey={event.pubkey} time={event.created_at} />
|
||||
</div>
|
||||
<RootNote id={rootID} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
68
src/app/newsfeed/components/note/rootNote.tsx
Normal file
68
src/app/newsfeed/components/note/rootNote.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { contentParser } from '@lume/app/newsfeed/components/contentParser';
|
||||
import { NoteDefaultUser } from '@lume/app/newsfeed/components/user/default';
|
||||
import { READONLY_RELAYS } from '@lume/stores/constants';
|
||||
|
||||
import { RelayPool } from 'nostr-relaypool';
|
||||
import { memo } from 'react';
|
||||
import useSWRSubscription from 'swr/subscription';
|
||||
import { navigate } from 'vite-plugin-ssr/client/router';
|
||||
|
||||
export const RootNote = memo(function RootNote({ id }: { id: string }) {
|
||||
const openThread = (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
navigate(`/newsfeed/note?id=${id}`);
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const { data, error } = useSWRSubscription(
|
||||
id
|
||||
? [
|
||||
{
|
||||
ids: [id],
|
||||
kinds: [1],
|
||||
},
|
||||
]
|
||||
: null,
|
||||
(key, { next }) => {
|
||||
const pool = new RelayPool(READONLY_RELAYS);
|
||||
const unsubscribe = pool.subscribe(
|
||||
key,
|
||||
READONLY_RELAYS,
|
||||
(event: any) => {
|
||||
next(null, event);
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
unsubscribeOnEose: true,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div>failed to load</div>}
|
||||
{!data ? (
|
||||
<div className="h-6 w-full animate-pulse select-text flex-col rounded bg-zinc-800"></div>
|
||||
) : (
|
||||
<div onClick={(e) => openThread(e)} className="relative z-10 flex flex-col">
|
||||
<NoteDefaultUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-1 pl-[52px]">
|
||||
<div className="whitespace-pre-line break-words text-[15px] leading-tight text-zinc-100">
|
||||
{contentParser(data.content, data.tags)}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} className="mt-5 pl-[52px]"></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
39
src/app/newsfeed/components/user/default.tsx
Normal file
39
src/app/newsfeed/components/user/default.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const NoteDefaultUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||
const profile = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex h-11 items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||
<img
|
||||
src={profile?.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 items-start justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h5 className="text-sm font-semibold leading-none group-hover:underline">
|
||||
{profile?.display_name || profile?.name || shortenKey(pubkey)}
|
||||
</h5>
|
||||
<span className="text-sm leading-none text-zinc-700"></span>
|
||||
</div>
|
||||
<span className="text-sm leading-none text-zinc-500">
|
||||
{profile?.nip05 || shortenKey(pubkey)} • {dayjs().to(dayjs.unix(time))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
src/app/newsfeed/components/user/mention.tsx
Normal file
9
src/app/newsfeed/components/user/mention.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
export const NoteMentionUser = ({ pubkey }: { pubkey: string }) => {
|
||||
const profile = useProfile(pubkey);
|
||||
return (
|
||||
<span className="cursor-pointer text-fuchsia-500">@{profile?.name || profile?.username || shortenKey(pubkey)}</span>
|
||||
);
|
||||
};
|
||||
36
src/app/newsfeed/components/user/repost.tsx
Normal file
36
src/app/newsfeed/components/user/repost.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { DEFAULT_AVATAR } from '@lume/stores/constants';
|
||||
import { useProfile } from '@lume/utils/hooks/useProfile';
|
||||
import { shortenKey } from '@lume/utils/shortenKey';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const NoteRepostUser = ({ pubkey, time }: { pubkey: string; time: number }) => {
|
||||
const profile = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-md bg-white">
|
||||
<img
|
||||
src={profile?.picture || DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-11 w-11 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<h5 className="font-semibold leading-tight group-hover:underline">
|
||||
{profile?.display_name || profile?.name || shortenKey(pubkey)}{' '}
|
||||
<span className="bg-gradient-to-r from-fuchsia-300 via-orange-100 to-amber-300 bg-clip-text text-transparent">
|
||||
reposted
|
||||
</span>
|
||||
</h5>
|
||||
<span className="leading-tight text-zinc-500">·</span>
|
||||
<span className="text-zinc-500">{dayjs().to(dayjs.unix(time))}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import Navigation from '@lume/shared/navigation';
|
||||
|
||||
export function LayoutNewsfeed({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-black dark:text-white">
|
||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-white">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import FormBase from '@lume/shared/form/base';
|
||||
import { NoteBase } from '@lume/shared/note/base';
|
||||
import { Placeholder } from '@lume/shared/note/placeholder';
|
||||
import { NoteQuoteRepost } from '@lume/shared/note/quoteRepost';
|
||||
import NoteForm from '@lume/app/newsfeed/components/form';
|
||||
import { NoteBase } from '@lume/app/newsfeed/components/note/base';
|
||||
import { Placeholder } from '@lume/app/newsfeed/components/note/placeholder';
|
||||
import { NoteQuoteRepost } from '@lume/app/newsfeed/components/note/quoteRepost';
|
||||
import { hasNewerNoteAtom } from '@lume/stores/note';
|
||||
import { countTotalNotes, getNotes } from '@lume/utils/storage';
|
||||
|
||||
@@ -73,7 +73,7 @@ export function Page() {
|
||||
<div>{error.message}</div>
|
||||
) : (
|
||||
<div ref={parentRef} className="scrollbar-hide h-full w-full overflow-y-auto" style={{ contain: 'strict' }}>
|
||||
<FormBase />
|
||||
<NoteForm />
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user