Merge pull request #151 from luminous-devs/feat/multi-lang

Add support for multi-languages
This commit is contained in:
Ren Amamiya
2024-01-30 09:12:06 +07:00
committed by GitHub
90 changed files with 2048 additions and 1342 deletions

View File

@@ -36,6 +36,8 @@
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.19", "@tanstack/react-query": "^5.17.19",
"framer-motion": "^10.18.0", "framer-motion": "^10.18.0",
"i18next": "^23.8.0",
"i18next-resources-to-backend": "^1.2.0",
"jotai": "^2.6.3", "jotai": "^2.6.3",
"minidenticons": "^4.2.0", "minidenticons": "^4.2.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
@@ -45,6 +47,7 @@
"react-currency-input-field": "^3.6.14", "react-currency-input-field": "^3.6.14",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.49.3", "react-hook-form": "^7.49.3",
"react-i18next": "^14.0.1",
"react-router-dom": "^6.21.3", "react-router-dom": "^6.21.3",
"smol-toml": "^1.1.4", "smol-toml": "^1.1.4",
"sonner": "^1.3.1", "sonner": "^1.3.1",

View File

@@ -1,7 +1,9 @@
import { ColumnProvider, LumeProvider } from "@lume/ark"; import { ColumnProvider, LumeProvider } from "@lume/ark";
import { StorageProvider } from "@lume/storage"; import { StorageProvider } from "@lume/storage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import i18n from "./i18n";
import Router from "./router"; import Router from "./router";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -14,6 +16,7 @@ const queryClient = new QueryClient({
export default function App() { export default function App() {
return ( return (
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster position="top-center" theme="system" closeButton /> <Toaster position="top-center" theme="system" closeButton />
<StorageProvider> <StorageProvider>
@@ -24,5 +27,6 @@ export default function App() {
</LumeProvider> </LumeProvider>
</StorageProvider> </StorageProvider>
</QueryClientProvider> </QueryClientProvider>
</I18nextProvider>
); );
} }

26
apps/desktop/src/i18n.ts Normal file
View File

@@ -0,0 +1,26 @@
import { resolveResource } from "@tauri-apps/api/path";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { locale } from "@tauri-apps/plugin-os";
import i18n from "i18next";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next";
const currentLocale = (await locale()).slice(0, 2);
i18n
.use(
resourcesToBackend(async (language: string) => {
const file_path = await resolveResource(`locales/${language}.json`);
return JSON.parse(await readTextFile(file_path));
}),
)
.use(initReactI18next)
.init({
lng: currentLocale,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -59,15 +59,6 @@ export default function Router() {
return { Component: ProfileSettingScreen }; return { Component: ProfileSettingScreen };
}, },
}, },
{
path: "edit-contact",
async lazy() {
const { EditContactScreen } = await import(
"./routes/settings/editContact"
);
return { Component: EditContactScreen };
},
},
{ {
path: "backup", path: "backup",
async lazy() { async lazy() {

View File

@@ -1,8 +1,11 @@
import { User } from "@lume/ark"; import { User } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export function ActivityRepost({ event }: { event: NDKEvent }) { export function ActivityRepost({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
return ( return (
<Link <Link
to={`/activity/${event.id}`} to={`/activity/${event.id}`}
@@ -14,7 +17,7 @@ export function ActivityRepost({ event }: { event: NDKEvent }) {
<User.Avatar className="size-8 rounded-lg shrink-0" /> <User.Avatar className="size-8 rounded-lg shrink-0" />
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" /> <User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
<p className="shrink-0">reposted</p> <p className="shrink-0">{t("activity.repost")}</p>
</div> </div>
</div> </div>
<User.Time <User.Time

View File

@@ -1,8 +1,11 @@
import { User } from "@lume/ark"; import { User } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export function ActivityText({ event }: { event: NDKEvent }) { export function ActivityText({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
return ( return (
<Link <Link
to={`/activity/${event.id}`} to={`/activity/${event.id}`}
@@ -14,7 +17,7 @@ export function ActivityText({ event }: { event: NDKEvent }) {
<User.Avatar className="size-8 rounded-lg shrink-0" /> <User.Avatar className="size-8 rounded-lg shrink-0" />
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" /> <User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
<p className="shrink-0">mention you</p> <p className="shrink-0">{t("activity.mention")}</p>
</div> </div>
</div> </div>
<User.Time <User.Time

View File

@@ -1,9 +1,11 @@
import { User } from "@lume/ark"; import { User } from "@lume/ark";
import { compactNumber } from "@lume/utils"; import { compactNumber } from "@lume/utils";
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export function ActivityZap({ event }: { event: NDKEvent }) { export function ActivityZap({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
const invoice = zapInvoiceFromEvent(event); const invoice = zapInvoiceFromEvent(event);
return ( return (
@@ -18,7 +20,7 @@ export function ActivityZap({ event }: { event: NDKEvent }) {
<div className="inline-flex items-center gap-1.5"> <div className="inline-flex items-center gap-1.5">
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" /> <User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
<p className="shrink-0"> <p className="shrink-0">
zapped {compactNumber.format(invoice.amount)} sats {t("activity.zap")} {compactNumber.format(invoice.amount)} sats
</p> </p>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityRepost } from "./activityRepost"; import { ActivityRepost } from "./activityRepost";
import { ActivityText } from "./activityText"; import { ActivityText } from "./activityText";
import { ActivityZap } from "./activityZap"; import { ActivityZap } from "./activityZap";
@@ -12,6 +13,7 @@ export function ActivityList() {
const ark = useArk(); const ark = useArk();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ["activity"], queryKey: ["activity"],
@@ -86,7 +88,7 @@ export function ActivityList() {
) : !allEvents.length ? ( ) : !allEvents.length ? (
<div className="w-full h-full flex flex-col items-center justify-center"> <div className="w-full h-full flex flex-col items-center justify-center">
<p className="mb-2 text-2xl">🎉</p> <p className="mb-2 text-2xl">🎉</p>
<p className="text-center font-medium">Yo! Nothing new yet.</p> <p className="text-center font-medium">{t("activity.empty")}</p>
</div> </div>
) : ( ) : (
allEvents.map((event) => renderEvenKind(event)) allEvents.map((event) => renderEvenKind(event))
@@ -104,7 +106,7 @@ export function ActivityList() {
) : ( ) : (
<> <>
<ArrowRightCircleIcon className="size-5" /> <ArrowRightCircleIcon className="size-5" />
Load more {t("global.loadMore")}
</> </>
)} )}
</button> </button>

View File

@@ -1,16 +1,20 @@
import { User } from "@lume/ark"; import { User } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { ActivityRootNote } from "./rootNote"; import { ActivityRootNote } from "./rootNote";
export function ActivitySingleRepost({ event }: { event: NDKEvent }) { export function ActivitySingleRepost({ event }: { event: NDKEvent }) {
const { t } = useTranslation();
const repostId = event.tags.find((el) => el[0] === "e")[1]; const repostId = event.tags.find((el) => el[0] === "e")[1];
return ( return (
<div className="pb-3 flex flex-col"> <div className="pb-3 flex flex-col">
<div className="h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3"> <div className="h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight">Boost</h3> <h3 className="text-center font-semibold leading-tight">
{t("activity.boost")}
</h3>
<p className="text-sm text-blue-500 font-medium leading-tight"> <p className="text-sm text-blue-500 font-medium leading-tight">
@ Someone has reposted to your note {t("activity.boostSubtitle")}
</p> </p>
</div> </div>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
@@ -22,7 +26,7 @@ export function ActivitySingleRepost({ event }: { event: NDKEvent }) {
</User.Provider> </User.Provider>
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="h-4 w-px bg-blue-500" /> <div className="h-4 w-px bg-blue-500" />
<h3 className="font-semibold">Reposted</h3> <h3 className="font-semibold capitalize">{t("activity.repost")}</h3>
<div className="h-4 w-px bg-blue-500" /> <div className="h-4 w-px bg-blue-500" />
</div> </div>
<ActivityRootNote eventId={repostId} /> <ActivityRootNote eventId={repostId} />

View File

@@ -1,5 +1,6 @@
import { Note, useArk } from "@lume/ark"; import { Note, useArk } from "@lume/ark";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useTranslation } from "react-i18next";
import { ActivityRootNote } from "./rootNote"; import { ActivityRootNote } from "./rootNote";
export function ActivitySingleText({ event }: { event: NDKEvent }) { export function ActivitySingleText({ event }: { event: NDKEvent }) {
@@ -9,14 +10,16 @@ export function ActivitySingleText({ event }: { event: NDKEvent }) {
tags: event.tags, tags: event.tags,
}); });
const { t } = useTranslation();
return ( return (
<div className="h-full w-full flex flex-col justify-between"> <div className="h-full w-full flex flex-col justify-between">
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3"> <div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
<h3 className="text-center font-semibold leading-tight"> <h3 className="text-center font-semibold leading-tight">
Conversation {t("activity.conversation")}
</h3> </h3>
<p className="text-sm text-blue-500 font-medium leading-tight"> <p className="text-sm text-blue-500 font-medium leading-tight">
@ Someone has replied to your note {t("activity.conversationSubtitle")}
</p> </p>
</div> </div>
<div className="overflow-y-auto"> <div className="overflow-y-auto">
@@ -33,7 +36,9 @@ export function ActivitySingleText({ event }: { event: NDKEvent }) {
) : null} ) : null}
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<p className="text-teal-500 font-medium">New reply</p> <p className="text-teal-500 font-medium">
{t("activity.newReply")}
</p>
<div className="flex-1 h-px bg-teal-300" /> <div className="flex-1 h-px bg-teal-300" />
<div className="w-4 shrink-0 h-px bg-teal-300" /> <div className="w-4 shrink-0 h-px bg-teal-300" />
</div> </div>

View File

@@ -1,10 +1,12 @@
import { activityUnreadAtom } from "@lume/utils"; import { activityUnreadAtom } from "@lume/utils";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { ActivityList } from "./components/list"; import { ActivityList } from "./components/list";
export function ActivityScreen() { export function ActivityScreen() {
const { t } = useTranslation();
const setUnreadActivity = useSetAtom(activityUnreadAtom); const setUnreadActivity = useSetAtom(activityUnreadAtom);
useEffect(() => { useEffect(() => {
@@ -15,7 +17,7 @@ export function ActivityScreen() {
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10"> <div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
<div className="h-full flex flex-col w-96 shrink-0 rounded-l-xl bg-white/50 backdrop-blur-xl dark:bg-black/50"> <div className="h-full flex flex-col w-96 shrink-0 rounded-l-xl bg-white/50 backdrop-blur-xl dark:bg-black/50">
<div className="h-14 shrink-0 flex items-center px-5 text-lg font-semibold border-b border-black/10 dark:border-white/10"> <div className="h-14 shrink-0 flex items-center px-5 text-lg font-semibold border-b border-black/10 dark:border-white/10">
Activity {t("activity.title")}
</div> </div>
<ActivityList /> <ActivityList />
</div> </div>

View File

@@ -14,6 +14,7 @@ import { Window } from "@tauri-apps/api/window";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useLoaderData, useNavigate } from "react-router-dom"; import { useLoaderData, useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -43,6 +44,7 @@ export function CreateAccountAddress() {
const [serviceId, setServiceId] = useState(services?.[0]?.id); const [serviceId, setServiceId] = useState(services?.[0]?.id);
const [loading, setIsLoading] = useState(false); const [loading, setIsLoading] = useState(false);
const { t } = useTranslation();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -156,7 +158,7 @@ export function CreateAccountAddress() {
<div className="flex flex-col w-full max-w-md gap-8 mx-auto"> <div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold"> <h1 className="text-2xl font-semibold">
Let's set up your account on Nostr {t("signupWithProvider.title")}
</h1> </h1>
</div> </div>
{!services ? ( {!services ? (
@@ -174,7 +176,7 @@ export function CreateAccountAddress() {
htmlFor="username" htmlFor="username"
className="text-sm font-semibold uppercase text-neutral-600" className="text-sm font-semibold uppercase text-neutral-600"
> >
Username * {t("signupWithProvider.username")}
</label> </label>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between w-full gap-2 bg-neutral-900 rounded-xl"> <div className="flex items-center justify-between w-full gap-2 bg-neutral-900 rounded-xl">
@@ -203,7 +205,7 @@ export function CreateAccountAddress() {
<Select.Viewport className="p-3"> <Select.Viewport className="p-3">
<Select.Group> <Select.Group>
<Select.Label className="mb-2 text-sm font-medium uppercase px-7 text-neutral-600"> <Select.Label className="mb-2 text-sm font-medium uppercase px-7 text-neutral-600">
Choose a Provider {t("signupWithProvider.chooseProvider")}
</Select.Label> </Select.Label>
{services.map((service) => ( {services.map((service) => (
<Item key={service.id} event={service} /> <Item key={service.id} event={service} />
@@ -215,8 +217,7 @@ export function CreateAccountAddress() {
</Select.Root> </Select.Root>
</div> </div>
<span className="text-sm text-neutral-600"> <span className="text-sm text-neutral-600">
Use to login to Lume and other Nostr apps. You can choose {t("signupWithProvider.usernameFooter")}
provider you trust to manage your account
</span> </span>
</div> </div>
</div> </div>
@@ -226,7 +227,7 @@ export function CreateAccountAddress() {
htmlFor="email" htmlFor="email"
className="text-sm font-semibold uppercase text-neutral-600" className="text-sm font-semibold uppercase text-neutral-600"
> >
Backup Email (optional) {t("signupWithProvider.email")}
</label> </label>
<input <input
type={"email"} type={"email"}
@@ -238,7 +239,7 @@ export function CreateAccountAddress() {
/> />
</div> </div>
<span className="text-sm text-neutral-600"> <span className="text-sm text-neutral-600">
Use for recover your account if you lose your password {t("signupWithProvider.emailFooter")}
</span> </span>
</div> </div>
</div> </div>
@@ -251,7 +252,7 @@ export function CreateAccountAddress() {
{loading ? ( {loading ? (
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</div> </div>

View File

@@ -11,6 +11,7 @@ import { useSetAtom } from "jotai";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { getPublicKey, nip19 } from "nostr-tools"; import { getPublicKey, nip19 } from "nostr-tools";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -20,6 +21,7 @@ export function CreateAccountKeys() {
const setOnboarding = useSetAtom(onboardingAtom); const setOnboarding = useSetAtom(onboardingAtom);
const navigate = useNavigate(); const navigate = useNavigate();
const [t] = useTranslation();
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
@@ -76,11 +78,10 @@ export function CreateAccountKeys() {
<div className="flex flex-col w-full max-w-md gap-8 mx-auto"> <div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold"> <h1 className="text-2xl font-semibold">
This is your new Account Key {t("signupWithSelfManage.title")}
</h1> </h1>
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500"> <p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
Keep your key in safe place. If you lose this key, you will lose {t("signupWithSelfManage.subtitle")}
access to your account.
</p> </p>
</div> </div>
<div className="flex flex-col gap-6 mb-0"> <div className="flex flex-col gap-6 mb-0">
@@ -122,7 +123,7 @@ export function CreateAccountKeys() {
className="text-sm leading-none text-neutral-500" className="text-sm leading-none text-neutral-500"
htmlFor="confirm1" htmlFor="confirm1"
> >
I understand the risk of lost private key. {t("signupWithSelfManage.confirm1")}
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -142,7 +143,7 @@ export function CreateAccountKeys() {
className="text-sm leading-none text-neutral-500" className="text-sm leading-none text-neutral-500"
htmlFor="confirm2" htmlFor="confirm2"
> >
I will make sure keep it safe and not sharing with anyone. {t("signupWithSelfManage.confirm2")}
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -162,7 +163,7 @@ export function CreateAccountKeys() {
className="text-sm leading-none text-neutral-500" className="text-sm leading-none text-neutral-500"
htmlFor="confirm3" htmlFor="confirm3"
> >
I understand I cannot recover private key. {t("signupWithSelfManage.confirm3")}
</label> </label>
</div> </div>
</div> </div>
@@ -176,7 +177,7 @@ export function CreateAccountKeys() {
{loading ? ( {loading ? (
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
) : ( ) : (
"Save key & Continue" t("signupWithSelfManage.button")
)} )}
</button> </button>
</div> </div>

View File

@@ -1,11 +1,13 @@
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
export function CreateAccountScreen() { export function CreateAccountScreen() {
const navigate = useNavigate(); const navigate = useNavigate();
const [t] = useTranslation();
const [method, setMethod] = useState<"self" | "managed">("self"); const [method, setMethod] = useState<"self" | "managed">("self");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -23,9 +25,9 @@ export function CreateAccountScreen() {
<div className="relative flex items-center justify-center w-full h-full"> <div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto"> <div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">Let's Get Started</h1> <h1 className="text-2xl font-semibold">{t("signup.title")}</h1>
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500"> <p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
Choose one of methods below to create your account {t("signup.subtitle")}
</p> </p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -37,9 +39,9 @@ export function CreateAccountScreen() {
method === "self" ? "ring-1 ring-teal-500" : "", method === "self" ? "ring-1 ring-teal-500" : "",
)} )}
> >
<p className="font-semibold">Self-Managed</p> <p className="font-semibold">{t("signup.selfManageMethod")}</p>
<p className="text-sm font-medium text-neutral-500"> <p className="text-sm font-medium text-neutral-500">
You create your keys and keep them safe. {t("signup.selfManageMethodDescription")}
</p> </p>
</button> </button>
<button <button
@@ -50,9 +52,9 @@ export function CreateAccountScreen() {
method === "managed" ? "ring-1 ring-teal-500" : "", method === "managed" ? "ring-1 ring-teal-500" : "",
)} )}
> >
<p className="font-semibold">Managed by Provider</p> <p className="font-semibold">{t("signup.providerMethod")}</p>
<p className="text-sm font-medium text-neutral-500"> <p className="text-sm font-medium text-neutral-500">
A 3rd party provider will handle your sign in keys for you. {t("signup.providerMethodDescription")}
</p> </p>
</button> </button>
<button <button
@@ -63,7 +65,7 @@ export function CreateAccountScreen() {
{loading ? ( {loading ? (
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useStorage } from "@lume/storage";
import { getPublicKey, nip19 } from "nostr-tools"; import { getPublicKey, nip19 } from "nostr-tools";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -15,6 +16,7 @@ export function LoginWithKey() {
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation("loginWithPrivkey.subtitle");
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -52,8 +54,11 @@ export function LoginWithKey() {
<div className="relative flex items-center justify-center w-full h-full"> <div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto"> <div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">Enter your Private Key</h1> <h1 className="text-2xl font-semibold">
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500"> {t("loginWithPrivkey.title")}
</h1>
<p className="text-lg font-medium whitespace-pre-line leading-snug text-neutral-600 dark:text-neutral-500">
<Trans t={t}>
Lume will put your private key to{" "} Lume will put your private key to{" "}
<span className="text-teal-500"> <span className="text-teal-500">
{storage.platform === "macos" {storage.platform === "macos"
@@ -62,9 +67,8 @@ export function LoginWithKey() {
? "Credential Manager" ? "Credential Manager"
: "Secret Service"} : "Secret Service"}
</span> </span>
. . It will be secured by your OS.
<br /> </Trans>
It will be secured by your OS.
</p> </p>
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -107,7 +111,7 @@ export function LoginWithKey() {
{loading ? ( {loading ? (
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</form> </form>

View File

@@ -5,6 +5,7 @@ import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -15,6 +16,7 @@ export function LoginWithNsecbunker() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -69,7 +71,7 @@ export function LoginWithNsecbunker() {
<div className="flex flex-col w-full max-w-md gap-8 mx-auto"> <div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold"> <h1 className="text-2xl font-semibold">
Enter your nsecbunker token {t("loginWithBunker.title")}
</h1> </h1>
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -101,7 +103,7 @@ export function LoginWithNsecbunker() {
{loading ? ( {loading ? (
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</form> </form>

View File

@@ -7,6 +7,7 @@ import { Window } from "@tauri-apps/api/window";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,6 +20,7 @@ export function LoginWithOAuth() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -130,7 +132,9 @@ export function LoginWithOAuth() {
<div className="relative flex items-center justify-center w-full h-full"> <div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto"> <div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">Enter your Nostr Address</h1> <h1 className="text-2xl font-semibold">
{t("loginWithAddress.title")}
</h1>
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<form <form
@@ -161,7 +165,7 @@ export function LoginWithOAuth() {
{loading ? ( {loading ? (
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</form> </form>

View File

@@ -1,11 +1,14 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export function LoginScreen() { export function LoginScreen() {
const { t } = useTranslation();
return ( return (
<div className="relative flex items-center justify-center w-full h-full"> <div className="relative flex items-center justify-center w-full h-full">
<div className="flex flex-col w-full max-w-md gap-8 mx-auto"> <div className="flex flex-col w-full max-w-md gap-8 mx-auto">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold">Welcome back, anon!</h1> <h1 className="text-2xl font-semibold">{t("login.title")}</h1>
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -13,13 +16,13 @@ export function LoginScreen() {
to="/auth/login-oauth" to="/auth/login-oauth"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600" className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
> >
Login with Nostr Address {t("login.loginWithAddress")}
</Link> </Link>
<Link <Link
to="/auth/login-nsecbunker" to="/auth/login-nsecbunker"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900" className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
> >
Login with nsecBunker {t("login.loginWithBunker")}
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -29,7 +32,7 @@ export function LoginScreen() {
</div> </div>
<div className="relative flex justify-center"> <div className="relative flex justify-center">
<span className="px-2 font-medium bg-black text-neutral-600"> <span className="px-2 font-medium bg-black text-neutral-600">
Or continue with {t("login.or")}
</span> </span>
</div> </div>
</div> </div>
@@ -38,13 +41,10 @@ export function LoginScreen() {
to="/auth/login-key" to="/auth/login-key"
className="mb-2 inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900" className="mb-2 inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
> >
Login with Private Key {t("login.loginWithPrivkey")}
</Link> </Link>
<p className="text-sm text-center text-neutral-500"> <p className="text-sm text-center text-neutral-500">
Lume will put your Private Key in{" "} {t("login.footer")}
<span className="text-teal-600">Secure Storage</span> depended
on your OS Platform. It will be secured by Password or Biometric
ID
</p> </p>
</div> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@ import {
requestPermission, requestPermission,
} from "@tauri-apps/plugin-notification"; } from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -16,6 +17,7 @@ export function OnboardingScreen() {
const storage = useStorage(); const storage = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [apiKey, setAPIKey] = useState(""); const [apiKey, setAPIKey] = useState("");
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
@@ -91,10 +93,10 @@ export function OnboardingScreen() {
<div className="mx-auto flex w-full max-w-md flex-col gap-8"> <div className="mx-auto flex w-full max-w-md flex-col gap-8">
<div className="flex flex-col gap-1 text-center items-center"> <div className="flex flex-col gap-1 text-center items-center">
<h1 className="text-2xl font-semibold"> <h1 className="text-2xl font-semibold">
You&apos;re almost ready to use Lume. {t("onboardingSettings.title")}
</h1> </h1>
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500"> <p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
Let&apos;s start personalizing your experience. {t("onboardingSettings.subtitle")}
</p> </p>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -107,10 +109,11 @@ export function OnboardingScreen() {
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> <Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root> </Switch.Root>
<div> <div>
<h3 className="font-semibold text-lg">Push notification</h3> <h3 className="font-semibold text-lg">
{t("onboardingSettings.notification.title")}
</h3>
<p className="text-neutral-500"> <p className="text-neutral-500">
Enabling push notifications will allow you to receive {t("onboardingSettings.notification.subtitle")}
notifications from Lume.
</p> </p>
</div> </div>
</div> </div>
@@ -123,10 +126,11 @@ export function OnboardingScreen() {
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" /> <Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root> </Switch.Root>
<div> <div>
<h3 className="font-semibold text-lg">Low Power Mode</h3> <h3 className="font-semibold text-lg">
{t("onboardingSettings.lowPower.title")}
</h3>
<p className="text-neutral-500"> <p className="text-neutral-500">
Limited relay connection and hide all media, sustainable for low {t("onboardingSettings.lowPower.subtitle")}
network environment.
</p> </p>
</div> </div>
</div> </div>
@@ -140,11 +144,10 @@ export function OnboardingScreen() {
</Switch.Root> </Switch.Root>
<div> <div>
<h3 className="font-semibold text-lg"> <h3 className="font-semibold text-lg">
Translation (nostr.wine) {t("onboardingSettings.translation.title")}
</h3> </h3>
<p className="text-neutral-500"> <p className="text-neutral-500">
Translate text to your preferred language, powered by Nostr {t("onboardingSettings.translation.subtitle")}
Wine.
</p> </p>
</div> </div>
</div> </div>
@@ -175,10 +178,7 @@ export function OnboardingScreen() {
) : null} ) : null}
<div className="flex items-center gap-2 rounded-xl px-5 py-3 text-sm bg-blue-950 text-blue-300"> <div className="flex items-center gap-2 rounded-xl px-5 py-3 text-sm bg-blue-950 text-blue-300">
<InfoIcon className="size-8" /> <InfoIcon className="size-8" />
<p> <p>{t("onboardingSettings.footer")}</p>
There are many more settings you can configure from the
&quot;Settings&quot; screen. Be sure to visit it later.
</p>
</div> </div>
<button <button
type="button" type="button"
@@ -188,7 +188,7 @@ export function OnboardingScreen() {
{loading ? ( {loading ? (
<LoaderIcon className="size-5 animate-spin" /> <LoaderIcon className="size-5 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</div> </div>

View File

@@ -1,6 +1,9 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export function WelcomeScreen() { export function WelcomeScreen() {
const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-center justify-between w-full h-full"> <div className="flex flex-col items-center justify-between w-full h-full">
<div /> <div />
@@ -12,10 +15,8 @@ export function WelcomeScreen() {
alt="lume" alt="lume"
className="w-2/3" className="w-2/3"
/> />
<p className="mt-5 text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500"> <p className="mt-5 text-lg whitespace-pre-line font-medium leading-snug text-neutral-600 dark:text-neutral-500">
Lume is a magnificent client for Nostr to meet, explore {t("welcome.title")}
<br />
and freely share your thoughts with everyone.
</p> </p>
</div> </div>
<div className="flex flex-col w-full max-w-xs gap-2 mx-auto"> <div className="flex flex-col w-full max-w-xs gap-2 mx-auto">
@@ -23,19 +24,19 @@ export function WelcomeScreen() {
to="/auth/create" to="/auth/create"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600" className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
> >
Join Nostr {t("welcome.signup")}
</Link> </Link>
<Link <Link
to="/auth/login" to="/auth/login"
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900" className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
> >
Login {t("welcome.login")}
</Link> </Link>
</div> </div>
</div> </div>
<div className="flex items-center justify-center h-11"> <div className="flex items-center justify-center h-11">
<p className="text-neutral-700"> <p className="text-neutral-700">
Before joining Nostr, you can take time to learn more about Nostr{" "} {t("welcome.footer")}{" "}
<Link <Link
to="https://nostr.com" to="https://nostr.com"
target="_blank" target="_blank"

View File

@@ -53,7 +53,7 @@ export function ErrorScreen() {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="relative flex h-screen w-screen items-center justify-center bg-blue-600 overflow-hidden rounded-t-xl" className="relative flex h-screen w-screen items-center justify-center bg-blue-500 overflow-hidden rounded-xl"
> >
<div className="flex w-full max-w-2xl flex-col items-start gap-8"> <div className="flex w-full max-w-2xl flex-col items-start gap-8">
<div className="flex flex-col"> <div className="flex flex-col">
@@ -95,7 +95,7 @@ export function ErrorScreen() {
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="text-xl font-semibold text-white"> <div className="text-xl font-semibold text-white">
3. Report this issue to Lume&apos;s Devs 3. Report this issue to Lume
</div> </div>
<a <a
href="https://github.com/luminous-devs/lume/issues/new" href="https://github.com/luminous-devs/lume/issues/new"
@@ -120,13 +120,13 @@ export function ErrorScreen() {
</div> </div>
<div className="select-text text-lg font-medium text-blue-300"> <div className="select-text text-lg font-medium text-blue-300">
<p> <p>
While waiting for Lume&apos;s Devs to release the bug fixes, While waiting for Lume release the bug fixes, you always can
you always can use other Nostr clients with your account: use other Nostr clients with your account:
</p> </p>
<div className="mt-2 flex flex-col gap-1 text-white"> <div className="mt-2 flex flex-col gap-1 text-white">
<a <a
className="hover:!underline" className="hover:!underline"
href="https://snort.social" href="https://snort.social/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@@ -134,15 +134,15 @@ export function ErrorScreen() {
</a> </a>
<a <a
className="hover:!underline" className="hover:!underline"
href="https://primal.net" href="https://nostter.app/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
primal.net nostter
</a> </a>
<a <a
className="hover:!underline" className="hover:!underline"
href="https://nostrudel.ninja" href="https://nostrudel.ninja/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >

View File

@@ -18,10 +18,13 @@ import { TutorialModal } from "@lume/ui/src/tutorial/modal";
import { COL_TYPES } from "@lume/utils"; import { COL_TYPES } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { VList } from "virtua"; import { VList } from "virtua";
export function HomeScreen() { export function HomeScreen() {
const { t } = useTranslation();
const { columns, vlistRef, addColumn } = useColumnContext(); const { columns, vlistRef, addColumn } = useColumnContext();
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const renderItem = (column: IColumn) => { const renderItem = (column: IColumn) => {
@@ -124,7 +127,7 @@ export function HomeScreen() {
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Move Left {t("global.moveLeft")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
@@ -151,7 +154,7 @@ export function HomeScreen() {
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Move Right {t("global.moveRight")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
@@ -174,7 +177,7 @@ export function HomeScreen() {
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
New Column {t("global.newColum")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>

View File

@@ -4,10 +4,13 @@ import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { VList } from "virtua"; import { VList } from "virtua";
export function RelayEventList({ relayUrl }: { relayUrl: string }) { export function RelayEventList({ relayUrl }: { relayUrl: string }) {
const ark = useArk(); const ark = useArk();
const { t } = useTranslation();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } = const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ["relay-events", relayUrl], queryKey: ["relay-events", relayUrl],
@@ -37,14 +40,10 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
if (!lastEvent) return; if (!lastEvent) return;
return lastEvent.created_at - 1; return lastEvent.created_at - 1;
}, },
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const allEvents = useMemo(
() => (data ? data.pages.flatMap((page) => page) : []),
[data],
);
const renderItem = useCallback( const renderItem = useCallback(
(event: NDKEvent) => { (event: NDKEvent) => {
switch (event.kind) { switch (event.kind) {
@@ -64,7 +63,7 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
{status === "pending" ? ( {status === "pending" ? (
<NoteSkeleton /> <NoteSkeleton />
) : ( ) : (
allEvents.map((item) => renderItem(item)) data.map((item) => renderItem(item))
)} )}
<div className="flex h-16 items-center justify-center px-3 pb-3"> <div className="flex h-16 items-center justify-center px-3 pb-3">
{hasNextPage ? ( {hasNextPage ? (
@@ -79,7 +78,7 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
) : ( ) : (
<> <>
<ArrowRightCircleIcon className="h-5 w-5" /> <ArrowRightCircleIcon className="h-5 w-5" />
Load more {t("global.loading")}
</> </>
)} )}
</button> </button>

View File

@@ -1,17 +1,20 @@
import { User, useRelaylist } from "@lume/ark"; import { User, useRelaylist } from "@lume/ark";
import { PlusIcon, SearchIcon } from "@lume/icons"; import { PlusIcon, SearchIcon } from "@lume/icons";
import { normalizeRelayUrl } from "nostr-fetch"; import { normalizeRelayUrl } from "nostr-fetch";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export function RelayItem({ url, users }: { url: string; users?: string[] }) { export function RelayItem({ url, users }: { url: string; users?: string[] }) {
const domain = new URL(url).hostname; const domain = new URL(url).hostname;
const { t } = useTranslation();
const { connectRelay } = useRelaylist(); const { connectRelay } = useRelaylist();
return ( return (
<div className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-950"> <div className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-950">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400"> <span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
Relay:{" "} {t("global.relay")}:{" "}
</span> </span>
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100"> <span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{url} {url}
@@ -39,7 +42,7 @@ export function RelayItem({ url, users }: { url: string; users?: string[] }) {
className="inline-flex h-8 items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800" className="inline-flex h-8 items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
> >
<SearchIcon className="size-4" /> <SearchIcon className="size-4" />
Inspect {t("global.inspect")}
</Link> </Link>
<button <button
type="button" type="button"

View File

@@ -1,91 +0,0 @@
import { useArk, useRelaylist } from "@lume/ark";
import { LoaderIcon, PlusIcon, ShareIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { VList } from "virtua";
export function RelayList() {
const ark = useArk();
const { connectRelay } = useRelaylist();
const { status, data } = useQuery({
queryKey: ["relays"],
queryFn: async () => {
return await ark.getAllRelaysFromContacts();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
const navigate = useNavigate();
const inspectRelay = (relayUrl: string) => {
const url = new URL(relayUrl);
navigate(`/relays/${url.hostname}`);
};
return (
<div className="col-span-2 bg-white">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center pb-10">
<div className="inline-flex flex-col items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
<p>Loading relay...</p>
</div>
</div>
) : (
<VList className="h-full">
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
<h3 className="font-semibold">Relay discovery</h3>
</div>
{[...data].map(([key, value]) => (
<div
key={key}
className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900"
>
<div className="inline-flex items-center gap-2 divide-x divide-neutral-100 dark:divide-neutral-900">
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => inspectRelay(key)}
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-200 px-1.5 text-sm font-medium text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<ShareIcon className="h-3 w-3" />
Inspect
</button>
<button
type="button"
onClick={() => connectRelay.mutate(key)}
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
<PlusIcon className="h-3 w-3" />
</button>
</div>
<div className="inline-flex items-center gap-2 pl-3">
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
Relay:{" "}
</span>
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{key}
</span>
</div>
</div>
<div className="isolate flex -space-x-2">
{value.slice(0, 4).map((item) => (
<User key={item} pubkey={item} variant="stacked" />
))}
{value.length > 4 ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
<span className="text-xs font-medium">+{value.length}</span>
</div>
) : null}
</div>
</div>
))}
</VList>
)}
</div>
);
}

View File

@@ -3,11 +3,13 @@ import { CancelIcon, LoaderIcon, RefreshIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; import { NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { RelayForm } from "./relayForm"; import { RelayForm } from "./relayForm";
export function RelaySidebar({ className }: { className?: string }) { export function RelaySidebar({ className }: { className?: string }) {
const ark = useArk(); const ark = useArk();
const { t } = useTranslation();
const { removeRelay } = useRelaylist(); const { removeRelay } = useRelaylist();
const { status, data, isRefetching, refetch } = useQuery({ const { status, data, isRefetching, refetch } = useQuery({
queryKey: ["relay-personal"], queryKey: ["relay-personal"],
@@ -40,7 +42,7 @@ export function RelaySidebar({ className }: { className?: string }) {
)} )}
> >
<div className="inline-flex items-center justify-between w-full h-14 px-3 border-b border-black/10 dark:border-white/10"> <div className="inline-flex items-center justify-between w-full h-14 px-3 border-b border-black/10 dark:border-white/10">
<h3 className="font-semibold">Connected relays</h3> <h3 className="font-semibold">{t("relays.sidebar.title")}</h3>
<button <button
type="button" type="button"
onClick={() => refetch()} onClick={() => refetch()}
@@ -58,7 +60,7 @@ export function RelaySidebar({ className }: { className?: string }) {
</div> </div>
) : !data.length ? ( ) : !data.length ? (
<div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10"> <div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10">
<p className="text-sm font-medium">Empty.</p> <p className="text-sm font-medium">{t("relays.sidebar.empty")}</p>
</div> </div>
) : ( ) : (
data.map((item) => ( data.map((item) => (

View File

@@ -1,8 +1,11 @@
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { RelaySidebar } from "./components/sidebar"; import { RelaySidebar } from "./components/sidebar";
export function RelaysScreen() { export function RelaysScreen() {
const { t } = useTranslation();
return ( return (
<div className="grid h-full w-full lg:grid-cols-4 xl:grid-cols-5 rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10"> <div className="grid h-full w-full lg:grid-cols-4 xl:grid-cols-5 rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
<RelaySidebar className="col-span-1" /> <RelaySidebar className="col-span-1" />
@@ -20,7 +23,7 @@ export function RelaysScreen() {
) )
} }
> >
Global {t("relays.global")}
</NavLink> </NavLink>
<NavLink <NavLink
to={"/relays/follows/"} to={"/relays/follows/"}
@@ -33,7 +36,7 @@ export function RelaysScreen() {
) )
} }
> >
Follows {t("relays.follows")}
</NavLink> </NavLink>
</div> </div>
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto"> <div className="flex flex-col flex-1 min-h-0 overflow-y-auto">

View File

@@ -1,15 +1,16 @@
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { NIP11 } from "@lume/types"; import { NIP11 } from "@lume/types";
import { User } from "@lume/ui"; import { User } from "@lume/ui";
import { Suspense } from "react"; import { Suspense } from "react";
import { Await, useLoaderData, useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next";
import { Await, useLoaderData, useParams } from "react-router-dom";
import { RelayEventList } from "./components/relayEventList"; import { RelayEventList } from "./components/relayEventList";
export function RelayUrlScreen() { export function RelayUrlScreen() {
const { t } = useTranslation();
const { url } = useParams(); const { url } = useParams();
const data: { relay?: { [key: string]: string } } = useLoaderData(); const data: { relay?: { [key: string]: string } } = useLoaderData();
const navigate = useNavigate();
const getSoftwareName = (url: string) => { const getSoftwareName = (url: string) => {
const filename = url.substring(url.lastIndexOf("/") + 1); const filename = url.substring(url.lastIndexOf("/") + 1);
@@ -32,7 +33,7 @@ export function RelayUrlScreen() {
fallback={ fallback={
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100"> <div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
<LoaderIcon className="h-4 w-4 animate-spin" /> <LoaderIcon className="h-4 w-4 animate-spin" />
Loading... {t("global.loading")}
</div> </div>
} }
> >
@@ -40,7 +41,7 @@ export function RelayUrlScreen() {
resolve={data.relay} resolve={data.relay}
errorElement={ errorElement={
<div className="text-sm font-medium"> <div className="text-sm font-medium">
<p>Could not load relay information 😬</p> <p>{t("relays.relayView.empty")}</p>
</div> </div>
} }
> >
@@ -55,7 +56,7 @@ export function RelayUrlScreen() {
{resolvedRelay.pubkey ? ( {resolvedRelay.pubkey ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400"> <h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Owner: {t("relays.relayView.owner")}:
</h5> </h5>
<div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900"> <div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900">
<User pubkey={resolvedRelay.pubkey} variant="simple" /> <User pubkey={resolvedRelay.pubkey} variant="simple" />
@@ -65,7 +66,7 @@ export function RelayUrlScreen() {
{resolvedRelay.contact ? ( {resolvedRelay.contact ? (
<div> <div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400"> <h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Contact: {t("relays.relayView.contact")}:
</h5> </h5>
<a <a
href={`mailto:${resolvedRelay.contact}`} href={`mailto:${resolvedRelay.contact}`}
@@ -79,7 +80,7 @@ export function RelayUrlScreen() {
) : null} ) : null}
<div> <div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400"> <h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Software: {t("relays.relayView.software")}:
</h5> </h5>
<a <a
href={resolvedRelay.software} href={resolvedRelay.software}
@@ -94,7 +95,7 @@ export function RelayUrlScreen() {
</div> </div>
<div> <div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400"> <h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Supported NIPs: {t("relays.relayView.nips")}:
</h5> </h5>
<div className="mt-2 grid grid-cols-7 gap-2"> <div className="mt-2 grid grid-cols-7 gap-2">
{resolvedRelay.supported_nips.map((item) => ( {resolvedRelay.supported_nips.map((item) => (
@@ -113,14 +114,13 @@ export function RelayUrlScreen() {
{resolvedRelay.limitation ? ( {resolvedRelay.limitation ? (
<div> <div>
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400"> <h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
Limitation {t("relays.relayView.limit")}
</h5> </h5>
<div className="flex flex-col gap-2 divide-y divide-white/5"> <div className="flex flex-col gap-2 divide-y divide-white/5">
{Object.keys(resolvedRelay.limitation).map( {Object.keys(resolvedRelay.limitation).map((key) => {
(key, index) => {
return ( return (
<div <div
key={key + index} key={key}
className="flex items-baseline justify-between pt-2" className="flex items-baseline justify-between pt-2"
> >
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -131,8 +131,7 @@ export function RelayUrlScreen() {
</p> </p>
</div> </div>
); );
}, })}
)}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -144,10 +143,10 @@ export function RelayUrlScreen() {
rel="noreferrer" rel="noreferrer"
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600" className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600"
> >
Open payment website {t("relays.relayView.payment")}
</a> </a>
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400"> <span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
You need to make a payment to connect this relay {t("relays.relayView.paymentNote")}
</span> </span>
</div> </div>
) : null} ) : null}

View File

@@ -2,10 +2,12 @@ import { getVersion } from "@tauri-apps/api/app";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import { Update, check } from "@tauri-apps/plugin-updater"; import { Update, check } from "@tauri-apps/plugin-updater";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
export function AboutScreen() { export function AboutScreen() {
const [t] = useTranslation();
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [newUpdate, setNewUpdate] = useState<Update>(null); const [newUpdate, setNewUpdate] = useState<Update>(null);
@@ -34,7 +36,7 @@ export function AboutScreen() {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<h1 className="leading-tight text-xl font-semibold">Lume</h1> <h1 className="leading-tight text-xl font-semibold">Lume</h1>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Version {version} {t("settings.about.version")} {version}
</p> </p>
</div> </div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2"> <div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
@@ -44,7 +46,7 @@ export function AboutScreen() {
onClick={() => checkUpdate()} onClick={() => checkUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600" className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
> >
Check for update {t("settings.about.checkUpdate")}
</button> </button>
) : ( ) : (
<button <button
@@ -52,7 +54,7 @@ export function AboutScreen() {
onClick={() => installUpdate()} onClick={() => installUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600" className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
> >
Install {newUpdate.version} {t("settings.about.installUpdate")} {newUpdate.version}
</button> </button>
)} )}
<Link <Link

View File

@@ -1,7 +1,9 @@
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import { useTranslation } from "react-i18next";
export function AdvancedSettingScreen() { export function AdvancedSettingScreen() {
const storage = useStorage(); const storage = useStorage();
const { t } = useTranslation();
const clearCache = async () => { const clearCache = async () => {
await storage.clearCache(); await storage.clearCache();
@@ -13,16 +15,18 @@ export function AdvancedSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold"> <div className="w-24 shrink-0 text-end text-sm font-semibold">
Cache {t("settings.advanced.cache.title")}
</div>
<div className="text-sm">
{t("settings.advanced.cache.subtitle")}
</div> </div>
<div className="text-sm">Use for boost up nostr connection</div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => clearCache()} onClick={() => clearCache()}
className="h-8 w-max rounded-lg px-3 text-sm font-semibold text-blue-500 bg-blue-100 hover:bg-blue-200" className="h-8 w-max rounded-lg px-3 text-sm font-semibold text-blue-500 bg-blue-100 hover:bg-blue-200"
> >
Clear {t("settings.advanced.cache.button")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -3,11 +3,13 @@ import { EyeOffIcon } from "@lume/icons";
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export function BackupSettingScreen() { export function BackupSettingScreen() {
const ark = useArk(); const ark = useArk();
const storage = useStorage(); const storage = useStorage();
const [t] = useTranslation();
const [privkey, setPrivkey] = useState(null); const [privkey, setPrivkey] = useState(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -29,7 +31,9 @@ export function BackupSettingScreen() {
<div> <div>
{privkey ? ( {privkey ? (
<div> <div>
<div className="mb-2 text-sm font-semibold">Private key</div> <div className="mb-2 text-sm font-semibold">
{t("settings.backup.privkey.title")}
</div>
<div className="relative"> <div className="relative">
<input <input
readOnly readOnly
@@ -50,7 +54,7 @@ export function BackupSettingScreen() {
onClick={() => removePrivkey()} onClick={() => removePrivkey()}
className="mt-2 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-red-200 dark:bg-red-800 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white" className="mt-2 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-red-200 dark:bg-red-800 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
> >
Remove private key {t("settings.backup.privkey.button")}
</button> </button>
</div> </div>
) : null} ) : null}

View File

@@ -1,10 +1,13 @@
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export function AvatarUpload({ setPicture }) { export function AvatarUpload({ setPicture }) {
const ark = useArk(); const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const upload = async () => { const upload = async () => {
@@ -36,7 +39,7 @@ export function AvatarUpload({ setPicture }) {
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Change avatar" t("user.avatarButton")
)} )}
</button> </button>
); );

View File

@@ -1,45 +0,0 @@
import { useArk } from "@lume/ark";
import { EditIcon, LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
export function ContactCard() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ["contacts"],
queryFn: async () => {
const contacts = await ark.getUserContacts();
return contacts;
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data.length)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Contacts
</p>
<Link
to="/settings/edit-contact"
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,13 @@
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export function CoverUpload({ setBanner }) { export function CoverUpload({ setBanner }) {
const ark = useArk(); const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const upload = async () => { const upload = async () => {
@@ -36,7 +39,7 @@ export function CoverUpload({ setBanner }) {
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Change cover" t("user.coverButton")
)} )}
</button> </button>
); );

View File

@@ -1,60 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
import { Link } from "react-router-dom";
export function PostCard() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ["user-stats", ark.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
{
signal,
},
);
if (!res.ok) {
throw new Error("Error");
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data.stats[ark.account.pubkey].pub_note_count,
)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Posts
</p>
<Link
to={`/users/${ark.account.pubkey}`}
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
View
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,77 +0,0 @@
import { useArk, useProfile } from "@lume/ark";
import { EditIcon, LoaderIcon } from "@lume/icons";
import { displayNpub } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { minidenticon } from "minidenticons";
import { nip19 } from "nostr-tools";
import { Link } from "react-router-dom";
export function ProfileCard() {
const ark = useArk();
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(ark.account.pubkey, 90, 50),
)}`;
const { isLoading, user } = useProfile(ark.account.pubkey);
const copyNpub = async () => {
return await writeText(nip19.npubEncode(ark.account.pubkey));
};
return (
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<div className="flex h-10 w-full justify-end gap-3">
<button
type="button"
onClick={copyNpub}
className="inline-flex h-8 w-28 transform items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 active:translate-y-1 dark:bg-neutral-800 dark:hover:bg-neutral-600"
>
Copy NPUB
</button>
<Link
to="/settings/edit-profile"
className="inline-flex h-8 w-20 items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-600"
>
<EditIcon className="h-4 w-4" />
Edit
</Link>
</div>
<div className="flex flex-col gap-2.5">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={ark.account.pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="h-16 w-16 rounded-xl border border-neutral-200/50 shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] dark:border-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={svgURI}
alt={ark.account.pubkey}
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div>
<h3 className="text-3xl font-semibold leading-8 text-neutral-900 dark:text-neutral-100">
{user?.display_name || user?.name}
</h3>
<p className="text-lg text-neutral-700 dark:text-neutral-300">
{user?.nip05 || displayNpub(ark.account.pubkey, 16)}
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,46 +0,0 @@
import { useArk } from "@lume/ark";
import { EditIcon, LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
export function RelayCard() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ["relays", ark.account.pubkey],
queryFn: async () => {
const relays = await ark.getUserRelays({});
return relays;
},
refetchOnWindowFocus: false,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(data?.relays?.length || 0)}
</h3>
<div className="mt-auto flex h-6 w-full items-center justify-between">
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Relays
</p>
<Link
to="/relays"
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<EditIcon className="h-3 w-3" />
Edit
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,62 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { useState } from "react";
import { toast } from "sonner";
export function NWCForm({ setWalletConnectURL }) {
const ark = useArk();
const storage = useStorage();
const [uri, setUri] = useState("");
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
setLoading(true);
if (!uri.startsWith("nostr+walletconnect:")) {
toast.error(
"Connect URI is required and must start with format nostr+walletconnect:, please check again",
);
setLoading(false);
return;
}
const uriObj = new URL(uri);
const params = new URLSearchParams(uriObj.search);
if (params.has("relay") && params.has("secret")) {
await storage.createPrivkey(`${ark.account.pubkey}-nwc`, uri);
setWalletConnectURL(uri);
setLoading(false);
} else {
setLoading(false);
toast.error("Connect URI is not valid, please check again");
return;
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<textarea
name="walletConnectURL"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostr+walletconnect://"
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={submit}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
>
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : "Connect"}
</button>
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { compactNumber } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
export function ZapCard() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ["user-stats", ark.account.pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
{
signal,
},
);
if (!res.ok) {
throw new Error("Error");
}
return await res.json();
},
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
return (
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
{status === "pending" ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
<div className="flex h-full w-full flex-col justify-between p-4">
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
{compactNumber.format(
data?.stats[ark.account.pubkey]?.zaps_received?.msats / 1000 || 0,
)}
</h3>
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
Sats received
</div>
</div>
)}
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons";
import { User } from "@lume/ui";
import { useQuery } from "@tanstack/react-query";
export function EditContactScreen() {
const ark = useArk();
const { status, data } = useQuery({
queryKey: ["contacts"],
queryFn: async () => {
return await ark.getUserContacts();
},
refetchOnWindowFocus: false,
});
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
{status === "pending" ? (
<div className="flex h-10 w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin" />
</div>
) : (
data.map((item) => (
<div
key={item}
className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900"
>
<User pubkey={item} variant="simple" />
</div>
))
)}
</div>
);
}

View File

@@ -10,20 +10,17 @@ import {
requestPermission, requestPermission,
} from "@tauri-apps/plugin-notification"; } from "@tauri-apps/plugin-notification";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export function GeneralSettingScreen() { export function GeneralSettingScreen() {
const storage = useStorage(); const storage = useStorage();
const [t] = useTranslation();
const [apiKey, setAPIKey] = useState(""); const [apiKey, setAPIKey] = useState("");
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
lowPower: false, ...storage.settings,
autoupdate: false, notification: false,
autolaunch: false, autolaunch: false,
outbox: false,
media: true,
hashtag: true,
notification: true,
translation: false,
appearance: "system", appearance: "system",
}); });
@@ -100,47 +97,6 @@ export function GeneralSettingScreen() {
const permissionGranted = await isPermissionGranted(); const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted })); setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await storage.getAllSettings();
if (!data) return;
for (const item of data) {
if (item.key === "autoupdate")
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === "lowPower")
setSettings((prev) => ({
...prev,
lowPower: !!parseInt(item.value),
}));
if (item.key === "outbox")
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
if (item.key === "media")
setSettings((prev) => ({
...prev,
media: !!parseInt(item.value),
}));
if (item.key === "hashtag")
setSettings((prev) => ({
...prev,
hashtag: !!parseInt(item.value),
}));
if (item.key === "translation")
setSettings((prev) => ({
...prev,
translation: !!parseInt(item.value),
}));
}
} }
loadSettings(); loadSettings();
@@ -152,9 +108,11 @@ export function GeneralSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Update {t("settings.general.update.title")}
</div>
<div className="text-sm">
{t("settings.general.update.subtitle")}
</div> </div>
<div className="text-sm">Automatically download new update</div>
</div> </div>
<Switch.Root <Switch.Root
checked={settings.autoupdate} checked={settings.autoupdate}
@@ -167,10 +125,10 @@ export function GeneralSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Low Power {t("settings.general.lowPower.title")}
</div> </div>
<div className="text-sm"> <div className="text-sm">
Sustainable for low network environment. {t("settings.general.lowPower.subtitle")}
</div> </div>
</div> </div>
<Switch.Root <Switch.Root
@@ -184,9 +142,11 @@ export function GeneralSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Startup {t("settings.general.startup.title")}
</div>
<div className="text-sm">
{t("settings.general.startup.subtitle")}
</div> </div>
<div className="text-sm">Launch Lume at Login</div>
</div> </div>
<Switch.Root <Switch.Root
checked={settings.autolaunch} checked={settings.autolaunch}
@@ -199,9 +159,11 @@ export function GeneralSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Media {t("settings.general.media.title")}
</div>
<div className="text-sm">
{t("settings.general.media.subtitle")}
</div> </div>
<div className="text-sm">Automatically load media</div>
</div> </div>
<Switch.Root <Switch.Root
checked={settings.media} checked={settings.media}
@@ -214,9 +176,11 @@ export function GeneralSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Hashtag {t("settings.general.hashtag.title")}
</div>
<div className="text-sm">
{t("settings.general.hashtag.subtitle")}
</div> </div>
<div className="text-sm">Show all hashtags in content</div>
</div> </div>
<Switch.Root <Switch.Root
checked={settings.hashtag} checked={settings.hashtag}
@@ -229,9 +193,11 @@ export function GeneralSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Notification {t("settings.general.notification.title")}
</div>
<div className="text-sm">
{t("settings.general.notification.subtitle")}
</div> </div>
<div className="text-sm">Automatically send notification</div>
</div> </div>
<Switch.Root <Switch.Root
checked={settings.notification} checked={settings.notification}
@@ -245,9 +211,11 @@ export function GeneralSettingScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Translation {t("settings.general.translation.title")}
</div>
<div className="text-sm">
{t("settings.general.translation.subtitle")}
</div> </div>
<div className="text-sm">Translate text to your language</div>
</div> </div>
<Switch.Root <Switch.Root
checked={settings.translation} checked={settings.translation}
@@ -260,7 +228,7 @@ export function GeneralSettingScreen() {
{settings.translation ? ( {settings.translation ? (
<div className="flex w-full items-center gap-8"> <div className="flex w-full items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
API Key {t("global.apiKey")}
</div> </div>
<div className="relative w-full"> <div className="relative w-full">
<input <input
@@ -276,7 +244,7 @@ export function GeneralSettingScreen() {
onClick={saveApi} onClick={saveApi}
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700" className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
> >
Save {t("global.save")}
</button> </button>
</div> </div>
</div> </div>
@@ -284,7 +252,7 @@ export function GeneralSettingScreen() {
) : null} ) : null}
<div className="flex w-full items-start gap-8"> <div className="flex w-full items-start gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Appearance {t("settings.general.appearance.title")}
</div> </div>
<div className="flex flex-1 gap-6"> <div className="flex flex-1 gap-6">
<button <button
@@ -303,7 +271,7 @@ export function GeneralSettingScreen() {
<LightIcon className="h-5 w-5" /> <LightIcon className="h-5 w-5" />
</div> </div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Light {t("settings.general.appearance.light")}
</p> </p>
</button> </button>
<button <button
@@ -322,7 +290,7 @@ export function GeneralSettingScreen() {
<DarkIcon className="h-5 w-5" /> <DarkIcon className="h-5 w-5" />
</div> </div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Dark {t("settings.general.appearance.dark")}
</p> </p>
</button> </button>
<button <button
@@ -341,7 +309,7 @@ export function GeneralSettingScreen() {
<SystemModeIcon className="h-5 w-5" /> <SystemModeIcon className="h-5 w-5" />
</div> </div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
System {t("settings.general.appearance.system")}
</p> </p>
</button> </button>
</div> </div>

View File

@@ -2,12 +2,14 @@ import { useArk } from "@lume/ark";
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import * as Switch from "@radix-ui/react-switch"; import * as Switch from "@radix-ui/react-switch";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export function NWCScreen() { export function NWCScreen() {
const ark = useArk(); const ark = useArk();
const storage = useStorage(); const storage = useStorage();
const [t] = useTranslation();
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
nwc: false, nwc: false,
instantZap: storage.settings.instantZap, instantZap: storage.settings.instantZap,
@@ -74,7 +76,7 @@ export function NWCScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex w-full items-start gap-8"> <div className="flex w-full items-start gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Connection String {t("settings.zap.nwc")}
</div> </div>
<div className="flex flex-col items-end gap-2 w-full"> <div className="flex flex-col items-end gap-2 w-full">
<textarea <textarea
@@ -89,7 +91,7 @@ export function NWCScreen() {
onClick={saveNWC} onClick={saveNWC}
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700" className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
> >
Save {t("global.save")}
</button> </button>
) : ( ) : (
<button <button
@@ -97,7 +99,7 @@ export function NWCScreen() {
onClick={remove} onClick={remove}
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700" className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
> >
Remove {t("global.delete")}
</button> </button>
)} )}
</div> </div>
@@ -108,10 +110,10 @@ export function NWCScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Instant Zap {t("settings.zap.instant.title")}
</div> </div>
<div className="text-sm"> <div className="text-sm">
Zap with default amount, no confirmation {t("settings.zap.instant.subtitle")}
</div> </div>
</div> </div>
<Switch.Root <Switch.Root
@@ -125,7 +127,7 @@ export function NWCScreen() {
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex w-full items-center gap-8"> <div className="flex w-full items-center gap-8">
<div className="w-36 shrink-0 text-end text-sm font-semibold"> <div className="w-36 shrink-0 text-end text-sm font-semibold">
Default amount {t("settings.zap.defaultAmount")}
</div> </div>
<div className="relative w-full"> <div className="relative w-full">
<input <input
@@ -141,7 +143,7 @@ export function NWCScreen() {
onClick={saveAmount} onClick={saveAmount}
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700" className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
> >
Save {t("global.save")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,15 +1,11 @@
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
import { import { CheckCircleIcon, LoaderIcon, UnverifiedIcon } from "@lume/icons";
CheckCircleIcon,
LoaderIcon,
PlusIcon,
UnverifiedIcon,
} from "@lume/icons";
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk"; import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { AvatarUpload } from "./components/avatarUpload"; import { AvatarUpload } from "./components/avatarUpload";
import { CoverUpload } from "./components/coverUpload"; import { CoverUpload } from "./components/coverUpload";
@@ -24,6 +20,7 @@ export function ProfileSettingScreen() {
const [banner, setBanner] = useState(""); const [banner, setBanner] = useState("");
const [nip05, setNIP05] = useState({ verified: true, text: "" }); const [nip05, setNIP05] = useState({ verified: true, text: "" });
const { t } = useTranslation();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -139,7 +136,7 @@ export function ProfileSettingScreen() {
htmlFor="displayName" htmlFor="displayName"
className="text-sm font-semibold uppercase tracking-wider" className="text-sm font-semibold uppercase tracking-wider"
> >
Display Name {t("user.displayName")}
</label> </label>
<input <input
type={"text"} type={"text"}
@@ -153,7 +150,7 @@ export function ProfileSettingScreen() {
htmlFor="name" htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider" className="text-sm font-semibold uppercase tracking-wider"
> >
Name {t("user.name")}
</label> </label>
<input <input
type={"text"} type={"text"}
@@ -179,12 +176,12 @@ export function ProfileSettingScreen() {
{nip05.verified ? ( {nip05.verified ? (
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white"> <span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
<CheckCircleIcon className="h-4 w-4" /> <CheckCircleIcon className="h-4 w-4" />
Verified {t("user.verified")}
</span> </span>
) : ( ) : (
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white"> <span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
<UnverifiedIcon className="h-4 w-4" /> <UnverifiedIcon className="h-4 w-4" />
Unverified {t("user.unverified")}
</span> </span>
)} )}
</div> </div>
@@ -200,7 +197,7 @@ export function ProfileSettingScreen() {
htmlFor="website" htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider" className="text-sm font-semibold uppercase tracking-wider"
> >
Website {t("user.website")}
</label> </label>
<input <input
type={"text"} type={"text"}
@@ -214,7 +211,7 @@ export function ProfileSettingScreen() {
htmlFor="website" htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider" className="text-sm font-semibold uppercase tracking-wider"
> >
Lightning address {t("user.lna")}
</label> </label>
<input <input
type={"text"} type={"text"}
@@ -228,7 +225,7 @@ export function ProfileSettingScreen() {
htmlFor="about" htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider" className="text-sm font-semibold uppercase tracking-wider"
> >
Bio {t("user.bio")}
</label> </label>
<textarea <textarea
{...register("about")} {...register("about")}
@@ -245,7 +242,7 @@ export function ProfileSettingScreen() {
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Update" t("global.update")
)} )}
</button> </button>
</div> </div>

View File

@@ -32,6 +32,7 @@
"re-resizable": "^6.9.11", "re-resizable": "^6.9.11",
"react": "^18.2.0", "react": "^18.2.0",
"react-currency-input-field": "^3.6.14", "react-currency-input-field": "^3.6.14",
"react-i18next": "^14.0.1",
"react-router-dom": "^6.21.3", "react-router-dom": "^6.21.3",
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"sonner": "^1.3.1", "sonner": "^1.3.1",

View File

@@ -3,12 +3,11 @@ import {
MoveLeftIcon, MoveLeftIcon,
MoveRightIcon, MoveRightIcon,
RefreshIcon, RefreshIcon,
ThreadIcon,
TrashIcon, TrashIcon,
} from "@lume/icons"; } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ReactNode } from "react"; import { useTranslation } from "react-i18next";
import { InterestModal } from "./interestModal"; import { InterestModal } from "./interestModal";
import { useColumnContext } from "./provider"; import { useColumnContext } from "./provider";
@@ -16,14 +15,14 @@ export function ColumnHeader({
id, id,
title, title,
queryKey, queryKey,
icon,
}: { }: {
id: number; id: number;
title: string; title: string;
queryKey?: string[]; queryKey?: string[];
icon?: ReactNode;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
const { moveColumn, removeColumn } = useColumnContext(); const { moveColumn, removeColumn } = useColumnContext();
const refresh = async () => { const refresh = async () => {
@@ -63,7 +62,7 @@ export function ColumnHeader({
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<RefreshIcon className="size-4" /> <RefreshIcon className="size-4" />
Refresh {t("global.refresh")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
{queryKey?.[0] === "foryou-9998" ? ( {queryKey?.[0] === "foryou-9998" ? (
@@ -81,7 +80,7 @@ export function ColumnHeader({
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<MoveLeftIcon className="size-4" /> <MoveLeftIcon className="size-4" />
Move left {t("global.moveLeft")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -91,7 +90,7 @@ export function ColumnHeader({
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<MoveRightIcon className="size-4" /> <MoveRightIcon className="size-4" />
Move right {t("global.moveRight")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" /> <DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />
@@ -102,7 +101,7 @@ export function ColumnHeader({
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none" className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
Delete {t("global.Delete")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -4,6 +4,7 @@ import { TOPICS, cn } from "@lume/utils";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export function InterestModal({ export function InterestModal({
@@ -14,6 +15,7 @@ export function InterestModal({
const storage = useStorage(); const storage = useStorage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [t] = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hashtags, setHashtags] = useState(storage.interests?.hashtags || []); const [hashtags, setHashtags] = useState(storage.interests?.hashtags || []);
@@ -65,7 +67,7 @@ export function InterestModal({
) : ( ) : (
<> <>
<EditInterestIcon className="size-4" /> <EditInterestIcon className="size-4" />
Edit interest {t("interests.edit")}
</> </>
)} )}
</Dialog.Trigger> </Dialog.Trigger>
@@ -80,7 +82,7 @@ export function InterestModal({
<div className="w-full h-full flex flex-col"> <div className="w-full h-full flex flex-col">
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between"> <div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<h3 className="font-semibold">Edit Interest</h3> <h3 className="font-semibold">{t("interests.edit")}</h3>
</div> </div>
</div> </div>
<div className="w-full flex-1 min-h-0 flex flex-col justify-between"> <div className="w-full flex-1 min-h-0 flex flex-col justify-between">
@@ -104,7 +106,7 @@ export function InterestModal({
onClick={() => toggleAll(topic.content)} onClick={() => toggleAll(topic.content)}
className="text-sm font-medium text-blue-500" className="text-sm font-medium text-blue-500"
> >
Follow All {t("interests.followAll")}
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -131,7 +133,7 @@ export function InterestModal({
<div className="h-16 shrink-0 w-full flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950"> <div className="h-16 shrink-0 w-full flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
<Dialog.Close className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200"> <Dialog.Close className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200">
<ArrowLeftIcon className="size-4" /> <ArrowLeftIcon className="size-4" />
Cancel {t("global.cancel")}
</Dialog.Close> </Dialog.Close>
<button <button
type="button" type="button"
@@ -141,7 +143,7 @@ export function InterestModal({
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Save" t("global.save")
)} )}
</button> </button>
</div> </div>

View File

@@ -1,11 +1,14 @@
import { PinIcon } from "@lume/icons"; import { PinIcon } from "@lume/icons";
import { COL_TYPES } from "@lume/utils"; import { COL_TYPES } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next";
import { useColumnContext } from "../../column/provider"; import { useColumnContext } from "../../column/provider";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
export function NotePin() { export function NotePin() {
const event = useNoteContext(); const event = useNoteContext();
const { t } = useTranslation();
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
return ( return (
@@ -24,12 +27,12 @@ export function NotePin() {
className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900" className="inline-flex items-center justify-center gap-2 pl-2 pr-3 text-sm font-medium rounded-full h-7 w-max bg-neutral-100 hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:bg-neutral-900"
> >
<PinIcon className="size-4" /> <PinIcon className="size-4" />
Pin {t("note.buttons.pin")}
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Pin note {t("note.buttons.pinTooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>

View File

@@ -1,5 +1,6 @@
import { ReplyIcon } from "@lume/icons"; import { ReplyIcon } from "@lume/icons";
import * as Tooltip from "@radix-ui/react-tooltip"; import * as Tooltip from "@radix-ui/react-tooltip";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
@@ -7,6 +8,8 @@ export function NoteReply() {
const event = useNoteContext(); const event = useNoteContext();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
return ( return (
<Tooltip.Provider> <Tooltip.Provider>
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={150}>
@@ -21,7 +24,7 @@ export function NoteReply() {
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
View thread {t("note.menu.viewThread")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>

View File

@@ -5,6 +5,7 @@ import * as Tooltip from "@radix-ui/react-tooltip";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
@@ -13,6 +14,7 @@ export function NoteRepost() {
const setEditorValue = useSetAtom(editorValueAtom); const setEditorValue = useSetAtom(editorValueAtom);
const setIsEditorOpen = useSetAtom(editorAtom); const setIsEditorOpen = useSetAtom(editorAtom);
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false); const [isRepost, setIsRepost] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -81,7 +83,7 @@ export function NoteRepost() {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Repost {t("note.buttons.repost")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
@@ -96,7 +98,7 @@ export function NoteRepost() {
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<RepostIcon className="size-4" /> <RepostIcon className="size-4" />
Repost {t("note.buttons.repost")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -106,7 +108,7 @@ export function NoteRepost() {
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<ReplyIcon className="size-4" /> <ReplyIcon className="size-4" />
Quote {t("note.buttons.quote")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -8,6 +8,7 @@ import * as Tooltip from "@radix-ui/react-tooltip";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { useState } from "react"; import { useState } from "react";
import CurrencyInput from "react-currency-input-field"; import CurrencyInput from "react-currency-input-field";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useProfile } from "../../../hooks/useProfile"; import { useProfile } from "../../../hooks/useProfile";
import { useNoteContext } from "../provider"; import { useNoteContext } from "../provider";
@@ -23,6 +24,7 @@ export function NoteZap() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [invoice, setInvoice] = useState<string>(null); const [invoice, setInvoice] = useState<string>(null);
const { t } = useTranslation();
const { user } = useProfile(event.pubkey); const { user } = useProfile(event.pubkey);
const createZapRequest = async (instant?: boolean) => { const createZapRequest = async (instant?: boolean) => {
@@ -99,7 +101,7 @@ export function NoteZap() {
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Zap {t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
@@ -124,7 +126,7 @@ export function NoteZap() {
</Dialog.Trigger> </Dialog.Trigger>
<Tooltip.Portal> <Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"> <Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Zap {t("note.zap.tooltip")}
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" /> <Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Portal> </Tooltip.Portal>
@@ -145,7 +147,7 @@ export function NoteZap() {
<div className="inline-flex items-center justify-center w-full px-5 py-3 shrink-0"> <div className="inline-flex items-center justify-center w-full px-5 py-3 shrink-0">
<div className="w-6" /> <div className="w-6" />
<Dialog.Title className="font-semibold text-center"> <Dialog.Title className="font-semibold text-center">
Send zap to{" "} {t("note.zap.modalTitle")}{" "}
{user?.name || {user?.name ||
user?.displayName || user?.displayName ||
displayNpub(event.pubkey, 16)} displayNpub(event.pubkey, 16)}
@@ -217,7 +219,7 @@ export function NoteZap() {
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="Enter message (optional)" placeholder={t("note.zap.messagePlaceholder")}
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:text-neutral-400" className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:text-neutral-400"
/> />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -227,10 +229,10 @@ export function NoteZap() {
className="inline-flex items-center justify-center w-full pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800" className="inline-flex items-center justify-center w-full pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
> >
{isCompleted {isCompleted
? "Zapped" ? t("note.zap.buttonFinish")
: isLoading : isLoading
? "Processing..." ? t("note.zap.buttonLoading")
: "Zap"} : t("note.zap.zap")}
</button> </button>
</div> </div>
</div> </div>
@@ -241,11 +243,11 @@ export function NoteZap() {
<QRCodeSVG value={invoice} size={256} /> <QRCodeSVG value={invoice} size={256} />
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<h3 className="text-lg font-medium">Scan to zap</h3> <h3 className="text-lg font-medium">
{t("note.zap.invoiceButton")}
</h3>
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400"> <span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
You must use Bitcoin wallet which support Lightning {t("note.zap.invoiceFooter")}
<br />
such as: Blue Wallet, Bitkit, Phoenix,...
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { NOSTR_MENTIONS } from "@lume/utils"; import { NOSTR_MENTIONS } from "@lume/utils";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { nip19 } from "nostr-tools";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { useEvent } from "../../hooks/useEvent"; import { useEvent } from "../../hooks/useEvent";
@@ -13,6 +13,7 @@ export function NoteChild({
eventId, eventId,
isRoot, isRoot,
}: { eventId: string; isRoot?: boolean }) { }: { eventId: string; isRoot?: boolean }) {
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => { const richContent = useMemo(() => {
@@ -91,7 +92,7 @@ export function NoteChild({
return ( return (
<div className="relative flex gap-3"> <div className="relative flex gap-3">
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800"> <div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
Failed to fetch event {t("note.error")}
</div> </div>
</div> </div>
); );
@@ -111,7 +112,7 @@ export function NoteChild({
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight"> <div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<User.Name className="max-w-[10rem] truncate" /> <User.Name className="max-w-[10rem] truncate" />
<div className="font-normal text-neutral-700 dark:text-neutral-300"> <div className="font-normal text-neutral-700 dark:text-neutral-300">
{isRoot ? "posted:" : "replied:"} {isRoot ? t("note.posted") : t("note.replied")}:
</div> </div>
</div> </div>
</User.Root> </User.Root>

View File

@@ -1,6 +1,7 @@
import { PinIcon } from "@lume/icons"; import { PinIcon } from "@lume/icons";
import { COL_TYPES, NOSTR_MENTIONS } from "@lume/utils"; import { COL_TYPES, NOSTR_MENTIONS } from "@lume/utils";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace"; import reactStringReplace from "react-string-replace";
import { useEvent } from "../../../hooks/useEvent"; import { useEvent } from "../../../hooks/useEvent";
@@ -13,6 +14,7 @@ export function MentionNote({
eventId, eventId,
openable = true, openable = true,
}: { eventId: string; openable?: boolean }) { }: { eventId: string; openable?: boolean }) {
const { t } = useTranslation();
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
const { isLoading, isError, data } = useEvent(eventId); const { isLoading, isError, data } = useEvent(eventId);
@@ -98,7 +100,7 @@ export function MentionNote({
contentEditable={false} contentEditable={false}
className="w-full p-3 my-1 rounded-lg cursor-default bg-neutral-100 dark:bg-neutral-900" className="w-full p-3 my-1 rounded-lg cursor-default bg-neutral-100 dark:bg-neutral-900"
> >
Failed to fetch event. {t("note.error")}
</div> </div>
); );
} }
@@ -127,7 +129,7 @@ export function MentionNote({
to={`/events/${data.id}`} to={`/events/${data.id}`}
className="text-sm text-blue-500 hover:text-blue-600" className="text-sm text-blue-500 hover:text-blue-600"
> >
Show more {t("note.showMore")}
</Link> </Link>
<button <button
type="button" type="button"

View File

@@ -1,5 +1,6 @@
import { COL_TYPES } from "@lume/utils"; import { COL_TYPES } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useArk } from "../../../hooks/useArk"; import { useArk } from "../../../hooks/useArk";
import { useProfile } from "../../../hooks/useProfile"; import { useProfile } from "../../../hooks/useProfile";
@@ -10,6 +11,7 @@ export function MentionUser({ pubkey }: { pubkey: string }) {
const cleanPubkey = ark.getCleanPubkey(pubkey); const cleanPubkey = ark.getCleanPubkey(pubkey);
const { isLoading, isError, user } = useProfile(pubkey); const { isLoading, isError, user } = useProfile(pubkey);
const { t } = useTranslation();
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
return ( return (
@@ -27,7 +29,7 @@ export function MentionUser({ pubkey }: { pubkey: string }) {
to={`/users/${cleanPubkey}`} to={`/users/${cleanPubkey}`}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
View profile {t("note.buttons.viewProfile")}
</Link> </Link>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -36,13 +38,13 @@ export function MentionUser({ pubkey }: { pubkey: string }) {
onClick={async () => onClick={async () =>
await addColumn({ await addColumn({
kind: COL_TYPES.user, kind: COL_TYPES.user,
title: user?.name || user?.displayName || "Profile", title: user?.name || user?.displayName || "User",
content: cleanPubkey, content: cleanPubkey,
}) })
} }
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
Pin {t("note.buttons.pin")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -5,6 +5,7 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { type EventPointer } from "nostr-tools/lib/types/nip19"; import { type EventPointer } from "nostr-tools/lib/types/nip19";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { useColumnContext } from "../column/provider"; import { useColumnContext } from "../column/provider";
@@ -13,7 +14,10 @@ import { useNoteContext } from "./provider";
export function NoteMenu() { export function NoteMenu() {
const event = useNoteContext(); const event = useNoteContext();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const copyID = async () => { const copyID = async () => {
@@ -67,7 +71,7 @@ export function NoteMenu() {
onClick={() => copyLink()} onClick={() => copyLink()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
View thread {t("note.menu.viewThread")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -76,7 +80,7 @@ export function NoteMenu() {
onClick={() => navigate(`/events/${event.id}`)} onClick={() => navigate(`/events/${event.id}`)}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
Copy shareable link {t("note.menu.copyLink")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -85,7 +89,7 @@ export function NoteMenu() {
onClick={() => copyID()} onClick={() => copyID()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
Copy note ID {t("note.menu.copyNoteId")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -94,7 +98,7 @@ export function NoteMenu() {
onClick={() => copyNpub()} onClick={() => copyNpub()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
Copy author ID {t("note.menu.copyAuthorId")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -102,7 +106,7 @@ export function NoteMenu() {
to={`/users/${event.pubkey}`} to={`/users/${event.pubkey}`}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
View author {t("note.menu.viewAuthor")}
</Link> </Link>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -117,7 +121,7 @@ export function NoteMenu() {
} }
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
Pin author {t("note.menu.pinAuthor")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" /> <DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />
@@ -127,7 +131,7 @@ export function NoteMenu() {
onClick={() => copyRaw()} onClick={() => copyRaw()}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
Copy raw event {t("note.menu.copyRaw")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -136,7 +140,7 @@ export function NoteMenu() {
onClick={muteUser} onClick={muteUser}
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none" className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
> >
Mute {t("note.menu.mute")}
</button> </button>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useArk } from "../../hooks/useArk"; import { useArk } from "../../hooks/useArk";
import { AppHandler } from "./appHandler"; import { AppHandler } from "./appHandler";
import { useNoteContext } from "./provider"; import { useNoteContext } from "./provider";
@@ -7,6 +8,7 @@ export function NIP89({ className }: { className?: string }) {
const ark = useArk(); const ark = useArk();
const event = useNoteContext(); const event = useNoteContext();
const { t } = useTranslation();
const { isLoading, isError, data } = useQuery({ const { isLoading, isError, data } = useQuery({
queryKey: ["app-recommend", event.id], queryKey: ["app-recommend", event.id],
queryFn: () => { queryFn: () => {
@@ -33,7 +35,7 @@ export function NIP89({ className }: { className?: string }) {
<div className="flex flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"> <div className="flex flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900">
<div className="inline-flex items-center justify-between h-10 px-3 border-b shrink-0 border-neutral-200 dark:border-neutral-800"> <div className="inline-flex items-center justify-between h-10 px-3 border-b shrink-0 border-neutral-200 dark:border-neutral-800">
<p className="text-sm font-medium text-amber-400"> <p className="text-sm font-medium text-amber-400">
Lume isn't support this event {t("nip89.unsupported")}
</p> </p>
<p className="text-sm text-neutral-600 dark:text-neutral-400"> <p className="text-sm text-neutral-600 dark:text-neutral-400">
{event.kind} {event.kind}
@@ -41,10 +43,10 @@ export function NIP89({ className }: { className?: string }) {
</div> </div>
<div className="flex flex-col flex-1 gap-2 px-3 py-3"> <div className="flex flex-col flex-1 gap-2 px-3 py-3">
<span className="text-sm font-medium uppercase text-neutral-600 dark:text-neutral-400"> <span className="text-sm font-medium uppercase text-neutral-600 dark:text-neutral-400">
Open with {t("nip89.openWith")}
</span> </span>
{data.map((item, index) => ( {data.map((item) => (
<AppHandler key={item[1] + index} tag={item} /> <AppHandler key={item[1]} tag={item} />
))} ))}
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { NDKEventWithReplies } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import * as Collapsible from "@radix-ui/react-collapsible"; import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Note } from ".."; import { Note } from "..";
import { ChildReply } from "./childReply"; import { ChildReply } from "./childReply";
@@ -11,6 +12,7 @@ export function Reply({
}: { }: {
event: NDKEventWithReplies; event: NDKEventWithReplies;
}) { }) {
const [t] = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -30,7 +32,9 @@ export function Reply({
className={cn("size-5", open ? "rotate-180 transform" : "")} className={cn("size-5", open ? "rotate-180 transform" : "")}
/> />
{`${event.replies?.length} ${ {`${event.replies?.length} ${
event.replies?.length === 1 ? "reply" : "replies" event.replies?.length === 1
? t("note.reply.single")
: t("note.reply.plural")
}`} }`}
</div> </div>
</Collapsible.Trigger> </Collapsible.Trigger>

View File

@@ -2,6 +2,7 @@ import { RepostIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Note } from ".."; import { Note } from "..";
import { useArk } from "../../../hooks/useArk"; import { useArk } from "../../../hooks/useArk";
import { User } from "../../user"; import { User } from "../../user";
@@ -12,6 +13,7 @@ export function RepostNote({
}: { event: NDKEvent; className?: string }) { }: { event: NDKEvent; className?: string }) {
const ark = useArk(); const ark = useArk();
const { t } = useTranslation();
const { const {
isLoading, isLoading,
isError, isError,
@@ -51,7 +53,7 @@ export function RepostNote({
<User.Avatar className="size-6 shrink-0 rounded object-cover" /> <User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1"> <div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" /> <User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">reposted</span> <span className="text-blue-500">{t("note.reposted")}</span>
</div> </div>
</div> </div>
</User.Root> </User.Root>
@@ -59,10 +61,6 @@ export function RepostNote({
<div className="px-3 mb-3 select-text"> <div className="px-3 mb-3 select-text">
<div className="flex flex-col items-start justify-start px-3 py-3 bg-red-100 rounded-lg dark:bg-red-900"> <div className="flex flex-col items-start justify-start px-3 py-3 bg-red-100 rounded-lg dark:bg-red-900">
<p className="text-red-500">Failed to get event</p> <p className="text-red-500">Failed to get event</p>
<p className="text-sm">
You can consider enable Outbox in Settings for better event
discovery.
</p>
</div> </div>
</div> </div>
</Note.Root> </Note.Root>
@@ -85,7 +83,7 @@ export function RepostNote({
<User.Avatar className="size-6 shrink-0 rounded object-cover" /> <User.Avatar className="size-6 shrink-0 rounded object-cover" />
<div className="inline-flex items-baseline gap-1"> <div className="inline-flex items-baseline gap-1">
<User.Name className="font-medium text-neutral-900 dark:text-neutral-100" /> <User.Name className="font-medium text-neutral-900 dark:text-neutral-100" />
<span className="text-blue-500">reposted</span> <span className="text-blue-500">{t("note.reposted")}</span>
</div> </div>
</div> </div>
</User.Root> </User.Root>

View File

@@ -1,5 +1,6 @@
import { PinIcon } from "@lume/icons"; import { PinIcon } from "@lume/icons";
import { COL_TYPES, cn } from "@lume/utils"; import { COL_TYPES, cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Note } from "."; import { Note } from ".";
import { useArk } from "../../hooks/useArk"; import { useArk } from "../../hooks/useArk";
@@ -18,6 +19,7 @@ export function NoteThread({
tags: event.tags, tags: event.tags,
}); });
const { t } = useTranslation();
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
if (!thread) return null; if (!thread) return null;
@@ -36,7 +38,7 @@ export function NoteThread({
to={`/events/${thread?.rootEventId || thread?.replyEventId}`} to={`/events/${thread?.rootEventId || thread?.replyEventId}`}
className="self-start text-blue-500 hover:text-blue-600" className="self-start text-blue-500 hover:text-blue-600"
> >
Show thread {t("note.showThread")}
</Link> </Link>
<button <button
type="button" type="button"

View File

@@ -1,6 +1,7 @@
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useArk } from "../../hooks/useArk"; import { useArk } from "../../hooks/useArk";
export function UserFollowButton({ export function UserFollowButton({
@@ -9,6 +10,7 @@ export function UserFollowButton({
}: { target: string; className?: string }) { }: { target: string; className?: string }) {
const ark = useArk(); const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
@@ -43,14 +45,14 @@ export function UserFollowButton({
type="button" type="button"
disabled={loading} disabled={loading}
onClick={toggleFollow} onClick={toggleFollow}
className={cn("", className)} className={cn("w-max", className)}
> >
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : followed ? ( ) : followed ? (
"Unfollow" t("user.unfollow")
) : ( ) : (
"Follow" t("user.follow")
)} )}
</button> </button>
); );

View File

@@ -1,4 +1,4 @@
import { UnverifiedIcon, VerifiedIcon } from "@lume/icons"; import { VerifiedIcon } from "@lume/icons";
import { cn, displayNpub } from "@lume/utils"; import { cn, displayNpub } from "@lume/utils";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useArk } from "../../hooks/useArk"; import { useArk } from "../../hooks/useArk";

View File

@@ -1,3 +1,4 @@
import { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useArk } from "./useArk"; import { useArk } from "./useArk";
@@ -20,7 +21,7 @@ export function useProfile(pubkey: string) {
return profile; return profile;
}, },
initialData: () => { initialData: () => {
return queryClient.getQueryData(["user", pubkey]); return queryClient.getQueryData(["user", pubkey]) as NDKUserProfile;
}, },
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,

View File

@@ -26,6 +26,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.49.3", "react-hook-form": "^7.49.3",
"react-hotkeys-hook": "^4.4.4", "react-hotkeys-hook": "^4.4.4",
"react-i18next": "^14.0.1",
"react-router-dom": "^6.21.3", "react-router-dom": "^6.21.3",
"slate": "^0.101.5", "slate": "^0.101.5",
"slate-react": "^0.101.6", "slate-react": "^0.101.6",

View File

@@ -5,6 +5,7 @@ import * as Avatar from "@radix-ui/react-avatar";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { minidenticon } from "minidenticons"; import { minidenticon } from "minidenticons";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Logout } from "./logout"; import { Logout } from "./logout";
@@ -19,6 +20,7 @@ export function ActiveAccount() {
[], [],
); );
const { t } = useTranslation();
const { user } = useProfile(ark.account.pubkey); const { user } = useProfile(ark.account.pubkey);
return ( return (
@@ -62,7 +64,7 @@ export function ActiveAccount() {
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<UserIcon className="size-4" /> <UserIcon className="size-4" />
Edit profile {t("user.editProfile")}
</Link> </Link>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item asChild> <DropdownMenu.Item asChild>
@@ -71,7 +73,7 @@ export function ActiveAccount() {
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<SettingsIcon className="size-4" /> <SettingsIcon className="size-4" />
Settings {t("user.settings")}
</Link> </Link>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" /> <DropdownMenu.Separator className="h-px my-1 bg-black/10 dark:bg-white/10" />

View File

@@ -3,6 +3,7 @@ import { LogoutIcon } from "@lume/icons";
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import * as AlertDialog from "@radix-ui/react-alert-dialog"; import * as AlertDialog from "@radix-ui/react-alert-dialog";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -12,6 +13,8 @@ export function Logout() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const logout = async () => { const logout = async () => {
try { try {
// logout // logout
@@ -38,7 +41,7 @@ export function Logout() {
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white" className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
> >
<LogoutIcon className="size-4" /> <LogoutIcon className="size-4" />
Logout {t("user.logout")}
</button> </button>
</AlertDialog.Trigger> </AlertDialog.Trigger>
<AlertDialog.Portal> <AlertDialog.Portal>
@@ -47,11 +50,10 @@ export function Logout() {
<div className="relative h-min w-full max-w-md rounded-xl bg-neutral-100 dark:bg-neutral-900"> <div className="relative h-min w-full max-w-md rounded-xl bg-neutral-100 dark:bg-neutral-900">
<div className="flex flex-col gap-1 border-b border-white/5 px-5 py-4"> <div className="flex flex-col gap-1 border-b border-white/5 px-5 py-4">
<AlertDialog.Title className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> <AlertDialog.Title className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Are you sure! {t("user.logoutConfirmTitle")}
</AlertDialog.Title> </AlertDialog.Title>
<AlertDialog.Description className="text-sm leading-tight text-neutral-600 dark:text-neutral-400"> <AlertDialog.Description className="text-sm leading-tight text-neutral-600 dark:text-neutral-400">
You can always log back in at any time. If you just want to {t("user.logoutConfirmSubtitle")}
switch accounts, you can do that by adding an existing account.
</AlertDialog.Description> </AlertDialog.Description>
</div> </div>
<div className="flex justify-end gap-2 px-5 py-3"> <div className="flex justify-end gap-2 px-5 py-3">
@@ -60,7 +62,7 @@ export function Logout() {
type="button" type="button"
className="inline-flex h-9 items-center justify-center rounded-lg px-4 text-sm font-medium text-neutral-900 outline-none hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800" className="inline-flex h-9 items-center justify-center rounded-lg px-4 text-sm font-medium text-neutral-900 outline-none hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
> >
Cancel {t("global.cancel")}
</button> </button>
</AlertDialog.Cancel> </AlertDialog.Cancel>
<AlertDialog.Action asChild> <AlertDialog.Action asChild>
@@ -69,7 +71,7 @@ export function Logout() {
onClick={() => logout()} onClick={() => logout()}
className="inline-flex h-9 items-center justify-center rounded-lg bg-red-500 px-4 text-sm font-medium text-white outline-none hover:bg-red-600" className="inline-flex h-9 items-center justify-center rounded-lg bg-red-500 px-4 text-sm font-medium text-white outline-none hover:bg-red-600"
> >
Logout {t("user.logout")}
</button> </button>
</AlertDialog.Action> </AlertDialog.Action>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { useArk } from "@lume/ark"; import { useArk } from "@lume/ark";
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
export function AvatarUploadButton({ export function AvatarUploadButton({
@@ -9,6 +10,8 @@ export function AvatarUploadButton({
setPicture: Dispatch<SetStateAction<string>>; setPicture: Dispatch<SetStateAction<string>>;
}) { }) {
const ark = useArk(); const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadAvatar = async () => { const uploadAvatar = async () => {
@@ -36,7 +39,7 @@ export function AvatarUploadButton({
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Change avatar" t("user.avatarButton")
)} )}
</button> </button>
); );

View File

@@ -6,6 +6,7 @@ import { COL_TYPES, cn, editorValueAtom } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
Descendant, Descendant,
Editor, Editor,
@@ -200,6 +201,7 @@ export function EditorForm() {
withMentions(withNostrEvent(withImages(withReact(createEditor())))), withMentions(withNostrEvent(withImages(withReact(createEditor())))),
); );
const { t } = useTranslation();
const { addColumn } = useColumnContext(); const { addColumn } = useColumnContext();
const filters = contacts const filters = contacts
@@ -247,9 +249,7 @@ export function EditorForm() {
const publish = await event.publish(); const publish = await event.publish();
if (publish) { if (publish) {
toast.success( toast.success(t("editor.successMessage"));
`Event has been published successfully to ${publish.size} relays.`,
);
// add current post as column thread // add current post as column thread
addColumn({ addColumn({
@@ -321,7 +321,7 @@ export function EditorForm() {
> >
<div className="flex items-center justify-between h-16 pl-7 pr-3 border-b shrink-0 border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950"> <div className="flex items-center justify-between h-16 pl-7 pr-3 border-b shrink-0 border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
<div> <div>
<h3 className="font-medium">New Post</h3> <h3 className="font-medium">{t("editor.title")}</h3>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
@@ -336,7 +336,7 @@ export function EditorForm() {
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Post" t("global.post")
)} )}
</button> </button>
</div> </div>
@@ -349,7 +349,7 @@ export function EditorForm() {
autoCorrect="none" autoCorrect="none"
spellCheck={false} spellCheck={false}
renderElement={(props) => <Element {...props} />} renderElement={(props) => <Element {...props} />}
placeholder="What are you up to?" placeholder={t("editor.placeholder")}
className="focus:outline-none" className="focus:outline-none"
/> />
{target && filters.length > 0 && ( {target && filters.length > 0 && (

View File

@@ -6,6 +6,7 @@ import { cn } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { Portal } from "@radix-ui/react-dropdown-menu"; import { Portal } from "@radix-ui/react-dropdown-menu";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
Descendant, Descendant,
Editor, Editor,
@@ -207,6 +208,8 @@ export function ReplyForm({
withMentions(withNostrEvent(withImages(withReact(createEditor())))), withMentions(withNostrEvent(withImages(withReact(createEditor())))),
); );
const { t } = useTranslation();
const filters = contacts const filters = contacts
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase())) ?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
?.slice(0, 10); ?.slice(0, 10);
@@ -334,7 +337,7 @@ export function ReplyForm({
autoCorrect="none" autoCorrect="none"
spellCheck={false} spellCheck={false}
renderElement={(props) => <Element {...props} />} renderElement={(props) => <Element {...props} />}
placeholder="Post your reply" placeholder={t("editor.replyPlaceholder")}
className="focus:outline-none h-28" className="focus:outline-none h-28"
/> />
{target && filters.length > 0 && ( {target && filters.length > 0 && (
@@ -383,7 +386,7 @@ export function ReplyForm({
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Post" t("global.post")
)} )}
</button> </button>
</div> </div>

View File

@@ -1,11 +1,14 @@
import { InfoIcon } from "@lume/icons"; import { InfoIcon } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
export function EmptyFeed({ export function EmptyFeed({
text, text,
subtext, subtext,
className, className,
}: { text?: string; subtext?: string; className?: string }) { }: { text?: string; subtext?: string; className?: string }) {
const { t } = useTranslation();
return ( return (
<div <div
className={cn( className={cn(
@@ -16,12 +19,10 @@ export function EmptyFeed({
<InfoIcon className="size-8 text-blue-500" /> <InfoIcon className="size-8 text-blue-500" />
<div className="text-center"> <div className="text-center">
<p className="font-semibold text-lg"> <p className="font-semibold text-lg">
{text ? text : "This feed is empty"} {text ? text : t("global.emptyFeedTitle")}
</p> </p>
<p className="leading-tight text-sm"> <p className="leading-tight text-sm">
{subtext {subtext ? subtext : t("global.emptyFeedSubtitle")}
? subtext
: "You can follow more users to build up your timeline"}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -7,9 +7,12 @@ import {
ZapIcon, ZapIcon,
} from "@lume/icons"; } from "@lume/icons";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
export function SettingsLayout() { export function SettingsLayout() {
const { t } = useTranslation();
return ( return (
<div className="flex h-full min-h-0 w-full flex-col rounded-xl overflow-y-auto"> <div className="flex h-full min-h-0 w-full flex-col rounded-xl overflow-y-auto">
<div className="flex h-24 shrink-0 w-full items-center justify-center px-2 bg-white/50 backdrop-blur-xl dark:bg-black/50"> <div className="flex h-24 shrink-0 w-full items-center justify-center px-2 bg-white/50 backdrop-blur-xl dark:bg-black/50">
@@ -27,7 +30,7 @@ export function SettingsLayout() {
} }
> >
<SettingsIcon className="size-6" /> <SettingsIcon className="size-6" />
<p className="text-sm font-medium">General</p> <p className="text-sm font-medium">{t("settings.general.title")}</p>
</NavLink> </NavLink>
<NavLink <NavLink
to="/settings/profile" to="/settings/profile"
@@ -42,7 +45,7 @@ export function SettingsLayout() {
} }
> >
<UserIcon className="size-6" /> <UserIcon className="size-6" />
<p className="text-sm font-medium">User</p> <p className="text-sm font-medium">{t("settings.general.user")}</p>
</NavLink> </NavLink>
<NavLink <NavLink
to="/settings/nwc" to="/settings/nwc"
@@ -56,7 +59,7 @@ export function SettingsLayout() {
} }
> >
<ZapIcon className="size-6" /> <ZapIcon className="size-6" />
<p className="text-sm font-medium">Zap</p> <p className="text-sm font-medium">{t("settings.zap.title")}</p>
</NavLink> </NavLink>
<NavLink <NavLink
to="/settings/backup" to="/settings/backup"
@@ -70,7 +73,7 @@ export function SettingsLayout() {
} }
> >
<SecureIcon className="size-6" /> <SecureIcon className="size-6" />
<p className="text-sm font-medium">Backup</p> <p className="text-sm font-medium">{t("settings.backup.title")}</p>
</NavLink> </NavLink>
<NavLink <NavLink
to="/settings/advanced" to="/settings/advanced"
@@ -84,7 +87,9 @@ export function SettingsLayout() {
} }
> >
<AdvancedSettingsIcon className="size-6" /> <AdvancedSettingsIcon className="size-6" />
<p className="text-sm font-medium">Advanced</p> <p className="text-sm font-medium">
{t("settings.advanced.title")}
</p>
</NavLink> </NavLink>
<NavLink <NavLink
to="/settings/about" to="/settings/about"
@@ -98,7 +103,7 @@ export function SettingsLayout() {
} }
> >
<InfoIcon className="size-6" /> <InfoIcon className="size-6" />
<p className="text-sm font-medium">About</p> <p className="text-sm font-medium">{t("settings.about.title")}</p>
</NavLink> </NavLink>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@ import {
import { NDKCacheUserProfile } from "@lume/types"; import { NDKCacheUserProfile } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
type MentionListRef = { type MentionListRef = {
onKeyDown: (props: { event: Event }) => boolean; onKeyDown: (props: { event: Event }) => boolean;
@@ -22,6 +23,7 @@ const List = (
}, },
ref: Ref<unknown>, ref: Ref<unknown>,
) => { ) => {
const [t] = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => { const selectItem = (index) => {
@@ -107,7 +109,9 @@ const List = (
</button> </button>
)) ))
) : ( ) : (
<div className="text-center text-sm font-medium">No result</div> <div className="text-center text-sm font-medium">
{t("global.noResult")}
</div>
)} )}
</div> </div>
); );

View File

@@ -1,74 +0,0 @@
import { UnverifiedIcon, VerifiedIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
import { memo } from "react";
interface NIP05 {
names: {
[key: string]: string;
};
}
export const NIP05 = memo(function NIP05({
pubkey,
nip05,
className,
}: {
pubkey: string;
nip05: string;
className?: string;
}) {
const { status, data } = useQuery({
queryKey: ["nip05", nip05],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
try {
const localPath = nip05.split("@")[0];
const service = nip05.split("@")[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: "GET",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
signal,
});
if (!res.ok)
throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data: NIP05 = await res.json();
if (data.names) {
if (data.names[localPath.toLowerCase()] === pubkey) return true;
if (data.names[localPath] === pubkey) return true;
return false;
}
return false;
} catch (e) {
throw new Error(`Failed to verify NIP-05, error: ${e}`);
}
},
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
if (status === "pending") {
<div className="h-4 w-4 animate-pulse rounded-full bg-neutral-100 dark:bg-neutral-900" />;
}
return (
<div className="inline-flex items-center gap-1">
<p className={cn("text-sm font-medium", className)}>
{nip05.startsWith("_@") ? nip05.replace("_@", "") : nip05}
</p>
{data === true ? (
<VerifiedIcon className="h-4 w-4 text-teal-500" />
) : (
<UnverifiedIcon className="h-4 w-4 text-red-500" />
)}
</div>
);
});

View File

@@ -5,12 +5,14 @@ import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
export function OnboardingFinishScreen() { export function OnboardingFinishScreen() {
const storage = useStorage(); const storage = useStorage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setOnboarding = useSetAtom(onboardingAtom); const setOnboarding = useSetAtom(onboardingAtom);
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const finish = async () => { const finish = async () => {
@@ -33,9 +35,9 @@ export function OnboardingFinishScreen() {
> >
<CheckIcon className="size-12 text-teal-500" /> <CheckIcon className="size-12 text-teal-500" />
<div className="text-center"> <div className="text-center">
<p className="text-lg font-medium">Profile setup complete!</p> <p className="text-lg font-medium">{t("onboarding.finish.title")}</p>
<p className="leading-tight text-neutral-600 dark:text-neutral-400"> <p className="leading-tight text-neutral-600 dark:text-neutral-400">
You can exit the setup here and start using Lume. {t("onboarding.finish.subtitle")}
</p> </p>
</div> </div>
<div className="mt-4 flex flex-col gap-2 items-center"> <div className="mt-4 flex flex-col gap-2 items-center">
@@ -44,7 +46,11 @@ export function OnboardingFinishScreen() {
onClick={finish} onClick={finish}
className="inline-flex items-center justify-center gap-2 w-44 font-medium h-11 rounded-xl bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800" className="inline-flex items-center justify-center gap-2 w-44 font-medium h-11 rounded-xl bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800"
> >
{loading ? <LoaderIcon className="size-4 animate-spin" /> : "Close"} {loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.close")
)}
</button> </button>
<a <a
href="https://github.com/luminous-devs/lume/issues" href="https://github.com/luminous-devs/lume/issues"
@@ -52,7 +58,7 @@ export function OnboardingFinishScreen() {
className="inline-flex items-center justify-center gap-2 w-44 px-5 font-medium h-11 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 text-neutral-700 dark:text-neutral-600" className="inline-flex items-center justify-center gap-2 w-44 px-5 font-medium h-11 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 text-neutral-700 dark:text-neutral-600"
rel="noreferrer" rel="noreferrer"
> >
Report a issue {t("onboarding.finish.report")}
</a> </a>
</div> </div>
</motion.div> </motion.div>

View File

@@ -1,310 +0,0 @@
import { User, useArk } from "@lume/ark";
import {
ArrowLeftIcon,
CancelIcon,
ChevronDownIcon,
LoaderIcon,
PlusIcon,
} from "@lume/icons";
import { cn } from "@lume/utils";
import * as Accordion from "@radix-ui/react-accordion";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { nip19 } from "nostr-tools";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
const POPULAR_USERS = [
"npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6",
"npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m",
"npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s",
"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
"npub1az9xj85cmxv8e9j9y80lvqp97crsqdu2fpu3srwthd99qfu9qsgstam8y8",
"npub1a2cww4kn9wqte4ry70vyfwqyqvpswksna27rtxd8vty6c74era8sdcw83a",
"npub168ghgug469n4r2tuyw05dmqhqv5jcwm7nxytn67afmz8qkc4a4zqsu2dlc",
"npub133vj8ycevdle0cq8mtgddq0xtn34kxkwxvak983dx0u5vhqnycyqj6tcza",
"npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424",
"npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac",
"npub1prya33fnqerq0fljwjtp77ehtu7jlsjt5ydhwveuwmqdsdm6k8esk42xcv",
"npub19mduaf5569jx9xz555jcx3v06mvktvtpu0zgk47n4lcpjsz43zzqhj6vzk",
];
const LUME_USERS = [
"npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6xw445",
];
export function OnboardingFollowScreen() {
const ark = useArk();
const navigate = useNavigate();
const { isLoading, isError, data } = useQuery({
queryKey: ["trending-users"],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch("https://api.nostr.band/v0/trending/profiles", {
signal,
});
if (!res.ok) {
throw new Error("Failed to fetch trending users from nostr.band API.");
}
return res.json();
},
});
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState<string[]>([]);
// toggle follow state
const toggleFollow = (pubkey: string) => {
const arr = follows.includes(pubkey)
? follows.filter((i) => i !== pubkey)
: [...follows, pubkey];
setFollows(arr);
};
const submit = async () => {
try {
setLoading(true);
if (!follows.length) return navigate("/finish");
const publish = await ark.newContactList({
tags: follows.map((item) => {
if (item.startsWith("npub1"))
return ["p", nip19.decode(item).data as string];
return ["p", item];
}),
});
if (publish) {
setLoading(false);
return navigate("/finish");
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<motion.div className="w-full h-full flex flex-col">
<div className="h-12 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex font-medium text-neutral-700 dark:text-neutral-600 w-full items-center">
Dive into the nostrverse
</div>
<div className="w-full flex-1 mb-0 min-h-0 flex flex-col justify-between h-full">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="flex-1 overflow-y-auto px-8"
>
<p className="leading-snug text-neutral-700 dark:text-neutral-500 my-4">
Nostr is fun when we are together. Try following some users that
interest you to build up your timeline.
</p>
<Accordion.Root type="single" defaultValue="recommended" collapsible>
<Accordion.Item
value="recommended"
className="mb-3 overflow-hidden rounded-xl"
>
<Accordion.Trigger className="flex h-11 w-full items-center justify-between px-3 rounded-t-xl font-medium bg-neutral-50 dark:bg-neutral-950">
Recommended
<ChevronDownIcon className="size-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex w-full flex-col overflow-y-auto rounded-b-xl px-3 bg-neutral-100 dark:bg-neutral-900">
{POPULAR_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col my-3 gap-4 overflow-hidden rounded-lg bg-white dark:bg-black"
>
<User.Provider pubkey={pubkey}>
<User.Root>
<User.Cover className="h-20 w-full rounded-t-lg" />
<div className="flex h-full w-full flex-col gap-2.5 px-3 -mt-6">
<User.Avatar className="size-10 shrink-0 rounded-lg" />
<div className="flex flex-col items-start text-start">
<User.Name className="max-w-[15rem] truncate text-lg font-semibold leadning-tight" />
<User.About className="break-p text-neutral-700 dark:text-neutral-600 max-w-none select-text whitespace-pre-line" />
</div>
</div>
</User.Root>
</User.Provider>
<div className="h-16 shrink-0 px-3 flex items-center border-t border-neutral-100 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={cn(
"inline-flex h-9 shrink-0 w-28 items-center justify-center gap-1 rounded-lg font-medium",
follows.includes(pubkey)
? "text-red-500 bg-red-100 hover:text-white hover:bg-red-500"
: "text-blue-500 bg-blue-100 hover:text-white hover:bg-blue-500",
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="size-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="size-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item
value="trending"
className="mb-3 overflow-hidden rounded-xl"
>
<Accordion.Trigger className="flex h-11 w-full items-center justify-between px-3 rounded-t-xl font-medium bg-neutral-50 dark:bg-neutral-950">
Trending users
<ChevronDownIcon className="size-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex w-full flex-col overflow-y-auto rounded-b-xl px-3 bg-neutral-100 dark:bg-neutral-900">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="size-4 animate-spin" />
</div>
) : isError ? (
<div className="flex h-full w-full items-center justify-center">
Error. Cannot get trending users
</div>
) : (
data?.profiles.map((item: { pubkey: string }) => (
<div
key={item.pubkey}
className="flex h-max w-full shrink-0 flex-col my-3 gap-4 overflow-hidden rounded-lg bg-white dark:bg-black"
>
<User.Provider pubkey={item.pubkey}>
<User.Root>
<User.Cover className="h-20 w-full rounded-t-lg" />
<div className="flex h-full w-full flex-col gap-2.5 px-3 -mt-6">
<User.Avatar className="size-10 shrink-0 rounded-lg" />
<div className="flex flex-col items-start text-start">
<User.Name className="max-w-[15rem] truncate text-lg font-semibold leadning-tight" />
<User.About className="break-p text-neutral-700 dark:text-neutral-600 max-w-none select-text whitespace-pre-line" />
</div>
</div>
</User.Root>
</User.Provider>
<div className="h-16 shrink-0 px-3 flex items-center border-t border-neutral-100 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(item.pubkey)}
className={cn(
"inline-flex h-9 shrink-0 w-28 items-center justify-center gap-1 rounded-lg font-medium",
follows.includes(item.pubkey)
? "text-red-500 bg-red-100 hover:text-white hover:bg-red-500"
: "text-blue-500 bg-blue-100 hover:text-white hover:bg-blue-500",
)}
>
{follows.includes(item.pubkey) ? (
<>
<CancelIcon className="size-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="size-4" />
Follow
</>
)}
</button>
</div>
</div>
))
)}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item
value="lume"
className="mb-3 overflow-hidden rounded-xl"
>
<Accordion.Trigger className="flex h-11 w-full items-center justify-between px-3 rounded-t-xl font-medium bg-neutral-50 dark:bg-neutral-950">
Lume HQ
<ChevronDownIcon className="size-4" />
</Accordion.Trigger>
<Accordion.Content>
<div className="flex w-full flex-col overflow-y-auto rounded-b-xl px-3 bg-neutral-100 dark:bg-neutral-900">
{LUME_USERS.map((pubkey) => (
<div
key={pubkey}
className="flex h-max w-full shrink-0 flex-col my-3 gap-4 overflow-hidden rounded-lg bg-white dark:bg-black"
>
<User.Provider pubkey={pubkey}>
<User.Root>
<User.Cover className="h-20 w-full rounded-t-lg" />
<div className="flex h-full w-full flex-col gap-2.5 px-3 -mt-6">
<User.Avatar className="size-10 shrink-0 rounded-lg" />
<div className="flex flex-col items-start text-start">
<User.Name className="max-w-[15rem] truncate text-lg font-semibold leadning-tight" />
<User.About className="break-p text-neutral-700 dark:text-neutral-600 max-w-none select-text whitespace-pre-line" />
</div>
</div>
</User.Root>
</User.Provider>
<div className="h-16 shrink-0 px-3 flex items-center border-t border-neutral-100 dark:border-neutral-900">
<button
type="button"
onClick={() => toggleFollow(pubkey)}
className={cn(
"inline-flex h-9 shrink-0 w-28 items-center justify-center gap-1 rounded-lg font-medium",
follows.includes(pubkey)
? "text-red-500 bg-red-100 hover:text-white hover:bg-red-500"
: "text-blue-500 bg-blue-100 hover:text-white hover:bg-blue-500",
)}
>
{follows.includes(pubkey) ? (
<>
<CancelIcon className="size-4" />
Unfollow
</>
) : (
<>
<PlusIcon className="size-4" />
Follow
</>
)}
</button>
</div>
</div>
))}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</motion.div>
<div className="h-16 w-full shrink-0 flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200"
>
<ArrowLeftIcon className="size-4" />
Back
</button>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 flex-1 shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
"Continue"
)}
</button>
</div>
</div>
</motion.div>
);
}

View File

@@ -2,10 +2,13 @@ import { ArrowRightIcon, PopperFilledIcon } from "@lume/icons";
import { onboardingAtom } from "@lume/utils"; import { onboardingAtom } from "@lume/utils";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export function OnboardingHomeScreen() { export function OnboardingHomeScreen() {
const navigate = useNavigate(); const navigate = useNavigate();
const [t] = useTranslation();
const [onboarding, setOnboarding] = useAtom(onboardingAtom); const [onboarding, setOnboarding] = useAtom(onboardingAtom);
return ( return (
@@ -17,11 +20,9 @@ export function OnboardingHomeScreen() {
> >
<PopperFilledIcon className="size-12 text-blue-500" /> <PopperFilledIcon className="size-12 text-blue-500" />
<div className="text-center"> <div className="text-center">
<p className="text-lg font-medium"> <p className="text-lg font-medium">{t("onboarding.home.title")}</p>
Your account was successfully created!
</p>
<p className="leading-tight text-neutral-600 dark:text-neutral-400"> <p className="leading-tight text-neutral-600 dark:text-neutral-400">
For starters, let's set up your profile. {t("onboarding.home.subtitle")}
</p> </p>
</div> </div>
<div className="mt-4 flex flex-col gap-2 items-center"> <div className="mt-4 flex flex-col gap-2 items-center">
@@ -32,7 +33,7 @@ export function OnboardingHomeScreen() {
} }
className="inline-flex items-center justify-center gap-2 w-44 font-medium h-11 rounded-xl bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800" className="inline-flex items-center justify-center gap-2 w-44 font-medium h-11 rounded-xl bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:bg-blue-800"
> >
Profile Settings {t("onboarding.home.profileSettings")}
<ArrowRightIcon className="size-4" /> <ArrowRightIcon className="size-4" />
</button> </button>
<button <button
@@ -40,7 +41,7 @@ export function OnboardingHomeScreen() {
onClick={() => setOnboarding({ open: false, newUser: false })} onClick={() => setOnboarding({ open: false, newUser: false })}
className="inline-flex items-center justify-center gap-2 w-44 px-5 font-medium h-11 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 text-neutral-700 dark:text-neutral-600" className="inline-flex items-center justify-center gap-2 w-44 px-5 font-medium h-11 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-900 text-neutral-700 dark:text-neutral-600"
> >
Skip {t("global.skip")}
</button> </button>
</div> </div>
</motion.div> </motion.div>

View File

@@ -2,6 +2,7 @@ import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage"; import { useStorage } from "@lume/storage";
import { TOPICS, cn } from "@lume/utils"; import { TOPICS, cn } from "@lume/utils";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -9,6 +10,7 @@ export function OnboardingInterestScreen() {
const storage = useStorage(); const storage = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const [t] = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hashtags, setHashtags] = useState([]); const [hashtags, setHashtags] = useState([]);
@@ -49,9 +51,9 @@ export function OnboardingInterestScreen() {
<div className="w-full h-full flex flex-col"> <div className="w-full h-full flex flex-col">
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between"> <div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<h3 className="font-semibold">Interests</h3> <h3 className="font-semibold">{t("interests.title")}</h3>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Pick things you'd like to see in your home feed. {t("interests.subtitle")}
</p> </p>
</div> </div>
</div> </div>
@@ -74,7 +76,7 @@ export function OnboardingInterestScreen() {
onClick={() => toggleAll(topic.content)} onClick={() => toggleAll(topic.content)}
className="text-sm font-medium text-blue-500" className="text-sm font-medium text-blue-500"
> >
Follow All {t("interests.followAll")}
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -105,7 +107,7 @@ export function OnboardingInterestScreen() {
className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200" className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200"
> >
<ArrowLeftIcon className="size-4" /> <ArrowLeftIcon className="size-4" />
Back {t("global.back")}
</button> </button>
<button <button
type="button" type="button"
@@ -115,7 +117,7 @@ export function OnboardingInterestScreen() {
{loading ? ( {loading ? (
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</div> </div>

View File

@@ -7,6 +7,7 @@ import { motion } from "framer-motion";
import { minidenticon } from "minidenticons"; import { minidenticon } from "minidenticons";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { AvatarUploadButton } from "../avatarUploadButton"; import { AvatarUploadButton } from "../avatarUploadButton";
@@ -20,6 +21,7 @@ export function OnboardingProfileScreen() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const { register, handleSubmit } = useForm(); const { register, handleSubmit } = useForm();
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent( const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
@@ -71,9 +73,9 @@ export function OnboardingProfileScreen() {
<div className="w-full h-full flex flex-col gap-4"> <div className="w-full h-full flex flex-col gap-4">
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between"> <div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<h3 className="font-semibold">About you</h3> <h3 className="font-semibold">{t("onboarding.profile.title")}</h3>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Tell Lume about yourself to start building your home feed. {t("onboarding.profile.subtitle")}
</p> </p>
</div> </div>
</div> </div>
@@ -89,7 +91,7 @@ export function OnboardingProfileScreen() {
className="flex flex-col px-8 gap-4" className="flex flex-col px-8 gap-4"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium">Avatar</span> <span className="font-medium">{t("user.avatar")}</span>
<div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-950"> <div className="flex h-36 w-full flex-col items-center justify-center gap-3 rounded-lg bg-neutral-100 dark:bg-neutral-950">
{picture.length ? ( {picture.length ? (
<img <img
@@ -109,7 +111,7 @@ export function OnboardingProfileScreen() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="name" className="font-medium"> <label htmlFor="name" className="font-medium">
Name * {t("user.name")} *
</label> </label>
<input <input
type={"text"} type={"text"}
@@ -121,7 +123,7 @@ export function OnboardingProfileScreen() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="about" className="font-medium"> <label htmlFor="about" className="font-medium">
Bio {t("user.bio")}
</label> </label>
<textarea <textarea
{...register("about")} {...register("about")}
@@ -132,7 +134,7 @@ export function OnboardingProfileScreen() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label htmlFor="website" className="font-medium"> <label htmlFor="website" className="font-medium">
Website {t("user.website")}
</label> </label>
<input <input
type="url" type="url"
@@ -150,7 +152,7 @@ export function OnboardingProfileScreen() {
className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200" className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200"
> >
<ArrowLeftIcon className="size-4" /> <ArrowLeftIcon className="size-4" />
Back {t("global.back")}
</button> </button>
<button <button
type="submit" type="submit"
@@ -159,7 +161,7 @@ export function OnboardingProfileScreen() {
{loading ? ( {loading ? (
<LoaderIcon className="h-4 w-4 animate-spin" /> <LoaderIcon className="h-4 w-4 animate-spin" />
) : ( ) : (
"Continue" t("global.continue")
)} )}
</button> </button>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { NDKEventWithReplies } from "@lume/types";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { NDKKind, type NDKSubscription } from "@nostr-dev-kit/ndk"; import { NDKKind, type NDKSubscription } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ReplyForm } from "./editor/replyForm"; import { ReplyForm } from "./editor/replyForm";
export function ReplyList({ export function ReplyList({
@@ -11,6 +12,8 @@ export function ReplyList({
className, className,
}: { eventId: string; className?: string }) { }: { eventId: string; className?: string }) {
const ark = useArk(); const ark = useArk();
const [t] = useTranslation();
const [data, setData] = useState<null | NDKEventWithReplies[]>(null); const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
useEffect(() => { useEffect(() => {
@@ -68,7 +71,7 @@ export function ReplyList({
<div className="flex flex-col items-center justify-center gap-2 py-6"> <div className="flex flex-col items-center justify-center gap-2 py-6">
<h3 className="text-3xl">👋</h3> <h3 className="text-3xl">👋</h3>
<p className="leading-none text-neutral-600 dark:text-neutral-400"> <p className="leading-none text-neutral-600 dark:text-neutral-400">
Be the first to Reply! {t("note.reply.empty")}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { User } from "@lume/ark"; import { User } from "@lume/ark";
import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons"; import { ArrowLeftIcon, ArrowRightIcon, LoaderIcon } from "@lume/icons";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { WindowVirtualizer } from "virtua"; import { WindowVirtualizer } from "virtua";
@@ -28,6 +29,7 @@ export function SuggestRoute({ queryKey }: { queryKey: string[] }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const { isLoading, isError, data } = useQuery({ const { isLoading, isError, data } = useQuery({
queryKey: ["trending-users"], queryKey: ["trending-users"],
queryFn: async ({ signal }: { signal: AbortSignal }) => { queryFn: async ({ signal }: { signal: AbortSignal }) => {
@@ -71,7 +73,7 @@ export function SuggestRoute({ queryKey }: { queryKey: string[] }) {
</div> </div>
<div className="relative px-3"> <div className="relative px-3">
<div className="flex items-center h-16"> <div className="flex items-center h-16">
<h3 className="font-semibold text-xl">Suggested Follows</h3> <h3 className="font-semibold text-xl">{t("suggestion.title")}</h3>
</div> </div>
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900"> <div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-900">
{isLoading ? ( {isLoading ? (
@@ -80,7 +82,7 @@ export function SuggestRoute({ queryKey }: { queryKey: string[] }) {
</div> </div>
) : isError ? ( ) : isError ? (
<div className="flex h-44 w-full items-center justify-center"> <div className="flex h-44 w-full items-center justify-center">
Error. Cannot get trending users {t("suggestion.error")}
</div> </div>
) : ( ) : (
data?.profiles.map((item: { pubkey: string }) => ( data?.profiles.map((item: { pubkey: string }) => (
@@ -115,7 +117,7 @@ export function SuggestRoute({ queryKey }: { queryKey: string[] }) {
onClick={submit} onClick={submit}
className="inline-flex items-center justify-center gap-2 px-6 font-medium shadow-xl dark:shadow-none shadow-neutral-500/50 text-white transform bg-blue-500 rounded-full active:translate-y-1 w-44 h-11 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed" className="inline-flex items-center justify-center gap-2 px-6 font-medium shadow-xl dark:shadow-none shadow-neutral-500/50 text-white transform bg-blue-500 rounded-full active:translate-y-1 w-44 h-11 hover:bg-blue-600 focus:outline-none disabled:cursor-not-allowed"
> >
Save & Go back {t("suggestion.button")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -9,6 +9,7 @@ import { FETCH_LIMIT } from "@lume/utils";
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { WindowVirtualizer } from "virtua"; import { WindowVirtualizer } from "virtua";
@@ -17,6 +18,7 @@ export function UserRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { t } = useTranslation();
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } = const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ["user-posts", id], queryKey: ["user-posts", id],
@@ -107,7 +109,7 @@ export function UserRoute() {
</User.Provider> </User.Provider>
<div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900"> <div className="pt-2 mt-2 border-t border-neutral-100 dark:border-neutral-900">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Latest posts {t("user.latestPosts")}
</h3> </h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10"> <div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{isLoading ? ( {isLoading ? (
@@ -130,7 +132,7 @@ export function UserRoute() {
) : ( ) : (
<> <>
<ArrowRightCircleIcon className="size-5" /> <ArrowRightCircleIcon className="size-5" />
Load more {t("global.loadMore")}
</> </>
)} )}
</button> </button>

View File

@@ -4,17 +4,20 @@ import { COL_TYPES, searchAtom } from "@lume/utils";
import { type NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; import { type NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { Command } from "../cmdk"; import { Command } from "../cmdk";
export function SearchDialog() { export function SearchDialog() {
const ark = useArk();
const [open, setOpen] = useAtom(searchAtom); const [open, setOpen] = useAtom(searchAtom);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<NDKEvent[]>([]); const [events, setEvents] = useState<NDKEvent[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [value] = useDebounce(search, 1200); const [value] = useDebounce(search, 1200);
const ark = useArk(); const { t } = useTranslation();
const { vlistRef, columns, addColumn } = useColumnContext(); const { vlistRef, columns, addColumn } = useColumnContext();
const searchEvents = async () => { const searchEvents = async () => {
@@ -90,7 +93,7 @@ export function SearchDialog() {
<Command.Input <Command.Input
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
placeholder="Type something to search..." placeholder={t("search.placeholder")}
className="w-full h-12 bg-neutral-100 dark:bg-neutral-900 rounded-xl border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600" className="w-full h-12 bg-neutral-100 dark:bg-neutral-900 rounded-xl border-none focus:outline-none focus:ring-0 placeholder:text-neutral-500 dark:placeholder:text-neutral-600"
/> />
</div> </div>
@@ -101,7 +104,7 @@ export function SearchDialog() {
</Command.Loading> </Command.Loading>
) : !events.length ? ( ) : !events.length ? (
<Command.Empty className="flex items-center justify-center h-full text-sm"> <Command.Empty className="flex items-center justify-center h-full text-sm">
No results found. {t("global.noResult")}
</Command.Empty> </Command.Empty>
) : ( ) : (
<> <>
@@ -161,7 +164,7 @@ export function SearchDialog() {
<div className="size-16 bg-blue-100 dark:bg-blue-900 rounded-full inline-flex items-center justify-center text-blue-500"> <div className="size-16 bg-blue-100 dark:bg-blue-900 rounded-full inline-flex items-center justify-center text-blue-500">
<SearchIcon className="size-6" /> <SearchIcon className="size-6" />
</div> </div>
Try searching for people, notes, or keywords {t("search.empty")}
</div> </div>
) : null} ) : null}
</Command.List> </Command.List>

View File

@@ -1,5 +1,10 @@
export const FETCH_LIMIT = 20; export const FETCH_LIMIT = 20;
export const LANGUAGES = [
{ label: "English", code: "en" },
{ label: "Japanese", code: "ja" },
];
export const NOSTR_MENTIONS = [ export const NOSTR_MENTIONS = [
"@npub1", "@npub1",
"nostr:npub1", "nostr:npub1",
@@ -26,7 +31,7 @@ export const NOSTR_EVENTS = [
"Nostr:nevent1", "Nostr:nevent1",
]; ];
export const BITCOINS = ['lnbc', 'bc1p', 'bc1q']; export const BITCOINS = ["lnbc", "bc1p", "bc1q"];
export const IMAGES = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]; export const IMAGES = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"];
@@ -374,4 +379,5 @@ export const QUOTES = [
"Are you a fan of following topics, instead of people? Use https://zapddit.com", "Are you a fan of following topics, instead of people? Use https://zapddit.com",
]; ];
// @ts-ignore, it works
export const VITE_FLATPAK_RESOURCE = import.meta.env.VITE_FLATPAK_RESOURCE; export const VITE_FLATPAK_RESOURCE = import.meta.env.VITE_FLATPAK_RESOURCE;

58
pnpm-lock.yaml generated
View File

@@ -150,6 +150,12 @@ importers:
framer-motion: framer-motion:
specifier: ^10.18.0 specifier: ^10.18.0
version: 10.18.0(react-dom@18.2.0)(react@18.2.0) version: 10.18.0(react-dom@18.2.0)(react@18.2.0)
i18next:
specifier: ^23.8.0
version: 23.8.0
i18next-resources-to-backend:
specifier: ^1.2.0
version: 1.2.0
jotai: jotai:
specifier: ^2.6.3 specifier: ^2.6.3
version: 2.6.3(@types/react@18.2.48)(react@18.2.0) version: 2.6.3(@types/react@18.2.48)(react@18.2.0)
@@ -177,6 +183,9 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.49.3 specifier: ^7.49.3
version: 7.49.3(react@18.2.0) version: 7.49.3(react@18.2.0)
react-i18next:
specifier: ^14.0.1
version: 14.0.1(i18next@23.8.0)(react-dom@18.2.0)(react@18.2.0)
react-router-dom: react-router-dom:
specifier: ^6.21.3 specifier: ^6.21.3
version: 6.21.3(react-dom@18.2.0)(react@18.2.0) version: 6.21.3(react-dom@18.2.0)(react@18.2.0)
@@ -359,6 +368,9 @@ importers:
react-currency-input-field: react-currency-input-field:
specifier: ^3.6.14 specifier: ^3.6.14
version: 3.6.14(react@18.2.0) version: 3.6.14(react@18.2.0)
react-i18next:
specifier: ^14.0.1
version: 14.0.1(i18next@23.8.0)(react-dom@18.2.0)(react@18.2.0)
react-router-dom: react-router-dom:
specifier: ^6.21.3 specifier: ^6.21.3
version: 6.21.3(react-dom@18.2.0)(react@18.2.0) version: 6.21.3(react-dom@18.2.0)(react@18.2.0)
@@ -993,6 +1005,9 @@ importers:
react-hotkeys-hook: react-hotkeys-hook:
specifier: ^4.4.4 specifier: ^4.4.4
version: 4.4.4(react-dom@18.2.0)(react@18.2.0) version: 4.4.4(react-dom@18.2.0)(react@18.2.0)
react-i18next:
specifier: ^14.0.1
version: 14.0.1(i18next@23.8.0)(react-dom@18.2.0)(react@18.2.0)
react-router-dom: react-router-dom:
specifier: ^6.21.3 specifier: ^6.21.3
version: 6.21.3(react-dom@18.2.0)(react@18.2.0) version: 6.21.3(react-dom@18.2.0)(react@18.2.0)
@@ -5180,6 +5195,12 @@ packages:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
dev: false dev: false
/html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
dependencies:
void-elements: 3.1.0
dev: false
/html-void-elements@3.0.0: /html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
dev: false dev: false
@@ -5203,6 +5224,18 @@ packages:
engines: {node: '>=16.17.0'} engines: {node: '>=16.17.0'}
dev: false dev: false
/i18next-resources-to-backend@1.2.0:
resolution: {integrity: sha512-8f1l03s+QxDmCfpSXCh9V+AFcxAwIp0UaroWuyOx+hmmv8484GcELHs+lnu54FrNij8cDBEXvEwhzZoXsKcVpg==}
dependencies:
'@babel/runtime': 7.23.9
dev: false
/i18next@23.8.0:
resolution: {integrity: sha512-1H+39doU9dQZrRprpnZ2aZetbX9I1N3bM/YGHN/ZkMJ//wJqrxDEqgI5mmSsh/rglsFBiNxI6UtFZfUO2A6XbA==}
dependencies:
'@babel/runtime': 7.23.9
dev: false
/iconv-lite@0.4.23: /iconv-lite@0.4.23:
resolution: {integrity: sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==} resolution: {integrity: sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -6970,6 +7003,26 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/react-i18next@14.0.1(i18next@23.8.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-TMV8hFismBmpMdIehoFHin/okfvgjFhp723RYgIqB4XyhDobVMyukyM3Z8wtTRmajyFMZrBl/OaaXF2P6WjUAw==}
peerDependencies:
i18next: '>= 23.2.3'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
dependencies:
'@babel/runtime': 7.23.9
html-parse-stringify: 3.0.1
i18next: 23.8.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-remove-scroll-bar@2.3.4(@types/react@18.2.48)(react@18.2.0): /react-remove-scroll-bar@2.3.4(@types/react@18.2.48)(react@18.2.0):
resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8594,6 +8647,11 @@ packages:
vite: 5.0.12(@types/node@20.11.8) vite: 5.0.12(@types/node@20.11.8)
dev: false dev: false
/void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
dev: false
/volar-service-css@0.0.17(@volar/language-service@1.11.1): /volar-service-css@0.0.17(@volar/language-service@1.11.1):
resolution: {integrity: sha512-bEDJykygMzn2+a9ud6KwZZLli9eqarxApAXZuf2CqJJh6Trw1elmbBCo9SlPfqMrIhpFnwV0Sa+Xoc9x5WPeGw==} resolution: {integrity: sha512-bEDJykygMzn2+a9ud6KwZZLli9eqarxApAXZuf2CqJJh6Trw1elmbBCo9SlPfqMrIhpFnwV0Sa+Xoc9x5WPeGw==}
peerDependencies: peerDependencies:

295
src-tauri/locales/cn.json Normal file
View File

@@ -0,0 +1,295 @@
{
"global": {
"relay": "Relay",
"back": "Back",
"continue": "Continue",
"loading": "Loading",
"error": "Error",
"moveLeft": "Move Left",
"moveRight": "Move Right",
"newColumn": "New Column",
"inspect": "Inspect",
"loadMore": "Load more",
"delete": "Delete",
"refresh": "Refresh",
"cancel": "Cancel",
"save": "Save",
"post": "Post",
"update": "Update",
"noResult": "No results found.",
"emptyFeedTitle": "This feed is empty",
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
"apiKey": "API Key",
"skip": "Skip",
"close": "Close"
},
"nip89": {
"unsupported": "Lume isn't support this event",
"openWith": "Open with"
},
"note": {
"showThread": "Show thread",
"showMore": "Show more",
"error": "Failed to fetch event.",
"posted": "posted",
"replied": "replied",
"reposted": "reposted",
"menu": {
"viewThread": "View thread",
"copyLink": "Copy shareable link",
"copyNoteId": "Copy note ID",
"copyAuthorId": "Copy author ID",
"viewAuthor": "View author",
"pinAuthor": "Pin author",
"copyRaw": "Copy raw event",
"mute": "Mute"
},
"buttons": {
"pin": "Pin",
"pinTooltip": "Pin Note",
"repost": "Repost",
"quote": "Quote",
"viewProfile": "View profile"
},
"zap": {
"zap": "Zap",
"tooltip": "Send zap",
"modalTitle": "Send zap to",
"messagePlaceholder": "Enter message (optional)",
"buttonFinish": "Zapped",
"buttonLoading": "Processing...",
"invoiceButton": "Scan to zap",
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
},
"reply": {
"single": "reply",
"plural": "replies",
"empty": "Be the first to Reply!"
}
},
"user": {
"avatar": "Avatar",
"displayName": "Display Name",
"name": "Name",
"bio": "Bio",
"lna": "Lightning address",
"website": "Website",
"verified": "Verified",
"unverified": "Unverified",
"follow": "Follow",
"unfollow": "Unfollow",
"latestPosts": "Latest posts",
"avatarButton": "Change avatar",
"coverButton": "Change cover",
"editProfile": "Edit profile",
"settings": "Settings",
"logout": "Log out",
"logoutConfirmTitle": "Are you sure!",
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
},
"editor": {
"title": "New Post",
"placeholder": "What are you up to?",
"successMessage": "Your note has been published successfully.",
"replyPlaceholder": "Post your reply"
},
"search": {
"placeholder": "Type something to search...",
"empty": "Try searching for people, notes, or keywords"
},
"welcome": {
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
"signup": "Join Nostr",
"login": "Login",
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
},
"login": {
"title": "Welcome back, anon!",
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
"loginWithAddress": "Login with Nostr Address",
"loginWithBunker": "Login with nsecBunker",
"or": "Or continue with",
"loginWithPrivkey": "Login with Private Key"
},
"loginWithAddress": {
"title": "Enter your Nostr Address"
},
"loginWithBunker": {
"title": "Enter your nsecbunker token"
},
"loginWithPrivkey": {
"title": "Enter your Private Key",
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
},
"signup": {
"title": "Let's Get Started",
"subtitle": "Choose one of methods below to create your account",
"selfManageMethod": "Self-Managed",
"selfManageMethodDescription": "You create your keys and keep them safe.",
"providerMethod": "Managed by Provider",
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
},
"signupWithSelfManage": {
"title": "This is your new Account Key",
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
"confirm1": "I understand the risk of lost private key.",
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
"confirm3": "I understand I cannot recover private key.",
"button": "Save key & Continue"
},
"signupWithProvider": {
"title": "Let's set up your account on Nostr",
"username": "Username *",
"chooseProvider": "Choose a Provider",
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
"email": "Backup Email (optional)",
"emailFooter": "Use for recover your account if you lose your password"
},
"onboardingSettings": {
"title": "You're almost ready to use Lume.",
"subtitle": "Let's start personalizing your experience.",
"notification": {
"title": "Push notification",
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
},
"lowPower": {
"title": "Low Power Mode",
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
},
"translation": {
"title": "Translation (nostr.wine)",
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
},
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
},
"relays": {
"global": "Global",
"follows": "Follows",
"sidebar": {
"title": "Connected relays",
"empty": "Empty."
},
"relayView": {
"empty": "Could not load relay information 😬",
"owner": "Owner",
"contact": "Contact",
"software": "Software",
"nips": "Supported NIPs",
"limit": "Limitation",
"payment": "Open payment website",
"paymentNote": "You need to make a payment to connect this relay"
}
},
"suggestion": {
"title": "Suggested Follows",
"error": "Error. Cannot get trending users",
"button": "Save & Go back"
},
"interests": {
"title": "Interests",
"subtitle": "Pick things you'd like to see in your home feed.",
"edit": "Edit Interest",
"followAll": "Follow All",
"unfollowAll": "Unfollow All"
},
"settings": {
"general": {
"title": "General",
"update": {
"title": "Update",
"subtitle": "Automatically download new update"
},
"lowPower": {
"title": "Low Power",
"subtitle": "Sustainable for low network environment"
},
"startup": {
"title": "Startup",
"subtitle": "Launch Lume at Login"
},
"media": {
"title": "Media",
"subtitle": "Automatically load media"
},
"hashtag": {
"title": "Hashtag",
"subtitle": "Show all hashtags in content"
},
"notification": {
"title": "Notification",
"subtitle": "Automatically send notification"
},
"translation": {
"title": "Translation",
"subtitle": "Translate text to your language"
},
"appearance": {
"title": "Appearance",
"light": "Light",
"dark": "Dark",
"system": "System"
}
},
"user": {
"title": "User"
},
"zap": {
"title": "Zap",
"nwc": "Connection String"
},
"backup": {
"title": "Backup",
"privkey": {
"title": "Private key",
"button": "Remove private key"
}
},
"advanced": {
"title": "Advanced",
"cache": {
"title": "Cache",
"subtitle": "Use for boost up nostr connection",
"button": "Clear"
},
"instant": {
"title": "Instant Zap",
"subtitle": "Zap with default amount, no confirmation"
},
"defaultAmount": "Default amount"
},
"about": {
"title": "About",
"version": "Version",
"checkUpdate": "Check for update",
"installUpdate": "Install"
}
},
"onboarding": {
"home": {
"title": "Your account was successfully created!",
"subtitle": "For starters, let's set up your profile.",
"profileSettings": "Profile Settings"
},
"profile": {
"title": "About you",
"subtitle": "Tell Lume about yourself to start building your home feed."
},
"finish": {
"title": "Profile setup complete!",
"subtitle": "You can exit the setup here and start using Lume.",
"report": "Report a issue"
}
},
"activity": {
"title": "Activity",
"empty": "Yo! Nothing new yet.",
"mention": "mention you",
"repost": "reposted",
"zap": "zapped",
"newReply": "New reply",
"boost": "Boost",
"boostSubtitle": "@ Someone has reposted to your note",
"conversation": "Conversation",
"conversationSubtitle": "@ Someone has replied to your note"
}
}

295
src-tauri/locales/en.json Normal file
View File

@@ -0,0 +1,295 @@
{
"global": {
"relay": "Relay",
"back": "Back",
"continue": "Continue",
"loading": "Loading",
"error": "Error",
"moveLeft": "Move Left",
"moveRight": "Move Right",
"newColumn": "New Column",
"inspect": "Inspect",
"loadMore": "Load more",
"delete": "Delete",
"refresh": "Refresh",
"cancel": "Cancel",
"save": "Save",
"post": "Post",
"update": "Update",
"noResult": "No results found.",
"emptyFeedTitle": "This feed is empty",
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
"apiKey": "API Key",
"skip": "Skip",
"close": "Close"
},
"nip89": {
"unsupported": "Lume isn't support this event",
"openWith": "Open with"
},
"note": {
"showThread": "Show thread",
"showMore": "Show more",
"error": "Failed to fetch event.",
"posted": "posted",
"replied": "replied",
"reposted": "reposted",
"menu": {
"viewThread": "View thread",
"copyLink": "Copy shareable link",
"copyNoteId": "Copy note ID",
"copyAuthorId": "Copy author ID",
"viewAuthor": "View author",
"pinAuthor": "Pin author",
"copyRaw": "Copy raw event",
"mute": "Mute"
},
"buttons": {
"pin": "Pin",
"pinTooltip": "Pin Note",
"repost": "Repost",
"quote": "Quote",
"viewProfile": "View profile"
},
"zap": {
"zap": "Zap",
"tooltip": "Send zap",
"modalTitle": "Send zap to",
"messagePlaceholder": "Enter message (optional)",
"buttonFinish": "Zapped",
"buttonLoading": "Processing...",
"invoiceButton": "Scan to zap",
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
},
"reply": {
"single": "reply",
"plural": "replies",
"empty": "Be the first to Reply!"
}
},
"user": {
"avatar": "Avatar",
"displayName": "Display Name",
"name": "Name",
"bio": "Bio",
"lna": "Lightning address",
"website": "Website",
"verified": "Verified",
"unverified": "Unverified",
"follow": "Follow",
"unfollow": "Unfollow",
"latestPosts": "Latest posts",
"avatarButton": "Change avatar",
"coverButton": "Change cover",
"editProfile": "Edit profile",
"settings": "Settings",
"logout": "Log out",
"logoutConfirmTitle": "Are you sure!",
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
},
"editor": {
"title": "New Post",
"placeholder": "What are you up to?",
"successMessage": "Your note has been published successfully.",
"replyPlaceholder": "Post your reply"
},
"search": {
"placeholder": "Type something to search...",
"empty": "Try searching for people, notes, or keywords"
},
"welcome": {
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
"signup": "Join Nostr",
"login": "Login",
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
},
"login": {
"title": "Welcome back, anon!",
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
"loginWithAddress": "Login with Nostr Address",
"loginWithBunker": "Login with nsecBunker",
"or": "Or continue with",
"loginWithPrivkey": "Login with Private Key"
},
"loginWithAddress": {
"title": "Enter your Nostr Address"
},
"loginWithBunker": {
"title": "Enter your nsecbunker token"
},
"loginWithPrivkey": {
"title": "Enter your Private Key",
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
},
"signup": {
"title": "Let's Get Started",
"subtitle": "Choose one of methods below to create your account",
"selfManageMethod": "Self-Managed",
"selfManageMethodDescription": "You create your keys and keep them safe.",
"providerMethod": "Managed by Provider",
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
},
"signupWithSelfManage": {
"title": "This is your new Account Key",
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
"confirm1": "I understand the risk of lost private key.",
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
"confirm3": "I understand I cannot recover private key.",
"button": "Save key & Continue"
},
"signupWithProvider": {
"title": "Let's set up your account on Nostr",
"username": "Username *",
"chooseProvider": "Choose a Provider",
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
"email": "Backup Email (optional)",
"emailFooter": "Use for recover your account if you lose your password"
},
"onboardingSettings": {
"title": "You're almost ready to use Lume.",
"subtitle": "Let's start personalizing your experience.",
"notification": {
"title": "Push notification",
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
},
"lowPower": {
"title": "Low Power Mode",
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
},
"translation": {
"title": "Translation (nostr.wine)",
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
},
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
},
"relays": {
"global": "Global",
"follows": "Follows",
"sidebar": {
"title": "Connected relays",
"empty": "Empty."
},
"relayView": {
"empty": "Could not load relay information 😬",
"owner": "Owner",
"contact": "Contact",
"software": "Software",
"nips": "Supported NIPs",
"limit": "Limitation",
"payment": "Open payment website",
"paymentNote": "You need to make a payment to connect this relay"
}
},
"suggestion": {
"title": "Suggested Follows",
"error": "Error. Cannot get trending users",
"button": "Save & Go back"
},
"interests": {
"title": "Interests",
"subtitle": "Pick things you'd like to see in your home feed.",
"edit": "Edit Interest",
"followAll": "Follow All",
"unfollowAll": "Unfollow All"
},
"settings": {
"general": {
"title": "General",
"update": {
"title": "Update",
"subtitle": "Automatically download new update"
},
"lowPower": {
"title": "Low Power",
"subtitle": "Sustainable for low network environment"
},
"startup": {
"title": "Startup",
"subtitle": "Launch Lume at Login"
},
"media": {
"title": "Media",
"subtitle": "Automatically load media"
},
"hashtag": {
"title": "Hashtag",
"subtitle": "Show all hashtags in content"
},
"notification": {
"title": "Notification",
"subtitle": "Automatically send notification"
},
"translation": {
"title": "Translation",
"subtitle": "Translate text to your language"
},
"appearance": {
"title": "Appearance",
"light": "Light",
"dark": "Dark",
"system": "System"
}
},
"user": {
"title": "User"
},
"zap": {
"title": "Zap",
"nwc": "Connection String"
},
"backup": {
"title": "Backup",
"privkey": {
"title": "Private key",
"button": "Remove private key"
}
},
"advanced": {
"title": "Advanced",
"cache": {
"title": "Cache",
"subtitle": "Use for boost up nostr connection",
"button": "Clear"
},
"instant": {
"title": "Instant Zap",
"subtitle": "Zap with default amount, no confirmation"
},
"defaultAmount": "Default amount"
},
"about": {
"title": "About",
"version": "Version",
"checkUpdate": "Check for update",
"installUpdate": "Install"
}
},
"onboarding": {
"home": {
"title": "Your account was successfully created!",
"subtitle": "For starters, let's set up your profile.",
"profileSettings": "Profile Settings"
},
"profile": {
"title": "About you",
"subtitle": "Tell Lume about yourself to start building your home feed."
},
"finish": {
"title": "Profile setup complete!",
"subtitle": "You can exit the setup here and start using Lume.",
"report": "Report a issue"
}
},
"activity": {
"title": "Activity",
"empty": "Yo! Nothing new yet.",
"mention": "mention you",
"repost": "reposted",
"zap": "zapped",
"newReply": "New reply",
"boost": "Boost",
"boostSubtitle": "@ Someone has reposted to your note",
"conversation": "Conversation",
"conversationSubtitle": "@ Someone has replied to your note"
}
}

295
src-tauri/locales/ja.json Normal file
View File

@@ -0,0 +1,295 @@
{
"global": {
"relay": "Relay",
"back": "Back",
"continue": "Continue",
"loading": "Loading",
"error": "Error",
"moveLeft": "Move Left",
"moveRight": "Move Right",
"newColumn": "New Column",
"inspect": "Inspect",
"loadMore": "Load more",
"delete": "Delete",
"refresh": "Refresh",
"cancel": "Cancel",
"save": "Save",
"post": "Post",
"update": "Update",
"noResult": "No results found.",
"emptyFeedTitle": "This feed is empty",
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
"apiKey": "API Key",
"skip": "Skip",
"close": "Close"
},
"nip89": {
"unsupported": "Lume isn't support this event",
"openWith": "Open with"
},
"note": {
"showThread": "Show thread",
"showMore": "Show more",
"error": "Failed to fetch event.",
"posted": "posted",
"replied": "replied",
"reposted": "reposted",
"menu": {
"viewThread": "View thread",
"copyLink": "Copy shareable link",
"copyNoteId": "Copy note ID",
"copyAuthorId": "Copy author ID",
"viewAuthor": "View author",
"pinAuthor": "Pin author",
"copyRaw": "Copy raw event",
"mute": "Mute"
},
"buttons": {
"pin": "Pin",
"pinTooltip": "Pin Note",
"repost": "Repost",
"quote": "Quote",
"viewProfile": "View profile"
},
"zap": {
"zap": "Zap",
"tooltip": "Send zap",
"modalTitle": "Send zap to",
"messagePlaceholder": "Enter message (optional)",
"buttonFinish": "Zapped",
"buttonLoading": "Processing...",
"invoiceButton": "Scan to zap",
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
},
"reply": {
"single": "reply",
"plural": "replies",
"empty": "Be the first to Reply!"
}
},
"user": {
"avatar": "Avatar",
"displayName": "Display Name",
"name": "Name",
"bio": "Bio",
"lna": "Lightning address",
"website": "Website",
"verified": "Verified",
"unverified": "Unverified",
"follow": "Follow",
"unfollow": "Unfollow",
"latestPosts": "Latest posts",
"avatarButton": "Change avatar",
"coverButton": "Change cover",
"editProfile": "Edit profile",
"settings": "Settings",
"logout": "Log out",
"logoutConfirmTitle": "Are you sure!",
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
},
"editor": {
"title": "New Post",
"placeholder": "What are you up to?",
"successMessage": "Your note has been published successfully.",
"replyPlaceholder": "Post your reply"
},
"search": {
"placeholder": "Type something to search...",
"empty": "Try searching for people, notes, or keywords"
},
"welcome": {
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
"signup": "Join Nostr",
"login": "Login",
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
},
"login": {
"title": "Welcome back, anon!",
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
"loginWithAddress": "Login with Nostr Address",
"loginWithBunker": "Login with nsecBunker",
"or": "Or continue with",
"loginWithPrivkey": "Login with Private Key"
},
"loginWithAddress": {
"title": "Enter your Nostr Address"
},
"loginWithBunker": {
"title": "Enter your nsecbunker token"
},
"loginWithPrivkey": {
"title": "Enter your Private Key",
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
},
"signup": {
"title": "Let's Get Started",
"subtitle": "Choose one of methods below to create your account",
"selfManageMethod": "Self-Managed",
"selfManageMethodDescription": "You create your keys and keep them safe.",
"providerMethod": "Managed by Provider",
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
},
"signupWithSelfManage": {
"title": "This is your new Account Key",
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
"confirm1": "I understand the risk of lost private key.",
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
"confirm3": "I understand I cannot recover private key.",
"button": "Save key & Continue"
},
"signupWithProvider": {
"title": "Let's set up your account on Nostr",
"username": "Username *",
"chooseProvider": "Choose a Provider",
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
"email": "Backup Email (optional)",
"emailFooter": "Use for recover your account if you lose your password"
},
"onboardingSettings": {
"title": "You're almost ready to use Lume.",
"subtitle": "Let's start personalizing your experience.",
"notification": {
"title": "Push notification",
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
},
"lowPower": {
"title": "Low Power Mode",
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
},
"translation": {
"title": "Translation (nostr.wine)",
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
},
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
},
"relays": {
"global": "Global",
"follows": "Follows",
"sidebar": {
"title": "Connected relays",
"empty": "Empty."
},
"relayView": {
"empty": "Could not load relay information 😬",
"owner": "Owner",
"contact": "Contact",
"software": "Software",
"nips": "Supported NIPs",
"limit": "Limitation",
"payment": "Open payment website",
"paymentNote": "You need to make a payment to connect this relay"
}
},
"suggestion": {
"title": "Suggested Follows",
"error": "Error. Cannot get trending users",
"button": "Save & Go back"
},
"interests": {
"title": "Interests",
"subtitle": "Pick things you'd like to see in your home feed.",
"edit": "Edit Interest",
"followAll": "Follow All",
"unfollowAll": "Unfollow All"
},
"settings": {
"general": {
"title": "General",
"update": {
"title": "Update",
"subtitle": "Automatically download new update"
},
"lowPower": {
"title": "Low Power",
"subtitle": "Sustainable for low network environment"
},
"startup": {
"title": "Startup",
"subtitle": "Launch Lume at Login"
},
"media": {
"title": "Media",
"subtitle": "Automatically load media"
},
"hashtag": {
"title": "Hashtag",
"subtitle": "Show all hashtags in content"
},
"notification": {
"title": "Notification",
"subtitle": "Automatically send notification"
},
"translation": {
"title": "Translation",
"subtitle": "Translate text to your language"
},
"appearance": {
"title": "Appearance",
"light": "Light",
"dark": "Dark",
"system": "System"
}
},
"user": {
"title": "User"
},
"zap": {
"title": "Zap",
"nwc": "Connection String"
},
"backup": {
"title": "Backup",
"privkey": {
"title": "Private key",
"button": "Remove private key"
}
},
"advanced": {
"title": "Advanced",
"cache": {
"title": "Cache",
"subtitle": "Use for boost up nostr connection",
"button": "Clear"
},
"instant": {
"title": "Instant Zap",
"subtitle": "Zap with default amount, no confirmation"
},
"defaultAmount": "Default amount"
},
"about": {
"title": "About",
"version": "Version",
"checkUpdate": "Check for update",
"installUpdate": "Install"
}
},
"onboarding": {
"home": {
"title": "Your account was successfully created!",
"subtitle": "For starters, let's set up your profile.",
"profileSettings": "Profile Settings"
},
"profile": {
"title": "About you",
"subtitle": "Tell Lume about yourself to start building your home feed."
},
"finish": {
"title": "Profile setup complete!",
"subtitle": "You can exit the setup here and start using Lume.",
"report": "Report a issue"
}
},
"activity": {
"title": "Activity",
"empty": "Yo! Nothing new yet.",
"mention": "mention you",
"repost": "reposted",
"zap": "zapped",
"newReply": "New reply",
"boost": "Boost",
"boostSubtitle": "@ Someone has reposted to your note",
"conversation": "Conversation",
"conversationSubtitle": "@ Someone has replied to your note"
}
}

295
src-tauri/locales/ru.json Normal file
View File

@@ -0,0 +1,295 @@
{
"global": {
"relay": "Relay",
"back": "Back",
"continue": "Continue",
"loading": "Loading",
"error": "Error",
"moveLeft": "Move Left",
"moveRight": "Move Right",
"newColumn": "New Column",
"inspect": "Inspect",
"loadMore": "Load more",
"delete": "Delete",
"refresh": "Refresh",
"cancel": "Cancel",
"save": "Save",
"post": "Post",
"update": "Update",
"noResult": "No results found.",
"emptyFeedTitle": "This feed is empty",
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
"apiKey": "API Key",
"skip": "Skip",
"close": "Close"
},
"nip89": {
"unsupported": "Lume isn't support this event",
"openWith": "Open with"
},
"note": {
"showThread": "Show thread",
"showMore": "Show more",
"error": "Failed to fetch event.",
"posted": "posted",
"replied": "replied",
"reposted": "reposted",
"menu": {
"viewThread": "View thread",
"copyLink": "Copy shareable link",
"copyNoteId": "Copy note ID",
"copyAuthorId": "Copy author ID",
"viewAuthor": "View author",
"pinAuthor": "Pin author",
"copyRaw": "Copy raw event",
"mute": "Mute"
},
"buttons": {
"pin": "Pin",
"pinTooltip": "Pin Note",
"repost": "Repost",
"quote": "Quote",
"viewProfile": "View profile"
},
"zap": {
"zap": "Zap",
"tooltip": "Send zap",
"modalTitle": "Send zap to",
"messagePlaceholder": "Enter message (optional)",
"buttonFinish": "Zapped",
"buttonLoading": "Processing...",
"invoiceButton": "Scan to zap",
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
},
"reply": {
"single": "reply",
"plural": "replies",
"empty": "Be the first to Reply!"
}
},
"user": {
"avatar": "Avatar",
"displayName": "Display Name",
"name": "Name",
"bio": "Bio",
"lna": "Lightning address",
"website": "Website",
"verified": "Verified",
"unverified": "Unverified",
"follow": "Follow",
"unfollow": "Unfollow",
"latestPosts": "Latest posts",
"avatarButton": "Change avatar",
"coverButton": "Change cover",
"editProfile": "Edit profile",
"settings": "Settings",
"logout": "Log out",
"logoutConfirmTitle": "Are you sure!",
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
},
"editor": {
"title": "New Post",
"placeholder": "What are you up to?",
"successMessage": "Your note has been published successfully.",
"replyPlaceholder": "Post your reply"
},
"search": {
"placeholder": "Type something to search...",
"empty": "Try searching for people, notes, or keywords"
},
"welcome": {
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
"signup": "Join Nostr",
"login": "Login",
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
},
"login": {
"title": "Welcome back, anon!",
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
"loginWithAddress": "Login with Nostr Address",
"loginWithBunker": "Login with nsecBunker",
"or": "Or continue with",
"loginWithPrivkey": "Login with Private Key"
},
"loginWithAddress": {
"title": "Enter your Nostr Address"
},
"loginWithBunker": {
"title": "Enter your nsecbunker token"
},
"loginWithPrivkey": {
"title": "Enter your Private Key",
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
},
"signup": {
"title": "Let's Get Started",
"subtitle": "Choose one of methods below to create your account",
"selfManageMethod": "Self-Managed",
"selfManageMethodDescription": "You create your keys and keep them safe.",
"providerMethod": "Managed by Provider",
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
},
"signupWithSelfManage": {
"title": "This is your new Account Key",
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
"confirm1": "I understand the risk of lost private key.",
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
"confirm3": "I understand I cannot recover private key.",
"button": "Save key & Continue"
},
"signupWithProvider": {
"title": "Let's set up your account on Nostr",
"username": "Username *",
"chooseProvider": "Choose a Provider",
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
"email": "Backup Email (optional)",
"emailFooter": "Use for recover your account if you lose your password"
},
"onboardingSettings": {
"title": "You're almost ready to use Lume.",
"subtitle": "Let's start personalizing your experience.",
"notification": {
"title": "Push notification",
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
},
"lowPower": {
"title": "Low Power Mode",
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
},
"translation": {
"title": "Translation (nostr.wine)",
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
},
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
},
"relays": {
"global": "Global",
"follows": "Follows",
"sidebar": {
"title": "Connected relays",
"empty": "Empty."
},
"relayView": {
"empty": "Could not load relay information 😬",
"owner": "Owner",
"contact": "Contact",
"software": "Software",
"nips": "Supported NIPs",
"limit": "Limitation",
"payment": "Open payment website",
"paymentNote": "You need to make a payment to connect this relay"
}
},
"suggestion": {
"title": "Suggested Follows",
"error": "Error. Cannot get trending users",
"button": "Save & Go back"
},
"interests": {
"title": "Interests",
"subtitle": "Pick things you'd like to see in your home feed.",
"edit": "Edit Interest",
"followAll": "Follow All",
"unfollowAll": "Unfollow All"
},
"settings": {
"general": {
"title": "General",
"update": {
"title": "Update",
"subtitle": "Automatically download new update"
},
"lowPower": {
"title": "Low Power",
"subtitle": "Sustainable for low network environment"
},
"startup": {
"title": "Startup",
"subtitle": "Launch Lume at Login"
},
"media": {
"title": "Media",
"subtitle": "Automatically load media"
},
"hashtag": {
"title": "Hashtag",
"subtitle": "Show all hashtags in content"
},
"notification": {
"title": "Notification",
"subtitle": "Automatically send notification"
},
"translation": {
"title": "Translation",
"subtitle": "Translate text to your language"
},
"appearance": {
"title": "Appearance",
"light": "Light",
"dark": "Dark",
"system": "System"
}
},
"user": {
"title": "User"
},
"zap": {
"title": "Zap",
"nwc": "Connection String"
},
"backup": {
"title": "Backup",
"privkey": {
"title": "Private key",
"button": "Remove private key"
}
},
"advanced": {
"title": "Advanced",
"cache": {
"title": "Cache",
"subtitle": "Use for boost up nostr connection",
"button": "Clear"
},
"instant": {
"title": "Instant Zap",
"subtitle": "Zap with default amount, no confirmation"
},
"defaultAmount": "Default amount"
},
"about": {
"title": "About",
"version": "Version",
"checkUpdate": "Check for update",
"installUpdate": "Install"
}
},
"onboarding": {
"home": {
"title": "Your account was successfully created!",
"subtitle": "For starters, let's set up your profile.",
"profileSettings": "Profile Settings"
},
"profile": {
"title": "About you",
"subtitle": "Tell Lume about yourself to start building your home feed."
},
"finish": {
"title": "Profile setup complete!",
"subtitle": "You can exit the setup here and start using Lume.",
"report": "Report a issue"
}
},
"activity": {
"title": "Activity",
"empty": "Yo! Nothing new yet.",
"mention": "mention you",
"repost": "reposted",
"zap": "zapped",
"newReply": "New reply",
"boost": "Boost",
"boostSubtitle": "@ Someone has reposted to your note",
"conversation": "Conversation",
"conversationSubtitle": "@ Someone has replied to your note"
}
}

295
src-tauri/locales/vi.json Normal file
View File

@@ -0,0 +1,295 @@
{
"global": {
"relay": "Relay",
"back": "Back",
"continue": "Continue",
"loading": "Loading",
"error": "Error",
"moveLeft": "Move Left",
"moveRight": "Move Right",
"newColumn": "New Column",
"inspect": "Inspect",
"loadMore": "Load more",
"delete": "Delete",
"refresh": "Refresh",
"cancel": "Cancel",
"save": "Save",
"post": "Post",
"update": "Update",
"noResult": "No results found.",
"emptyFeedTitle": "This feed is empty",
"emptyFeedSubtitle": "You can follow more users to build up your timeline",
"apiKey": "API Key",
"skip": "Skip",
"close": "Close"
},
"nip89": {
"unsupported": "Lume isn't support this event",
"openWith": "Open with"
},
"note": {
"showThread": "Show thread",
"showMore": "Show more",
"error": "Failed to fetch event.",
"posted": "posted",
"replied": "replied",
"reposted": "reposted",
"menu": {
"viewThread": "View thread",
"copyLink": "Copy shareable link",
"copyNoteId": "Copy note ID",
"copyAuthorId": "Copy author ID",
"viewAuthor": "View author",
"pinAuthor": "Pin author",
"copyRaw": "Copy raw event",
"mute": "Mute"
},
"buttons": {
"pin": "Pin",
"pinTooltip": "Pin Note",
"repost": "Repost",
"quote": "Quote",
"viewProfile": "View profile"
},
"zap": {
"zap": "Zap",
"tooltip": "Send zap",
"modalTitle": "Send zap to",
"messagePlaceholder": "Enter message (optional)",
"buttonFinish": "Zapped",
"buttonLoading": "Processing...",
"invoiceButton": "Scan to zap",
"invoiceFooter": "You must use Bitcoin wallet which support Lightning\nsuch as: Blue Wallet, Bitkit, Phoenix,..."
},
"reply": {
"single": "reply",
"plural": "replies",
"empty": "Be the first to Reply!"
}
},
"user": {
"avatar": "Avatar",
"displayName": "Display Name",
"name": "Name",
"bio": "Bio",
"lna": "Lightning address",
"website": "Website",
"verified": "Verified",
"unverified": "Unverified",
"follow": "Follow",
"unfollow": "Unfollow",
"latestPosts": "Latest posts",
"avatarButton": "Change avatar",
"coverButton": "Change cover",
"editProfile": "Edit profile",
"settings": "Settings",
"logout": "Log out",
"logoutConfirmTitle": "Are you sure!",
"logoutConfirmSubtitle": "You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account."
},
"editor": {
"title": "New Post",
"placeholder": "What are you up to?",
"successMessage": "Your note has been published successfully.",
"replyPlaceholder": "Post your reply"
},
"search": {
"placeholder": "Type something to search...",
"empty": "Try searching for people, notes, or keywords"
},
"welcome": {
"title": "Lume is a magnificent client for Nostr to meet, explore\nand freely share your thoughts with everyone.",
"signup": "Join Nostr",
"login": "Login",
"footer": "Before joining Nostr, you can take time to learn more about Nostr"
},
"login": {
"title": "Welcome back, anon!",
"footer": "Lume will put your Private Key in Secure Storage depended on your OS Platform. It will be secured by Password or Biometric ID",
"loginWithAddress": "Login with Nostr Address",
"loginWithBunker": "Login with nsecBunker",
"or": "Or continue with",
"loginWithPrivkey": "Login with Private Key"
},
"loginWithAddress": {
"title": "Enter your Nostr Address"
},
"loginWithBunker": {
"title": "Enter your nsecbunker token"
},
"loginWithPrivkey": {
"title": "Enter your Private Key",
"subtitle": "Lume will put your private key to <1>{{service}}</1>.\nIt will be secured by your OS."
},
"signup": {
"title": "Let's Get Started",
"subtitle": "Choose one of methods below to create your account",
"selfManageMethod": "Self-Managed",
"selfManageMethodDescription": "You create your keys and keep them safe.",
"providerMethod": "Managed by Provider",
"providerMethodDescription": "A 3rd party provider will handle your sign in keys for you."
},
"signupWithSelfManage": {
"title": "This is your new Account Key",
"subtitle": "Keep your key in safe place. If you lose this key, you will lose access to your account.",
"confirm1": "I understand the risk of lost private key.",
"confirm2": "I will make sure keep it safe and not sharing with anyone.",
"confirm3": "I understand I cannot recover private key.",
"button": "Save key & Continue"
},
"signupWithProvider": {
"title": "Let's set up your account on Nostr",
"username": "Username *",
"chooseProvider": "Choose a Provider",
"usernameFooter": "Use to login to Lume and other Nostr apps. You can choose provider you trust to manage your account",
"email": "Backup Email (optional)",
"emailFooter": "Use for recover your account if you lose your password"
},
"onboardingSettings": {
"title": "You're almost ready to use Lume.",
"subtitle": "Let's start personalizing your experience.",
"notification": {
"title": "Push notification",
"subtitle": "Enabling push notifications will allow you to receive notifications from Lume."
},
"lowPower": {
"title": "Low Power Mode",
"subtitle": "Limited relay connection and hide all media, sustainable for low network environment."
},
"translation": {
"title": "Translation (nostr.wine)",
"subtitle": "Translate text to your preferred language, powered by Nostr Wine."
},
"footer": "There are many more settings you can configure from the 'Settings' Screen. Be sure to visit it later."
},
"relays": {
"global": "Global",
"follows": "Follows",
"sidebar": {
"title": "Connected relays",
"empty": "Empty."
},
"relayView": {
"empty": "Could not load relay information 😬",
"owner": "Owner",
"contact": "Contact",
"software": "Software",
"nips": "Supported NIPs",
"limit": "Limitation",
"payment": "Open payment website",
"paymentNote": "You need to make a payment to connect this relay"
}
},
"suggestion": {
"title": "Suggested Follows",
"error": "Error. Cannot get trending users",
"button": "Save & Go back"
},
"interests": {
"title": "Interests",
"subtitle": "Pick things you'd like to see in your home feed.",
"edit": "Edit Interest",
"followAll": "Follow All",
"unfollowAll": "Unfollow All"
},
"settings": {
"general": {
"title": "General",
"update": {
"title": "Update",
"subtitle": "Automatically download new update"
},
"lowPower": {
"title": "Low Power",
"subtitle": "Sustainable for low network environment"
},
"startup": {
"title": "Startup",
"subtitle": "Launch Lume at Login"
},
"media": {
"title": "Media",
"subtitle": "Automatically load media"
},
"hashtag": {
"title": "Hashtag",
"subtitle": "Show all hashtags in content"
},
"notification": {
"title": "Notification",
"subtitle": "Automatically send notification"
},
"translation": {
"title": "Translation",
"subtitle": "Translate text to your language"
},
"appearance": {
"title": "Appearance",
"light": "Light",
"dark": "Dark",
"system": "System"
}
},
"user": {
"title": "User"
},
"zap": {
"title": "Zap",
"nwc": "Connection String"
},
"backup": {
"title": "Backup",
"privkey": {
"title": "Private key",
"button": "Remove private key"
}
},
"advanced": {
"title": "Advanced",
"cache": {
"title": "Cache",
"subtitle": "Use for boost up nostr connection",
"button": "Clear"
},
"instant": {
"title": "Instant Zap",
"subtitle": "Zap with default amount, no confirmation"
},
"defaultAmount": "Default amount"
},
"about": {
"title": "About",
"version": "Version",
"checkUpdate": "Check for update",
"installUpdate": "Install"
}
},
"onboarding": {
"home": {
"title": "Your account was successfully created!",
"subtitle": "For starters, let's set up your profile.",
"profileSettings": "Profile Settings"
},
"profile": {
"title": "About you",
"subtitle": "Tell Lume about yourself to start building your home feed."
},
"finish": {
"title": "Profile setup complete!",
"subtitle": "You can exit the setup here and start using Lume.",
"report": "Report a issue"
}
},
"activity": {
"title": "Activity",
"empty": "Yo! Nothing new yet.",
"mention": "mention you",
"repost": "reposted",
"zap": "zapped",
"newReply": "New reply",
"boost": "Boost",
"boostSubtitle": "@ Someone has reposted to your note",
"conversation": "Conversation",
"conversationSubtitle": "@ Someone has replied to your note"
}
}

View File

View File

@@ -1,108 +0,0 @@
[info]
relay_url = "<url>"
name = "depot"
description = "Nostr Relay inside Lume. Powered by nostr-rs-relay"
pubkey = ""
favicon = "favicon.ico"
relay_icon = "https://example.test/img.png"
#contact = "mailto:contact@example.com"
[diagnostics]
#tracing = false
[database]
engine = "sqlite"
data_directory = "."
max_conn = 8
min_conn = 0
[logging]
#folder_path = "./log"
#file_prefix = "nostr-relay"
[network]
address = "0.0.0.0"
port = 6090
#remote_ip_header = "x-forwarded-for"
#remote_ip_header = "cf-connecting-ip"
#ping_interval = 300
[options]
reject_future_seconds = 1800
[limits]
messages_per_sec = 10
subscriptions_per_min = 10
limit_scrapers = false
[authorization]
pubkey_whitelist = []
nip42_auth = true
nip42_dms = true
[verified_users]
mode = "passive"
#domain_blacklist = ["wellorder.net"]
#domain_whitelist = ["example.com"]
verify_expiration = "1 week"
#verify_update_frequency = "24 hours"
max_consecutive_failures = 3
[grpc]
# gRPC interfaces for externalized decisions and other extensions to
# functionality.
#
# Events can be authorized through an external service, by providing
# the URL below. In the event the server is not accessible, events
# will be permitted. The protobuf3 schema used is available in
# `proto/nauthz.proto`.
# event_admission_server = "http://[::1]:50051"
# If the event admission server denies writes
# in any case (excluding spam filtering).
# This is reflected in the relay information document.
# restricts_write = true
[pay_to_relay]
# Enable pay to relay
#enabled = false
# The cost to be admitted to relay
#admission_cost = 4200
# The cost in sats per post
#cost_per_event = 0
# Url of lnbits api
#node_url = "<node url>"
# LNBits api secret
#api_secret = "<ln bits api>"
# Nostr direct message on signup
#direct_message=false
# Terms of service
#terms_message = """
#This service (and supporting services) are provided "as is", without warranty of any kind, express or implied.
#
#By using this service, you agree:
#* Not to engage in spam or abuse the relay service
#* Not to disseminate illegal content
#* That requests to delete content cannot be guaranteed
#* To use the service in compliance with all applicable laws
#* To grant necessary rights to your content for unlimited time
#* To be of legal age and have capacity to use this service
#* That the service may be terminated at any time without notice
#* That the content you publish may be removed at any time without notice
#* To have your IP address collected to detect abuse or misuse
#* To cooperate with the relay to combat abuse or misuse
#* You may be exposed to content that you might find triggering or distasteful
#* The relay operator is not liable for content produced by users of the relay
#"""
# Whether or not new sign ups should be allowed
#sign_ups = false
# optional if `direct_message=false`
#secret_key = "<nostr nsec>"

View File

@@ -26,7 +26,8 @@
"$VIDEO/*", "$VIDEO/*",
"$RESOURCE", "$RESOURCE",
"$RESOURCE/*", "$RESOURCE/*",
"$RESOURCE/**" "$RESOURCE/**",
"$RESOURCE/locales/*"
] ]
}, },
"http": { "http": {
@@ -34,7 +35,7 @@
}, },
"shell": { "shell": {
"open": true, "open": true,
"scope": [{ "name": "bin/depot", "sidecar": true, "args": true }] "scope": []
}, },
"updater": { "updater": {
"endpoints": [ "endpoints": [
@@ -51,7 +52,7 @@
"depends": [] "depends": []
}, },
"externalBin": [], "externalBin": [],
"resources": ["resources/*"], "resources": ["resources/*", "./locales/*"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",