final design (#184)

* feat: redesign

* feat: update other columns to new design

* chore: small fixes

* fix: better manage external webview

* feat: redesign note

* feat: update ui

* chore: update

* chore: update

* chore: polish ui

* chore: update auth ui

* feat: finalize note design

* chore: small fixes

* feat: add window management in rust

* chore: format

* feat: update ui for event screen

* feat: update event screen

* feat: final
This commit is contained in:
雨宮蓮
2024-05-03 15:15:48 +07:00
committed by GitHub
parent 61d1f095d4
commit a4aef25adb
250 changed files with 9360 additions and 9235 deletions

View File

@@ -1,25 +1,25 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
import type { ReactNode } from "react";
export function Box({
children,
className,
children,
className,
}: {
children: ReactNode;
className?: string;
children: ReactNode;
className?: string;
}) {
return (
<div className="flex h-full min-h-0 w-full">
<div className="h-full w-full flex-1 px-2 pb-2">
<div
className={cn(
"h-full w-full overflow-y-auto rounded-xl bg-white px-4 shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] sm:px-0 dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5",
className,
)}
>
{children}
</div>
</div>
</div>
);
return (
<div className="flex h-full min-h-0 w-full">
<div className="h-full w-full flex-1 px-2 pb-2">
<div
className={cn(
"h-full w-full overflow-y-auto rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] sm:px-0 dark:bg-white/5 dark:shadow-none",
className,
)}
>
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useSnapCarousel } from "react-snap-carousel";
interface CarouselProps<T> {
readonly items: T[];
readonly renderItem: (
props: CarouselRenderItemProps<T>,
) => React.ReactElement<CarouselItemProps>;
}
interface CarouselRenderItemProps<T> {
readonly item: T;
readonly isSnapPoint: boolean;
}
export const Carousel = <T,>({ items, renderItem }: CarouselProps<T>) => {
const {
scrollRef,
pages,
activePageIndex,
prev,
next,
goTo,
snapPointIndexes,
} = useSnapCarousel();
return (
<div className="group relative">
<ul
ref={scrollRef}
className="relative flex overflow-auto snap-x scrollbar-none"
>
{items.map((item, i) =>
renderItem({
item,
isSnapPoint: snapPointIndexes.has(i),
}),
)}
</ul>
<div
aria-hidden
className="hidden group-hover:flex z-10 absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full justify-between items-center px-5"
>
<button
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
)}
onClick={() => prev()}
>
<ArrowLeftIcon className="size-6" />
</button>
<button
className={cn(
"size-11 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white",
activePageIndex <= 0 ? "opacity-50" : "",
)}
onClick={() => next()}
>
<ArrowRightIcon className="size-6" />
</button>
</div>
<div className="absolute top-3 right-3 flex justify-center bg-black mix-blend-multiply bg-opacity-20 backdrop-blur-sm h-6 w-12 items-center rounded-full text-sm font-medium text-white">
{activePageIndex + 1} / {pages.length}
</div>
</div>
);
};
interface CarouselItemProps {
readonly isSnapPoint: boolean;
readonly children?: React.ReactNode;
}
export const CarouselItem = ({ isSnapPoint, children }: CarouselItemProps) => (
<li
className={cn(
"w-[240px] h-[320px] shrink-0 pl-3 last:pr-2",
isSnapPoint ? "" : "snap-start",
)}
>
{children}
</li>
);

View File

@@ -1,21 +0,0 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function ColumnContent({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"relative flex-1 overflow-y-auto overflow-x-hidden scrollbar-none",
className,
)}
>
{children}
</div>
);
}

View File

@@ -1,96 +0,0 @@
import { CancelIcon, CheckIcon, RefreshIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useSearch } from "@tanstack/react-router";
import { getCurrent } from "@tauri-apps/api/window";
import { ReactNode, useEffect, useState } from "react";
export function ColumnHeader({
label,
name,
className,
children,
}: {
label: string;
name: string;
className?: string;
children?: ReactNode;
}) {
const search = useSearch({ strict: false });
const [title, setTitle] = useState(name);
const [isChanged, setIsChanged] = useState(false);
const saveNewTitle = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "set_title", label, title });
// update search params
// @ts-ignore, hahaha
search.name = title;
// reset state
setIsChanged(false);
};
const reload = () => {
window.location.reload();
};
const close = async () => {
const mainWindow = getCurrent();
await mainWindow.emit("columns", { type: "remove", label });
};
useEffect(() => {
if (title.length !== name.length) setIsChanged(true);
}, [title]);
return (
<div
className={cn(
"h-11 w-full flex items-center justify-between shrink-0 px-3 border-b border-neutral-100 dark:border-neutral-900",
className,
)}
>
<div className="relative flex gap-2 items-center">
{!children ? (
<div
contentEditable
suppressContentEditableWarning={true}
onBlur={(e) => setTitle(e.currentTarget.textContent)}
className="text-[13px] font-medium focus:outline-none"
>
{name}
</div>
) : (
children
)}
{isChanged ? (
<button
type="button"
onClick={saveNewTitle}
className="text-teal-500 hover:text-teal-600"
>
<CheckIcon className="size-4" />
</button>
) : null}
</div>
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={reload}
className="size-7 inline-flex hover:bg-neutral-100 rounded-md dark:hover:bg-neutral-900 items-center justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<RefreshIcon className="size-4" />
</button>
<button
type="button"
onClick={close}
className="size-7 inline-flex items-center hover:bg-neutral-100 rounded-md dark:hover:bg-neutral-900 justify-center text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<CancelIcon className="size-4" />
</button>
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { ColumnContent } from "./content";
import { ColumnHeader } from "./header";
import { ColumnRoot } from "./root";
export const Column = {
Root: ColumnRoot,
Header: ColumnHeader,
Content: ColumnContent,
};

View File

@@ -1,29 +0,0 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function ColumnRoot({
children,
className,
background = true,
shadow = true,
}: {
children: ReactNode;
className?: string;
background?: boolean;
shadow?: boolean;
}) {
return (
<div className="h-full w-full p-2">
<div
className={cn(
"relative flex h-full w-full flex-col rounded-xl overflow-hidden",
shadow ? "shadow-primary" : "",
background ? "bg-white dark:bg-black" : "",
className,
)}
>
{children}
</div>
</div>
);
}

View File

@@ -1,51 +1,46 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { ReactNode } from "react";
import type { ReactNode } from "react";
export function Container({
children,
withDrag = false,
withNavigate = true,
className,
children,
withDrag = false,
withNavigate = true,
className,
}: {
children: ReactNode;
withDrag?: boolean;
withNavigate?: boolean;
className?: string;
children: ReactNode;
withDrag?: boolean;
withNavigate?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900",
className,
)}
>
{withDrag ? (
<div
data-tauri-drag-region
className="flex h-11 w-full shrink-0 items-center justify-end pr-2"
>
{withNavigate ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => window.history.back()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={() => window.history.forward()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<ArrowRightIcon className="size-5" />
</button>
</div>
) : null}
</div>
) : null}
{children}
</div>
);
return (
<div className={cn("flex h-screen w-screen flex-col", className)}>
{withDrag ? (
<div
data-tauri-drag-region
className="flex h-11 w-full shrink-0 items-center justify-end pr-2"
>
{withNavigate ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => window.history.back()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={() => window.history.forward()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
>
<ArrowRightIcon className="size-5" />
</button>
</div>
) : null}
</div>
) : null}
{children}
</div>
);
}

