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:
雨宮蓮 2024-06-19 14:00:58 +07:00 committed by GitHub
parent 0061ecea78
commit 18c133d096
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 937 additions and 1167 deletions

View File

@ -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();

View File

@ -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 () => {

View File

@ -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"

View File

@ -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

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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

View File

@ -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>
);
}

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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);
};
}, []);

View File

@ -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 (

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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(

View File

@ -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>

View File

@ -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 />

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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,
});

View File

@ -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>
);
}

View File

@ -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)}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -129,6 +129,16 @@ export class NostrAccount {
}
}
static async getContactList() {
const query = await commands.getContactList();
if (query.status === "ok") {
return query.data;
} else {
return [];
}
}
static async isContactListEmpty() {
const query = await commands.isContactListEmpty();

View File

@ -100,17 +100,9 @@ try {
else return { status: "error", error: e as any };
}
},
async verifyNip05(key: string, nip05: string) : Promise<Result<boolean, string>> {
async getCurrentProfile() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { key, nip05 }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getCurrentUserProfile() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_current_user_profile") };
return { status: "ok", data: await TAURI_INVOKE("get_current_profile") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
@ -244,6 +236,30 @@ try {
else return { status: "error", error: e as any };
}
},
async getSettings() : Promise<Result<Settings, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_settings") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setNewSettings(settings: string) : Promise<Result<null, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_new_settings", { settings }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async verifyNip05(key: string, nip05: string) : Promise<Result<boolean, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("verify_nip05", { key, nip05 }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getEventMeta(content: string) : Promise<Result<Meta, null>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_event_meta", { content }) };
@ -399,11 +415,11 @@ try {
else return { status: "error", error: e as any };
}
},
async setBadge(count: number) : Promise<void> {
await TAURI_INVOKE("set_badge", { count });
},
async openMainWindow() : Promise<void> {
await TAURI_INVOKE("open_main_window");
},
async setBadge(count: number) : Promise<void> {
await TAURI_INVOKE("set_badge", { count });
}
}
@ -421,6 +437,7 @@ export type Account = { npub: string; nsec: string }
export type Meta = { content: string; images: string[]; videos: string[]; events: string[]; mentions: string[]; hashtags: string[] }
export type Relays = { connected: string[]; read: string[] | null; write: string[] | null; both: string[] | null }
export type RichEvent = { raw: string; parsed: Meta | null }
export type Settings = { proxy: string | null; image_resize_service: string | null; use_relay_hint: boolean; content_warning: boolean; display_avatar: boolean; display_zap_button: boolean; display_repost_button: boolean; display_media: boolean }
/** tauri-specta globals **/

View File

@ -1,28 +0,0 @@
import { NostrEvent } from "@lume/types";
export function dedupEvents(nostrEvents: NostrEvent[], nsfw: boolean = false) {
const seens = new Set<string>();
const events = nostrEvents.filter((event) => {
const eTags = event.tags.filter((el) => el[0] === "e");
const ids = eTags.map((item) => item[1]);
const isDup = ids.some((id) => seens.has(id));
// Add found ids to seen list
for (const id of ids) {
seens.add(id);
}
// Filter NSFW event
if (nsfw) {
const wTags = event.tags.filter((t) => t[0] === "content-warning");
const isLewd = wTags.length > 0;
return !isDup && !isLewd;
}
// Filter duplicate event
return !isDup;
});
return events;
}

View File

@ -24,6 +24,11 @@ export class LumeEvent {
Object.assign(this, event);
}
get isWarning() {
const tag = this.tags.find((tag) => tag[0] === "content-warning");
return tag?.[1]; // return: reason;
}
get isQuote() {
return this.tags.filter((tag) => tag[0] === "q").length > 0;
}

View File

