Compare commits

..
Author SHA1 Message Date
reya 1be84f3139 chore: release v4.0.0-alpha+2 2024-03-13 09:40:30 +07:00
Ren AmamiyaandGitHub 04bde7dd43 Merge pull request #172 from lumehq/v4/functions
Some polish for v4
2024-03-13 09:39:57 +07:00
reya f1504d99ac feat: add mock tray menu 2024-03-13 09:19:25 +07:00
reya e928f2ee37 feat: polish 2024-03-13 08:40:18 +07:00
Ren AmamiyaandGitHub ccab78ca11 Merge pull request #171 from lumehq/v4/zap
Add zap to v4
2024-03-11 15:39:35 +07:00
reya 5b13c3e45c feat: auto close window if zapp successfully 2024-03-11 15:38:54 +07:00
reya e6b97ab9ae feat: add zap 2024-03-11 15:36:17 +07:00
reya bb6badfed6 feat: add zap setup screen 2024-03-10 16:39:23 +07:00
Ren AmamiyaandGitHub 0c4b309a11 Merge pull request #170 from lumehq/v4/home-screen
v4/home-screen
2024-03-09 10:09:29 +07:00
reya f4400d711f feat: improve home screen 2024-03-09 10:08:50 +07:00
reya a3e46aa96b fix: crash when database is not exist 2024-03-08 14:47:36 +07:00
reya a4fdcfdf0b feat: add local and global newsfeeds 2024-03-07 15:39:43 +07:00
reya 25d07303a3 fix: build error in windows and linux 2024-03-06 15:32:51 +07:00
54 changed files with 906 additions and 667 deletions
+1
View File
@@ -26,6 +26,7 @@
"i18next-resources-to-backend": "^1.2.0",
"nostr-tools": "^2.3.1",
"react": "^18.2.0",
"react-currency-input-field": "^3.8.0",
"react-dom": "^18.2.0",
"react-i18next": "^14.0.5",
"slate": "^0.101.5",
+1 -1
View File
@@ -24,7 +24,7 @@ input::-ms-clear {
}
media-controller {
@apply w-full;
@apply w-full overflow-hidden rounded-xl;
}
@layer utilities {
+11 -1
View File
@@ -12,6 +12,7 @@ import { locale, platform } from "@tauri-apps/plugin-os";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { routeTree } from "./router.gen"; // auto generated file
import { CancelCircleIcon, CheckCircleIcon, InfoCircleIcon } from "@lume/icons";
const queryClient = new QueryClient({
defaultOptions: {
@@ -72,7 +73,16 @@ if (!rootElement.innerHTML) {
persistOptions={{ persister }}
>
<StrictMode>
<Toaster position="top-center" richColors />
<Toaster
position="bottom-right"
icons={{
success: <CheckCircleIcon className="size-5" />,
info: <InfoCircleIcon className="size-5" />,
error: <CancelCircleIcon className="size-5" />,
}}
closeButton
theme="system"
/>
<App />
</StrictMode>
</PersistQueryClientProvider>
+16 -14
View File
@@ -45,7 +45,8 @@ function Inactive({ pubkey }: { pubkey: string }) {
const changeAccount = async (npub: string) => {
const select = await ark.load_selected_account(npub);
if (select) navigate({ to: "/$account/home", params: { account: npub } });
if (select)
navigate({ to: "/$account/home/local", params: { account: npub } });
};
return (
@@ -60,13 +61,17 @@ function Inactive({ pubkey }: { pubkey: string }) {
}
function Active({ pubkey }: { pubkey: string }) {
const [open, setOpen] = useState(true);
const ark = useArk();
const navigate = useNavigate();
// @ts-ignore, magic !!!
const { guest } = useSearch({ strict: false });
// @ts-ignore, magic !!!
const { account } = useParams({ strict: false });
if (guest) {
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Root open={true}>
<Popover.Trigger asChild>
<button type="button">
<User.Provider pubkey={pubkey}>
@@ -119,23 +124,20 @@ function Active({ pubkey }: { pubkey: string }) {
>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
Add account
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+N
</div>
</DropdownMenu.Item>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
<DropdownMenu.Item
onClick={() => ark.open_profile(account)}
className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100"
>
Profile
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+P
</div>
</DropdownMenu.Item>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
Settings
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+S
</div>
</DropdownMenu.Item>
<DropdownMenu.Item className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100">
<DropdownMenu.Item
onClick={() => navigate({ to: "/", search: { manually: true } })}
className="group relative flex h-9 select-none items-center rounded-md px-3 text-sm font-medium leading-none outline-none hover:bg-neutral-900 dark:hover:bg-neutral-100"
>
Logout
<div className="ml-auto pl-5 text-xs text-neutral-800 dark:text-neutral-200">
+Shift+L
+1 -1
View File
@@ -104,7 +104,7 @@ export function BackupDialog() {
</button>
) : (
<Link
to="/$account/home"
to="/$account/home/local"
params={{ account }}
search={{ guest: false }}
className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
+51
View File
@@ -0,0 +1,51 @@
import { useArk } from "@lume/ark";
import { User } from "@lume/ui";
import { getBitcoinDisplayValues } from "@lume/utils";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
export function Balance({
recipient,
account,
}: {
recipient: string;
account: string;
}) {
const [t] = useTranslation();
const [balance, setBalance] = useState(0);
const ark = useArk();
const value = useMemo(() => getBitcoinDisplayValues(balance), [balance]);
useEffect(() => {
async function getBalance() {
const val = await ark.get_balance();
setBalance(val);
}
getBalance();
}, []);
return (
<div
data-tauri-drag-region
className="flex h-16 items-center justify-end px-3"
>
<div className="flex items-center gap-2">
<div className="text-end">
<div className="text-sm leading-tight text-neutral-700 dark:text-neutral-300">
Your balance
</div>
<div className="font-medium leading-tight">
{value.bitcoinFormatted}
</div>
</div>
<User.Provider pubkey={account}>
<User.Root>
<User.Avatar className="size-9 rounded-full" />
</User.Root>
</User.Provider>
</div>
</div>
);
}
+8 -3
View File
@@ -11,11 +11,16 @@ export function LoginDialog() {
const [nsec, setNsec] = useState("");
const [passphase, setPassphase] = useState("");
const [loading, setLoading] = useState(false);
const login = async () => {
try {
setLoading(true);
if (!nsec.length) {
return toast.info("You must enter a valid nsec or ncrypto");
}
if (nsec.startsWith("ncrypto") && !passphase.length) {
return toast.warning("You must provide a passphase for ncrypto key");
}
const save = await ark.save_account(nsec, passphase);
@@ -23,7 +28,6 @@ export function LoginDialog() {
navigate({ to: "/", search: { guest: false } });
}
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
@@ -85,6 +89,7 @@ export function LoginDialog() {
<input
name="nsec"
type="text"
placeholder="nsec or ncrypto..."
value={nsec}
onChange={(e) => setNsec(e.target.value)}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
+2 -1
View File
@@ -46,6 +46,7 @@ function App() {
</button>
<button
type="button"
onClick={() => ark.open_settings()}
className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-800 hover:bg-neutral-400 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-600"
>
<HorizontalDotsIcon className="size-5" />
@@ -68,7 +69,7 @@ function Navigation() {
data-tauri-drag-region
className="flex h-full flex-1 items-center gap-2"
>
<Link to="/$account/home" params={{ account }}>
<Link to="/$account/home/local" params={{ account }}>
{({ isActive }) => (
<div
className={cn(
@@ -1,133 +0,0 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { useArk } from "@lume/ark";
import {
LoaderIcon,
ArrowRightCircleIcon,
RefreshIcon,
InfoIcon,
} from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { EmptyFeed } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/$account/home")({
component: Home,
});
function Home() {
const ark = useArk();
const currentDate = new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
});
const { account } = Route.useParams();
const {
data,
hasNextPage,
isLoading,
isRefetching,
isFetchingNextPage,
fetchNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["local_newsfeed", account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(
"local",
FETCH_LIMIT,
pageParam,
true,
);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="flex flex-col gap-3">
<div className="mx-auto flex h-12 w-full max-w-xl shrink-0 items-center justify-between border-b border-neutral-100 dark:border-neutral-900">
<h3 className="text-sm font-medium uppercase leading-tight text-neutral-600 dark:text-neutral-400">
{currentDate}
</h3>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => refetch()}
className="text-neutral-700 hover:text-blue-500 dark:text-neutral-300"
>
<RefreshIcon className="size-4" />
</button>
</div>
</div>
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
<div className="flex-1">
{isLoading || isRefetching ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !data.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<InfoIcon className="size-5" />
<p>
Empty newsfeed. Or you can go to{" "}
<a href="" className="text-blue-500 hover:text-blue-600">
Discover
</a>
</p>
</div>
<Suggest />
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
<div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { GlobalIcon, LoaderIcon, LocalIcon, RefreshIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/$account/home")({
component: Screen,
});
function Screen() {
const queryClient = useQueryClient();
const { account } = Route.useParams();
const refresh = async () => {
const queryKey = `${window.location.pathname.split("/").at(-1)}_newsfeed`;
await queryClient.refetchQueries({ queryKey: [queryKey, account] });
};
return (
<div className="flex flex-col">
<div className="mx-auto mb-4 flex h-16 w-full max-w-xl shrink-0 items-center justify-between border-b border-neutral-100 dark:border-neutral-900">
<div className="flex items-center gap-2">
<Link to="/$account/home/local">
{({ isActive }) => (
<div
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm leading-tight hover:bg-neutral-100 dark:hover:bg-neutral-900",
isActive
? "bg-neutral-100 font-semibold text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
: "text-neutral-600 dark:text-neutral-400",
)}
>
<LocalIcon className="size-4" />
Local
</div>
)}
</Link>
<Link to="/$account/home/global">
{({ isActive }) => (
<div
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm leading-tight hover:bg-neutral-100 dark:hover:bg-neutral-900",
isActive
? "bg-neutral-100 font-semibold text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
: "text-neutral-600 dark:text-neutral-400",
)}
>
<GlobalIcon className="size-4" />
Global
</div>
)}
</Link>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={refresh}
className="text-neutral-700 hover:text-blue-500 dark:text-neutral-300"
>
<RefreshIcon className="size-4" />
</button>
</div>
</div>
<Outlet />
</div>
);
}
@@ -0,0 +1,91 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { useArk } from "@lume/ark";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/$account/home/global")({
component: Screen,
});
function Screen() {
const ark = useArk();
const { account } = Route.useParams();
const {
data,
hasNextPage,
isLoading,
isRefetching,
isFetchingNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["global_newsfeed", account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(
"global",
FETCH_LIMIT,
pageParam,
true,
);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
<div className="flex-1">
{isLoading || isRefetching ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
<div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text";
import { useArk } from "@lume/ark";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Virtualizer } from "virtua";
export const Route = createLazyFileRoute("/$account/home/local")({
component: Screen,
});
function Screen() {
const ark = useArk();
const { account } = Route.useParams();
const {
data,
hasNextPage,
isLoading,
isRefetching,
isFetchingNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["local_newsfeed", account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(
"local",
FETCH_LIMIT,
pageParam,
true,
);
return events;
},
getNextPageParam: (lastPage) => {
const lastEvent = lastPage?.at(-1);
if (!lastEvent) return;
return lastEvent.created_at - 1;
},
select: (data) => data?.pages.flatMap((page) => page),
refetchOnWindowFocus: false,
});
const renderItem = (event: Event) => {
if (!event) return;
switch (event.kind) {
case Kind.Repost:
return <RepostNote key={event.id} event={event} />;
default:
return <TextNote key={event.id} event={event} />;
}
};
return (
<div className="mx-auto flex w-full max-w-xl flex-1 flex-col">
<div className="flex-1">
{isLoading || isRefetching ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1">
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !data.length ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" />
<p>
Empty newsfeed. Or you view the{" "}
<Link
to="/$account/home/global"
className="text-blue-500 hover:text-blue-600"
>
Global Newsfeed
</Link>
</p>
</div>
<Suggest />
</div>
) : (
<Virtualizer overscan={3}>
{data.map((item) => renderItem(item))}
</Virtualizer>
)}
<div className="flex h-20 items-center justify-center">
{hasNextPage ? (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
className="inline-flex h-12 w-36 items-center justify-center gap-2 rounded-full bg-neutral-100 px-3 font-medium hover:bg-neutral-200 focus:outline-none dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isFetchingNextPage ? (
<LoaderIcon className="size-5 animate-spin" />
) : (
<>
<ArrowRightCircleIcon className="size-5" />
Load more
</>
)}
</button>
) : null}
</div>
</div>
</div>
);
}
@@ -26,7 +26,7 @@ function Create() {
try {
await ark.save_account(keys);
navigate({
to: "/$account/home",
to: "/$account/home/local",
params: { account: keys.npub },
search: { onboarding: true },
replace: true,
@@ -32,7 +32,7 @@ function Import() {
nsec: key,
});
navigate({
to: "/$account/home",
to: "/$account/home/local",
params: { account: npub },
search: { onboarding: true },
replace: true,
+13 -7
View File
@@ -6,7 +6,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/")({
beforeLoad: async ({ context }) => {
beforeLoad: async ({ search, context }) => {
const ark = context.ark;
const accounts = await ark.get_all_accounts();
@@ -15,18 +15,22 @@ export const Route = createFileRoute("/")({
case 0:
const guest = await ark.create_guest_account();
throw redirect({
to: "/$account/home",
to: "/$account/home/local",
params: { account: guest },
search: { guest: true },
replace: true,
});
// Only 1 account, skip account selection screen
case 1:
// @ts-ignore, totally fine !!!
if (search.manually) return;
const account = accounts[0].npub;
const loadAccount = await ark.load_selected_account(account);
if (loadAccount) {
const loadedAccount = await ark.load_selected_account(account);
if (loadedAccount) {
throw redirect({
to: "/$account/home",
to: "/$account/home/local",
params: { account },
replace: true,
});
@@ -50,7 +54,7 @@ function Screen() {
const loadAccount = await ark.load_selected_account(npub);
if (loadAccount) {
navigate({
to: "/$account/home",
to: "/$account/home/local",
params: { account: npub },
replace: true,
});
@@ -72,7 +76,9 @@ function Screen() {
</div>
<div className="flex items-center justify-center gap-6">
{loading ? (
<LoaderIcon className="size-6 animate-spin text-white" />
<div className="inline-flex size-6 items-center justify-center">
<LoaderIcon className="size-6 animate-spin text-white" />
</div>
) : (
<>
{ark.accounts.map((account) => (
+68
View File
@@ -0,0 +1,68 @@
import { useArk } from "@lume/ark";
import { ArrowRightIcon, ZapIcon } from "@lume/icons";
import { Container } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createLazyFileRoute("/nwc")({
component: Screen,
});
function Screen() {
const ark = useArk();
const [uri, setUri] = useState("");
const [isDone, setIsDone] = useState(false);
const save = async () => {
const nwc = await ark.set_nwc(uri);
if (nwc) {
setIsDone(true);
}
};
return (
<Container withDrag>
<div className="h-full w-full flex-1 px-5">
{!isDone ? (
<>
<div className="flex flex-col gap-2">
<div className="inline-flex size-14 items-center justify-center rounded-xl bg-black text-white shadow-md">
<ZapIcon className="size-5" />
</div>
<div>
<h3 className="text-2xl font-light">
Connect <span className="font-semibold">bitcoin wallet</span>{" "}
to start zapping to your favorite content and creator.
</h3>
</div>
</div>
<div className="mt-10 flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<label>Paste a Nostr Wallet Connect connection string</label>
<textarea
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder="nostrconnect://"
className="h-24 w-full resize-none rounded-lg border-transparent bg-white placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-black dark:focus:ring-blue-900"
/>
</div>
<button
type="button"
onClick={save}
className="inline-flex h-11 w-full items-center justify-between gap-1.5 rounded-lg bg-blue-500 px-5 font-medium text-white hover:bg-blue-600"
>
<div className="size-5" />
<div>Save & Connect</div>
<ArrowRightIcon className="size-5" />
</button>
</div>
</>
) : (
<div>Done</div>
)}
</div>
</Container>
);
}
@@ -1,9 +1,8 @@
import { TextNote } from "@/components/text";
import { RepostNote } from "@/components/repost";
import { useArk } from "@lume/ark";
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
import { ArrowRightCircleIcon, InfoIcon, LoaderIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types";
import { EmptyFeed } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils";
import { useInfiniteQuery } from "@tanstack/react-query";
@@ -43,7 +42,10 @@ export function EventList({ id }: { id: string }) {
<LoaderIcon className="size-5 animate-spin" />
</div>
) : !data.length ? (
<EmptyFeed />
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" />
<p>Empty newsfeed.</p>
</div>
) : (
data.map((item) => renderItem(item))
)}
+124
View File
@@ -0,0 +1,124 @@
import { Balance } from "@/components/balance";
import { useArk } from "@lume/ark";
import { Box, Container, User } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { toast } from "sonner";
import CurrencyInput from "react-currency-input-field";
const DEFAULT_VALUES = [69, 100, 200, 500];
export const Route = createLazyFileRoute("/zap/$id")({
component: Screen,
});
function Screen() {
const { t } = useTranslation();
const { id } = Route.useParams();
// @ts-ignore, magic !!!
const { pubkey, account } = Route.useSearch();
const [amount, setAmount] = useState(21);
const [message, setMessage] = useState("");
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const ark = useArk();
const submit = async () => {
try {
// start loading
setIsLoading(true);
const val = await ark.zap_event(id, amount, message);
if (val) {
setIsCompleted(true);
const window = getCurrent();
// close current window
window.close();
}
} catch (e) {
setIsLoading(false);
toast.error(e);
}
};
return (
<Container>
<Balance recipient={pubkey} account={account} />
<Box className="flex flex-col gap-3">
<div className="flex h-full flex-col justify-between py-5">
<div className="flex h-11 shrink-0 items-center justify-center gap-2">
{t("note.zap.modalTitle")}{" "}
<User.Provider pubkey={pubkey}>
<User.Root className="inline-flex items-center gap-2 rounded-full bg-neutral-100 p-1 dark:bg-neutral-900">
<User.Avatar className="size-6 rounded-full" />
<User.Name className="pr-2 text-sm font-medium" />
</User.Root>
</User.Provider>
</div>
<div className="flex flex-1 flex-col justify-between px-5">
<div className="relative flex flex-1 flex-col pb-8">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={21}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(Number(value))}
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-500 dark:text-neutral-400">
sats
</span>
</div>
<div className="inline-flex items-center justify-center gap-2">
{DEFAULT_VALUES.map((value) => (
<button
type="button"
onClick={() => setAmount(value)}
className="w-max rounded-full bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{value} sats
</button>
))}
</div>
</div>
<div className="flex w-full flex-col gap-2">
<input
name="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder={t("note.zap.messagePlaceholder")}
className="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-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">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg border-t border-neutral-900 bg-neutral-950 pb-[2px] font-semibold text-neutral-50 hover:bg-neutral-900 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
{isCompleted
? t("note.zap.buttonFinish")
: isLoading
? t("note.zap.buttonLoading")
: t("note.zap.zap")}
</button>
</div>
</div>
</div>
</div>
</Box>
</Container>
);
}
+64 -8
View File
@@ -404,8 +404,26 @@ export class Ark {
try {
const cmd: boolean = await invoke("set_nwc", { uri });
return cmd;
} catch {
return false;
} catch (e) {
throw new Error(String(e));
}
}
public async load_nwc() {
try {
const cmd: boolean = await invoke("load_nwc");
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
public async get_balance() {
try {
const cmd: number = await invoke("get_balance");
return cmd;
} catch (e) {
throw new Error(String(e));
}
}
@@ -413,8 +431,8 @@ export class Ark {
try {
const cmd: boolean = await invoke("zap_profile", { id, amount, message });
return cmd;
} catch {
return false;
} catch (e) {
throw new Error(String(e));
}
}
@@ -422,8 +440,8 @@ export class Ark {
try {
const cmd: boolean = await invoke("zap_event", { id, amount, message });
return cmd;
} catch {
return false;
} catch (e) {
throw new Error(String(e));
}
}
@@ -477,8 +495,7 @@ export class Ark {
return content.url as string;
} catch (e) {
console.error(String(e));
return null;
throw new Error(String(e));
}
}
@@ -526,4 +543,43 @@ export class Ark {
fileDropEnabled: true,
});
}
public open_nwc() {
return new WebviewWindow("nwc", {
title: "Nostr Wallet Connect",
url: "/nwc",
minWidth: 400,
width: 400,
height: 600,
hiddenTitle: true,
titleBarStyle: "overlay",
fileDropEnabled: true,
});
}
public open_zap(id: string, pubkey: string, account: string) {
return new WebviewWindow(`zap-${id}`, {
title: "Nostr Wallet Connect",
url: `/zap/${id}?pubkey=${pubkey}&account=${account}`,
minWidth: 400,
width: 400,
height: 500,
hiddenTitle: true,
titleBarStyle: "overlay",
fileDropEnabled: true,
});
}
public open_settings() {
return new WebviewWindow("settings", {
title: "Settings",
url: "/settings",
minWidth: 600,
width: 800,
height: 500,
hiddenTitle: true,
titleBarStyle: "overlay",
fileDropEnabled: true,
});
}
}
+4
View File
@@ -116,3 +116,7 @@ export * from "./src/arrowUp";
export * from "./src/arrowUpSquare";
export * from "./src/arrowDown";
export * from "./src/link";
export * from "./src/local";
export * from "./src/global";
export * from "./src/infoCircle";
export * from "./src/cancelCircle";
+12
View File
@@ -0,0 +1,12 @@
export function CancelCircleIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm7.707-3.707a1 1 0 0 0-1.414 1.414L10.586 12l-2.293 2.293a1 1 0 1 0 1.414 1.414L12 13.414l2.293 2.293a1 1 0 0 0 1.414-1.414L13.414 12l2.293-2.293a1 1 0 0 0-1.414-1.414L12 10.586 9.707 8.293Z"
clipRule="evenodd"
/>
</svg>
);
}
+6 -13
View File
@@ -1,22 +1,15 @@
import { SVGProps } from 'react';
import { SVGProps } from "react";
export function CheckCircleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.5805 9.97493C15.8428 9.65434 15.7955 9.18183 15.4749 8.91953C15.1543 8.65724 14.6818 8.70449 14.4195 9.02507L10.4443 13.8837L9.03033 12.4697C8.73744 12.1768 8.26256 12.1768 7.96967 12.4697C7.67678 12.7626 7.67678 13.2374 7.96967 13.5303L9.96967 15.5303C10.1195 15.6802 10.3257 15.7596 10.5374 15.7491C10.749 15.7385 10.9463 15.6389 11.0805 15.4749L15.5805 9.97493Z"
fill="currentColor"
fillRule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm3.774 8.133a1 1 0 0 0-1.548-1.266l-3.8 4.645-1.219-1.22a1 1 0 0 0-1.414 1.415l2 2a1 1 0 0 0 1.481-.074l4.5-5.5Z"
clipRule="evenodd"
/>
</svg>
);
+14
View File
@@ -0,0 +1,14 @@
export function GlobalIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M12 2a1 1 0 0 0 0 2 8 8 0 0 1 8 8 1 1 0 0 0 2 0c0-5.523-4.477-10-10-10Z"
/>
<path
fill="currentColor"
d="M12 5a1 1 0 0 0 0 2 5 5 0 0 1 5 5 1 1 0 0 0 2 0 7 7 0 0 0-7-7ZM7.39 6.977a2.9 2.9 0 0 0-2.258-.857c-.83.064-1.632.52-2.065 1.38-1.89 3.749-1.27 8.44 1.862 11.571 3.132 3.132 7.822 3.751 11.57 1.862a2.494 2.494 0 0 0 1.38-2.066 2.9 2.9 0 0 0-.856-2.258L12.914 12.5l.793-.793a1 1 0 0 0-1.414-1.414l-.793.793-4.11-4.11Z"
/>
</svg>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { SVGProps } from "react";
export function InfoCircleIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm-2 9a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm2-4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"
clipRule="evenodd"
/>
</svg>
);
}
+10 -4
View File
@@ -1,9 +1,15 @@
import { SVGProps } from 'react';
import { SVGProps } from "react";
export function LoaderIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
export function LoaderIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<title id="loading">Loading</title>
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
+14
View File
@@ -0,0 +1,14 @@
export function LocalIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M11.19 2.413a.996.996 0 0 0-.19.603V17a1 1 0 1 0 2 0v-6.426l5.504-3.21a1 1 0 0 0 0-1.728l-5.987-3.492a.995.995 0 0 0-1.007-.016.993.993 0 0 0-.32.285Z"
/>
<path
fill="currentColor"
d="M8.19 15.346a1 1 0 1 0-.38-1.964c-1.552.3-2.928.773-3.945 1.398C2.89 15.38 2 16.282 2 17.5c0 .858.45 1.566 1.03 2.099.58.532 1.361.965 2.244 1.308C7.044 21.596 9.423 22 12 22s4.956-.404 6.726-1.093c.883-.343 1.665-.776 2.244-1.308.58-.533 1.03-1.241 1.03-2.099 0-1.218-.89-2.12-1.865-2.72-1.017-.625-2.393-1.098-3.945-1.398a1 1 0 1 0-.38 1.964c1.412.273 2.535.681 3.278 1.138.784.482.912.86.912 1.016 0 .11-.053.322-.384.627-.332.305-.868.626-1.614.916-1.487.578-3.608.957-6.002.957-2.394 0-4.515-.379-6.002-.957-.746-.29-1.282-.611-1.614-.916C4.053 17.822 4 17.609 4 17.5c0-.155.128-.534.912-1.016.743-.457 1.866-.865 3.278-1.138Z"
/>
</svg>
);
}
-1
View File
@@ -33,7 +33,6 @@
"react-hook-form": "^7.51.0",
"react-hotkeys-hook": "^4.5.0",
"react-i18next": "^14.0.5",
"react-router-dom": "^6.22.2",
"react-string-replace": "^1.1.1",
"slate": "^0.101.5",
"slate-react": "^0.101.6",
+1 -1
View File
@@ -13,7 +13,7 @@ export function Box({
<div className="h-full w-full flex-1 px-2 pb-2">
<div
className={cn(
"h-full w-full overflow-hidden overflow-y-auto rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5",
"h-full w-full overflow-y-auto rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-none dark:ring-1 dark:ring-white/5",
className,
)}
>
-6
View File
@@ -1,6 +0,0 @@
import { ReactNode } from "react";
import { Routes } from "react-router-dom";
export function ColumnContent({ children }: { children: ReactNode }) {
return <Routes>{children}</Routes>;
}
-80
View File
@@ -1,80 +0,0 @@
import {
ChevronDownIcon,
MoveLeftIcon,
MoveRightIcon,
RefreshIcon,
TrashIcon,
} from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
export function ColumnHeader({ queryKey }: { queryKey?: string[] }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
return (
<DropdownMenu.Root>
<div className="flex h-11 w-full shrink-0 items-center justify-center gap-2 border-b border-neutral-100 px-3 dark:border-neutral-900">
<DropdownMenu.Trigger asChild>
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{column.title}</div>
<ChevronDownIcon className="size-5" />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={5}
className="flex w-[200px] flex-col overflow-hidden rounded-2xl bg-white/50 p-2 ring-1 ring-black/10 backdrop-blur-2xl focus:outline-none dark:bg-black/50 dark:ring-white/10"
>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={refresh}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium 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" />
{t("global.refresh")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "left")}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium 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" />
{t("global.moveLeft")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => move(column.id, "right")}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium 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" />
{t("global.moveRight")}
</button>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-black/10 dark:bg-white/10" />
<DropdownMenu.Item asChild>
<button
type="button"
onClick={() => remove(column.id)}
className="inline-flex h-9 items-center gap-3 rounded-lg px-3 text-sm font-medium text-red-500 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-4" />
{t("global.delete")}
</button>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</div>
</DropdownMenu.Root>
);
}
-15
View File
@@ -1,15 +0,0 @@
import { Route } from "react-router-dom";
import { ColumnContent } from "./content";
import { ColumnHeader } from "./header";
import { ColumnLiveWidget } from "./live";
import { ColumnProvider } from "./provider";
import { ColumnRoot } from "./root";
export const Column = {
Provider: ColumnProvider,
Root: ColumnRoot,
Live: ColumnLiveWidget,
Header: ColumnHeader,
Content: ColumnContent,
Route: Route,
};
-46
View File
@@ -1,46 +0,0 @@
import { ArrowUpIcon } from "@lume/icons";
import { useEffect, useState } from "react";
export function ColumnLiveWidget({
filter,
onClick,
}: {
filter: NDKFilter;
onClick: (event: NDKEvent[]) => void;
}) {
const ark = useArk();
const [events, setEvents] = useState<NDKEvent[]>([]);
const update = async () => {
onClick(events);
// reset
setEvents([]);
};
useEffect(() => {
const sub = ark.subscribe({
filter,
closeOnEose: false,
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
});
return () => {
if (sub) sub.stop();
};
}, []);
if (!events.length) return null;
return (
<div className="absolute left-0 z-40 flex items-center justify-center w-full top-12 h-11">
<button
type="button"
onClick={update}
className="inline-flex items-center justify-center h-9 gap-1 pl-4 pr-3 text-sm font-semibold rounded-full w-max bg-neutral-950 dark:bg-neutral-50 hover:bg-neutral-900 dark:hover:bg-neutral-100 text-neutral-50 dark:text-neutral-950"
>
{events.length} {events.length === 1 ? "new note" : "new notes"}
<ArrowUpIcon className="size-4" />
</button>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { LumeColumn } from "@lume/types";
import { ReactNode, createContext, useContext } from "react";
const ColumnContext = createContext<LumeColumn>(null);
export function ColumnProvider({
column,
children,
}: { column: LumeColumn; children: ReactNode }) {
return (
<ColumnContext.Provider value={column}>{children}</ColumnContext.Provider>
);
}
export function useColumnContext() {
const context = useContext(ColumnContext);
if (!context) {
throw new Error(
"Please import Column Provider to use useColumnContext() hook",
);
}
return context;
}
-37
View File
@@ -1,37 +0,0 @@
import { cn } from "@lume/utils";
import { Resizable } from "re-resizable";
import { ReactNode, useState } from "react";
import { MemoryRouter, UNSAFE_LocationContext } from "react-router-dom";
export function ColumnRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(420);
return (
<UNSAFE_LocationContext.Provider value={null}>
<Resizable
size={{ width, height: "100%" }}
onResizeStart={(e) => e.preventDefault()}
onResizeStop={(_e, _direction, _ref, d) => {
setWidth((prevWidth) => prevWidth + d.width);
}}
minWidth={420}
maxWidth={600}
className={cn(
"relative flex flex-col border-r-2 border-neutral-50 hover:border-neutral-100 dark:border-neutral-950 dark:hover:border-neutral-900",
className,
)}
enable={{ right: true }}
>
<MemoryRouter future={{ v7_startTransition: true }}>
{children}
</MemoryRouter>
</Resizable>
</UNSAFE_LocationContext.Provider>
);
}
-34
View File
@@ -1,34 +0,0 @@
import { InfoIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
export function EmptyFeed({
text,
subtext,
className,
}: {
text?: string;
subtext?: string;
className?: string;
}) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex w-full flex-col items-center justify-center gap-2 rounded-xl bg-neutral-50 py-3 dark:bg-neutral-950",
className,
)}
>
<InfoIcon className="size-8 text-blue-500" />
<div className="text-center">
<p className="text-lg font-semibold">
{text ? text : t("global.emptyFeedTitle")}
</p>
<p className="text-sm leading-tight">
{subtext ? subtext : t("global.emptyFeedSubtitle")}
</p>
</div>
</div>
);
}
-2
View File
@@ -1,7 +1,5 @@
export * from "./user";
export * from "./note";
export * from "./column";
export * from "./emptyFeed";
// UI
export * from "./container";
+20
View File
@@ -1,9 +1,29 @@
import { useArk } from "@lume/ark";
import { ZapIcon } from "@lume/icons";
import { toast } from "sonner";
import { useNoteContext } from "../provider";
export function NoteZap() {
const ark = useArk();
const event = useNoteContext();
const zap = async () => {
try {
const nwc = await ark.load_nwc();
if (!nwc) {
ark.open_nwc();
} else {
ark.open_zap(event.id, event.pubkey);
}
} catch (e) {
toast.error(String(e));
}
};
return (
<button
type="button"
onClick={zap}
className="group inline-flex size-7 items-center justify-center text-neutral-800 dark:text-neutral-200"
>
<ZapIcon className="size-5 group-hover:text-blue-500" />
+2 -68
View File
@@ -1,11 +1,5 @@
import { NOSTR_MENTIONS } from "@lume/utils";
import { nanoid } from "nanoid";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import reactStringReplace from "react-string-replace";
import { User } from "../user";
import { Hashtag } from "./mentions/hashtag";
import { MentionUser } from "./mentions/user";
import { useEvent } from "@lume/ark";
export function NoteChild({
@@ -18,66 +12,6 @@ export function NoteChild({
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
if (!data) return "";
let parsedContent: string | ReactNode[] =
data.content.substring(0, 160) + "...";
const text = data.content;
const words = text.split(/( |\n)/);
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
const regex = new RegExp(`(|^)${hashtag}\\b`, "g");
parsedContent = reactStringReplace(parsedContent, regex, () => {
return <Hashtag key={nanoid()} tag={hashtag} />;
});
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
return (
<a
key={match + i}
href={url.toString()}
target="_blank"
rel="noreferrer"
className="content-break font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</a>
);
},
);
return parsedContent;
} catch (e) {
console.log(e);
return parsedContent;
}
}, [data]);
if (isLoading) {
return (
<div className="relative flex gap-3">
@@ -102,8 +36,8 @@ export function NoteChild({
<div className="relative flex gap-3">
<div className="relative flex-1 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-100 dark:bg-neutral-900" />
<div className="content-break mt-6 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{richContent}
<div className="content-break mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
{data.content}
</div>
</div>
<User.Provider pubkey={data.pubkey}>
+2 -6
View File
@@ -5,28 +5,24 @@ import {
NOSTR_EVENTS,
NOSTR_MENTIONS,
VIDEOS,
canPreview,
cn,
} from "@lume/utils";
import { NIP89 } from "./nip89";
import { useNoteContext } from "./provider";
import { ReactNode, useMemo } from "react";
import reactStringReplace from "react-string-replace";
import { nanoid } from "nanoid";
import { MentionUser } from "./mentions/user";
import { MentionNote } from "./mentions/note";
import { Hashtag } from "./mentions/hashtag";
import { VideoPreview } from "./preview/video";
import { stripHtml } from "string-strip-html";
import getUrl from "get-urls";
import { ImagePreview } from "./preview/image";
import reactStringReplace from "react-string-replace";
export function NoteContent({ className }: { className?: string }) {
const event = useNoteContext();
const content = useMemo(() => {
const text = stripHtml(event.content.trim()).result;
const text = event.content.trim();
const words = text.split(/( |\n)/);
const urls = [...getUrl(text)];
// @ts-ignore, kaboom !!!
let parsedContent: ReactNode[] = text;
+3 -70
View File
@@ -1,13 +1,7 @@
import { NOSTR_MENTIONS } from "@lume/utils";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import reactStringReplace from "react-string-replace";
import { User } from "../../user";
import { Hashtag } from "./hashtag";
import { MentionUser } from "./user";
import { useArk, useEvent } from "@lume/ark";
import { LinkIcon } from "@lume/icons";
import { stripHtml } from "string-strip-html";
export function MentionNote({
eventId,
@@ -16,72 +10,11 @@ export function MentionNote({
eventId: string;
openable?: boolean;
}) {
const ark = useArk();
const { t } = useTranslation();
const { isLoading, isError, data } = useEvent(eventId);
const ark = useArk();
const content = useMemo(() => {
if (!data) return "";
const text = stripHtml(data.content.trim()).result;
const words = text.split(/( |\n)/);
// @ts-ignore, kaboom !!!
let parsedContent: ReactNode[] = text;
const hashtags = words.filter((word) => word.startsWith("#"));
const mentions = words.filter((word) =>
NOSTR_MENTIONS.some((el) => word.startsWith(el)),
);
try {
if (hashtags.length) {
for (const hashtag of hashtags) {
parsedContent = reactStringReplace(
parsedContent,
hashtag,
(match, i) => {
return <Hashtag key={match + i} tag={hashtag} />;
},
);
}
}
if (mentions.length) {
for (const mention of mentions) {
parsedContent = reactStringReplace(
parsedContent,
mention,
(match, i) => <MentionUser key={match + i} pubkey={mention} />,
);
}
}
parsedContent = reactStringReplace(
parsedContent,
/(https?:\/\/\S+)/g,
(match, i) => {
const url = new URL(match);
return (
<a
key={match + i}
href={url.toString()}
target="_blank"
rel="noreferrer"
className="content-break inline-block w-full truncate font-normal text-blue-500 hover:text-blue-600"
>
{url.toString()}
</a>
);
},
);
return parsedContent;
} catch (e) {
return text;
}
}, [data]);
if (isLoading) {
return (
<div
@@ -120,7 +53,7 @@ export function MentionNote({
</User.Root>
</User.Provider>
<div className="line-clamp-4 select-text whitespace-normal text-balance leading-normal">
{content}
{data.content}
</div>
{openable ? (
<div className="flex h-10 items-center justify-between">
+5
View File
@@ -4,6 +4,7 @@ import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { useNoteContext } from "./provider";
import { useArk } from "@lume/ark";
import { toast } from "sonner";
export function NoteMenu() {
const ark = useArk();
@@ -13,20 +14,24 @@ export function NoteMenu() {
const copyID = async () => {
await writeText(await ark.event_to_bech32(event.id, [""]));
toast.success("Copied");
};
const copyRaw = async () => {
await writeText(JSON.stringify(event));
toast.success("Copied");
};
const copyNpub = async () => {
await writeText(await ark.user_to_bech32(event.pubkey, [""]));
toast.success("Copied");
};
const copyLink = async () => {
await writeText(
`https://njump.me/${await ark.event_to_bech32(event.id, [""])}`,
);
toast.success("Copied");
};
return (
+1 -1
View File
@@ -37,7 +37,7 @@ export function ImagePreview({ url }: { url: string }) {
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
onClick={open}
className="group relative my-1 overflow-hidden rounded-xl"
className="group relative my-1 overflow-hidden rounded-xl ring-1 ring-neutral-100 dark:ring-neutral-900"
>
<img
src={url}
+1 -1
View File
@@ -21,7 +21,7 @@ export function VideoPreview({ url }: { url: string }) {
<MediaControlBar>
<MediaPlayButton />
<MediaTimeRange />
<MediaTimeDisplay showDuration />
<MediaTimeDisplay />
<MediaMuteButton />
</MediaControlBar>
</MediaController>
+9 -12
View File
@@ -2,18 +2,15 @@ import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function NoteRoot({
children,
className,
children,
className,
}: {
children: ReactNode;
className?: string;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn("h-min w-full overflow-hidden", className)}
contentEditable={false}
>
{children}
</div>
);
return (
<div className={cn("h-min w-full", className)} contentEditable={false}>
{children}
</div>
);
}
+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.24.1",
"bitcoin-units": "^1.0.0",
"clsx": "^2.1.0",
"dayjs": "^1.11.10",
"nostr-tools": "^2.3.1",
+32
View File
@@ -3,6 +3,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale";
import { nip19 } from "nostr-tools";
import { AUDIOS, IMAGES, VIDEOS } from "./constants";
import { BitcoinUnit } from "bitcoin-units";
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
@@ -89,3 +90,34 @@ export function canPreview(text: string) {
return true;
}
// source: https://github.com/synonymdev/bitkit/blob/master/src/utils/displayValues/index.ts
export function getBitcoinDisplayValues(satoshis: number) {
let bitcoinFormatted = new BitcoinUnit(satoshis, "satoshis")
.getValue()
.toFixed(10)
.replace(/\.?0+$/, "");
const [bitcoinWhole, bitcoinDecimal] = bitcoinFormatted.split(".");
// format sats to group thousands
// 4000000 -> 4 000 000
let res = "";
bitcoinFormatted
.split("")
.reverse()
.forEach((c, index) => {
if (index > 0 && index % 3 === 0) {
res = " " + res;
}
res = c + res;
});
bitcoinFormatted = res;
return {
bitcoinFormatted,
bitcoinWhole,
bitcoinDecimal,
};
}
+16 -31
View File
@@ -111,6 +111,9 @@ importers:
react:
specifier: ^18.2.0
version: 18.2.0
react-currency-input-field:
specifier: ^3.8.0
version: 3.8.0(react@18.2.0)
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
@@ -935,9 +938,6 @@ importers:
react-i18next:
specifier: ^14.0.5
version: 14.0.5(i18next@23.10.0)(react-dom@18.2.0)(react@18.2.0)
react-router-dom:
specifier: ^6.22.2
version: 6.22.2(react-dom@18.2.0)(react@18.2.0)
react-string-replace:
specifier: ^1.1.1
version: 1.1.1
@@ -987,6 +987,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.24.1
version: 5.24.1(react@18.2.0)
bitcoin-units:
specifier: ^1.0.0
version: 1.0.0
clsx:
specifier: ^2.1.0
version: 2.1.0
@@ -2625,11 +2628,6 @@ packages:
'@babel/runtime': 7.24.0
dev: false
/@remix-run/router@1.15.2:
resolution: {integrity: sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==}
engines: {node: '>=14.0.0'}
dev: false
/@rollup/plugin-virtual@3.0.2:
resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==}
engines: {node: '>=14.0.0'}
@@ -3636,10 +3634,20 @@ packages:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: false
/big.js@6.2.1:
resolution: {integrity: sha512-bCtHMwL9LeDIozFn+oNhhFoq+yQ3BNdnsLSASUxLciOb1vgvpHsIO1dsENiGMgbb4SkP5TrzWzRiLddn8ahVOQ==}
dev: false
/binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
/bitcoin-units@1.0.0:
resolution: {integrity: sha512-brac+Ttz7ovf/8D0jQHSWHnN2hmdjxDRBStxhjO752URLJlQIFpfZxzUteSZ81UYnRNiMkvsW9WsYPDuxHfnYA==}
dependencies:
big.js: 6.2.1
dev: false
/bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
requiresBuild: true
@@ -5973,29 +5981,6 @@ packages:
use-sidecar: 1.1.2(@types/react@18.2.61)(react@18.2.0)
dev: false
/react-router-dom@6.22.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-WgqxD2qySEIBPZ3w0sHH+PUAiamDeszls9tzqMPBDA1YYVucTBXLU7+gtRfcSnhe92A3glPnvSxK2dhNoAVOIQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
dependencies:
'@remix-run/router': 1.15.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router: 6.22.2(react@18.2.0)
dev: false
/react-router@6.22.2(react@18.2.0):
resolution: {integrity: sha512-YD3Dzprzpcq+tBMHBS822tCjnWD3iIZbTeSXMY9LPSG541EfoBGyZ3bS25KEnaZjLcmQpw2AVLkFyfgXY8uvcw==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
dependencies:
'@remix-run/router': 1.15.2
react: 18.2.0
dev: false
/react-string-replace@1.1.1:
resolution: {integrity: sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==}
engines: {node: '>=0.12.0'}
+2
View File
@@ -8,6 +8,8 @@
"splash",
"editor",
"settings",
"nwc",
"zap-*",
"event-*",
"user-*",
"column-*"
+1 -1
View File
@@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","splash","editor","settings","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","dialog:allow-open","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","splash","editor","settings","nwc","zap-*","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","dialog:allow-open","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}
+22 -17
View File
@@ -22,7 +22,6 @@ fn main() {
.setup(|app| {
let _tray = tray::create_tray(app.handle()).unwrap();
let handle = app.handle().clone();
let resource_dir = handle.path().resource_dir().unwrap();
let home_dir = handle.path().home_dir().unwrap();
// create data folder if not exist
@@ -30,12 +29,13 @@ fn main() {
tauri::async_runtime::spawn(async move {
// Create nostr database connection
let nostr_db = SQLiteDatabase::open(resource_dir.join("lume.db"))
.await
.expect("Open database failed.");
let sqlite = SQLiteDatabase::open(home_dir.join("Lume/lume.db")).await;
// Create nostr connection
let client = ClientBuilder::default().database(nostr_db).build();
let client = match sqlite {
Ok(db) => ClientBuilder::default().database(db).build(),
Err(_) => ClientBuilder::default().build(),
};
// Add some bootstrap relays
// #TODO: Pull bootstrap relays from user's settings
@@ -101,8 +101,9 @@ fn main() {
nostr::metadata::get_interest,
nostr::metadata::set_settings,
nostr::metadata::get_settings,
nostr::metadata::get_nwc_status,
nostr::metadata::set_nwc,
nostr::metadata::load_nwc,
nostr::metadata::get_balance,
nostr::metadata::zap_profile,
nostr::metadata::zap_event,
nostr::event::get_event,
@@ -120,16 +121,20 @@ fn main() {
])
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app, event| {
if let tauri::RunEvent::Opened { urls } = event {
if let Some(w) = app.get_webview_window("main") {
let urls = urls
.iter()
.map(|u| u.as_str())
.collect::<Vec<_>>()
.join(",");
let _ = w.eval(&format!("window.onFileOpen(`{urls}`)"));
.run(
#[allow(unused_variables)]
|app, event| {
#[cfg(any(target_os = "macos"))]
if let tauri::RunEvent::Opened { urls } = event {
if let Some(w) = app.get_webview_window("main") {
let urls = urls
.iter()
.map(|u| u.as_str())
.collect::<Vec<_>>()
.join(",");
let _ = w.eval(&format!("window.onFileOpen(`{urls}`)"));
}
}
}
});
},
);
}
+4
View File
@@ -132,6 +132,7 @@ pub async fn load_selected_account(npub: &str, state: State<'_, Nostr>) -> Resul
if let Some(event) = events.first() {
let list = nip65::extract_relay_list(&event);
for item in list.into_iter() {
println!("connecting to relay: {}", item.0.to_string());
client
.connect_relay(item.0.to_string())
.await
@@ -140,6 +141,9 @@ pub async fn load_selected_account(npub: &str, state: State<'_, Nostr>) -> Resul
}
}
// #TODO
// Subscribe new event for activity and local newsfeed
Ok(true)
} else {
Err("nsec not found".into())
+48 -12
View File
@@ -1,4 +1,5 @@
use crate::Nostr;
use keyring::Entry;
use nostr_sdk::prelude::*;
use std::{str::FromStr, time::Duration};
use tauri::State;
@@ -273,30 +274,65 @@ pub async fn get_settings(id: &str, state: State<'_, Nostr>) -> Result<String, S
}
}
#[tauri::command]
pub async fn get_nwc_status(state: State<'_, Nostr>) -> Result<bool, ()> {
let client = &state.client;
let zapper = client.zapper().await.is_ok();
Ok(zapper)
}
#[tauri::command]
pub async fn set_nwc(uri: &str, state: State<'_, Nostr>) -> Result<bool, String> {
let client = &state.client;
if let Ok(uri) = NostrWalletConnectURI::from_str(&uri) {
if let Ok(nwc) = NWC::new(uri).await {
let _ = client.set_zapper(nwc);
if let Ok(nwc_uri) = NostrWalletConnectURI::from_str(&uri) {
if let Ok(nwc) = NWC::new(nwc_uri).await {
let keyring = Entry::new("Lume Secret Storage", "NWC").unwrap();
let _ = keyring.set_password(uri);
let _ = client.set_zapper(nwc).await;
Ok(true)
} else {
Ok(false)
Err("URI is not valid".into())
}
} else {
Err("Set NWC failed".into())
}
}
#[tauri::command]
pub async fn load_nwc(state: State<'_, Nostr>) -> Result<bool, bool> {
let client = &state.client;
let keyring = Entry::new("Lume Secret Storage", "NWC").unwrap();
match keyring.get_password() {
Ok(val) => {
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
if let Ok(nwc) = NWC::new(uri).await {
client.set_zapper(nwc).await;
Ok(true)
} else {
Err(false)
}
}
Err(_) => Err(false),
}
}
#[tauri::command]
pub async fn get_balance() -> Result<u64, String> {
let keyring = Entry::new("Lume Secret Storage", "NWC").unwrap();
match keyring.get_password() {
Ok(val) => {
let uri = NostrWalletConnectURI::from_str(&val).unwrap();
if let Ok(nwc) = NWC::new(uri).await {
if let Ok(balance) = nwc.get_balance().await {
Ok(balance)
} else {
Err("Get balance failed".into())
}
} else {
Err("Cannot connect to NWC".into())
}
}
Err(_) => Err("Something wrong".into()),
}
}
#[tauri::command]
pub async fn zap_profile(
id: &str,
+25 -11
View File
@@ -1,8 +1,32 @@
use tauri::{tray::ClickType, Manager, Runtime};
use tauri::{Manager, Runtime};
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let version = app.package_info().version.to_string();
let tray = app.tray().unwrap();
let menu = tauri::menu::MenuBuilder::new(app)
.item(
&tauri::menu::MenuItem::with_id(app, "open_lume", "Open Lume", true, None::<&str>).unwrap(),
)
.item(&tauri::menu::MenuItem::with_id(app, "editor", "New Post", true, None::<&str>).unwrap())
.separator()
.item(
&tauri::menu::MenuItem::with_id(
app,
"version",
format!("Version {}", version),
false,
None::<&str>,
)
.unwrap(),
)
.item(&tauri::menu::MenuItem::with_id(app, "about", "About Lume", true, None::<&str>).unwrap())
.item(
&tauri::menu::MenuItem::with_id(app, "update", "Check for Updates", true, None::<&str>)
.unwrap(),
)
.item(
&tauri::menu::MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>).unwrap(),
)
.item(&tauri::menu::MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap())
.build()
.unwrap();
@@ -16,15 +40,5 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
_ => {}
});
tray.on_tray_icon_event(|tray, event| {
if event.click_type == ClickType::Left {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
});
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"productName": "Lume",
"version": "4.0.0-alpha+1",
"version": "4.0.0-alpha+2",
"identifier": "nu.lume.Lume",
"build": {
"beforeBuildCommand": "pnpm desktop:build",