View File

@@ -1,6 +1,6 @@
export * from "./user";
export * from "./note";
export * from "./column";
export * from "./note/mentions/note";
// UI
export * from "./container";

View File

@@ -0,0 +1,31 @@
import { VisitIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
export function NoteOpenThread() {
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => ark.open_event(event)}
className="group inline-flex h-7 w-14 bg-neutral-100 dark:bg-white/10 rounded-full items-center justify-center text-sm font-medium text-neutral-800 dark:text-neutral-200 hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
>
<VisitIcon className="shrink-0 size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Open
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,31 +0,0 @@
import { LinkIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
export function NotePin() {
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => ark.open_thread(event.id)}
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
<LinkIcon className="size-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Open as new window
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,31 +1,37 @@
import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useNoteContext } from "../provider";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "../provider";
import { cn } from "@lume/utils";
export function NoteReply() {
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
export function NoteReply({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => ark.open_editor(event.id)}
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
<ReplyIcon className="size-5 group-hover:text-blue-500" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Reply
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => ark.open_editor(event.id)}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
<ReplyIcon className="shrink-0 size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Reply
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -2,96 +2,100 @@ import { QuoteIcon, RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useRouteContext } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
import { useRouteContext } from "@tanstack/react-router";
import { Spinner } from "../../spinner";
import { useNoteContext } from "../provider";
export function NoteRepost() {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
export function NoteRepost({ large = false }: { large?: boolean }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const repost = async () => {
try {
setLoading(true);
const repost = async () => {
try {
if (isRepost) return;
setLoading(true);
// repost
await ark.repost(event.id, event.pubkey);
// repost
await ark.repost(event.id, event.pubkey);
// update state
setLoading(false);
setIsRepost(true);
// update state
setLoading(false);
setIsRepost(true);
// notify
toast.success("You've reposted this post successfully");
} catch (e) {
setLoading(false);
toast.error("Repost failed, try again later");
}
};
// notify
toast.success("You've reposted this post successfully");
} catch (e) {
setLoading(false);
toast.error("Repost failed, try again later");
}
};
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn(
"size-5 group-hover:text-blue-600",
isRepost ? "text-blue-500" : "",
)}
/>
)}
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
{t("note.buttons.repost")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={repost}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_editor(event.id, true)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<QuoteIcon className="size-4" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200 rounded-full",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
{loading ? (
<Spinner className="size-4" />
) : (
<RepostIcon
className={cn("size-4", isRepost ? "text-blue-500" : "")}
/>
)}
{large ? "Repost" : null}
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
{t("note.buttons.repost")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => repost()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<RepostIcon className="size-4" />
{t("note.buttons.repost")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_editor(event.id, true)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
<QuoteIcon className="size-4" />
{t("note.buttons.quote")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -1,33 +1,42 @@
import { ZapIcon } from "@lume/icons";
import { useRouteContext, useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
import { useRouteContext, useSearch } from "@tanstack/react-router";
import { cn } from "@lume/utils";
export function NoteZap() {
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
const { account } = useSearch({ strict: false });
export function NoteZap({ large = false }: { large?: boolean }) {
const event = useNoteContext();
const { ark, settings } = useRouteContext({ strict: false });
const { account } = useSearch({ strict: false });
const zap = async () => {
try {
const nwc = await ark.load_nwc();
if (!nwc) {
ark.open_nwc();
} else {
ark.open_zap(event.id, event.pubkey, account);
}
} catch (e) {
toast.error(String(e));
}
};
const zap = async () => {
try {
const nwc = await ark.load_nwc();
if (!nwc) {
ark.open_nwc();
} else {
ark.open_zap(event.id, event.pubkey, account);
}
} catch (e) {
toast.error(String(e));
}
};
return (
<button
type="button"
onClick={zap}
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
<ZapIcon className="size-5 group-hover:text-blue-500" />
</button>
);
if (!settings.zap) return null;
return (
<button
type="button"
onClick={() => zap()}
className={cn(
"inline-flex items-center justify-center text-neutral-800 dark:text-neutral-200",
large
? "bg-neutral-100 dark:bg-white/10 h-7 gap-1.5 w-24 text-sm font-medium hover:text-blue-500 hover:bg-neutral-200 dark:hover:bg-white/20"
: "size-7",
)}
>
<ZapIcon className="size-4" />
{large ? "Zap" : null}
</button>
);
}

View File

@@ -1,56 +1,34 @@
import { useTranslation } from "react-i18next";
import { User } from "../user";
import { useEvent } from "@lume/ark";
import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { Note } from ".";
import { User } from "../user";
export function NoteChild({
eventId,
isRoot,
eventId,
isRoot,
}: {
eventId: string;
isRoot?: boolean;
eventId: string;
isRoot?: boolean;
}) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-xl bg-neutral-100 p-3 text-sm dark:bg-neutral-900">
Loading...
</div>
</div>
);
}
if (isLoading) {
return <div>Loading...</div>;
}
if (isError || !data) {
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-xl bg-neutral-100 p-3 text-sm dark:bg-neutral-900">
{t("note.error")}
</div>
</div>
);
}
if (isError || !data) {
return <div>Error</div>;
}
return (
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-100 dark:bg-neutral-900" />
<div className="content-break mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content}
</div>
</div>
<User.Provider pubkey={data.pubkey}>
<User.Root>
<User.Avatar className="size-10 shrink-0 rounded-full object-cover" />
<div className="absolute left-3 top-3">
<User.Name className="inline font-semibold" />{" "}
<span className="inline font-normal text-neutral-700 dark:text-neutral-300">
{isRoot ? t("note.posted") : t("note.replied")}:
</span>
</div>
</User.Root>
</User.Provider>
</div>
);
return (
<Note.Provider event={data}>
<Note.Root className={cn(isRoot ? "mb-3" : "")}>
<div className="h-14 px-3 flex items-center justify-between">
<Note.User />
</div>
<Note.Content className="px-3" />
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,131 +1,120 @@
import { Kind, Settings } from "@lume/types";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
cn,
} from "@lume/utils";
import { useNoteContext } from "./provider";
import { ReactNode, useMemo } from "react";
import { nanoid } from "nanoid";
import { MentionUser } from "./mentions/user";
import { MentionNote } from "./mentions/note";
import { Hashtag } from "./mentions/hashtag";
import { VideoPreview } from "./preview/video";
import { ImagePreview } from "./preview/image";
import { NOSTR_EVENTS, NOSTR_MENTIONS, cn, parser } from "@lume/utils";
import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { Images } from "./preview/images";
import { Videos } from "./preview/videos";
import { useNoteContext } from "./provider";
import { useRouteContext } from "@tanstack/react-router";
export function NoteContent({ className }: { className?: string }) {
const { settings }: { settings: Settings } = useRouteContext({
strict: false,
});
const event = useNoteContext();
const content = useMemo(() => {
const text = event.content.trim();
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
export function NoteContent({
quote = true,
mention = true,
clean,
className,
}: {
quote?: boolean;
mention?: boolean;
clean?: boolean;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
const data = useMemo(() => {
const { content, images, videos } = parser(event.content);
const words = content.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
let parsedContent: ReactNode[] | string = text;
let richContent: ReactNode[] | string = content;
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
richContent = reactStringReplace(richContent, regex, (_, index) => {
return <Hashtag key={hashtag + index} tag={hashtag} />;
});
}
}
if (events.length) {
for (const event of events) {
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
if (events.length) {
for (const event of events) {
if (quote) {
richContent = reactStringReplace(richContent, event, (_, index) => (
<MentionNote key={event + index} eventId={event} />
));
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
if (!quote && clean) {
richContent = reactStringReplace(richContent, event, () => null);
}
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/gi,
(match, i) => {
try {
const url = new URL(match);
const ext = url.pathname.split(".")[1];
if (mentions.length) {
for (const user of mentions) {
if (mention) {
richContent = reactStringReplace(richContent, user, (_, index) => (
<MentionUser key={user + index} pubkey={user} />
));
}
if (!settings.enhancedPrivacy) {
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (!mention && clean) {
richContent = reactStringReplace(richContent, user, () => null);
}
}
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
richContent = reactStringReplace(
richContent,
/(https?:\/\/\S+)/gi,
(match, index) => (
<a
key={match + index}
href={match}
target="_blank"
rel="noreferrer"
className="line-clamp-1 text-blue-500 hover:text-blue-600"
onClick={(e) => e.stopPropagation()}
>
{match}
</a>
),
);
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
}
richContent = reactStringReplace(
richContent,
/(\r\n|\r|\n)+/g,
(_, index) => <div key={`${event.id}_div_${index}`} className="h-3" />,
);
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
} catch {
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
}
},
);
return { content: richContent, images, videos };
} catch (e) {
return { content, images, videos };
}
}, []);
return parsedContent;
} catch (e) {
return text;
}
}, []);
return (
<div className={cn("select-text", className)}>
<div className="content-break whitespace-pre-line text-balance leading-normal">
{content}
</div>
</div>
);
return (
<div className="flex flex-col gap-2">
<div
className={cn(
"select-text text-[15px] text-balance break-words overflow-hidden",
event.content.length > 500 ? "max-h-[300px] gradient-mask-b-0" : "",
className,
)}
>
{data.content}
</div>
{data.images.length ? <Images urls={data.images} /> : null}
{data.videos.length ? <Videos urls={data.videos} /> : null}
</div>
);
}

View File

@@ -0,0 +1,151 @@
import type { Settings } from "@lume/types";
import {
AUDIOS,
IMAGES,
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
cn,
} from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { nanoid } from "nanoid";
import { type ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { Hashtag } from "./mentions/hashtag";
import { MentionNote } from "./mentions/note";
import { MentionUser } from "./mentions/user";
import { ImagePreview } from "./preview/image";
import { VideoPreview } from "./preview/video";
import { useNoteContext } from "./provider";
export function NoteContentLarge({
compact = true,
className,
}: {
compact?: boolean;
className?: string;
}) {
const { settings }: { settings: Settings } = useRouteContext({
strict: false,
});
const event = useNoteContext();
const content = useMemo(() => {
const text = event.content.trim();
const words = text.split(/( |\n)/);
// @ts-ignore, kaboom !!!
let parsedContent: ReactNode[] = compact
? text.replace(/\n\s*\n/g, "\n")
: text;
const hashtags = words.filter((word) => word.startsWith("#"));
const events = words.filter((word) =>
NOSTR_EVENTS.some((el) => word.startsWith(el)),
);
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
if (events.length) {
for (const event of events) {
parsedContent = reactStringReplace(
parsedContent,
event,
(match, i) => <MentionNote key={match + i} eventId={event} />,
);
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/gi,
(match, i) => {
try {
const url = new URL(match);
const ext = url.pathname.split(".")[1];
if (!settings.enhancedPrivacy) {
if (IMAGES.includes(ext)) {
return <ImagePreview key={match + i} url={url.toString()} />;
}
if (VIDEOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
if (AUDIOS.includes(ext)) {
return <VideoPreview key={match + i} url={url.toString()} />;
}
}
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
} catch {
return (
<a
key={match + i}
href={match}
target="_blank"
rel="noreferrer"
className="content-break w-full font-normal text-blue-500 hover:text-blue-600"
>
{match}
</a>
);
}
},
);
if (compact) {
parsedContent = reactStringReplace(parsedContent, /\n*\n/g, () => (
<div key={nanoid()} className="h-1.5" />
));
}
parsedContent = reactStringReplace(
parsedContent,
/[\r]?\n[\r]?\n/g,
(_, index) => <br key={event.id + "_br_" + index} />,
);
return parsedContent;
} catch (e) {
return text;
}
}, []);
return (
<div className={cn("select-text", className)}>
<div className="text-[15px] text-balance content-break leading-normal">{content}</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { NotePin } from "./buttons/pin";
import { NoteOpenThread } from "./buttons/open";
import { NoteReply } from "./buttons/reply";
import { NoteRepost } from "./buttons/repost";
import { NoteZap } from "./buttons/zap";
import { NoteChild } from "./child";
import { NoteContent } from "./content";
import { NoteContentLarge } from "./contentLarge";
import { NoteMenu } from "./menu";
import { NoteProvider } from "./provider";
import { NoteRoot } from "./root";
@@ -17,9 +18,10 @@ export const Note = {
Menu: NoteMenu,
Reply: NoteReply,
Repost: NoteRepost,
Pin: NotePin,
Content: NoteContent,
ContentLarge: NoteContentLarge,
Zap: NoteZap,
Open: NoteOpenThread,
Child: NoteChild,
Thread: NoteThread,
};

View File

@@ -2,9 +2,12 @@ export function Hashtag({ tag }: { tag: string }) {
return (
<button
type="button"
className="text-blue-500 break-all cursor-default hover:text-blue-600"
className="break-all cursor-default leading-normal group"
>
{tag}
<span className="text-blue-500">#</span>
<span className="underline-offset-1 underline decoration-2 decoration-blue-200 dark:decoration-blue-800 group-hover:decoration-blue-500">
{tag.replace("#", "")}
</span>
</button>
);
}

View File

@@ -1,10 +1,10 @@
import { QRCodeSVG } from 'qrcode.react';
import { memo } from 'react';
import { QRCodeSVG } from "qrcode.react";
import { memo } from "react";
export const Invoice = memo(function Invoice({ invoice }: { invoice: string }) {
return (
<div className="mt-2 flex items-center rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
<QRCodeSVG value={invoice} includeMargin={true} className="rounded-lg" />
</div>
);
return (
<div className="mt-2 flex items-center rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
<QRCodeSVG value={invoice} includeMargin={true} className="rounded-lg" />
</div>
);
});

View File

@@ -1,74 +1,77 @@
import { useTranslation } from "react-i18next";
import { User } from "../../user";
import { useEvent } from "@lume/ark";
import { LinkIcon } from "@lume/icons";
import { useRouteContext } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { User } from "../../user";
import { cn } from "@lume/utils";
export function MentionNote({
eventId,
openable = true,
eventId,
openable = true,
}: {
eventId: string;
openable?: boolean;
eventId: string;
openable?: boolean;
}) {
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
if (isLoading) {
return (
<div
contentEditable={false}
className="my-1 flex w-full cursor-default items-center justify-between rounded-xl border border-black/10 p-3 dark:border-white/10"
>
<p>Loading...</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex w-full cursor-default items-center justify-between rounded-xl border border-black/10 p-3 dark:border-white/10">
<p>Loading...</p>
</div>
);
}
if (isError || !data) {
return (
<div
contentEditable={false}
className="my-1 w-full cursor-default rounded-xl border border-black/10 p-3 dark:border-white/10"
>
{t("note.error")}
</div>
);
}
if (isError || !data) {
return (
<div className="w-full cursor-default rounded-xl border border-black/10 p-3 dark:border-white/10">
{t("note.error")}
</div>
);
}
return (
<div className="my-1 flex w-full cursor-default flex-col rounded-xl border border-black/10 px-3 pt-1 dark:border-white/10">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-10 items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex flex-1 items-center gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<User.Time
time={data.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</User.Root>
</User.Provider>
<div className="line-clamp-3 select-text whitespace-normal text-balance leading-normal">
{data.content}
</div>
{openable ? (
<div className="flex h-10 items-center justify-between">
<button
type="button"
onClick={() => ark.open_thread(data.id)}
className="inline-flex items-center gap-1 text-sm text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
{t("note.showMore")}
<LinkIcon className="size-4" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
);
return (
<div className="mt-2 flex w-full cursor-default flex-col rounded-xl border border-black/10 dark:border-white/10">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-12 items-center gap-2 px-3">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex flex-1 items-center gap-2">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<User.Time
time={data.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</User.Root>
</User.Provider>
<div
className={cn(
"px-3 select-text content-break whitespace-normal text-balance leading-normal",
data.content.length > 100 ? "max-h-[150px] gradient-mask-b-0" : "",
)}
>
{data.content}
</div>
{openable ? (
<div className="flex h-14 items-center justify-end px-3">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
ark.open_event_id(data.id);
}}
className="z-10 h-7 w-28 inline-flex items-center justify-center gap-1 text-sm bg-neutral-100 dark:bg-white/10 rounded-full text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
View post
<LinkIcon className="size-4" />
</button>
</div>
) : (
<div className="h-3" />
)}
</div>
);
}

View File

@@ -3,20 +3,22 @@ import { displayNpub } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
export function MentionUser({ pubkey }: { pubkey: string }) {
const { ark } = useRouteContext({ strict: false });
const { isLoading, isError, profile } = useProfile(pubkey);
const { ark } = useRouteContext({ strict: false });
const { isLoading, isError, profile } = useProfile(pubkey);
return (
<button
type="button"
onClick={() => ark.open_profile(pubkey)}
className="break-words text-start text-blue-500 hover:text-blue-600"
>
{isLoading
? "@anon"
: isError
? displayNpub(pubkey, 16)
: `@${profile?.name || profile?.display_name || profile?.name || "anon"}`}
</button>
);
return (
<button
type="button"
onClick={() => ark.open_profile(pubkey)}
className="break-words text-start text-blue-500 hover:text-blue-600"
>
{isLoading
? "@anon"
: isError
? displayNpub(pubkey, 16)
: `@${
profile?.name || profile?.display_name || profile?.name || "anon"
}`}
</button>
);
}

View File

@@ -1,108 +1,108 @@
import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useRouteContext } from "@tanstack/react-router";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "./provider";
import { toast } from "sonner";
import { useRouteContext } from "@tanstack/react-router";
import { useNoteContext } from "./provider";
export function NoteMenu() {
const event = useNoteContext();
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const copyID = async () => {
await writeText(await ark.event_to_bech32(event.id, [""]));
toast.success("Copied");
};
const copyID = async () => {
await writeText(await ark.event_to_bech32(event.id, [""]));
toast.success("Copied");
};
const copyRaw = async () => {
await writeText(JSON.stringify(event));
toast.success("Copied");
};
const copyRaw = async () => {
await writeText(JSON.stringify(event));
toast.success("Copied");
};
const copyNpub = async () => {
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
toast.success("Copied");
};
const copyNpub = async () => {
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
toast.success("Copied");
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
toast.success("Copied");
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
toast.success("Copied");
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<HorizontalDotsIcon className="size-5" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_thread(event.id)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewThread")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
onClick={() => ark.open_profile(event.pubkey)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewAuthor")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyRaw")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="group inline-flex size-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
>
<HorizontalDotsIcon className="size-5" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-black p-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => ark.open_event(event)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewThread")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyLink()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyLink")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyID()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyNoteId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyNpub()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyAuthorId")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
onClick={() => ark.open_profile(event.pubkey)}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.viewAuthor")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-neutral-900 dark:bg-neutral-100" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => copyRaw()}
className="inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium text-white hover:bg-neutral-900 focus:outline-none dark:text-black dark:hover:bg-neutral-100"
>
{t("note.menu.copyRaw")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Arrow className="fill-black dark:fill-white" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@@ -2,69 +2,60 @@ import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { download } from "@tauri-apps/plugin-upload";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { type SyntheticEvent, useState } from "react";
export function ImagePreview({ url }: { url: string }) {
const imgRef = useRef<HTMLImageElement>(null);
const [downloaded, setDownloaded] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const downloadImage = async (e: { stopPropagation: () => void }) => {
try {
e.stopPropagation();
const downloadImage = async (e: { stopPropagation: () => void }) => {
try {
e.stopPropagation();
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, `${downloadDirPath}/${filename}`);
const downloadDirPath = await downloadDir();
const filename = url.substring(url.lastIndexOf("/") + 1);
await download(url, `${downloadDirPath}/${filename}`);
setDownloaded(true);
} catch (e) {
toast.error(String(e));
}
};
setDownloaded(true);
} catch (e) {
console.error(e);
}
};
const open = async () => {
const label = new URL(url).pathname
.split("/")
.pop()
.replace(/[^a-zA-Z ]/g, "");
const window = new WebviewWindow(`viewer-${label}`, {
url,
title: "Image Viewer",
width: imgRef?.current.width || 600,
height: imgRef?.current.height || 600,
titleBarStyle: "overlay",
});
const open = async () => {
const name = new URL(url).pathname.split("/").pop();
return new WebviewWindow("image-viewer", {
url,
title: name,
});
};
return window;
};
const fallback = (event: SyntheticEvent<HTMLImageElement, Event>) => {
event.currentTarget.src = "/fallback-image.jpg";
};
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
onClick={open}
className="group relative my-1 overflow-hidden rounded-xl ring-1 ring-neutral-100 dark:ring-neutral-900"
>
<img
src={url}
alt={url}
ref={imgRef}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="h-auto w-full object-cover"
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-black/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
>
{downloaded ? (
<CheckCircleIcon className="size-5" />
) : (
<DownloadIcon className="size-5" />
)}
</button>
</div>
);
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div onClick={() => open()} className="group relative my-1">
<img
src={url}
alt={url}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
onError={fallback}
className="max-h-[600px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
/>
<button
type="button"
onClick={(e) => downloadImage(e)}
className="absolute right-2 top-2 z-20 hidden size-8 items-center justify-center rounded-md bg-white/10 text-white/70 backdrop-blur-2xl hover:bg-blue-500 hover:text-white group-hover:inline-flex"
>
{downloaded ? (
<CheckCircleIcon className="size-4" />
) : (
<DownloadIcon className="size-4" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { Carousel, CarouselItem } from "../../carousel";
export function Images({ urls }: { urls: string[] }) {
const open = async (url: string) => {
const name = new URL(url).pathname
.split("/")
.pop()
.replace(/[^a-zA-Z ]/g, "");
const label = `viewer-${name}`;
const window = WebviewWindow.getByLabel(label);
if (!window) {
const newWindow = new WebviewWindow(label, {
url,
title: "Image Viewer",
width: 800,
height: 800,
titleBarStyle: "overlay",
});
return newWindow;
}
return await window.setFocus();
};
if (urls.length === 1) {
return (
<div className="group px-3">
<img
src={urls[0]}
alt={urls[0]}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="max-h-[400px] w-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(urls[0])}
/>
</div>
);
}
return (
<Carousel
items={urls}
renderItem={({ item, isSnapPoint }) => (
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
<img
src={item}
alt={item}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
onClick={() => open(item)}
/>
</CarouselItem>
)}
/>
);
}

View File

@@ -1,87 +1,87 @@
import { useOpenGraph } from "@lume/utils";
function isImage(url: string) {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|avif)$/.test(url);
}
export function LinkPreview({ url }: { url: string }) {
const domain = new URL(url);
const { isLoading, isError, data } = useOpenGraph(url);
const domain = new URL(url);
const { isLoading, isError, data } = useOpenGraph(url);
if (isLoading) {
return (
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="my-1.5 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 p-3 dark:border-white/10">
<div className="h-48 w-full shrink-0 animate-pulse bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-2 px-3 py-3">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-3/4 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<span className="mt-2.5 text-sm leading-none text-neutral-600 dark:text-neutral-400">
{domain.hostname}
</span>
</div>
</div>
);
}
if (!data.title && !data.image && !data.description) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (!data.title && !data.image && !data.description) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (isError) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
if (isError) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-block text-blue-500 hover:text-blue-600"
>
{url}
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
loading="lazy"
decoding="async"
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start p-3">
<div className="flex flex-col items-start text-left">
{data.title ? (
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm font-semibold text-blue-500">
{domain.hostname}
</div>
</div>
</a>
);
return (
<a
href={url}
target="_blank"
rel="noreferrer"
className="my-1 flex w-full flex-col overflow-hidden rounded-2xl border border-black/10 dark:border-white/10"
>
{isImage(data.image) ? (
<img
src={data.image}
alt={url}
loading="lazy"
decoding="async"
className="h-48 w-full shrink-0 rounded-t-lg bg-white object-cover"
/>
) : null}
<div className="flex flex-col items-start p-3">
<div className="flex flex-col items-start text-left">
{data.title ? (
<div className="content-break line-clamp-1 text-base font-semibold text-neutral-900 dark:text-neutral-100">
{data.title}
</div>
) : null}
{data.description ? (
<div className="content-break mb-2 line-clamp-3 text-balance text-sm text-neutral-700 dark:text-neutral-400">
{data.description}
</div>
) : null}
</div>
<div className="break-all text-sm font-semibold text-blue-500">
{domain.hostname}
</div>
</div>
</a>
);
}

View File

@@ -1,14 +1,14 @@
export function VideoPreview({ url }: { url: string }) {
return (
<div className="my-1 overflow-hidden rounded-xl">
<video
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
controls
muted
>
<source src={url} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
);
return (
<div className="my-1 overflow-hidden rounded-xl">
<video
className="h-auto w-full bg-neutral-100 text-sm dark:bg-neutral-900"
controls
muted
>
<source src={url} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Carousel, CarouselItem } from "../../carousel";
export function Videos({ urls }: { urls: string[] }) {
if (urls.length === 1) {
return (
<div className="group px-3">
<video
className="w-full h-auto object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls
muted
>
<source src={urls[0]} type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
);
}
return (
<Carousel
items={urls}
renderItem={({ item, isSnapPoint }) => (
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
<video
className="w-full h-full object-cover rounded-lg outline outline-1 -outline-offset-1 outline-black/15"
controls={false}
muted
>
<source src={item} type="video/mp4" />
Your browser does not support the video tag.
</video>
</CarouselItem>
)}
/>
);
}

View File

@@ -1,4 +1,4 @@
import { Event } from "@lume/types";
import type { Event } from "@lume/types";
import { Note } from "..";
export function ChildReply({ event }: { event: Event; rootEventId?: string }) {

View File

@@ -1,5 +1,5 @@
import { NavArrowDownIcon } from "@lume/icons";
import { EventWithReplies } from "@lume/types";
import type { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";

View File

@@ -1,117 +1,117 @@
import { RepostIcon } from "@lume/icons";
import { Event } from "@lume/types";
import type { Event } from "@lume/types";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useRouteContext } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Note } from "..";
import { User } from "../../user";
import { useRouteContext } from "@tanstack/react-router";
export function RepostNote({
event,
className,
event,
className,
}: {
event: Event;
className?: string;
event: Event;
className?: string;
}) {
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed = JSON.parse(event.content) as Event;
return embed;
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.get_event(id);
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
const { ark } = useRouteContext({ strict: false });
const { t } = useTranslation();
const {
isLoading,
isError,
data: repostEvent,
} = useQuery({
queryKey: ["repost", event.id],
queryFn: async () => {
try {
if (event.content.length > 50) {
const embed = JSON.parse(event.content) as Event;
return embed;
}
const id = event.tags.find((el) => el[0] === "e")[1];
return await ark.get_event(id);
} catch {
throw new Error("Failed to get repost event");
}
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
if (isLoading) {
return <div className="w-full px-3 pb-3">Loading...</div>;
}
if (isLoading) {
return <div className="w-full px-3 pb-3">Loading...</div>;
}
if (isError || !repostEvent) {
return (
<Note.Root className={className}>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex h-14 gap-2 px-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<div className="mb-3 select-text px-3">
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
<p className="text-red-500">Failed to get event</p>
</div>
</div>
</Note.Root>
);
}
if (isError || !repostEvent) {
return (
<Note.Root className={className}>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex h-14 gap-2 px-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<div className="mb-3 select-text px-3">
<div className="flex flex-col items-start justify-start rounded-lg bg-red-100 px-3 py-3 dark:bg-red-900">
<p className="text-red-500">Failed to get event</p>
</div>
</div>
</Note.Root>
);
}
return (
<Note.Root
className={cn(
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex gap-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<Note.Provider event={repostEvent}>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<div className="flex gap-3">
<div className="size-10 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Content />
<div className="mt-5 flex items-center justify-between">
<Note.Reaction />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
</div>
</Note.Provider>
</Note.Root>
);
return (
<Note.Root
className={cn(
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
className,
)}
>
<User.Provider pubkey={event.pubkey}>
<User.Root className="flex gap-3">
<div className="inline-flex w-10 shrink-0 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<User.Avatar className="size-6 shrink-0 rounded-full object-cover" />
<div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">{t("note.reposted")}</span>
</div>
</div>
</User.Root>
</User.Provider>
<Note.Provider event={repostEvent}>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<div className="flex gap-3">
<div className="size-10 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Content />
<div className="mt-5 flex items-center justify-between">
<Note.Reaction />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
</div>
</Note.Provider>
</Note.Root>
);
}

View File

@@ -1,24 +1,24 @@
import { Note } from '..';
import { Note } from "..";
export function NoteSkeleton() {
return (
<Note.Root>
<div className="flex h-min flex-col p-3">
<div className="flex items-start gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
<div className="h-6 w-full">
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
<div className="-mt-4 flex gap-3">
<div className="w-10 shrink-0" />
<div className="flex w-full flex-col gap-1">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
</div>
</Note.Root>
);
return (
<Note.Root>
<div className="flex h-min flex-col p-3">
<div className="flex items-start gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
<div className="h-6 w-full">
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
<div className="-mt-4 flex gap-3">
<div className="w-10 shrink-0" />
<div className="flex w-full flex-col gap-1">
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
</div>
</Note.Root>
);
}

View File

@@ -1,42 +1,42 @@
import { Event } from "@lume/types";
import type { Event } from "@lume/types";
import { cn } from "@lume/utils";
import { Note } from "..";
export function TextNote({
event,
className,
event,
className,
}: {
event: Event;
className?: string;
event: Event;
className?: string;
}) {
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
className,
)}
>
<div className="flex items-start justify-between">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<div className="flex gap-3">
<div className="size-11 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Thread className="mb-2" />
<Note.Content />
<div className="mt-5 flex items-center justify-between">
<Note.Reaction />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
</Note.Root>
</Note.Provider>
);
return (
<Note.Provider event={event}>
<Note.Root
className={cn(
"mb-3 flex flex-col gap-2 border-b border-neutral-100 pb-3 dark:border-neutral-900",
className,
)}
>
<div className="flex items-start justify-between">
<Note.User className="flex-1 pr-2" />
<Note.Menu />
</div>
<div className="flex gap-3">
<div className="size-11 shrink-0" />
<div className="min-w-0 flex-1">
<Note.Thread className="mb-2" />
<Note.Content />
<div className="mt-5 flex items-center justify-between">
<Note.Reaction />
<div className="inline-flex items-center gap-4">
<Note.Reply />
<Note.Repost />
<Note.Zap />
</div>
</div>
</div>
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -3,41 +3,41 @@ import { Note } from "..";
import { User } from "../../user";
export function ThreadNote({ eventId }: { eventId: string }) {
const { isLoading, data } = useEvent(eventId);
const { isLoading, data } = useEvent(eventId);
if (isLoading) {
return <div>Loading...</div>;
}
if (isLoading) {
return <div>Loading...</div>;
}
return (
<Note.Provider event={data}>
<Note.Root className="flex flex-col">
<div className="flex h-16 items-center justify-between">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-16 flex-1 items-center gap-3">
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<div className="flex flex-1 flex-col">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<User.Time time={data.created_at} />
<span>·</span>
<User.NIP05 />
</div>
</div>
</User.Root>
</User.Provider>
<Note.Menu />
</div>
<Note.Thread className="mb-2" />
<Note.Content className="min-w-0" />
<div className="flex h-14 items-center justify-between">
<Note.Reaction />
<div className="inline-flex items-center gap-4">
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</Note.Provider>
);
return (
<Note.Provider event={data}>
<Note.Root className="flex flex-col">
<div className="flex h-16 items-center justify-between">
<User.Provider pubkey={data.pubkey}>
<User.Root className="flex h-16 flex-1 items-center gap-3">
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
<div className="flex flex-1 flex-col">
<User.Name className="font-semibold text-neutral-900 dark:text-neutral-100" />
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<User.Time time={data.created_at} />
<span>·</span>
<User.NIP05 />
</div>
</div>
</User.Root>
</User.Provider>
<Note.Menu />
</div>
<Note.Thread className="mb-2" />
<Note.Content className="min-w-0" />
<div className="flex h-14 items-center justify-between">
<Note.Reaction />
<div className="inline-flex items-center gap-4">
<Note.Repost />
<Note.Zap />
</div>
</div>
</Note.Root>
</Note.Provider>
);
}

View File

@@ -1,5 +1,5 @@
import { Event } from "@lume/types";
import { ReactNode, createContext, useContext } from "react";
import type { Event } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react";
const EventContext = createContext<Event>(null);

View File

@@ -1,16 +1,16 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
import type { ReactNode } from "react";
export function NoteRoot({
children,
className,
children,
className,
}: {
children: ReactNode;
className?: string;
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("h-min w-full", className)} contentEditable={false}>
{children}
</div>
);
return (
<div className={cn("h-min w-full", className)} contentEditable={false}>
{children}
</div>
);
}

View File

@@ -1,44 +1,44 @@
import { LinkIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { Note } from ".";
import { useNoteContext } from "./provider";
import { LinkIcon } from "@lume/icons";
import { useRouteContext } from "@tanstack/react-router";
export function NoteThread({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
const thread = ark.parse_event_thread({
content: event.content,
tags: event.tags,
});
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
const thread = ark.parse_event_thread({
content: event.content,
tags: event.tags,
});
const { t } = useTranslation();
const { t } = useTranslation();
if (!thread) return null;
if (!thread) return null;
return (
<div className={cn("w-full", className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-2xl border border-black/10 p-3 dark:border-white/10">
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<div className="inline-flex justify-end">
<button
type="button"
onClick={() =>
ark.open_thread(thread.rootEventId || thread.replyEventId)
}
className="inline-flex items-center gap-1 text-sm text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
{t("note.showThread")}
<LinkIcon className="size-4" />
</button>
</div>
</div>
</div>
);
return (
<div className={cn("w-full", className)}>
<div className="flex h-min w-full flex-col gap-3 rounded-2xl border border-black/10 p-3 dark:border-white/10">
{thread.rootEventId ? (
<Note.Child eventId={thread.rootEventId} isRoot />
) : null}
{thread.replyEventId ? (
<Note.Child eventId={thread.replyEventId} />
) : null}
<div className="inline-flex justify-end">
<button
type="button"
onClick={() =>
ark.open_thread(thread.rootEventId || thread.replyEventId)
}
className="inline-flex items-center gap-1 text-sm text-neutral-600 hover:text-blue-500 dark:text-neutral-400"
>
{t("note.showThread")}
<LinkIcon className="size-4" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,61 +1,62 @@
import { cn } from "@lume/utils";
import * as HoverCard from "@radix-ui/react-hover-card";
import { useRouteContext } from "@tanstack/react-router";
import { User } from "../user";
import { useNoteContext } from "./provider";
import { useRouteContext } from "@tanstack/react-router";
export function NoteUser({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
const { ark } = useRouteContext({ strict: false });
const event = useNoteContext();
return (
<User.Provider pubkey={event.pubkey}>
<HoverCard.Root>
<User.Root
className={cn("flex items-start justify-between", className)}
>
<div className="flex w-full gap-3">
<HoverCard.Trigger>
<User.Avatar className="size-11 shrink-0 rounded-full object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50" />
</HoverCard.Trigger>
<div className="flex-1">
<div className="flex w-full items-center justify-between">
<User.Name className="font-semibold leading-tight text-neutral-950 dark:text-neutral-50" />
<User.Time
time={event.created_at}
className="leading-tight text-neutral-600 dark:text-neutral-400"
/>
</div>
<User.NIP05 className="leading-tight text-neutral-600 dark:text-neutral-400" />
</div>
</div>
</User.Root>
<HoverCard.Portal>
<HoverCard.Content
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
sideOffset={5}
side="right"
>
<div className="flex flex-col gap-2">
<User.Avatar className="size-11 rounded-lg object-cover" />
<div className="flex flex-col gap-2">
<div>
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
<User.NIP05 className="leading-tight text-neutral-400 dark:text-neutral-500" />
</div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<button
onClick={() => ark.open_profile(event.pubkey)}
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-100 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
View profile
</button>
</div>
</div>
<HoverCard.Arrow className="fill-black dark:fill-white" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
</User.Provider>
);
return (
<User.Provider pubkey={event.pubkey}>
<HoverCard.Root>
<User.Root
className={cn("flex items-start justify-between", className)}
>
<div className="flex w-full gap-2">
<HoverCard.Trigger className="shrink-0">
<User.Avatar className="size-8 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/15" />
</HoverCard.Trigger>
<div className="flex w-full items-center gap-3">
<div className="flex items-center gap-1">
<User.Name className="font-semibold text-neutral-950 dark:text-neutral-50" />
<User.NIP05 />
</div>
<div className="text-neutral-600 dark:text-neutral-400">·</div>
<User.Time
time={event.created_at}
className="text-neutral-600 dark:text-neutral-400"
/>
</div>
</div>
</User.Root>
<HoverCard.Portal>
<HoverCard.Content
className="w-[300px] rounded-xl bg-black p-3 data-[side=bottom]:animate-slideUpAndFade data-[state=open]:transition-all dark:bg-white dark:shadow-none"
sideOffset={5}
side="right"
>
<div className="flex flex-col gap-2">
<User.Avatar className="size-11 rounded-lg object-cover" />
<div className="flex flex-col gap-2">
<div className="inline-flex items-center gap-1">
<User.Name className="font-semibold leading-tight text-white dark:text-neutral-900" />
<User.NIP05 />
</div>
<User.About className="line-clamp-3 text-sm text-white dark:text-neutral-900" />
<button
onClick={() => ark.open_profile(event.pubkey)}
className="mt-2 inline-flex h-9 w-full items-center justify-center rounded-lg bg-white text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-200"
>
View profile
</button>
</div>
</div>
<HoverCard.Arrow className="fill-black dark:fill-white" />
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
</User.Provider>
);
}

View File

@@ -1,47 +1,47 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
import type { ReactNode } from "react";
export function Spinner({
children,
className,
children,
className,
}: {
children?: ReactNode;
className?: string;
children?: ReactNode;
className?: string;
}) {
const spinner = (
<span className={cn("block relative opacity-65 size-4", className)}>
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
</span>
);
const spinner = (
<span className={cn("block relative opacity-65 size-4", className)}>
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
</span>
);
if (children === undefined) return spinner;
if (children === undefined) return spinner;
return (
<div className="relative flex items-center justify-center">
<span>
{/**
* `display: contents` removes the content from the accessibility tree in some browsers,
* so we force remove it with `aria-hidden`
*/}
<span
aria-hidden
style={{ display: "contents", visibility: "hidden" }}
// Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
{...{ inert: true ? "" : undefined }}
>
{children}
</span>
<div className="absolute flex items-center justify-center">
<span>{spinner}</span>
</div>
</span>
</div>
);
return (
<div className="relative flex items-center justify-center">
<span>
{/**
* `display: contents` removes the content from the accessibility tree in some browsers,
* so we force remove it with `aria-hidden`
*/}
<span
aria-hidden
style={{ display: "contents", visibility: "hidden" }}
// Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
{...{ inert: true ? "" : undefined }}
>
{children}
</span>
<div className="absolute flex items-center justify-center">
<span>{spinner}</span>
</div>
</span>
</div>
);
}

View File

@@ -2,11 +2,11 @@ import { cn } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserAbout({ className }: { className?: string }) {
const user = useUserContext();
const user = useUserContext();
return (
<div className={cn("content-break select-text", className)}>
{user.profile?.about?.trim() || "No bio"}
</div>
);
return (
<div className={cn("content-break select-text", className)}>
{user.profile?.about?.trim() || "No bio"}
</div>
);
}

View File

@@ -6,32 +6,32 @@ import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext();
const user = useUserContext();
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50),
)}`,
[user],
);
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey || nanoid(), 90, 50),
)}`,
[user],
);
return (
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user.profile?.picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("ring-1 ring-black/5 dark:ring-white/5", className)}
/>
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
return (
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user.profile?.picture}
alt={user.pubkey}
loading="eager"
decoding="async"
className={cn("ring-1 ring-black/5 dark:ring-white/5", className)}
/>
<Avatar.Fallback delayMs={120}>
<img
src={fallbackAvatar}
alt={user.pubkey}
className={cn("bg-black dark:bg-white", className)}
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -1,58 +1,63 @@
import { cn } from "@lume/utils";
import { useRouteContext } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useUserContext } from "./provider";
import { useRouteContext } from "@tanstack/react-router";
import { Spinner } from "../spinner";
import { useUserContext } from "./provider";
export function UserFollowButton({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const user = useUserContext();
export function UserFollowButton({
simple = false,
className,
}: { simple?: boolean; className?: string }) {
const { ark } = useRouteContext({ strict: false });
const user = useUserContext();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);
const toggleFollow = async () => {
setLoading(true);
if (!followed) {
const add = await ark.follow(user.pubkey);
if (add) setFollowed(true);
} else {
const remove = await ark.unfollow(user.pubkey);
if (remove) setFollowed(false);
}
setLoading(false);
};
const toggleFollow = async () => {
setLoading(true);
if (!followed) {
const add = await ark.follow(user.pubkey);
if (add) setFollowed(true);
} else {
const remove = await ark.unfollow(user.pubkey);
if (remove) setFollowed(false);
}
setLoading(false);
};
useEffect(() => {
async function status() {
setLoading(true);
useEffect(() => {
async function status() {
setLoading(true);
const contacts = await ark.get_contact_list();
if (contacts?.includes(user.pubkey)) {
setFollowed(true);
}
const contacts = await ark.get_contact_list();
if (contacts?.includes(user.pubkey)) {
setFollowed(true);
}
setLoading(false);
}
status();
}, []);
setLoading(false);
}
status();
}, []);
return (
<button
type="button"
disabled={loading}
onClick={toggleFollow}
className={cn("w-max", className)}
>
{loading ? (
<Spinner className="size-4" />
) : followed ? (
t("user.unfollow")
) : (
t("user.follow")
)}
</button>
);
return (
<button
type="button"
disabled={loading}
onClick={() => toggleFollow()}
className={cn("w-max", className)}
>
{loading ? (
<Spinner className="size-4" />
) : followed ? (
!simple ? (
t("user.unfollow")
) : null
) : (
t("user.follow")
)}
</button>
);
}

View File

@@ -2,20 +2,20 @@ import { cn, displayNpub } from "@lume/utils";
import { useUserContext } from "./provider";
export function UserName({
className,
suffix,
className,
suffix,
}: {
className?: string;
suffix?: string;
className?: string;
suffix?: string;
}) {
const user = useUserContext();
const user = useUserContext();
return (
<div className={cn("max-w-[12rem] truncate", className)}>
{user.profile?.display_name ||
user.profile?.name ||
displayNpub(user.pubkey, 16)}
{suffix}
</div>
);
return (
<div className={cn("max-w-[12rem] truncate", className)}>
{user.profile?.display_name ||
user.profile?.name ||
displayNpub(user.pubkey, 16)}
{suffix}
</div>
);
}

View File

@@ -1,35 +1,45 @@
import { VerifiedIcon } from "@lume/icons";
import { cn, displayLongHandle, displayNpub } from "@lume/utils";
import { displayLongHandle, displayNpub } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { useQuery } from "@tanstack/react-query";
import { useUserContext } from "./provider";
import { useRouteContext } from "@tanstack/react-router";
import { useUserContext } from "./provider";
export function UserNip05({ className }: { className?: string }) {
const user = useUserContext();
export function UserNip05() {
const user = useUserContext();
const { ark } = useRouteContext({ strict: false });
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.pubkey],
queryFn: async () => {
if (!user.profile?.nip05) return false;
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
return verify;
},
enabled: !!user.profile,
});
const { ark } = useRouteContext({ strict: false });
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.pubkey],
queryFn: async () => {
if (!user.profile?.nip05) return false;
const verify = await ark.verify_nip05(user.pubkey, user.profile?.nip05);
return verify;
},
enabled: !!user.profile,
});
return (
<div className="inline-flex items-center gap-1">
<p className={cn("text-sm", className)}>
{!user.profile?.nip05
? displayNpub(user.pubkey, 16)
: user.profile?.nip05.length > 50
? displayLongHandle(user.profile?.nip05)
: user.profile.nip05?.replace("_@", "")}
</p>
{!isLoading && verified ? (
<VerifiedIcon className="size-4 text-teal-500" />
) : null}
</div>
);
if (!user.profile?.nip05?.length) return;
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger>
{!isLoading && verified ? (
<VerifiedIcon className="size-4 text-teal-500" />
) : null}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm font-medium text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
{!user.profile?.nip05
? displayNpub(user.pubkey, 16)
: user.profile?.nip05.length > 50
? displayLongHandle(user.profile?.nip05)
: user.profile.nip05?.replace("_@", "")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,33 +1,33 @@
import { useProfile } from "@lume/ark";
import { Metadata } from "@lume/types";
import { ReactNode, createContext, useContext } from "react";
import type { Metadata } from "@lume/types";
import { type ReactNode, createContext, useContext } from "react";
const UserContext = createContext<{
pubkey: string;
isError: boolean;
isLoading: boolean;
profile: Metadata;
pubkey: string;
isError: boolean;
isLoading: boolean;
profile: Metadata;
}>(null);
export function UserProvider({
pubkey,
children,
embedProfile,
pubkey,
children,
embedProfile,
}: {
pubkey: string;
children: ReactNode;
embedProfile?: string;
pubkey: string;
children: ReactNode;
embedProfile?: string;
}) {
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
const { isLoading, isError, profile } = useProfile(pubkey, embedProfile);
return (
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
{children}
</UserContext.Provider>
);
return (
<UserContext.Provider value={{ pubkey, isError, isLoading, profile }}>
{children}
</UserContext.Provider>
);
}
export function useUserContext() {
const context = useContext(UserContext);
return context;
const context = useContext(UserContext);
return context;
}

View File

@@ -1,5 +1,5 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
import type { ReactNode } from "react";
export function UserRoot({
children,

View File

@@ -2,17 +2,17 @@ import { cn, formatCreatedAt } from "@lume/utils";
import { useMemo } from "react";
export function UserTime({
time,
className,
time,
className,
}: {
time: number;
className?: string;
time: number;
className?: string;
}) {
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
return (
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
{createdAt}
</div>
);
return (
<div className={cn("text-neutral-600 dark:text-neutral-400", className)}>
{createdAt}
</div>
);
}