@ -1,25 +1,12 @@
import type {
LumeColumn,
Metadata,
NostrEvent,
Relay,
Settings,
} from "@lume/types";
import { type Result, type RichEvent, commands } from "./commands";
import type { LumeColumn, Metadata, NostrEvent, Relay } from "@lume/types";
import { resolveResource } from "@tauri-apps/api/path";
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import { readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { relaunch } from "@tauri-apps/plugin-process";
import { nip19 } from "nostr-tools";
import { type Result, type RichEvent, commands } from "./commands";
import { LumeEvent } from "./event";
enum NSTORE_KEYS {
settings = "lume_user_settings",
columns = "lume_user_columns",
}
export class NostrQuery {
static #toLumeEvents(richEvents: RichEvent[]) {
const events = richEvents.map((item) => {
@ -200,18 +187,7 @@ export class NostrQuery {
const query = await commands.getEventsBy(pubkey, until);
if (query.status === "ok") {
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
@ -235,18 +211,7 @@ export class NostrQuery {
const query = await commands.getGroupEvents(pubkeys, until);
if (query.status === "ok") {
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
@ -258,18 +223,7 @@ export class NostrQuery {
const query = await commands.getGlobalEvents(until);
if (query.status === "ok") {
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
@ -282,18 +236,7 @@ export class NostrQuery {
const query = await commands.getHashtagEvents(nostrTags, until);
if (query.status === "ok") {
const data = query.data.map((item) => {
const raw = JSON.parse(item.raw) as NostrEvent;
if (item.parsed) {
raw.meta = item.parsed;
} else {
raw.meta = null;
}
return raw;
});
const data = NostrQuery.#toLumeEvents(query.data);
return data;
} else {
return [];
@ -314,9 +257,7 @@ export class NostrQuery {
const query = await commands.getNstore(key);
if (query.status === "ok") {
const data: string | string[] = query.data
? JSON.parse(query.data)
: null;
const data = query.data ? JSON.parse(query.data) : null;
return data;
} else {
return null;
@ -333,51 +274,33 @@ export class NostrQuery {
}
}
static async getSettings() {
const query = await commands.getNstore(NSTORE_KEYS.settings);
if (query.status === "ok") {
const settings: Settings = query.data ? JSON.parse(query.data) : null;
const isGranted = await isPermissionGranted();
const theme: "auto" | "light" | "dark" = await invoke(
"plugin:theme|get_theme",
);
return { ...settings, theme, notification: isGranted };
} else {
const initial: Settings = {
autoUpdate: false,
enhancedPrivacy: false,
notification: false,
zap: false,
nsfw: false,
gossip: false,
theme: "auto",
};
return initial;
}
}
static async setSettings(settings: Settings) {
const query = await commands.setNstore(
NSTORE_KEYS.settings,
JSON.stringify(settings),
);
static async getUserSettings() {
const query = await commands.getSettings();
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
return query.error;
}
}
static async setUserSettings(newSettings: string) {
const query = await commands.setNewSettings(newSettings);
if (query.status === "ok") {
return query.data;
} else {
return query.error;
}
}
static async getColumns() {
const key = "lume:columns";
const systemPath = "resources/system_columns.json";
const resourcePath = await resolveResource(systemPath);
const resourceFile = await readTextFile(resourcePath);
const systemColumns: LumeColumn[] = JSON.parse(resourceFile);
const query = await commands.getNstore(NSTORE_KEYS.columns);
const query = await commands.getNstore(key);
try {
if (query.status === "ok") {
@ -399,8 +322,9 @@ export class NostrQuery {
}
static async setColumns(columns: LumeColumn[]) {
const key = "lume:columns";
const content = JSON.stringify(columns);
const query = await commands.setNstore(NSTORE_KEYS.columns, content);
const query = await commands.setNstore(key, content);
if (query.status === "ok") {
return query.data;

View File

@ -1,16 +1,11 @@
import type { NostrEvent } from "@lume/types";
import type { LumeEvent } from "./event";
import { commands } from "./commands";
import type { LumeEvent } from "./event";
export class LumeWindow {
static async openMainWindow() {
const query = await commands.openMainWindow();
if (query.status === "ok") {
return query.data;
} else {
throw new Error(query.error);
}
return query;
}
static async openEvent(event: NostrEvent | LumeEvent) {

View File

@ -1,14 +1,3 @@
export interface Settings {
notification: boolean;
enhancedPrivacy: boolean;
autoUpdate: boolean;
zap: boolean;
nsfw: boolean;
gossip: boolean;
theme: "auto" | "light" | "dark";
[key: string]: string | number | boolean;
}
export interface Keys {
npub: string;
nsec: string;
@ -69,42 +58,6 @@ export interface Metadata {
lud16?: string;
}
export interface Contact {
pubkey: string;
profile: Metadata;
}
export interface Account {
npub: string;
nsec?: string;
contacts?: string[];
interests?: Interests;
}
export interface Topic {
icon: string;
title: string;
content: string[];
}
export interface Interests {
hashtags: string[];
users: string[];
words: string[];
}
export interface RichContent {
parsed: string;
images: string[];
videos: string[];
links: string[];
notes: string[];
}
export interface AppRouteSearch {
account: string;
}
export interface ColumnRouteSearch {
account: string;
label: string;
@ -124,70 +77,13 @@ export interface LumeColumn {
featured?: boolean;
}
export interface EventColumns {
export interface ColumnEvent {
type: "reset" | "add" | "remove" | "update" | "left" | "right" | "set_title";
label?: string;
title?: string;
column?: LumeColumn;
}
export interface Opengraph {
url: string;
title?: string;
description?: string;
image?: string;
}
export interface NostrBuildResponse {
ok: boolean;
data?: {
message: string;
status: string;
data: Array<{
blurhash: string;
dimensions: {
width: number;
height: number;
};
mime: string;
name: string;
sha256: string;
size: number;
url: string;
}>;
};
}
export interface NIP11 {
name: string;
description: string;
pubkey: string;
contact: string;
supported_nips: number[];
software: string;
version: string;
limitation: {
[key: string]: string | number | boolean;
};
relay_countries: string[];
language_tags: string[];
tags: string[];
posting_policy: string;
payments_url: string;
icon: string[];
}
export interface NIP05 {
names: {
[key: string]: string;
};
nip46: {
[key: string]: {
[key: string]: string[];
};
};
}
export interface Relays {
connected: string[];
read: string[];

View File

@ -1,14 +1,18 @@
use std::path::PathBuf;
use std::str::FromStr;
#[cfg(target_os = "macos")]
use cocoa::{appkit::NSApp, base::nil, foundation::NSString};
use tauri::{LogicalPosition, LogicalSize, Manager, WebviewUrl};
use tauri::{LogicalPosition, LogicalSize, Manager, State, WebviewUrl};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::utils::config::WindowEffectsConfig;
use tauri::WebviewWindowBuilder;
use tauri::window::Effect;
use tauri_plugin_decorum::WebviewWindowExt;
use url::Url;
use crate::Nostr;
#[tauri::command]
#[specta::specta]
@ -20,18 +24,32 @@ pub fn create_column(
height: f32,
url: &str,
app_handle: tauri::AppHandle,
state: State<'_, Nostr>,
) -> Result<String, String> {
let settings = state.settings.lock().unwrap().clone();
match app_handle.get_window("main") {
Some(main_window) => match app_handle.get_webview(label) {
Some(_) => Ok(label.into()),
None => {
let path = PathBuf::from(url);
let webview_url = WebviewUrl::App(path);
let builder = tauri::webview::WebviewBuilder::new(label, webview_url)
.user_agent("Lume/4.0")
.zoom_hotkeys_enabled(true)
.enable_clipboard_access()
.transparent(true);
let builder = match settings.proxy {
Some(url) => {
let proxy = Url::from_str(&url).unwrap();
tauri::webview::WebviewBuilder::new(label, webview_url)
.user_agent("Lume/4.0")
.zoom_hotkeys_enabled(true)
.enable_clipboard_access()
.transparent(true)
.proxy_url(proxy)
}
None => tauri::webview::WebviewBuilder::new(label, webview_url)
.user_agent("Lume/4.0")
.zoom_hotkeys_enabled(true)
.enable_clipboard_access()
.transparent(true),
};
match main_window.add_child(
builder,
LogicalPosition::new(x, y),

View File

@ -15,9 +15,11 @@ use std::{
str::FromStr,
};
use std::sync::Mutex;
use std::time::Duration;
use nostr_sdk::prelude::*;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{Manager, path::BaseDirectory};
#[cfg(target_os = "macos")]
use tauri::tray::{MouseButtonState, TrayIconEvent};
@ -39,10 +41,39 @@ pub struct Nostr {
#[serde(skip_serializing)]
client: Client,
contact_list: Mutex<Vec<Contact>>,
settings: Mutex<Settings>,
}
#[derive(Clone, Serialize, Deserialize, Type)]
pub struct Settings {
proxy: Option<String>,
image_resize_service: Option<String>,
use_relay_hint: bool,
content_warning: bool,
display_avatar: bool,
display_zap_button: bool,
display_repost_button: bool,
display_media: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
proxy: None,
image_resize_service: Some("https://wsrv.nl/".into()),
use_relay_hint: true,
content_warning: true,
display_avatar: true,
display_zap_button: true,
display_repost_button: true,
display_media: true,
}
}
}
fn main() {
let mut ctx = tauri::generate_context!();
let invoke_handler = {
let builder = tauri_specta::ts::builder().commands(tauri_specta::collect_commands![
nostr::relay::get_relays,
@ -57,8 +88,7 @@ fn main() {
nostr::keys::get_private_key,
nostr::keys::connect_remote_account,
nostr::keys::load_account,
nostr::keys::verify_nip05,
nostr::metadata::get_current_user_profile,
nostr::metadata::get_current_profile,
nostr::metadata::get_profile,
nostr::metadata::get_contact_list,
nostr::metadata::set_contact_list,
@ -75,6 +105,9 @@ fn main() {
nostr::metadata::zap_event,
nostr::metadata::friend_to_friend,
nostr::metadata::get_notifications,
nostr::metadata::get_settings,
nostr::metadata::set_new_settings,
nostr::metadata::verify_nip05,
nostr::event::get_event_meta,
nostr::event::get_event,
nostr::event::get_event_from,
@ -95,8 +128,8 @@ fn main() {
commands::window::reposition_column,
commands::window::resize_column,
commands::window::open_window,
commands::window::set_badge,
commands::window::open_main_window
commands::window::open_main_window,
commands::window::set_badge
]);
#[cfg(debug_assertions)]
@ -153,9 +186,15 @@ fn main() {
let _ = fs::create_dir_all(home_dir.join("Lume/"));
tauri::async_runtime::block_on(async move {
// Create nostr connection
// Setup database
let database = SQLiteDatabase::open(home_dir.join("Lume/lume.db")).await;
let opts = Options::new().automatic_authentication(true);
// Config
let opts = Options::new()
.automatic_authentication(true)
.connection_timeout(Some(Duration::from_secs(5)));
// Setup nostr client
let client = match database {
Ok(db) => ClientBuilder::default().database(db).opts(opts).build(),
Err(_) => ClientBuilder::default().opts(opts).build(),
@ -197,6 +236,7 @@ fn main() {
app.handle().manage(Nostr {
client,
contact_list: Mutex::new(vec![]),
settings: Mutex::new(Settings::default()),
})
});
@ -218,7 +258,7 @@ fn main() {
.invoke_handler(invoke_handler)
.build(ctx)
.expect("error while running tauri application")
.run(|app, event| {
.run(|_, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
// Hide app icon on macOS
// let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);

View File

@ -26,22 +26,93 @@ pub async fn get_event_meta(content: &str) -> Result<Meta, ()> {
#[specta::specta]
pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, String> {
let client = &state.client;
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
let event_id = match Nip19::from_bech32(id) {
Ok(val) => match val {
Nip19::EventId(id) => Some(id),
Nip19::Event(event) => Some(event.event_id),
_ => None,
Nip19::EventId(id) => id,
Nip19::Event(event) => event.event_id,
_ => return Err("Event ID is not valid.".into()),
},
Err(_) => match EventId::from_hex(id) {
Ok(val) => Some(val),
Err(_) => None,
Ok(id) => id,
Err(_) => return Err("Event ID is not valid.".into()),
},
};
match event_id {
Some(id) => {
match client
.get_events_of(vec![Filter::new().id(event_id)], None)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
}
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_event_from(
id: &str,
relay_hint: &str,
state: State<'_, Nostr>,
) -> Result<RichEvent, String> {
let client = &state.client;
let settings = state.settings.lock().unwrap().clone();
let event_id = match Nip19::from_bech32(id) {
Ok(val) => match val {
Nip19::EventId(id) => id,
Nip19::Event(event) => event.event_id,
_ => return Err("Event ID is not valid.".into()),
},
Err(_) => match EventId::from_hex(id) {
Ok(id) => id,
Err(_) => return Err("Event ID is not valid.".into()),
},
};
if !settings.use_relay_hint {
match client
.get_events_of(vec![Filter::new().id(event_id)], None)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
return Err("Cannot found this event with current relay list".into());
}
}
Err(err) => return Err(err.to_string()),
}
} else {
// Add relay hint to relay pool
if let Err(err) = client.add_relay(relay_hint).await {
return Err(err.to_string());
}
if client.connect_relay(relay_hint).await.is_ok() {
match client
.get_events_of(vec![Filter::new().id(id)], Some(Duration::from_secs(10)))
.get_events_from(vec![relay_hint], vec![Filter::new().id(event_id)], None)
.await
{
Ok(events) => {
@ -60,65 +131,9 @@ pub async fn get_event(id: &str, state: State<'_, Nostr>) -> Result<RichEvent, S
}
Err(err) => Err(err.to_string()),
}
} else {
Err("Relay connection failed.".into())
}
None => Err("Event ID is not valid.".into()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_event_from(
id: &str,
relay_hint: &str,
state: State<'_, Nostr>,
) -> Result<RichEvent, String> {
let client = &state.client;
let event_id: Option<EventId> = match Nip19::from_bech32(id) {
Ok(val) => match val {
Nip19::EventId(id) => Some(id),
Nip19::Event(event) => Some(event.event_id),
_ => None,
},
Err(_) => match EventId::from_hex(id) {
Ok(val) => Some(val),
Err(_) => None,
},
};
// Add relay hint to relay pool
if let Err(err) = client.add_relay(relay_hint).await {
return Err(err.to_string());
}
if client.connect_relay(relay_hint).await.is_ok() {
match event_id {
Some(id) => {
match client
.get_events_from(vec![relay_hint], vec![Filter::new().id(id)], None)
.await
{
Ok(events) => {
if let Some(event) = events.first() {
let raw = event.as_json();
let parsed = if event.kind == Kind::TextNote {
Some(parse_event(&event.content).await)
} else {
None
};
Ok(RichEvent { raw, parsed })
} else {
Err("Cannot found this event with current relay list".into())
}
}
Err(err) => Err(err.to_string()),
}
}
None => Err("Event ID is not valid.".into()),
}
} else {
Err("Relay connection failed.".into())
}
}
@ -225,7 +240,7 @@ pub async fn get_local_events(
.await
{
Ok(events) => {
let dedup = dedup_event(&events, false);
let dedup = dedup_event(&events);
let futures = dedup.into_iter().map(|ev| async move {
let raw = ev.as_json();
@ -282,7 +297,7 @@ pub async fn get_group_events(
.await
{
Ok(events) => {
let dedup = dedup_event(&events, false);
let dedup = dedup_event(&events);
let futures = dedup.into_iter().map(|ev| async move {
let raw = ev.as_json();
@ -325,7 +340,7 @@ pub async fn get_global_events(
.await
{
Ok(events) => {
let dedup = dedup_event(&events, false);
let dedup = dedup_event(&events);
let futures = dedup.into_iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {
@ -364,7 +379,7 @@ pub async fn get_hashtag_events(
match client.get_events_of(vec![filter], None).await {
Ok(events) => {
let dedup = dedup_event(&events, false);
let dedup = dedup_event(&events);
let futures = dedup.into_iter().map(|ev| async move {
let raw = ev.as_json();
let parsed = if ev.kind == Kind::TextNote {

View File

@ -1,4 +1,3 @@
use std::str::FromStr;
use std::time::Duration;
use keyring::Entry;
@ -9,7 +8,7 @@ use specta::Type;
use tauri::{EventTarget, Manager, State};
use tauri_plugin_notification::NotificationExt;
use crate::Nostr;
use crate::{Nostr, Settings};
#[derive(Serialize, Type)]
pub struct Account {
@ -109,7 +108,6 @@ pub async fn load_account(
state: State<'_, Nostr>,
app: tauri::AppHandle,
) -> Result<bool, String> {
let handle = app.clone();
let client = &state.client;
let keyring = Entry::new(npub, "nostr_secret").unwrap();
@ -141,15 +139,6 @@ pub async fn load_account(
let signer = client.signer().await.unwrap();
let public_key = signer.public_key().await.unwrap();
// Get user's contact list
let contacts = client
.get_contact_list(Some(Duration::from_secs(10)))
.await
.unwrap();
// Update state
*state.contact_list.lock().unwrap() = contacts;
// Connect to user's relay (NIP-65)
if let Ok(events) = client
.get_events_of(
@ -189,7 +178,49 @@ pub async fn load_account(
}
};
// Get user's contact list
let contacts = client
.get_contact_list(Some(Duration::from_secs(10)))
.await
.unwrap();
// Update state
*state.contact_list.lock().unwrap() = contacts;
// Get user's settings
let handle = app.clone();
// Spawn a thread to handle it
tauri::async_runtime::spawn(async move {
let window = handle.get_window("main").unwrap();
let state = window.state::<Nostr>();
let client = &state.client;
let ident = "lume:settings";
let filter = Filter::new()
.author(public_key)
.kind(Kind::ApplicationSpecificData)
.identifier(ident)
.limit(1);
if let Ok(events) = client
.get_events_of(vec![filter], Some(Duration::from_secs(5)))
.await
{
if let Some(event) = events.first() {
let content = event.content();
if let Ok(decrypted) = signer.nip44_decrypt(public_key, content).await {
let parsed: Settings =
serde_json::from_str(&decrypted).expect("Could not parse settings payload");
*state.settings.lock().unwrap() = parsed;
}
}
}
});
// Run sync service
let handle = app.clone();
// Spawn a thread to handle it
tauri::async_runtime::spawn(async move {
let window = handle.get_window("main").unwrap();
let state = window.state::<Nostr>();
@ -212,6 +243,7 @@ pub async fn load_account(
});
// Run notification service
// Spawn a thread to handle it
tauri::async_runtime::spawn(async move {
println!("Starting notification service...");
@ -316,7 +348,7 @@ pub async fn load_account(
Ok(true)
} else {
Err("Key not found.".into())
Err("Cancelled".into())
}
}
@ -382,15 +414,3 @@ pub fn get_private_key(npub: &str) -> Result<String, String> {
Err("Key not found".into())
}
}
#[tauri::command]
#[specta::specta]
pub async fn verify_nip05(key: &str, nip05: &str) -> Result<bool, String> {
match PublicKey::from_str(key) {
Ok(public_key) => {
let status = nip05::verify(&public_key, nip05, None).await;
Ok(status.is_ok())
}
Err(err) => Err(err.to_string()),
}
}

View File

@ -4,13 +4,13 @@ use keyring::Entry;
use nostr_sdk::prelude::*;
use tauri::State;
use crate::Nostr;
use crate::{Nostr, Settings};
use super::get_latest_event;
#[tauri::command]
#[specta::specta]
pub async fn get_current_user_profile(state: State<'_, Nostr>) -> Result<String, String> {
pub async fn get_current_profile(state: State<'_, Nostr>) -> Result<String, String> {
let client = &state.client;
let signer = match client.signer().await {
@ -566,3 +566,31 @@ pub async fn get_notifications(state: State<'_, Nostr>) -> Result<Vec<String>, S
Err(err) => Err(err.to_string()),
}
}
#[tauri::command]
#[specta::specta]
pub async fn get_settings(state: State<'_, Nostr>) -> Result<Settings, ()> {
let settings = state.settings.lock().unwrap().clone();
Ok(settings)
}
#[tauri::command]
#[specta::specta]
pub async fn set_new_settings(settings: &str, state: State<'_, Nostr>) -> Result<(), ()> {
let parsed: Settings = serde_json::from_str(settings).expect("Could not parse settings payload");
*state.settings.lock().unwrap() = parsed;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn verify_nip05(key: &str, nip05: &str) -> Result<bool, String> {
match PublicKey::from_str(key) {
Ok(public_key) => {
let status = nip05::verify(&public_key, nip05, None).await;
Ok(status.is_ok())
}
Err(err) => Err(err.to_string()),
}
}

View File

@ -50,7 +50,7 @@ pub fn get_latest_event(events: &[Event]) -> Option<&Event> {
events.iter().max_by_key(|event| event.created_at())
}
pub fn dedup_event(events: &[Event], nsfw: bool) -> Vec<Event> {
pub fn dedup_event(events: &[Event]) -> Vec<Event> {
let mut seen_ids = HashSet::new();
events
.iter()
@ -64,16 +64,7 @@ pub fn dedup_event(events: &[Event], nsfw: bool) -> Vec<Event> {
seen_ids.insert(*id);
}
if nsfw {
let w_tags: Vec<&Tag> = event
.tags
.iter()
.filter(|el| el.kind() == TagKind::ContentWarning)
.collect();
!is_dup && w_tags.is_empty()
} else {
!is_dup
}
!is_dup
})
.cloned()
.collect()