Settings Manager (#211)
* refactor: landing screen * fix: code debt * feat: add settings screen * chore: clean up * feat: settings * feat: small updates
This commit is contained in:
@@ -4,9 +4,9 @@ import React, { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import "./app.css";
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import i18n from "./locale";
|
||||
import { routeTree } from "./router.gen"; // auto generated file
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const os = await type();
|
||||
|
||||
@@ -44,9 +44,9 @@ export function Column({
|
||||
useEffect(() => {
|
||||
if (!isCreated) return;
|
||||
|
||||
const unlisten = listen<WindowEvent>("window", (data) => {
|
||||
const unlisten = listen<WindowEvent>("child-webview", (data) => {
|
||||
if (data.payload.scroll) repositionWebview();
|
||||
if (data.payload.resize) resizeWebview();
|
||||
if (data.payload.resize) repositionWebview().then(() => resizeWebview());
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { RepostIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem } from "@tauri-apps/api/menu";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
@@ -56,6 +57,8 @@ export function NoteRepost({ large = false }: { large?: boolean }) {
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
if (!settings.display_repost_button) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { ZapIcon } from "@lume/icons";
|
||||
import { useNoteContext } from "../provider";
|
||||
import { cn } from "@lume/utils";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { useNoteContext } from "../provider";
|
||||
|
||||
export function NoteZap({ large = false }: { large?: boolean }) {
|
||||
const event = useNoteContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!settings.display_zap_button) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { 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";
|
||||
@@ -7,7 +9,6 @@ import { MentionUser } from "./mentions/user";
|
||||
import { Images } from "./preview/images";
|
||||
import { Videos } from "./preview/videos";
|
||||
import { useNoteContext } from "./provider";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export function NoteContent({
|
||||
quote = true,
|
||||
@@ -20,6 +21,7 @@ export function NoteContent({
|
||||
clean?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
const event = useNoteContext();
|
||||
const content = useMemo(() => {
|
||||
try {
|
||||
@@ -27,7 +29,9 @@ export function NoteContent({
|
||||
const { content, hashtags, events, mentions } = event.meta;
|
||||
|
||||
// Define rich content
|
||||
let richContent: ReactNode[] | string = content;
|
||||
let richContent: ReactNode[] | string = settings.display_media
|
||||
? content
|
||||
: event.content;
|
||||
|
||||
for (const hashtag of hashtags) {
|
||||
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
|
||||
@@ -98,8 +102,12 @@ export function NoteContent({
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{event.meta?.images.length ? <Images urls={event.meta.images} /> : null}
|
||||
{event.meta?.videos.length ? <Videos urls={event.meta.videos} /> : null}
|
||||
{settings.display_media && event.meta?.images.length ? (
|
||||
<Images urls={event.meta.images} />
|
||||
) : null}
|
||||
{settings.display_media && event.meta?.videos.length ? (
|
||||
<Videos urls={event.meta.videos} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function ImagePreview({ url }: { url: 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);
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!window) {
|
||||
const newWindow = new WebviewWindow(label, {
|
||||
url,
|
||||
title: "Image Viewer",
|
||||
width: 800,
|
||||
height: 800,
|
||||
titleBarStyle: "overlay",
|
||||
});
|
||||
|
||||
return newWindow;
|
||||
const imageUrl = useMemo(() => {
|
||||
if (settings.image_resize_service.length) {
|
||||
const newUrl = `${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`;
|
||||
return newUrl;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}, [settings.image_resize_service]);
|
||||
|
||||
return await window.setFocus();
|
||||
};
|
||||
if (!settings.display_media) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative my-1 group">
|
||||
<div className="my-1">
|
||||
<img
|
||||
src={url}
|
||||
src={imageUrl}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,42 +1,35 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { Carousel, CarouselItem } from "@lume/ui";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useMemo } from "react";
|
||||
|
||||
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);
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (!window) {
|
||||
const newWindow = new WebviewWindow(label, {
|
||||
url,
|
||||
title: "Image Viewer",
|
||||
width: 800,
|
||||
height: 800,
|
||||
titleBarStyle: "overlay",
|
||||
});
|
||||
|
||||
return newWindow;
|
||||
const imageUrls = useMemo(() => {
|
||||
if (settings.image_resize_service.length) {
|
||||
const newUrls = urls.map(
|
||||
(url) =>
|
||||
`${settings.image_resize_service}?url=${url}&ll&af&default=1&n=-1`,
|
||||
);
|
||||
return newUrls;
|
||||
} else {
|
||||
return urls;
|
||||
}
|
||||
|
||||
return await window.setFocus();
|
||||
};
|
||||
}, [settings.image_resize_service]);
|
||||
|
||||
if (urls.length === 1) {
|
||||
return (
|
||||
<div className="px-3 group">
|
||||
<img
|
||||
src={urls[0]}
|
||||
src={imageUrls[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])}
|
||||
onKeyDown={() => open(urls[0])}
|
||||
onClick={() => urls[0]}
|
||||
onKeyDown={() => urls[0]}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null;
|
||||
currentTarget.src = "/404.jpg";
|
||||
@@ -48,7 +41,7 @@ export function Images({ urls }: { urls: string[] }) {
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
items={urls}
|
||||
items={imageUrls}
|
||||
renderItem={({ item, isSnapPoint }) => (
|
||||
<CarouselItem key={item} isSnapPoint={isSnapPoint}>
|
||||
<img
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useOpenGraph } from "@lume/utils";
|
||||
|
||||
function isImage(url: string) {
|
||||
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);
|
||||
|
||||
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 (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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,21 @@
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export function VideoPreview({ url }: { url: string }) {
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
if (settings.display_media) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<video
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useMemo } from "react";
|
||||
@@ -7,19 +8,46 @@ import { useUserContext } from "./provider";
|
||||
|
||||
export function UserAvatar({ className }: { className?: string }) {
|
||||
const user = useUserContext();
|
||||
const { settings } = useRouteContext({ strict: false });
|
||||
|
||||
const picture = useMemo(() => {
|
||||
if (
|
||||
settings?.image_resize_service?.length &&
|
||||
user.profile?.picture?.length
|
||||
) {
|
||||
const url = `${settings.image_resize_service}?url=${user.profile?.picture}&w=100&h=100&default=1&n=-1`;
|
||||
return url;
|
||||
} else {
|
||||
return user.profile?.picture;
|
||||
}
|
||||
}, [user.profile?.picture]);
|
||||
|
||||
const fallbackAvatar = useMemo(
|
||||
() =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(user.pubkey || nanoid(), 90, 50),
|
||||
)}`,
|
||||
[user],
|
||||
[user.pubkey],
|
||||
);
|
||||
|
||||
if (settings && !settings.display_avatar) {
|
||||
return (
|
||||
<Avatar.Root className="shrink-0">
|
||||
<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}
|
||||
src={picture}
|
||||
alt={user.pubkey}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
|
||||
@@ -15,7 +15,7 @@ export function UserCover({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (user && !user.profile.banner) {
|
||||
if (user && !user.profile?.banner) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
|
||||
@@ -25,7 +25,7 @@ export function UserCover({ className }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<img
|
||||
src={user.profile.banner}
|
||||
src={user?.profile?.banner}
|
||||
alt="banner"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Column } from "@/components/column";
|
||||
import { Toolbar } from "@/components/toolbar";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { EventColumns, LumeColumn } from "@lume/types";
|
||||
import type { ColumnEvent, LumeColumn } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
|
||||
export const Route = createFileRoute("/$account/home")({
|
||||
loader: async () => {
|
||||
@@ -26,7 +26,7 @@ function Screen() {
|
||||
const [columns, setColumns] = useState<LumeColumn[]>([]);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
watchDrag: false,
|
||||
loop: true,
|
||||
loop: false,
|
||||
});
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
@@ -38,11 +38,11 @@ function Screen() {
|
||||
}, [emblaApi]);
|
||||
|
||||
const emitScrollEvent = useCallback(() => {
|
||||
getCurrent().emit("window", { scroll: true });
|
||||
getCurrent().emit("child-webview", { scroll: true });
|
||||
}, []);
|
||||
|
||||
const emitResizeEvent = useCallback(() => {
|
||||
getCurrent().emit("window", { resize: true });
|
||||
getCurrent().emit("child-webview", { resize: true, direction: "x" });
|
||||
}, []);
|
||||
|
||||
const openLumeStore = useDebouncedCallback(async () => {
|
||||
@@ -58,7 +58,7 @@ function Screen() {
|
||||
|
||||
const add = useDebouncedCallback((column: LumeColumn) => {
|
||||
column.label = `${column.label}-${nanoid()}`; // update col label
|
||||
setColumns((prev) => [...prev, column]);
|
||||
setColumns((prev) => [column, ...prev]);
|
||||
}, 150);
|
||||
|
||||
const remove = useDebouncedCallback((label: string) => {
|
||||
@@ -80,9 +80,7 @@ function Screen() {
|
||||
const reset = useDebouncedCallback(() => setColumns([]), 150);
|
||||
|
||||
const handleKeyDown = useDebouncedCallback((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
switch (event.code) {
|
||||
case "ArrowLeft":
|
||||
@@ -91,6 +89,8 @@ function Screen() {
|
||||
case "ArrowRight":
|
||||
if (emblaApi) emblaApi.scrollNext(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
@@ -102,6 +102,12 @@ function Screen() {
|
||||
emblaApi.on("resize", emitResizeEvent);
|
||||
emblaApi.on("slidesChanged", emitScrollEvent);
|
||||
}
|
||||
|
||||
return () => {
|
||||
emblaApi.off("scroll", emitScrollEvent);
|
||||
emblaApi.off("resize", emitResizeEvent);
|
||||
emblaApi.off("slidesChanged", emitScrollEvent);
|
||||
};
|
||||
}, [emblaApi, emitScrollEvent, emitResizeEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,9 +120,18 @@ function Screen() {
|
||||
setColumns(initialColumnList);
|
||||
}, [initialColumnList]);
|
||||
|
||||
// Listen for keyboard event
|
||||
useEffect(() => {
|
||||
// Listen for columns event
|
||||
const unlisten = listen<EventColumns>("columns", (data) => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// Listen for columns event
|
||||
useEffect(() => {
|
||||
const unlisten = listen<ColumnEvent>("columns", (data) => {
|
||||
if (data.payload.type === "reset") reset();
|
||||
if (data.payload.type === "add") add(data.payload.column);
|
||||
if (data.payload.type === "remove") remove(data.payload.label);
|
||||
@@ -124,12 +139,8 @@ function Screen() {
|
||||
updateName(data.payload.label, data.payload.title);
|
||||
});
|
||||
|
||||
// Listen for keyboard event
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
ComposeFilledIcon,
|
||||
HorizontalDotsIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "@lume/icons";
|
||||
import { User } from "@/components/user";
|
||||
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||
import { cn } from "@lume/utils";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { LumeWindow, NostrAccount } from "@lume/system";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account")({
|
||||
beforeLoad: async () => {
|
||||
@@ -44,13 +44,6 @@ function Screen() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
className="inline-flex items-center justify-center rounded-full size-8 text-neutral-800 hover:bg-black/10 dark:text-neutral-200 dark:hover:bg-white/10"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor()}
|
||||
@@ -59,7 +52,6 @@ function Screen() {
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
|
||||
<div id="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,10 +119,15 @@ function Accounts() {
|
||||
setWindowWidth(getWindowDimensions().width);
|
||||
}
|
||||
|
||||
if (!windowWidth) setWindowWidth(getWindowDimensions().width);
|
||||
if (!windowWidth) {
|
||||
setWindowWidth(getWindowDimensions().width);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckCircleIcon, InfoCircleIcon, CancelCircleIcon } from "@lume/icons";
|
||||
import type { Settings } from "@lume/types";
|
||||
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
|
||||
import type { Settings } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
@@ -40,7 +40,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { LaurelIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/$account/settings")({
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
pendingComponent: Pending,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { settings } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [newSettings, setNewSettings] = useState(settings);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const toggleEnhancedPrivacy = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleNsfw = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
nsfw: !newSettings.nsfw,
|
||||
}));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
// publish settings
|
||||
const eventId = await NostrQuery.setSettings(newSettings);
|
||||
|
||||
if (eventId) {
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
<div className="flex items-center justify-center text-teal-500 bg-teal-100 rounded-full size-20 dark:bg-teal-950">
|
||||
<LaurelIcon className="size-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("onboardingSettings.title")}
|
||||
</h1>
|
||||
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
||||
{t("onboardingSettings.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between w-full gap-4 px-5 py-4 rounded-lg bg-neutral-100 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Lume will display external resources like image, video or link
|
||||
preview as plain text.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.enhancedPrivacy}
|
||||
onClick={() => toggleEnhancedPrivacy()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 px-5 py-4 rounded-lg bg-neutral-100 dark:bg-white/10">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
By default, Lume will display all content which have Content
|
||||
Warning tag, it's may include NSFW content.
|
||||
</p>
|
||||
</div>
|
||||
<Switch.Root
|
||||
checked={newSettings.nsfw}
|
||||
onClick={() => toggleNsfw()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/20"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-full mb-1 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<button type="button" className="size-5" disabled>
|
||||
<Spinner className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/new/profile")({
|
||||
export const Route = createFileRoute("/auth/create-profile")({
|
||||
component: Screen,
|
||||
loader: async () => {
|
||||
const account = await NostrAccount.createAccount();
|
||||
@@ -58,24 +58,24 @@ function Screen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||
<div className="relative rounded-full size-24 bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt="avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
||||
className="absolute inset-0 z-10 object-cover w-full h-full rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarUploader
|
||||
setPicture={setPicture}
|
||||
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full dark:text-black bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="absolute inset-0 z-20 flex items-center justify-center w-full h-full text-white rounded-full dark:text-black bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-8" />
|
||||
</AvatarUploader>
|
||||
@@ -83,7 +83,7 @@ function Screen() {
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-3"
|
||||
className="flex flex-col w-full gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="display_name" className="font-medium">
|
||||
@@ -94,7 +94,7 @@ function Screen() {
|
||||
{...register("display_name", { required: true, minLength: 1 })}
|
||||
placeholder="e.g. Alice in Nostrland"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -106,7 +106,7 @@ function Screen() {
|
||||
{...register("name")}
|
||||
placeholder="e.g. alice"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -129,12 +129,12 @@ function Screen() {
|
||||
{...register("website")}
|
||||
placeholder="e.g. https://alice.me"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-500 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : t("global.continue")}
|
||||
</button>
|
||||
@@ -28,7 +28,7 @@ function Screen() {
|
||||
|
||||
if (npub) {
|
||||
navigate({
|
||||
to: "/auth/$account/settings",
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
replace: true,
|
||||
});
|
||||
@@ -40,11 +40,11 @@ function Screen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="key"
|
||||
@@ -58,7 +58,7 @@ function Screen() {
|
||||
placeholder="nsec or ncryptsec..."
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -73,14 +73,14 @@ function Screen() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
|
||||
@@ -27,7 +27,7 @@ function Screen() {
|
||||
|
||||
if (remoteAccount?.length) {
|
||||
return navigate({
|
||||
to: "/auth/$account/settings",
|
||||
to: "/$account/home",
|
||||
params: { account: remoteAccount },
|
||||
replace: true,
|
||||
});
|
||||
@@ -39,11 +39,11 @@ function Screen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Nostr Connect</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="uri"
|
||||
@@ -57,20 +57,20 @@ function Screen() {
|
||||
placeholder="bunker://..."
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
className="px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center w-full mt-3 font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Login"}
|
||||
</button>
|
||||
{loading ? (
|
||||
<p className="text-neutral-600 dark:text-neutral-400 text-sm text-center">
|
||||
<p className="text-sm text-center text-neutral-600 dark:text-neutral-400">
|
||||
Waiting confirmation...
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CancelIcon, PlusIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { Relay } from "@lume/types";
|
||||
import type { Relay } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -51,16 +51,16 @@ function Screen() {
|
||||
}, [bootstrapRelays]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center h-screen w-screen">
|
||||
<div className="mx-auto max-w-sm lg:max-w-lg w-full">
|
||||
<div className="h-11 text-center">
|
||||
<div className="flex flex-col items-center justify-center w-screen h-screen">
|
||||
<div className="w-full max-w-sm mx-auto lg:max-w-lg">
|
||||
<div className="text-center h-11">
|
||||
<h1 className="font-semibold">Customize Bootstrap Relays</h1>
|
||||
</div>
|
||||
<div className="flex w-full flex-col bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50 px-2">
|
||||
<div className="flex flex-col w-full px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
|
||||
{relays.map((relay) => (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="flex justify-between items-center h-11"
|
||||
className="flex items-center justify-between h-11"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
{relay.url}
|
||||
@@ -69,7 +69,7 @@ function Screen() {
|
||||
{relay.purpose?.length ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-max rounded-md inline-flex items-center justify-center px-2 uppercase text-xs font-medium hover:bg-black/10 dark:hover:bg-white/10"
|
||||
className="inline-flex items-center justify-center px-2 text-xs font-medium uppercase rounded-md h-7 w-max hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
{relay.purpose}
|
||||
</button>
|
||||
@@ -77,19 +77,19 @@ function Screen() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRelay(relay.url)}
|
||||
className="inline-flex items-center justify-center size-7 rounded-md text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
className="inline-flex items-center justify-center rounded-md size-7 text-neutral-700 dark:text-white/20 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<CancelIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center h-14 border-t border-neutral-100 dark:border-white/5">
|
||||
<div className="flex items-center border-t h-14 border-neutral-100 dark:border-white/5">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full flex items-center gap-2 mb-0"
|
||||
className="flex items-center w-full gap-2 mb-0"
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2 rounded-lg border border-neutral-300 dark:border-white/20">
|
||||
<div className="flex items-center flex-1 gap-2 border rounded-lg border-neutral-300 dark:border-white/20">
|
||||
<input
|
||||
{...register("url", {
|
||||
required: true,
|
||||
@@ -98,11 +98,11 @@ function Screen() {
|
||||
name="url"
|
||||
placeholder="wss://..."
|
||||
spellCheck={false}
|
||||
className="h-9 flex-1 bg-transparent border-none rounded-l-lg px-3 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
|
||||
className="flex-1 px-3 bg-transparent border-none rounded-l-lg h-9 placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<select
|
||||
{...register("purpose")}
|
||||
className="flex-1 h-9 p-0 m-0 text-sm bg-transparent border-none ring-0 outline-none focus:outline-none focus:ring-0"
|
||||
className="flex-1 p-0 m-0 text-sm bg-transparent border-none outline-none h-9 ring-0 focus:outline-none focus:ring-0"
|
||||
>
|
||||
<option value="read">Read</option>
|
||||
<option value="write">Write</option>
|
||||
@@ -111,7 +111,7 @@ function Screen() {
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 inline-flex h-9 w-14 px-2 items-center justify-center rounded-lg bg-black/20 dark:bg-white/20 font-medium text-sm text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center px-2 text-sm font-medium text-white rounded-lg shrink-0 h-9 w-14 bg-black/20 dark:bg-white/20 hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="size-7" />
|
||||
</button>
|
||||
@@ -122,7 +122,7 @@ function Screen() {
|
||||
type="button"
|
||||
onClick={() => save()}
|
||||
disabled={isLoading}
|
||||
className="mt-4 inline-flex h-10 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 text-sm font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center w-full h-10 mt-4 text-sm font-semibold text-white bg-blue-500 rounded-lg shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Save & Relaunch"}
|
||||
</button>
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/create-newsfeed")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
Build up your timeline.
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Follow some people to keep up to date with them.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-0.5">
|
||||
<Link to="/create-newsfeed/users" className="flex-1 h-8">
|
||||
<Link
|
||||
to="/create-newsfeed/users"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -33,7 +46,11 @@ function Screen() {
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/create-newsfeed/f2f" className="flex-1 h-8">
|
||||
<Link
|
||||
to="/create-newsfeed/f2f"
|
||||
search={search}
|
||||
className="flex-1 h-8"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { ColumnRouteSearch, Topic } from "@lume/types";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { TOPICS } from "@lume/utils";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Topic = {
|
||||
title: string;
|
||||
content: string[];
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/create-topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
@@ -53,33 +58,34 @@ function Screen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-center flex flex-col items-center justify-center">
|
||||
<h1 className="text-2xl font-serif font-medium">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-4">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<h1 className="font-serif text-2xl font-medium">
|
||||
What are your interests?
|
||||
</h1>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
Add some topics you want to focus on.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-4/5 max-w-full flex flex-col gap-3">
|
||||
<div className="w-full h-9 shrink-0 flex items-center justify-between bg-black/5 dark:bg-white/5 rounded-lg px-3">
|
||||
<div className="flex flex-col w-4/5 max-w-full gap-3">
|
||||
<div className="flex items-center justify-between w-full px-3 rounded-lg h-9 shrink-0 bg-black/5 dark:bg-white/5">
|
||||
<span className="text-sm font-medium">Added: {topics.length}</span>
|
||||
</div>
|
||||
<div className="w-full flex flex-col items-center gap-3">
|
||||
<div className="flex flex-col items-center w-full gap-3">
|
||||
<div className="overflow-y-auto scrollbar-none p-2 w-full h-[450px] bg-black/5 dark:bg-white/5 backdrop-blur-lg rounded-xl">
|
||||
<div className="flex flex-col gap-3">
|
||||
{TOPICS.map((topic) => (
|
||||
<button
|
||||
key={topic.title}
|
||||
type="button"
|
||||
onClick={() => toggleTopic(topic)}
|
||||
className="h-11 px-3 flex items-center justify-between bg-white dark:bg-black/20 backdrop-blur-lg border border-transparent hover:border-blue-500 rounded-lg shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
className="flex items-center justify-between px-3 bg-white border border-transparent rounded-lg h-11 dark:bg-black/20 backdrop-blur-lg hover:border-blue-500 shadow-primary dark:ring-1 ring-neutral-800/50"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div>{topic.icon}</div>
|
||||
<div className="text-sm font-medium">
|
||||
<span>{topic.title}</span>
|
||||
<span className="ml-1 italic text-neutral-400 dark:text-neutral-600 font-normal">
|
||||
<span className="ml-1 italic font-normal text-neutral-400 dark:text-neutral-600">
|
||||
{topic.content.length} hashtags
|
||||
</span>
|
||||
</div>
|
||||
@@ -95,7 +101,7 @@ function Screen() {
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={isLoading || topics.length < 1}
|
||||
className="inline-flex items-center justify-center w-36 rounded-full h-9 bg-blue-500 text-white text-sm font-medium hover:bg-blue-600 disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center text-sm font-medium text-white bg-blue-500 rounded-full w-36 h-9 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <Spinner /> : "Confirm"}
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { NostrQuery, useEvent } from "@lume/system";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Box, Container, Spinner } from "@lume/ui";
|
||||
import { Note } from "@/components/note";
|
||||
import { type LumeEvent, NostrQuery, useEvent } from "@lume/system";
|
||||
import { Box, Container, Spinner } from "@lume/ui";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { WindowVirtualizer } from "virtua";
|
||||
import { ReplyList } from "./-components/replyList";
|
||||
|
||||
export const Route = createFileRoute("/events/$eventId")({
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
@@ -20,14 +19,14 @@ function Screen() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<p>Not found.</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -40,7 +39,7 @@ function Screen() {
|
||||
{data ? (
|
||||
<ReplyList eventId={eventId} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
@@ -50,16 +49,16 @@ function Screen() {
|
||||
);
|
||||
}
|
||||
|
||||
function MainNote({ data }: { data: NostrEvent }) {
|
||||
function MainNote({ data }: { data: LumeEvent }) {
|
||||
return (
|
||||
<Note.Provider event={data}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.ContentLarge className="px-3" />
|
||||
<div className="mt-4 h-11 gap-2 flex items-center justify-end px-3">
|
||||
<div className="flex items-center justify-end gap-2 px-3 mt-4 h-11">
|
||||
<Note.Reply large />
|
||||
<Note.Repost large />
|
||||
<Note.Zap large />
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
@@ -19,6 +19,10 @@ export const Route = createFileRoute("/global")({
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
@@ -39,32 +43,25 @@ export function Screen() {
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
(event: LumeEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
if (event.isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} event={event} className="mb-3" />
|
||||
<Conversation key={event.id} className="mb-3" event={event} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
if (event.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
@@ -73,9 +70,9 @@ export function Screen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="flex items-center justify-center w-full h-11">
|
||||
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-5" />
|
||||
<span className="text-sm font-medium">Fetching new notes...</span>
|
||||
@@ -88,7 +85,9 @@ export function Screen() {
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
@@ -100,7 +99,7 @@ export function Screen() {
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 px-3 font-medium rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
@@ -116,16 +115,3 @@ export function Screen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 py-10">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||
<div className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, type NostrEvent, Kind } from "@lume/types";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
@@ -20,8 +20,9 @@ export const Route = createFileRoute("/group")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_group_${search.label}`;
|
||||
const groups = (await NostrQuery.getNstore(key)) as string[];
|
||||
const key = `lume:group:${search.label}`;
|
||||
const groups: string[] = await NostrQuery.getNstore(key);
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
|
||||
if (!groups?.length) {
|
||||
throw redirect({
|
||||
@@ -33,7 +34,7 @@ export const Route = createFileRoute("/group")({
|
||||
});
|
||||
}
|
||||
|
||||
return { groups };
|
||||
return { groups, settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -56,33 +57,25 @@ export function Screen() {
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) =>
|
||||
data?.pages.flatMap((page) => page.filter((ev) => ev.kind === Kind.Text)),
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
(event: LumeEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
if (event.isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} event={event} className="mb-3" />
|
||||
<Conversation key={event.id} className="mb-3" event={event} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
if (event.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +84,7 @@ export function Screen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -106,7 +99,9 @@ export function Screen() {
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
@@ -118,7 +113,7 @@ export function Screen() {
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 px-3 font-medium rounded-xl bg-neutral-100 hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
@@ -134,16 +129,3 @@ export function Screen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 py-10">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||
<div className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PlusIcon, RelayIcon } from "@lume/icons";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { User } from "@/components/user";
|
||||
import { checkForAppUpdates } from "@lume/utils";
|
||||
import { checkForAppUpdates, displayNpub } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
@@ -10,7 +10,12 @@ import { NostrAccount } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: async () => {
|
||||
await checkForAppUpdates(true); // check for app updates
|
||||
// Check for app updates
|
||||
// TODO: move this function to rust
|
||||
await checkForAppUpdates(true);
|
||||
|
||||
// Get all accounts
|
||||
// TODO: use emit & listen
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
|
||||
if (accounts.length < 1) {
|
||||
@@ -29,11 +34,11 @@ function Screen() {
|
||||
const navigate = Route.useNavigate();
|
||||
const context = Route.useRouteContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState({ npub: "", status: false });
|
||||
|
||||
const select = async (npub: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoading({ npub, status: true });
|
||||
|
||||
const status = await NostrAccount.loadAccount(npub);
|
||||
|
||||
@@ -45,7 +50,7 @@ function Screen() {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
setLoading({ npub: "", status: false });
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
@@ -59,9 +64,9 @@ function Screen() {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col items-center justify-between"
|
||||
className="flex flex-col items-center justify-between w-full h-full"
|
||||
>
|
||||
<div className="w-full flex-1 flex items-end justify-center px-4">
|
||||
<div className="flex items-end justify-center flex-1 w-full px-4 pb-10">
|
||||
<div className="text-center">
|
||||
<h2 className="mb-1 text-lg text-neutral-700 dark:text-neutral-300">
|
||||
{currentDate}
|
||||
@@ -69,49 +74,60 @@ function Screen() {
|
||||
<h2 className="text-2xl font-semibold">Welcome back!</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-1 flex flex-wrap items-center justify-center gap-6 px-3">
|
||||
{loading ? (
|
||||
<div className="inline-flex size-6 items-center justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{context.accounts.map((account) => (
|
||||
<button
|
||||
key={account}
|
||||
type="button"
|
||||
onClick={() => select(account)}
|
||||
>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<User.Avatar className="size-20 rounded-full object-cover" />
|
||||
<div className="flex flex-col items-center flex-1 w-full gap-3">
|
||||
<div className="flex flex-col w-full max-w-sm mx-auto overflow-hidden bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/10 dark:ring-1 ring-white/15">
|
||||
{context.accounts.map((account) => (
|
||||
<div
|
||||
key={account}
|
||||
onClick={() => select(account)}
|
||||
onKeyDown={() => select(account)}
|
||||
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="flex items-center gap-2.5 p-3">
|
||||
<User.Avatar className="object-cover rounded-full size-10 shrink-0" />
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<Link to="/landing">
|
||||
<div className="flex h-36 w-32 flex-col items-center justify-center gap-3 rounded-2xl p-2 hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-black/5 dark:bg-white/5">
|
||||
<PlusIcon className="size-8" />
|
||||
</div>
|
||||
<p className="font-medium leading-tight">Add</p>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{displayNpub(account, 16)}
|
||||
</span>
|
||||
</div>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<div className="inline-flex items-center justify-center size-10">
|
||||
{loading.npub === account ? (
|
||||
loading.status ? (
|
||||
<Spinner />
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex-1 flex items-end justify-center pb-4 px-4">
|
||||
<div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to="/landing"
|
||||
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 p-3">
|
||||
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
|
||||
<PlusIcon className="size-5" />
|
||||
</div>
|
||||
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
|
||||
Add account
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-full max-w-sm mx-auto">
|
||||
<Link
|
||||
to="/bootstrap-relays"
|
||||
className="inline-flex items-center justify-center gap-2 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 rounded-full h-8 w-36 px-2 text-xs font-medium text-neutral-700 dark:text-white/40"
|
||||
className="inline-flex items-center justify-center w-full h-8 gap-2 px-2 text-xs font-medium rounded-lg bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-neutral-700 dark:text-white/40"
|
||||
>
|
||||
<RelayIcon className="size-4" />
|
||||
Bootstrap Relays
|
||||
Custom Bootstrap Relays
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,27 +9,27 @@ function Screen() {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex flex-col justify-center items-center h-screen w-screen"
|
||||
className="flex flex-col items-center justify-center w-screen h-screen"
|
||||
>
|
||||
<div className="mx-auto max-w-xs lg:max-w-md w-full">
|
||||
<div className="flex w-full flex-col gap-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50 px-2">
|
||||
<div className="h-20 flex items-center border-b border-neutral-100 dark:border-white/5">
|
||||
<div className="w-full max-w-xs mx-auto lg:max-w-md">
|
||||
<div className="flex flex-col w-full gap-2 px-2 bg-white rounded-xl shadow-primary backdrop-blur-lg dark:bg-white/20 dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center h-20 border-b border-neutral-100 dark:border-white/5">
|
||||
<Link
|
||||
to="/auth/new/profile"
|
||||
className="h-14 w-full flex items-center justify-center gap-2 hover:bg-neutral-100 dark:hover:bg-white/10 rounded-lg px-2"
|
||||
to="/auth/create-profile"
|
||||
className="flex items-center justify-center w-full gap-2 px-2 rounded-lg h-14 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="size-9 shrink-0 rounded-full inline-flex items-center justify-center">
|
||||
<div className="inline-flex items-center justify-center rounded-full size-9 shrink-0">
|
||||
<img
|
||||
src="/icon.jpeg"
|
||||
alt="App Icon"
|
||||
className="size-9 object-cover rounded-full"
|
||||
className="object-cover rounded-full size-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 inline-flex flex-col">
|
||||
<span className="leading-tight font-semibold">
|
||||
<div className="inline-flex flex-col flex-1">
|
||||
<span className="font-semibold leading-tight">
|
||||
Create new account
|
||||
</span>
|
||||
<span className="leading-tight text-sm text-neutral-500">
|
||||
<span className="text-sm leading-tight text-neutral-500">
|
||||
Use everywhere
|
||||
</span>
|
||||
</div>
|
||||
@@ -38,18 +38,18 @@ function Screen() {
|
||||
<div className="flex flex-col gap-1 pb-2.5">
|
||||
<Link
|
||||
to="/auth/privkey"
|
||||
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="size-9 inline-flex items-center justify-center">
|
||||
<div className="inline-flex items-center justify-center size-9">
|
||||
<KeyIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
|
||||
</div>
|
||||
Login with Private Key
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/remote"
|
||||
className="inline-flex h-11 w-full items-center gap-2 rounded-lg px-2 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
className="inline-flex items-center w-full gap-2 px-2 rounded-lg h-11 hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="size-9 inline-flex items-center justify-center">
|
||||
<div className="inline-flex items-center justify-center size-9">
|
||||
<RemoteIcon className="size-5 text-neutral-600 dark:text-neutral-400" />
|
||||
</div>
|
||||
Nostr Connect
|
||||
|
||||
@@ -21,6 +21,8 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
const isContactListEmpty = await NostrAccount.isContactListEmpty();
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
|
||||
if (isContactListEmpty) {
|
||||
throw redirect({
|
||||
to: "/create-newsfeed/users",
|
||||
@@ -30,6 +32,8 @@ export const Route = createFileRoute("/newsfeed")({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -51,7 +55,7 @@ export function Screen() {
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import type { LumeColumn } from "@lume/types";
|
||||
import { createLazyRoute } from "@tanstack/react-router";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
|
||||
export const Route = createLazyRoute("/open")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const install = async (column: LumeColumn) => {
|
||||
const mainWindow = getCurrent();
|
||||
await mainWindow.emit("columns", { type: "add", column });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div className="group absolute left-0 top-0 z-10 h-full w-12">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "store",
|
||||
name: "Store",
|
||||
content: "/store/official",
|
||||
})
|
||||
}
|
||||
className="flex h-full w-full items-center justify-center rounded-xl bg-transparent transition-colors duration-100 ease-in-out group-hover:bg-black/5 dark:group-hover:bg-white/5"
|
||||
>
|
||||
<PlusIcon className="size-6 scale-0 transform transition-transform duration-150 ease-in-out will-change-transform group-hover:scale-100" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
install({
|
||||
label: "store",
|
||||
name: "Store",
|
||||
content: "/store/official",
|
||||
})
|
||||
}
|
||||
className="inline-flex size-14 items-center justify-center rounded-full bg-black/10 backdrop-blur-lg hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-8" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Note } from "@/components/note";
|
||||
import { User } from "@/components/user";
|
||||
import {
|
||||
HorizontalDotsIcon,
|
||||
InfoIcon,
|
||||
RepostIcon,
|
||||
SearchIcon,
|
||||
} from "@lume/icons";
|
||||
import { type LumeEvent, LumeWindow, NostrQuery, useEvent } from "@lume/system";
|
||||
import { Kind } from "@lume/types";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { HorizontalDotsIcon, InfoIcon, RepostIcon } from "@lume/icons";
|
||||
import {
|
||||
checkForAppUpdates,
|
||||
decodeZapInvoice,
|
||||
formatCreatedAt,
|
||||
} from "@lume/utils";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { getCurrent } from "@tauri-apps/api/window";
|
||||
import { exit } from "@tauri-apps/plugin-process";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface EmitAccount {
|
||||
account: string;
|
||||
@@ -166,6 +171,13 @@ function Screen() {
|
||||
<User.Avatar className="rounded-full size-7" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openSearch()}
|
||||
className="inline-flex items-center justify-center rounded-full size-7 bg-black/5 dark:bg-white/5"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { LumeWindow } from "@lume/system";
|
||||
import { LumeEvent, LumeWindow } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/search")({
|
||||
component: Screen,
|
||||
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/search")({
|
||||
|
||||
function Screen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
||||
const [events, setEvents] = useState<LumeEvent[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchValue] = useDebounce(search, 500);
|
||||
|
||||
@@ -27,7 +27,8 @@ function Screen() {
|
||||
const res = await fetch(query);
|
||||
const content = await res.json();
|
||||
const events = content.data as NostrEvent[];
|
||||
const sorted = events.sort((a, b) => b.created_at - a.created_at);
|
||||
const lumeEvents = events.map((ev) => new LumeEvent(ev));
|
||||
const sorted = lumeEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
setLoading(false);
|
||||
setEvents(sorted);
|
||||
@@ -45,7 +46,7 @@ function Screen() {
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex flex-col w-full h-full">
|
||||
<div className="relative h-24 shrink-0 flex flex-col border-b border-black/5 dark:border-white/5">
|
||||
<div className="relative flex flex-col h-24 border-b shrink-0 border-black/5 dark:border-white/5">
|
||||
<div data-tauri-drag-region className="w-full h-4 shrink-0" />
|
||||
<input
|
||||
value={search}
|
||||
@@ -54,12 +55,12 @@ function Screen() {
|
||||
if (e.key === "Enter") searchEvents();
|
||||
}}
|
||||
placeholder="Search anything..."
|
||||
className="w-full h-20 pt-10 px-3 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
|
||||
className="w-full h-20 px-3 pt-10 text-lg bg-transparent border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 p-3 overflow-y-auto scrollbar-none">
|
||||
{loading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : events.length ? (
|
||||
@@ -68,11 +69,11 @@ function Screen() {
|
||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
||||
Users
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<div className="flex flex-col flex-1 gap-1">
|
||||
{events
|
||||
.filter((ev) => ev.kind === Kind.Metadata)
|
||||
.map((event, index) => (
|
||||
<SearchUser key={event.pubkey + index} event={event} />
|
||||
.map((event) => (
|
||||
<SearchUser key={event.pubkey} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +81,7 @@ function Screen() {
|
||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 shrink-0">
|
||||
Notes
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
<div className="flex flex-col flex-1 gap-3">
|
||||
{events
|
||||
.filter((ev) => ev.kind === Kind.Text)
|
||||
.map((event) => (
|
||||
@@ -91,8 +92,8 @@ function Screen() {
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !events.length ? (
|
||||
<div className="h-full flex items-center justify-center flex-col gap-3">
|
||||
<div className="size-16 bg-black/10 dark:bg-white/10 rounded-full inline-flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<div className="inline-flex items-center justify-center rounded-full size-16 bg-black/10 dark:bg-white/10">
|
||||
<SearchIcon className="size-6" />
|
||||
</div>
|
||||
Try searching for people, notes, or keywords
|
||||
@@ -103,17 +104,17 @@ function Screen() {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchUser({ event }: { event: NostrEvent }) {
|
||||
function SearchUser({ event }: { event: LumeEvent }) {
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openProfile(event.pubkey)}
|
||||
className="col-span-1 p-2 hover:bg-black/10 dark:hover:bg-white/10 rounded-lg"
|
||||
className="col-span-1 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<User.Provider pubkey={event.pubkey} embedProfile={event.content}>
|
||||
<User.Root className="flex items-center gap-2">
|
||||
<User.Avatar className="size-9 rounded-full shrink-0" />
|
||||
<User.Avatar className="rounded-full size-9 shrink-0" />
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User.Name className="font-semibold" />
|
||||
<User.NIP05 />
|
||||
@@ -124,17 +125,17 @@ function SearchUser({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchNote({ event }: { event: NostrEvent }) {
|
||||
function SearchNote({ event }: { event: LumeEvent }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-black/20 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<Note.Provider event={event}>
|
||||
<Note.Root>
|
||||
<div className="px-3 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center justify-between px-3 h-14">
|
||||
<Note.User />
|
||||
<Note.Menu />
|
||||
</div>
|
||||
<Note.Content className="px-3" quote={false} mention={false} />
|
||||
<div className="mt-3 flex items-center gap-4 h-14 px-3">
|
||||
<div className="flex items-center gap-4 px-3 mt-3 h-14">
|
||||
<Note.Open />
|
||||
</div>
|
||||
</Note.Root>
|
||||
|
||||
@@ -18,10 +18,10 @@ function Screen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-20 w-full shrink-0 items-center justify-center border-b border-black/10 dark:border-white/10"
|
||||
className="flex items-center justify-center w-full h-20 border-b shrink-0 border-black/10 dark:border-white/10"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link to="/settings/general">
|
||||
@@ -119,7 +119,7 @@ function Screen() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex-1 overflow-y-auto scrollbar-none px-5 py-4">
|
||||
<div className="flex-1 w-full px-5 py-4 overflow-y-auto scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,119 +1,80 @@
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import type { Settings } from "@lume/types";
|
||||
import { NostrQuery, type Settings } from "@lume/system";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { requestPermission } from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
type Theme = "auto" | "light" | "dark";
|
||||
|
||||
export const Route = createFileRoute("/settings/general")({
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
const initialSettings = await NostrQuery.getUserSettings();
|
||||
return { initialSettings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { settings } = Route.useRouteContext();
|
||||
const [newSettings, setNewSettings] = useState<Settings>(settings);
|
||||
const { initialSettings } = Route.useRouteContext();
|
||||
|
||||
const toggleNofitication = async () => {
|
||||
await requestPermission();
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
notification: !newSettings.notification,
|
||||
}));
|
||||
};
|
||||
const [theme, setTheme] = useState<Theme>(null);
|
||||
const [settings, setSettings] = useState<Settings>(null);
|
||||
|
||||
const toggleGossip = async () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
gossip: !newSettings.gossip,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAutoUpdate = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
autoUpdate: !newSettings.autoUpdate,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleEnhancedPrivacy = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
enhancedPrivacy: !newSettings.enhancedPrivacy,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleNsfw = () => {
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
nsfw: !newSettings.nsfw,
|
||||
}));
|
||||
};
|
||||
|
||||
const changeTheme = (theme: string) => {
|
||||
const changeTheme = async (theme: string) => {
|
||||
if (theme === "auto" || theme === "light" || theme === "dark") {
|
||||
invoke("plugin:theme|set_theme", {
|
||||
theme: theme,
|
||||
}).then(() =>
|
||||
setNewSettings((prev) => ({
|
||||
...prev,
|
||||
theme,
|
||||
})),
|
||||
);
|
||||
}).then(() => setTheme(theme));
|
||||
}
|
||||
};
|
||||
|
||||
const updateSettings = useDebouncedCallback(() => {
|
||||
NostrQuery.setSettings(newSettings);
|
||||
const updateSettings = useDebouncedCallback(async () => {
|
||||
const newSettings = JSON.stringify(settings);
|
||||
await NostrQuery.setUserSettings(newSettings);
|
||||
}, 200);
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings();
|
||||
}, [newSettings]);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke("plugin:theme|get_theme").then((data: Theme) => setTheme(data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSettings(initialSettings);
|
||||
}, [initialSettings]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center w-full h-12 px-3 text-sm rounded-xl bg-black/5 dark:bg-white/5">
|
||||
* Setting changes require restarting the app to take effect.
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
General
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Notification</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
By turning on push notifications, you'll start getting
|
||||
notifications from Lume directly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Relay Hint</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically connect to the necessary relay suggested by
|
||||
Relay Hint when fetching a new event.
|
||||
Use the relay hint if necessary.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.gossip}
|
||||
onClick={() => toggleGossip()}
|
||||
checked={settings.use_relay_hint}
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
use_relay_hint: !prev.use_relay_hint,
|
||||
}))
|
||||
}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
@@ -122,51 +83,20 @@ function Screen() {
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Enhanced Privacy</h3>
|
||||
<h3 className="font-medium">Content Warning</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Lume presents external resources like images, videos, or link
|
||||
previews in plain text.
|
||||
Shows a warning for notes that have a content warning.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.enhancedPrivacy}
|
||||
onClick={() => toggleEnhancedPrivacy()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Auto Update</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically download and install new version.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.autoUpdate}
|
||||
onClick={() => toggleAutoUpdate()}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Filter sensitive content</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
By default, Lume will display all content which have Content
|
||||
Warning tag, it's may include NSFW content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={newSettings.nsfw}
|
||||
onClick={() => toggleNsfw()}
|
||||
checked={settings.content_warning}
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
content_warning: !prev.content_warning,
|
||||
}))
|
||||
}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
@@ -177,21 +107,21 @@ function Screen() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Interface
|
||||
Appearance
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Appearance</h3>
|
||||
<h3 className="font-medium">Appearance</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
* Require restarting the app to take effect.
|
||||
Require restarting the app to take effect.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<select
|
||||
name="theme"
|
||||
className="w-24 py-1 bg-transparent rounded-lg shadow-none outline-none border-1 border-black/10 dark:border-white/10"
|
||||
defaultValue={settings.theme}
|
||||
defaultValue={theme}
|
||||
onChange={(e) => changeTheme(e.target.value)}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
@@ -200,6 +130,121 @@ function Screen() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Zap Button</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Shows the Zap button when viewing a note.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={settings.display_zap_button}
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
display_zap_button: !prev.display_zap_button,
|
||||
}))
|
||||
}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Repost Button</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Shows the Repost button when viewing a note.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={settings.display_zap_button}
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
display_zap_button: !prev.display_zap_button,
|
||||
}))
|
||||
}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||
Privacy & Performance
|
||||
</h2>
|
||||
<div className="flex flex-col px-3 divide-y divide-black/10 dark:divide-white/10 bg-black/5 dark:bg-white/5 rounded-xl">
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Proxy</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Set proxy address.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<input
|
||||
type="url"
|
||||
defaultValue={settings.proxy}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
proxy: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Image Resize Service</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Use weserv/images for resize image on-the-fly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<input
|
||||
type="url"
|
||||
defaultValue={settings.image_resize_service}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
image_resize_service: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="py-1 bg-transparent rounded-lg shadow-none outline-none w-44 border-1 border-black/10 dark:border-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between w-full gap-4 py-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">Load Remote Media</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
View the remote media directly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end w-36 shrink-0">
|
||||
<Switch.Root
|
||||
checked={settings.display_media}
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
display_image_link: !prev.display_media,
|
||||
}))
|
||||
}
|
||||
className="relative h-7 w-12 shrink-0 cursor-default rounded-full bg-black/10 outline-none data-[state=checked]:bg-blue-500 dark:bg-white/10"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { GlobalIcon, LaurelIcon } from "@lume/icons";
|
||||
import type { ColumnRouteSearch } from "@lume/types";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/store")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
account: search.account,
|
||||
label: search.label,
|
||||
name: search.name,
|
||||
};
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
@@ -19,7 +11,7 @@ function Screen() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 mt-2 mb-1">
|
||||
<div className="p-1 shrink-0 inline-flex w-full rounded-lg items-center gap-1 bg-black/5 dark:bg-white/5">
|
||||
<div className="inline-flex items-center w-full gap-1 p-1 rounded-lg shrink-0 bg-black/5 dark:bg-white/5">
|
||||
<Link to="/store/official" className="flex-1">
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
|
||||
@@ -3,19 +3,18 @@ import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { ArrowRightCircleIcon } from "@lume/icons";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import {
|
||||
type ColumnRouteSearch,
|
||||
type NostrEvent,
|
||||
type Topic,
|
||||
Kind,
|
||||
} from "@lume/types";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
import { type ColumnRouteSearch, Kind } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useCallback } from "react";
|
||||
import { Virtualizer } from "virtua";
|
||||
|
||||
type Topic = {
|
||||
content: string[];
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/topic")({
|
||||
validateSearch: (search: Record<string, string>): ColumnRouteSearch => {
|
||||
return {
|
||||
@@ -25,8 +24,9 @@ export const Route = createFileRoute("/topic")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async ({ search }) => {
|
||||
const key = `lume_topic_${search.label}`;
|
||||
const topics = (await NostrQuery.getNstore(key)) as unknown as Topic[];
|
||||
const key = `lume:topic:${search.label}`;
|
||||
const topics: Topic[] = await NostrQuery.getNstore(key);
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
|
||||
if (!topics?.length) {
|
||||
throw redirect({
|
||||
@@ -44,7 +44,7 @@ export const Route = createFileRoute("/topic")({
|
||||
hashtags.push(...topic.content);
|
||||
}
|
||||
|
||||
return { hashtags };
|
||||
return { settings, hashtags };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
@@ -67,32 +67,25 @@ export function Screen() {
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage?.at(-1)?.created_at - 1,
|
||||
select: (data) => data?.pages.flatMap((page) => page),
|
||||
select: (data) => data?.pages.flat(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
(event: LumeEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
if (event.isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} event={event} className="mb-3" />
|
||||
<Conversation key={event.id} className="mb-3" event={event} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
if (event.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +94,7 @@ export function Screen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||
<div className="w-full h-full p-3 overflow-y-auto scrollbar-none">
|
||||
{isFetching && !isLoading && !isFetchingNextPage ? (
|
||||
<div className="flex items-center justify-center w-full mb-3 h-11 bg-black/10 dark:bg-white/10 backdrop-blur-lg rounded-xl shadow-primary dark:ring-1 ring-neutral-800/50">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -116,7 +109,9 @@ export function Screen() {
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
) : !data.length ? (
|
||||
<Empty />
|
||||
<div className="flex items-center justify-center">
|
||||
Yo. You're catching up on all the things happening around you.
|
||||
</div>
|
||||
) : (
|
||||
<Virtualizer overscan={3}>
|
||||
{data.map((item) => renderItem(item))}
|
||||
@@ -128,7 +123,7 @@ export function Screen() {
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage || isLoading}
|
||||
className="inline-flex items-center justify-center w-full h-12 gap-2 px-3 font-medium rounded-xl bg-neutral-100 hover:bg-neutral-50 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
className="inline-flex items-center justify-center w-full gap-2 px-3 font-medium h-9 rounded-xl bg-black/5 hover:bg-black/10 focus:outline-none dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-5" />
|
||||
@@ -144,16 +139,3 @@ export function Screen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 py-10">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="flex flex-col items-center justify-end mb-8 overflow-hidden bg-blue-100 rounded-full size-24 dark:bg-blue-900">
|
||||
<div className="w-12 h-16 rounded-t-lg bg-gradient-to-b from-blue-500 dark:from-blue-200 to-blue-50 dark:to-blue-900" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">Your newsfeed is empty</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TextNote } from "@/components/text";
|
||||
import { LumeEvent } from "@lume/system";
|
||||
import type { NostrEvent } from "@lume/types";
|
||||
import { Spinner } from "@lume/ui";
|
||||
import { Await, createFileRoute } from "@tanstack/react-router";
|
||||
@@ -15,7 +16,13 @@ export const Route = createFileRoute("/trending/notes")({
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => res.notes.map((item) => item.event) as NostrEvent[]),
|
||||
.then((res) => {
|
||||
const events: NostrEvent[] = res.notes.map(
|
||||
(item: { event: NostrEvent }) => item.event,
|
||||
);
|
||||
const lumeEvents = events.map((ev) => new LumeEvent(ev));
|
||||
return lumeEvents;
|
||||
}),
|
||||
),
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -33,7 +40,7 @@ export function Screen() {
|
||||
<Virtualizer overscan={3}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
|
||||
<div className="flex flex-col items-center justify-center w-full h-20 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium"
|
||||
|
||||
@@ -14,18 +14,20 @@ export const Route = createFileRoute("/trending")({
|
||||
};
|
||||
},
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
return { settings };
|
||||
},
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const search = Route.useSearch();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="h-11 shrink-0 inline-flex w-full items-center gap-1 px-3">
|
||||
<div className="inline-flex h-full w-full items-center gap-1">
|
||||
<Link to="/trending/notes">
|
||||
<div className="inline-flex items-center w-full gap-1 px-3 h-11 shrink-0">
|
||||
<div className="inline-flex items-center w-full h-full gap-1">
|
||||
<Link to="/trending/notes" search={search}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -38,7 +40,7 @@ function Screen() {
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/trending/users">
|
||||
<Link to="/trending/users" search={search}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -53,7 +55,7 @@ function Screen() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 flex-1 overflow-y-auto w-full h-full scrollbar-none">
|
||||
<div className="flex-1 w-full h-full p-2 overflow-y-auto scrollbar-none">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,12 @@ import { Conversation } from "@/components/conversation";
|
||||
import { Quote } from "@/components/quote";
|
||||
import { RepostNote } from "@/components/repost";
|
||||
import { TextNote } from "@/components/text";
|
||||
import { type NostrEvent, Kind } from "@lume/types";
|
||||
import { Suspense } from "react";
|
||||
import { Kind } from "@lume/types";
|
||||
import { Suspense, useCallback } from "react";
|
||||
import { Await } from "@tanstack/react-router";
|
||||
import { NostrQuery } from "@lume/system";
|
||||
import { type LumeEvent, NostrQuery } from "@lume/system";
|
||||
|
||||
export const Route = createFileRoute("/users/$pubkey")({
|
||||
beforeLoad: async () => {
|
||||
const settings = await NostrQuery.getSettings();
|
||||
return { settings };
|
||||
},
|
||||
loader: async ({ params }) => {
|
||||
return { data: defer(NostrQuery.getUserEvents(params.pubkey)) };
|
||||
},
|
||||
@@ -26,29 +22,27 @@ function Screen() {
|
||||
const { pubkey } = Route.useParams();
|
||||
const { data } = Route.useLoaderData();
|
||||
|
||||
const renderItem = (event: NostrEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default: {
|
||||
const isConversation =
|
||||
event.tags.filter((tag) => tag[0] === "e" && tag[3] !== "mention")
|
||||
.length > 0;
|
||||
const isQuote = event.tags.filter((tag) => tag[0] === "q").length > 0;
|
||||
|
||||
if (isConversation) {
|
||||
return <Conversation key={event.id} event={event} className="mb-3" />;
|
||||
const renderItem = useCallback(
|
||||
(event: LumeEvent) => {
|
||||
if (!event) return;
|
||||
switch (event.kind) {
|
||||
case Kind.Repost:
|
||||
return <RepostNote key={event.id} event={event} className="mb-3" />;
|
||||
default: {
|
||||
if (event.isConversation) {
|
||||
return (
|
||||
<Conversation key={event.id} className="mb-3" event={event} />
|
||||
);
|
||||
}
|
||||
if (event.isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
if (isQuote) {
|
||||
return <Quote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
|
||||
return <TextNote key={event.id} event={event} className="mb-3" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container withDrag>
|
||||
@@ -56,15 +50,15 @@ function Screen() {
|
||||
<WindowVirtualizer>
|
||||
<User.Provider pubkey={pubkey}>
|
||||
<User.Root>
|
||||
<User.Cover className="h-44 w-full object-cover" />
|
||||
<div className="relative -mt-8 flex flex-col px-3">
|
||||
<User.Avatar className="size-14 rounded-full" />
|
||||
<div className="mb-4 inline-flex items-center justify-between">
|
||||
<User.Cover className="object-cover w-full h-44" />
|
||||
<div className="relative flex flex-col px-3 -mt-8">
|
||||
<User.Avatar className="rounded-full size-14" />
|
||||
<div className="inline-flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<User.Name className="text-lg font-semibold leading-tight" />
|
||||
<User.NIP05 />
|
||||
</div>
|
||||
<User.Button className="h-9 w-24 rounded-full inline-flex items-center justify-center bg-black text-sm font-medium text-white hover:bg-neutral-900 dark:bg-neutral-900" />
|
||||
<User.Button className="inline-flex items-center justify-center w-24 text-sm font-medium text-white bg-black rounded-full h-9 hover:bg-neutral-900 dark:bg-neutral-900" />
|
||||
</div>
|
||||
<User.About />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user