Files
lume/src/routes/columns/_layout/stories.$id.lazy.tsx
2024-10-26 09:08:50 +07:00

247 lines
7.6 KiB
TypeScript

import { commands } from "@/commands.gen";
import { replyTime, toLumeEvents } from "@/commons";
import { Note, Spinner, User } from "@/components";
import { Hashtag } from "@/components/note/mentions/hashtag";
import { MentionUser } from "@/components/note/mentions/user";
import { type LumeEvent, LumeWindow } from "@/system";
import { Kind } from "@/types";
import { ArrowRight } from "@phosphor-icons/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools";
import { type ReactNode, type RefObject, memo, useMemo, useRef } from "react";
import reactStringReplace from "react-string-replace";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/columns/_layout/stories/$id")({
component: Screen,
});
function Screen() {
const contacts = Route.useLoaderData();
const ref = useRef<HTMLDivElement>(null);
return (
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="relative h-full px-3 pb-3">
<Virtualizer
scrollRef={ref as unknown as RefObject<HTMLElement>}
overscan={0}
>
{!contacts ? (
<div className="w-full h-24 flex items-center justify-center">
<Spinner className="size-4" />
</div>
) : (
contacts.map((contact) => (
<StoryItem key={contact} contact={contact} />
))
)}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
);
}
function StoryItem({ contact }: { contact: string }) {
const {
isLoading,
isError,
error,
data: events,
} = useQuery({
queryKey: ["events", "story", contact],
queryFn: async () => {
const res = await commands.getAllEventsByAuthor(contact, 10);
if (res.status === "ok") {
const data = toLumeEvents(res.data);
return data;
} else {
throw new Error(res.error);
}
},
select: (data) => data.filter((ev) => ev.kind === Kind.Text),
refetchOnWindowFocus: false,
});
const ref = useRef<HTMLDivElement>(null);
return (
<div className="mb-3 flex flex-col w-full h-[300px] bg-white dark:bg-neutral-800 rounded-xl border-[.5px] border-neutral-300 dark:border-neutral-700">
<div className="h-12 shrink-0 px-2 flex items-center justify-between border-b border-neutral-100 dark:border-white/5">
<User.Provider pubkey={contact}>
<User.Root className="inline-flex items-center gap-2">
<User.Avatar className="size-8 rounded-full" />
<User.Name className="text-sm font-medium" />
</User.Root>
</User.Provider>
<div>
<button
type="button"
onClick={() => LumeWindow.openProfile(contact)}
className="h-7 w-max px-2.5 inline-flex gap-1 items-center justify-center rounded-full text-sm font-medium hover:bg-neutral-100 dark:hover:bg-white/20"
>
Open
<ArrowRight className="size-3" weight="bold" />
</button>
</div>
</div>
<ScrollArea.Root
type={"scroll"}
scrollHideDelay={300}
className="flex-1 min-h-0 overflow-hidden size-full"
>
<ScrollArea.Viewport ref={ref} className="relative h-full px-2 pt-2">
<Virtualizer
scrollRef={ref as unknown as RefObject<HTMLElement>}
overscan={0}
>
{isLoading ? (
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
<Spinner className="size-4" />
</div>
) : isError ? (
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
{error.message}
</div>
) : !events?.length ? (
<div className="w-full h-[calc(300px-48px)] flex items-center justify-center text-sm">
This user didn't have any new notes in the last few days.
</div>
) : (
events.map((event) => <StoryEvent key={event.id} event={event} />)
)}
</Virtualizer>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex select-none touch-none p-0.5 duration-[160ms] ease-out data-[orientation=vertical]:w-2"
orientation="vertical"
>
<ScrollArea.Thumb className="flex-1 bg-black/10 dark:bg-white/10 rounded-full relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.Scrollbar>
<ScrollArea.Corner className="bg-transparent" />
</ScrollArea.Root>
</div>
);
}
const StoryEvent = memo(function StoryEvent({ event }: { event: LumeEvent }) {
return (
<Note.Provider event={event}>
<User.Provider pubkey={event.pubkey}>
<Note.Root className="group flex flex-col gap-1 mb-3">
<div>
<User.Name
className="shrink-0 inline font-medium text-blue-500"
suffix=":"
/>
<Content
text={event.content}
className="pl-2 inline select-text text-balance content-break overflow-hidden"
/>
</div>
<div className="flex-1 flex items-center justify-between">
<span className="text-sm text-neutral-500">
{replyTime(event.created_at)}
</span>
<div className="invisible group-hover:visible flex items-center justify-end gap-3">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</User.Provider>
</Note.Provider>
);
});
function Content({ text, className }: { text: string; className?: string }) {
const content = useMemo(() => {
let replacedText: ReactNode[] | string = text.trim();
const nostr = replacedText
.split(/\s+/)
.filter((w) => w.startsWith("nostr:"));
replacedText = reactStringReplace(text, /(https?:\/\/\S+)/g, (match, i) => (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
));
replacedText = reactStringReplace(replacedText, /#(\w+)/g, (match, i) => (
<Hashtag key={match + i} tag={match} />
));
for (const word of nostr) {
const bech32 = word.replace("nostr:", "").replace(/[^\w\s]/gi, "");
try {
const data = nip19.decode(bech32);
switch (data.type) {
case "npub":
replacedText = reactStringReplace(
replacedText,
word,
(match, i) => <MentionUser key={match + i} pubkey={data.data} />,
);
break;
case "nprofile":
replacedText = reactStringReplace(
replacedText,
word,
(match, i) => (
<MentionUser key={match + i} pubkey={data.data.pubkey} />
),
);
break;
default:
replacedText = reactStringReplace(
replacedText,
word,
(match, i) => (
<a
key={match + i}
href={`https://njump.me/${bech32}`}
target="_blank"
rel="noreferrer"
className="text-blue-600 dark:text-blue-400 !underline"
>
{match}
</a>
),
);
break;
}
} catch {
console.log(word);
}
}
return replacedText;
}, [text]);
return <div className={className}>{content}</div>;
}