This commit is contained in:
Ren Amamiya 2023-06-25 15:50:34 +07:00
parent 85b30f770c
commit fe25dbaed0
43 changed files with 933 additions and 402 deletions

View File

@ -19,6 +19,7 @@
"@tanstack/react-query": "^4.29.15",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.8",
"destr": "^1.2.2",
"framer-motion": "^10.12.17",
@ -30,6 +31,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.0",
"react-hotkeys-hook": "^4.4.0",
"react-player": "^2.12.0",
"react-resizable-panels": "^0.0.48",
"react-router-dom": "^6.14.0",
"react-string-replace": "^1.1.1",

View File

@ -19,6 +19,9 @@ dependencies:
'@tauri-apps/api':
specifier: ^1.4.0
version: 1.4.0
cheerio:
specifier: 1.0.0-rc.12
version: 1.0.0-rc.12
dayjs:
specifier: ^1.11.8
version: 1.11.8
@ -52,6 +55,9 @@ dependencies:
react-hotkeys-hook:
specifier: ^4.4.0
version: 4.4.0(react-dom@18.2.0)(react@18.2.0)
react-player:
specifier: ^2.12.0
version: 2.12.0(react@18.2.0)
react-resizable-panels:
specifier: ^0.0.48
version: 0.0.48(react-dom@18.2.0)(react@18.2.0)
@ -1399,6 +1405,10 @@ packages:
engines: {node: '>=8'}
dev: true
/boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
dev: false
/brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
dependencies:
@ -1511,6 +1521,30 @@ packages:
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
dev: true
/cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
dependencies:
boolbase: 1.0.0
css-select: 5.1.0
css-what: 6.1.0
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
dev: false
/cheerio@1.0.0-rc.12:
resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
engines: {node: '>= 6'}
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.1.0
htmlparser2: 8.0.2
parse5: 7.1.2
parse5-htmlparser2-tree-adapter: 7.0.0
dev: false
/chokidar@3.5.3:
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
engines: {node: '>= 8.10.0'}
@ -1645,6 +1679,21 @@ packages:
shebang-command: 2.0.0
which: 2.0.2
/css-select@5.1.0:
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
dependencies:
boolbase: 1.0.0
css-what: 6.1.0
domhandler: 5.0.3
domutils: 3.1.0
nth-check: 2.1.1
dev: false
/css-what@6.1.0:
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
dev: false
/cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@ -1721,6 +1770,11 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: false
/deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
dev: false
/define-properties@1.2.0:
resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==}
engines: {node: '>= 0.4'}
@ -1781,6 +1835,33 @@ packages:
esutils: 2.0.3
dev: false
/dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
dev: false
/domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: false
/domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dependencies:
domelementtype: 2.3.0
dev: false
/domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dev: false
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@ -1799,6 +1880,11 @@ packages:
dependencies:
iconv-lite: 0.6.3
/entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
dev: false
/env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@ -2560,6 +2646,15 @@ packages:
lru-cache: 6.0.0
dev: false
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
dev: false
/http-cache-semantics@4.1.1:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
dev: false
@ -2986,6 +3081,10 @@ packages:
strip-bom: 3.0.0
dev: false
/load-script@1.0.0:
resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==}
dev: false
/locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@ -3107,6 +3206,10 @@ packages:
engines: {node: '>=16'}
dev: false
/memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
dev: false
/memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
engines: {node: '>= 0.10.0'}
@ -3428,10 +3531,15 @@ packages:
set-blocking: 2.0.0
dev: false
/nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
dependencies:
boolbase: 1.0.0
dev: false
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
dev: true
/object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
@ -3560,6 +3668,19 @@ packages:
lines-and-columns: 1.2.4
dev: false
/parse5-htmlparser2-tree-adapter@7.0.0:
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies:
domhandler: 5.0.3
parse5: 7.1.2
dev: false
/parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
dependencies:
entities: 4.5.0
dev: false
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -3754,7 +3875,6 @@ packages:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
dev: true
/punycode@2.3.0:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
@ -3790,6 +3910,10 @@ packages:
scheduler: 0.23.0
dev: false
/react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
dev: false
/react-hook-form@7.45.0(react@18.2.0):
resolution: {integrity: sha512-AbHeZ4ad+0dEIknSW9dBgIwcvRDfZ1O97sgj75WaMdOX0eg8TBiUf9wxzVkIjZbk76BBIE9lmFOzyD4PN80ZQg==}
engines: {node: '>=12.22.0'}
@ -3811,12 +3935,24 @@ packages:
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true
/react-is@18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
dev: false
/react-player@2.12.0(react@18.2.0):
resolution: {integrity: sha512-rymLRz/2GJJD+Wc01S7S+i9pGMFYnNmQibR2gVE3KmHJCBNN8BhPAlOPTGZtn1uKpJ6p4RPLlzPQ1OLreXd8gw==}
peerDependencies:
react: '>=16.6.0'
dependencies:
deepmerge: 4.3.1
load-script: 1.0.0
memoize-one: 5.2.1
prop-types: 15.8.1
react: 18.2.0
react-fast-compare: 3.2.2
dev: false
/react-resizable-panels@0.0.48(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-fJa3itmJ3HLLmVG7y8tka80wFW63N6ai76q7MGwU8nSXeA0qkX36vnmPyXm34lvtsGjn1Cgi5IPhPQnf42SVpA==}
peerDependencies:
@ -3981,8 +4117,8 @@ packages:
glob: 7.2.3
dev: false
/rollup@3.25.1:
resolution: {integrity: sha512-tywOR+rwIt5m2ZAWSe5AIJcTat8vGlnPFAv15ycCrw33t6iFsXZ6mzHVFh2psSjxQPmI+xgzMZZizUAukBI4aQ==}
/rollup@3.25.2:
resolution: {integrity: sha512-VLnkxZMDr3jpxgtmS8pQZ0UvhslmF4ADq/9w4erkctbgjCqLW9oa89fJuXEs4ZmgyoF7Dm8rMDKSS5b5u2hHUg==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
@ -4753,7 +4889,7 @@ packages:
'@types/node': 18.16.18
esbuild: 0.17.19
postcss: 8.4.24
rollup: 3.25.1
rollup: 3.25.2
optionalDependencies:
fsevents: 2.3.2
dev: true

View File

@ -22,11 +22,7 @@
"http": {
"all": true,
"request": true,
"scope": [
"https://void.cat/*",
"https://skrape.dev/*",
"https://lume.nu/*"
]
"scope": ["http://**", "https://**"]
},
"fs": {
"all": false,

View File

@ -1,4 +1,4 @@
import { createAccount } from "@libs/storage";
import { createAccount, createBlock } from "@libs/storage";
import { Button } from "@shared/button";
import { EyeOffIcon, EyeOnIcon } from "@shared/icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@ -30,6 +30,11 @@ export function CreateStep1Screen() {
mutationFn: (data: any) =>
createAccount(data.npub, data.pubkey, data.privkey, null, 1),
onSuccess: () => {
createBlock(
0,
"Preserve your freedom",
"https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv",
);
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
// redirect to next step
navigate("/auth/create/step-2", { replace: true });

View File

@ -6,10 +6,13 @@ import { useOnboarding } from "@stores/onboarding";
import { Body, fetch } from "@tauri-apps/api/http";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
export function CreateStep3Screen() {
const ndk = useContext(RelayContext);
const profile = useOnboarding((state: any) => state.profile);
const navigate = useNavigate();
const { account } = useAccount();
const [username, setUsername] = useState("");
@ -48,6 +51,7 @@ export function CreateStep3Screen() {
event.publish();
// redirect to step 4
navigate("/auth/create/step-4", { replace: true });
}
} catch (error) {
setLoading(false);

View File

@ -131,8 +131,6 @@ export function CreateStep4Screen() {
updateAccount("follows", follows, account.pubkey),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
// redirect to next step
navigate("/auth/onboarding", { replace: true });
},
});
@ -156,6 +154,9 @@ export function CreateStep4Screen() {
// update
update.mutate(follows);
// redirect to next step
setTimeout(() => navigate("/auth/onboarding", { replace: true }), 1200);
} catch {
console.log("error");
}

View File

@ -1,4 +1,4 @@
import { createAccount } from "@libs/storage";
import { createAccount, createBlock } from "@libs/storage";
import { LoaderIcon } from "@shared/icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getPublicKey, nip19 } from "nostr-tools";
@ -31,6 +31,11 @@ export function ImportStep1Screen() {
mutationFn: (data: any) =>
createAccount(data.npub, data.pubkey, data.privkey, null, 1),
onSuccess: () => {
createBlock(
0,
"Preserve your freedom",
"https://void.cat/d/949GNg7ZjSLHm2eTR3jZqv",
);
queryClient.invalidateQueries({ queryKey: ["currentAccount"] });
// redirect to next step
navigate("/auth/import/step-2", { replace: true });

View File

@ -1,10 +1,11 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { LoaderIcon } from "@shared/icons";
import { ArrowRightCircleIcon } from "@shared/icons/arrowRightCircle";
import { RelayContext } from "@shared/relayProvider";
import { User } from "@shared/user";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useContext } from "react";
import { useContext, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
export function OnboardingScreen() {
@ -12,9 +13,12 @@ export function OnboardingScreen() {
const navigate = useNavigate();
const { status, account } = useAccount();
const [loading, setLoading] = useState(false);
const publish = async () => {
try {
setLoading(true);
const event = new NDKEvent(ndk);
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
@ -30,7 +34,7 @@ export function OnboardingScreen() {
event.publish();
// redirect to home
navigate("/", { replace: true });
setTimeout(() => navigate("/", { replace: true }), 1200);
} catch (error) {
console.log(error);
}
@ -80,9 +84,15 @@ export function OnboardingScreen() {
onClick={() => publish()}
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg px-6 font-medium text-zinc-100 bg-fuchsia-500 hover:bg-fuchsia-600"
>
<span className="w-5" />
<span>Publish</span>
<ArrowRightCircleIcon className="w-5 h-5" />
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
<>
<span className="w-5" />
<span>Publish</span>
<ArrowRightCircleIcon className="w-5 h-5" />
</>
)}
</button>
<Link
to="/"

View File

@ -167,7 +167,7 @@ export function ChannelCreateModal() {
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4"
className="flex h-full w-full flex-col gap-4 mb-0"
>
<input
type={"hidden"}

View File

@ -7,6 +7,7 @@ export function ChannelsListItem({ data }: { data: any }) {
return (
<NavLink
to={`/app/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",

View File

@ -3,18 +3,19 @@ import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, HideIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip";
import { useActiveAccount } from "@stores/accounts";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useState } from "react";
export function MessageHideButton({ id }: { id: string }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const hide = useChannelMessages((state: any) => state.hideMessage);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};

View File

@ -15,7 +15,7 @@ export function ChannelMessageItem({ data }: { data: LumeEvent }) {
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} />
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}

View File

@ -3,18 +3,19 @@ import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon, MuteIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip";
import { useActiveAccount } from "@stores/accounts";
import { useChannelMessages } from "@stores/channels";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useState } from "react";
export function MessageMuteButton({ pubkey }: { pubkey: string }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const mute = useChannelMessages((state: any) => state.muteUser);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};

View File

@ -109,29 +109,31 @@ export function ChannelScreen() {
>
<h3 className="font-semibold text-zinc-100">Public Channel</h3>
</div>
<div className="w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between rounded-md bg-zinc-900">
{!messages ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto h-full w-full"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
)}
<div className="w-full inline-flex shrink-0 px-5 py-3 border-t border-zinc-800">
<div className="w-full h-full flex-1 p-3">
<div className="h-full flex flex-col justify-between rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<div className="flex-1 w-full h-full">
{!messages ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="shrink-0 px-5 p-3 rounded-b-xl border-t border-zinc-800 bg-zinc-900 z-50">
<ChannelMessageForm channelID={id} />
</div>
</div>

View File

@ -20,6 +20,7 @@ export function ChatsListItem({ data }: { data: any }) {
) : (
<NavLink
to={`/app/chat/${data.sender_pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",

View File

@ -26,7 +26,11 @@ export function ChatMessageItem({
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<User pubkey={data.sender_pubkey} time={data.created_at} />
<User
pubkey={data.sender_pubkey}
time={data.created_at}
isChat={true}
/>
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}

View File

@ -94,7 +94,7 @@ export function NewMessageModal() {
</Dialog.Description>
</div>
</div>
<div className="h-[500px] flex flex-col pb-5 overflow-y-auto">
<div className="h-[500px] flex flex-col pb-5 overflow-x-hidden overflow-y-auto">
{status === "loading" || isFetching ? (
<p>Loading...</p>
) : (

View File

@ -20,6 +20,7 @@ export function ChatsListSelfItem({ data }: { data: any }) {
) : (
<NavLink
to={`/app/chat/${data.pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"inline-flex h-9 items-center gap-2.5 rounded-md px-2.5",

View File

@ -52,39 +52,37 @@ export function ChatScreen() {
>
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
</div>
<div className="w-full flex-1 p-3">
{account && (
<div className="flex h-full flex-col justify-between rounded-md bg-zinc-900">
<div className="w-full h-full flex-1 p-3">
<div className="h-full flex flex-col justify-between rounded-xl border-t border-zinc-800/50 bg-zinc-900 overflow-hidden">
<div className="flex-1 w-full h-full">
{status === "loading" ? (
<p>Loading...</p>
) : (
<div className="h-full w-full">
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide h-full w-full overflow-y-auto"
components={{
EmptyPlaceholder: () => Empty,
}}
/>
</div>
)}
<div className="shrink-0 px-5 p-3 border-t border-zinc-800">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
<Virtuoso
ref={virtuosoRef}
data={data}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={data.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="relative scrollbar-hide overflow-y-auto"
components={{
EmptyPlaceholder: () => Empty,
}}
/>
</div>
)}
</div>
)}
<div className="shrink-0 px-5 p-3 rounded-b-xl border-t border-zinc-800 bg-zinc-900 z-50">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={account.pubkey}
userPrivkey={account.privkey}
/>
</div>
</div>
</div>
</div>
<div className="col-span-1">
@ -92,18 +90,16 @@ export function ChatScreen() {
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
{pubkey && <ChatSidebar pubkey={pubkey} />}
<ChatSidebar pubkey={pubkey} />
</div>
</div>
);
}
const Empty = (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</h3>
<p className="text-base leading-none text-zinc-400">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full flex flex-col gap-1 text-center">
<h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-zinc-400">
You two didn't talk yet, let's send first message
</p>
</div>

View File

@ -8,7 +8,7 @@ import {
getLastLogin,
} from "@libs/storage";
import { NDKFilter } from "@nostr-dev-kit/ndk";
import { LumeIcon } from "@shared/icons";
import { LoaderIcon, LumeIcon } from "@shared/icons";
import { RelayContext } from "@shared/relayProvider";
import { dateToUnix, getHourAgo } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
@ -177,27 +177,7 @@ export function Root() {
</div>
</div>
<div className="absolute bottom-16 left-1/2 -translate-x-1/2 transform">
<svg
className="h-5 w-5 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
</div>

View File

@ -1,12 +1,15 @@
import { Dialog, Transition } from "@headlessui/react";
import { createBlock } from "@libs/storage";
import { CancelIcon } from "@shared/icons";
import { useActiveAccount } from "@stores/accounts";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useAccount } from "@utils/hooks/useAccount";
import { nip19 } from "nostr-tools";
import { Fragment, useState } from "react";
import { useForm } from "react-hook-form";
export function AddFeedBlock({ parentState }: { parentState: any }) {
const addBlock = useActiveAccount((state: any) => state.addBlock);
const queryClient = useQueryClient();
const { account } = useAccount();
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(true);
@ -18,6 +21,13 @@ export function AddFeedBlock({ parentState }: { parentState: any }) {
parentState(false);
};
const block = useMutation({
mutationFn: (data: any) => createBlock(data.kind, data.title, data.content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const {
register,
handleSubmit,
@ -35,7 +45,7 @@ export function AddFeedBlock({ parentState }: { parentState: any }) {
}
// insert to database
addBlock(1, data.title, pubkey);
block.mutate({ kind: 1, title: data.title, content: pubkey });
setTimeout(() => {
setLoading(false);
@ -43,7 +53,7 @@ export function AddFeedBlock({ parentState }: { parentState: any }) {
reset();
// close modal
closeModal();
}, 1000);
}, 1200);
};
return (
@ -70,7 +80,7 @@ export function AddFeedBlock({ parentState }: { parentState: any }) {
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
@ -102,7 +112,7 @@ export function AddFeedBlock({ parentState }: { parentState: any }) {
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4"
className="flex h-full w-full flex-col gap-4 mb-0"
>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium uppercase tracking-wider text-zinc-400">

View File

@ -1,28 +1,29 @@
import { Dialog, Transition } from "@headlessui/react";
import { createBlock } from "@libs/storage";
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { CancelIcon } from "@shared/icons";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from "@tauri-apps/api/http";
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment, useContext, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export function AddImageBlock({ parentState }: { parentState: any }) {
const ndk = useContext(RelayContext);
const queryClient = useQueryClient();
const [account, addBlock] = useActiveAccount((state: any) => [
state.account,
state.addBlock,
]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(true);
const [image, setImage] = useState("");
const { account } = useAccount();
const tags = useRef(null);
const closeModal = () => {
@ -88,6 +89,13 @@ export function AddImageBlock({ parentState }: { parentState: any }) {
}
};
const block = useMutation({
mutationFn: (data: any) => createBlock(data.kind, data.title, data.content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
@ -105,8 +113,8 @@ export function AddImageBlock({ parentState }: { parentState: any }) {
// publish event
event.publish();
// insert to database
addBlock(0, data.title, data.content);
// mutate
block.mutate({ kind: 0, title: data.title, content: data.content });
setTimeout(() => {
setLoading(false);
@ -114,7 +122,7 @@ export function AddImageBlock({ parentState }: { parentState: any }) {
reset();
// close modal
closeModal();
}, 1000);
}, 1200);
};
useEffect(() => {
@ -145,7 +153,7 @@ export function AddImageBlock({ parentState }: { parentState: any }) {
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900">
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
@ -177,7 +185,7 @@ export function AddImageBlock({ parentState }: { parentState: any }) {
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full w-full flex-col gap-4"
className="flex h-full w-full flex-col gap-4 mb-0"
>
<input
type={"hidden"}

View File

@ -1,9 +1,12 @@
import { getNotesByAuthor } from "@libs/storage";
import { getNotesByAuthor, removeBlock } from "@libs/storage";
import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { useActiveAccount } from "@stores/accounts";
import { useInfiniteQuery } from "@tanstack/react-query";
import {
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useRef } from "react";
@ -11,12 +14,7 @@ const ITEM_PER_PAGE = 10;
const TIME = Math.floor(Date.now() / 1000);
export function FeedBlock({ params }: { params: any }) {
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
const close = () => {
removeBlock(params.id, true);
};
const queryClient = useQueryClient();
const {
status,
data,
@ -65,6 +63,13 @@ export function FeedBlock({ params }: { params: any }) {
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
const block = useMutation({
mutationFn: (id: string) => removeBlock(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const renderItem = (index: string | number) => {
const note = notes[index];
@ -78,7 +83,7 @@ export function FeedBlock({ params }: { params: any }) {
return (
<div className="shrink-0 w-[400px] border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => close()} />
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div
ref={parentRef}
className="scrollbar-hide flex w-full h-full flex-col justify-between gap-1.5 pt-1.5 pb-20 overflow-y-auto"

View File

@ -4,7 +4,6 @@ import { Note } from "@shared/notes/note";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { RelayContext } from "@shared/relayProvider";
import { TitleBar } from "@shared/titleBar";
import { useActiveAccount } from "@stores/accounts";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { dateToUnix } from "@utils/date";
@ -63,17 +62,16 @@ export function FollowingBlock({ block }: { block: number }) {
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
useEffect(() => {
let sub: NDKSubscription;
const follows = account ? JSON.parse(account.follows) : [];
if (account) {
const follows = JSON.parse(account.follows);
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: dateToUnix(),
};
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
since: dateToUnix(),
};
sub = ndk.subscribe(filter);
const sub = account ? ndk.subscribe(filter) : null;
if (sub) {
sub.addListener("event", (event: NDKEvent) => {
createNote(
event.id,
@ -87,7 +85,9 @@ export function FollowingBlock({ block }: { block: number }) {
}
return () => {
sub.stop();
if (sub) {
sub.stop();
}
};
}, [account]);

View File

@ -1,25 +1,30 @@
import { removeBlock } from "@libs/storage";
import { Image } from "@shared/image";
import { TitleBar } from "@shared/titleBar";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function ImageBlock({ params }: { params: any }) {
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
const queryClient = useQueryClient();
const close = () => {
removeBlock(params.id, true);
};
const block = useMutation({
mutationFn: (id: string) => removeBlock(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
return (
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => close()} />
<div className="w-full flex-1 p-3">
<Image
src={params.content}
fallback={DEFAULT_AVATAR}
alt={params.title}
className="w-full h-full object-cover rounded-md"
/>
<div className="shrink-0 w-[350px] h-full flex flex-col justify-between border-r border-zinc-900">
<div className="flex-1 w-full h-full overflow-hidden p-3">
<div className="w-full h-full">
<Image
src={params.content}
fallback={DEFAULT_AVATAR}
alt={params.title}
className="w-full h-full object-cover rounded-xl border-t border-zinc-800/50"
/>
</div>
</div>
</div>
);

View File

@ -1,4 +1,4 @@
import { getNoteByID } from "@libs/storage";
import { getNoteByID, removeBlock } from "@libs/storage";
import { Kind1 } from "@shared/notes/contents/kind1";
import { Kind1063 } from "@shared/notes/contents/kind1063";
import { NoteMetadata } from "@shared/notes/metadata";
@ -7,11 +7,12 @@ import { RepliesList } from "@shared/notes/replies/list";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { TitleBar } from "@shared/titleBar";
import { User } from "@shared/user";
import { useActiveAccount } from "@stores/accounts";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { parser } from "@utils/parser";
export function ThreadBlock({ params }: { params: any }) {
const queryClient = useQueryClient();
const { status, data, isFetching } = useQuery(
["thread", params.content],
async () => {
@ -19,16 +20,18 @@ export function ThreadBlock({ params }: { params: any }) {
},
);
const content = data ? parser(data) : null;
const removeBlock = useActiveAccount((state: any) => state.removeBlock);
const block = useMutation({
mutationFn: (id: string) => removeBlock(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const close = () => {
removeBlock(params.id, false);
};
const content = data ? parser(data) : null;
return (
<div className="shrink-0 w-[400px] border-r border-zinc-900">
<TitleBar title={params.title} onClick={() => close()} />
<TitleBar title={params.title} onClick={() => block.mutate(params.id)} />
<div className="scrollbar-hide flex w-full h-full flex-col gap-1.5 pt-1.5 pb-20 overflow-y-auto">
{status === "loading" || isFetching ? (
<div className="px-3 py-1.5">

View File

@ -4,15 +4,41 @@ import { FollowingBlock } from "@app/space/components/blocks/following";
import { ImageBlock } from "@app/space/components/blocks/image";
import { ThreadBlock } from "@app/space/components/blocks/thread";
import { getBlocks } from "@libs/storage";
const blocks = await getBlocks();
import { LoaderIcon } from "@shared/icons";
import { useQuery } from "@tanstack/react-query";
export function SpaceScreen() {
const {
status,
data: blocks,
isFetching,
} = useQuery(
["blocks"],
async () => {
return await getBlocks();
},
{
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
);
return (
<div className="h-full w-full flex flex-nowrap overflow-x-auto overflow-y-hidden scrollbar-hide">
<FollowingBlock block={1} />
{!blocks ? (
<p>Loading...</p>
{status === "loading" ? (
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900">
<div
data-tauri-drag-region
className="group overflow-hidden h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900"
/>
<div className="w-full flex-1 flex items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
) : (
blocks.map((block: any) => {
switch (block.kind) {
@ -27,6 +53,18 @@ export function SpaceScreen() {
}
})
)}
{isFetching && (
<div className="shrink-0 w-[350px] flex-col flex border-r border-zinc-900">
<div
data-tauri-drag-region
className="group overflow-hidden h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900"
/>
<div className="w-full flex-1 flex items-center justify-center p-3">
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
</div>
</div>
)}
<div className="shrink-0 w-[90px]">
<div className="w-full h-full inline-flex items-center justify-center">
<AddBlock />

View File

@ -1,10 +1,10 @@
import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { useQuery } from "@tanstack/react-query";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { usePageContext } from "@utils/hooks/usePageContext";
import { useProfile } from "@utils/hooks/useProfile";
import { compactNumber } from "@utils/number";
@ -18,6 +18,7 @@ export function UserScreen() {
const searchParams: any = pageContext.urlParsed.search;
const pubkey = searchParams.pubkey || "";
const { account } = useAccount();
const { user } = useProfile(pubkey);
const { data: userStats, error } = useQuery(["user", pubkey], async () => {
const res = await fetch(
@ -28,7 +29,6 @@ export function UserScreen() {
}
});
const account = useActiveAccount((state: any) => state.account);
const follows = account ? JSON.parse(account.follows) : [];
const follow = (pubkey: string) => {

360
src/libs/openGraph.tsx Normal file
View File

@ -0,0 +1,360 @@
import { OPENGRAPH } from "@stores/constants";
import { FetchOptions, ResponseType, fetch } from "@tauri-apps/api/http";
import * as cheerio from "cheerio";
interface ILinkPreviewOptions {
headers?: Record<string, string>;
imagesPropertyType?: string;
proxyUrl?: string;
timeout?: number;
followRedirects?: `follow` | `error` | `manual`;
resolveDNSHost?: (url: string) => Promise<string>;
handleRedirects?: (baseURL: string, forwardedURL: string) => boolean;
}
interface IPreFetchedResource {
headers: Record<string, string>;
status?: number;
imagesPropertyType?: string;
proxyUrl?: string;
url: string;
data: any;
}
function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) {
const nodes = doc(`meta[${attr}='${type}']`);
return nodes.length ? nodes : null;
}
function metaTagContent(doc: cheerio.CheerioAPI, type: string, attr: string) {
return doc(`meta[${attr}='${type}']`).attr("content");
}
function getTitle(doc: cheerio.CheerioAPI) {
let title =
metaTagContent(doc, "og:title", "property") ||
metaTagContent(doc, "og:title", "name");
if (!title) {
title = doc("title").text();
}
return title;
}
function getSiteName(doc: cheerio.CheerioAPI) {
const siteName =
metaTagContent(doc, "og:site_name", "property") ||
metaTagContent(doc, "og:site_name", "name");
return siteName;
}
function getDescription(doc: cheerio.CheerioAPI) {
const description =
metaTagContent(doc, "description", "name") ||
metaTagContent(doc, "Description", "name") ||
metaTagContent(doc, "og:description", "property");
return description;
}
function getMediaType(doc: cheerio.CheerioAPI) {
const node = metaTag(doc, "medium", "name");
if (node) {
const content = node.attr("content");
return content === "image" ? "photo" : content;
}
return (
metaTagContent(doc, "og:type", "property") ||
metaTagContent(doc, "og:type", "name")
);
}
function getImages(
doc: cheerio.CheerioAPI,
rootUrl: string,
imagesPropertyType?: string,
) {
let images: string[] = [];
let nodes: cheerio.Cheerio<cheerio.Element> | null;
let src: string | undefined;
let dic: Record<string, boolean> = {};
const imagePropertyType = imagesPropertyType ?? "og";
nodes =
metaTag(doc, `${imagePropertyType}:image`, "property") ||
metaTag(doc, `${imagePropertyType}:image`, "name");
if (nodes) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") {
src = node.attribs.content;
if (src) {
src = new URL(src, rootUrl).href;
images.push(src);
}
}
});
}
if (images.length <= 0 && !imagesPropertyType) {
src = doc("link[rel=image_src]").attr("href");
if (src) {
src = new URL(src, rootUrl).href;
images = [src];
} else {
nodes = doc("img");
if (nodes?.length) {
dic = {};
images = [];
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") src = node.attribs.src;
if (src && !dic[src]) {
dic[src] = true;
// width = node.attribs.width;
// height = node.attribs.height;
images.push(new URL(src, rootUrl).href);
}
});
}
}
}
return images;
}
function getVideos(doc: cheerio.CheerioAPI) {
const videos = [];
let nodeTypes;
let nodeSecureUrls;
let nodeType;
let nodeSecureUrl;
let video;
let videoType;
let videoSecureUrl;
let width;
let height;
let videoObj;
let index;
const nodes =
metaTag(doc, "og:video", "property") || metaTag(doc, "og:video", "name");
if (nodes?.length) {
nodeTypes =
metaTag(doc, "og:video:type", "property") ||
metaTag(doc, "og:video:type", "name");
nodeSecureUrls =
metaTag(doc, "og:video:secure_url", "property") ||
metaTag(doc, "og:video:secure_url", "name");
width =
metaTagContent(doc, "og:video:width", "property") ||
metaTagContent(doc, "og:video:width", "name");
height =
metaTagContent(doc, "og:video:height", "property") ||
metaTagContent(doc, "og:video:height", "name");
for (index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node.type === "tag") video = node.attribs.content;
nodeType = nodeTypes?.[index];
if (nodeType?.type === "tag") {
videoType = nodeType ? nodeType.attribs.content : null;
}
nodeSecureUrl = nodeSecureUrls?.[index];
if (nodeSecureUrl?.type === "tag") {
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
}
videoObj = {
url: video,
secureUrl: videoSecureUrl,
type: videoType,
width,
height,
};
if (videoType && videoType.indexOf("video/") === 0) {
videos.splice(0, 0, videoObj);
} else {
videos.push(videoObj);
}
}
}
return videos;
}
// returns default favicon (//hostname/favicon.ico) for a url
function getDefaultFavicon(rootUrl: string) {
return `${new URL(rootUrl).origin}/favicon.ico`;
}
// returns an array of URLs to favicon images
function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
const images = [];
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
let src: string | undefined;
const relSelectors = [
"rel=icon",
`rel="shortcut icon"`,
"rel=apple-touch-icon",
];
relSelectors.forEach((relSelector) => {
// look for all icon tags
nodes = doc(`link[${relSelector}]`);
// collect all images from icon tags
if (nodes.length) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === "tag") src = node.attribs.href;
if (src) {
src = new URL(rootUrl).href;
images.push(src);
}
});
}
});
// if no icon images, use default favicon location
if (images.length <= 0) {
images.push(getDefaultFavicon(rootUrl));
}
return images;
}
function parseImageResponse(url: string, contentType: string) {
return {
url,
mediaType: "image",
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseAudioResponse(url: string, contentType: string) {
return {
url,
mediaType: "audio",
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseVideoResponse(url: string, contentType: string) {
return {
url,
mediaType: "video",
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseApplicationResponse(url: string, contentType: string) {
return {
url,
mediaType: "application",
contentType,
favicons: [getDefaultFavicon(url)],
};
}
function parseTextResponse(
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string,
) {
const doc = cheerio.load(body);
return {
url,
title: getTitle(doc),
siteName: getSiteName(doc),
description: getDescription(doc),
mediaType: getMediaType(doc) || "website",
contentType,
images: getImages(doc, url, options.imagesPropertyType),
videos: getVideos(doc),
favicons: getFavicons(doc, url),
};
}
function parseUnknownResponse(
body: string,
url: string,
options: ILinkPreviewOptions = {},
contentType?: string,
) {
return parseTextResponse(body, url, options, contentType);
}
function parseResponse(
response: IPreFetchedResource,
options?: ILinkPreviewOptions,
) {
try {
let contentType = response.headers["content-type"];
// console.warn(`original content type`, contentType);
if (contentType?.indexOf(";")) {
// eslint-disable-next-line prefer-destructuring
contentType = contentType.split(";")[0];
// console.warn(`splitting content type`, contentType);
}
if (!contentType) {
return parseUnknownResponse(response.data, response.url, options);
}
if ((contentType as any) instanceof Array) {
// eslint-disable-next-line no-param-reassign, prefer-destructuring
contentType = contentType[0];
}
// parse response depending on content type
if (OPENGRAPH.REGEX_CONTENT_TYPE_IMAGE.test(contentType)) {
return parseImageResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_AUDIO.test(contentType)) {
return parseAudioResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_VIDEO.test(contentType)) {
return parseVideoResponse(response.url, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_TEXT.test(contentType)) {
const htmlString = response.data;
return parseTextResponse(htmlString, response.url, options, contentType);
}
if (OPENGRAPH.REGEX_CONTENT_TYPE_APPLICATION.test(contentType)) {
return parseApplicationResponse(response.url, contentType);
}
const htmlString = response.data;
return parseUnknownResponse(htmlString, response.url, options);
} catch (e) {
throw new Error(
`link-preview-js could not fetch link information ${(
e as any
).toString()}`,
);
}
}
export async function getLinkPreview(text: string) {
const fetchUrl = text;
const options: FetchOptions = {
method: "GET",
timeout: 30,
responseType: ResponseType.Text,
};
let response = await fetch(fetchUrl, options);
if (response.status > 300 && response.status < 309) {
const forwardedUrl = response.headers.location || "";
response = await fetch(forwardedUrl, options);
}
return parseResponse(response);
}

View File

@ -414,20 +414,16 @@ export async function getBlocks() {
}
// create block
export async function addBlockToDB(
account_id: number,
kind: number,
title: string,
content: any,
) {
export async function createBlock(kind: number, title: string, content: any) {
const db = await connect();
const activeAccount = await getActiveAccount();
return await db.execute(
"INSERT OR IGNORE INTO blocks (account_id, kind, title, content) VALUES (?, ?, ?, ?);",
[account_id, kind, title, content],
[activeAccount.id, kind, title, content],
);
}
export async function removeBlockFromDB(id: string) {
export async function removeBlock(id: string) {
const db = await connect();
return await db.execute(`DELETE FROM blocks WHERE id = "${id}";`);
}

View File

@ -8,14 +8,14 @@ import {
ChevronRightIcon,
ComposeIcon,
} from "@shared/icons";
import { useActiveAccount } from "@stores/accounts";
import { useComposer } from "@stores/composer";
import { COMPOSE_SHORTCUT } from "@stores/shortcuts";
import { useAccount } from "@utils/hooks/useAccount";
import { Fragment } from "react";
import { useHotkeys } from "react-hotkeys-hook";
export function Composer() {
const account = useActiveAccount((state) => state.account);
const { account } = useAccount();
const [toggle, open] = useComposer((state: any) => [
state.toggleModal,

View File

@ -30,6 +30,7 @@ export function Navigation({ reverse = false }: { reverse?: boolean }) {
<div className="flex flex-col">
<NavLink
to="/app/space"
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200",
@ -44,6 +45,7 @@ export function Navigation({ reverse = false }: { reverse?: boolean }) {
</NavLink>
<NavLink
to="/app/trending"
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
"flex h-9 items-center gap-2.5 rounded-md px-2.5 text-zinc-200",

View File

@ -1,7 +1,9 @@
import { createBlock } from "@libs/storage";
import { Kind1 } from "@shared/notes/contents/kind1";
import { Kind1063 } from "@shared/notes/contents/kind1063";
import { NoteSkeleton } from "@shared/notes/skeleton";
import { User } from "@shared/user";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEvent } from "@utils/hooks/useEvent";
import { memo } from "react";
@ -11,8 +13,30 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
const kind1 = data?.kind === 1 ? data.content : null;
const kind1063 = data?.kind === 1063 ? data.tags : null;
const queryClient = useQueryClient();
const block = useMutation({
mutationFn: (data: any) => createBlock(data.kind, data.title, data.content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const openThread = (event: any, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
block.mutate({ kind: 2, title: "Thread", content: thread });
} else {
event.stopPropagation();
}
};
return (
<div className="mt-3 rounded-lg border border-zinc-800 px-3 py-3">
<div
onClick={(e) => openThread(e, id)}
onKeyDown={(e) => openThread(e, id)}
className="mt-3 rounded-lg bg-zinc-800 border-t border-zinc-700/50 px-3 py-3"
>
{isFetching || status === "loading" ? (
<NoteSkeleton />
) : (

View File

@ -1,18 +1,25 @@
import { createBlock } from "@libs/storage";
import { ReplyIcon } from "@shared/icons";
import { useActiveAccount } from "@stores/accounts";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { compactNumber } from "@utils/number";
export function NoteReply({
id,
replies,
currentBlock,
}: { id: string; replies: number; currentBlock?: number }) {
const addTempBlock = useActiveAccount((state: any) => state.addTempBlock);
const queryClient = useQueryClient();
const block = useMutation({
mutationFn: (data: any) => createBlock(data.kind, data.title, data.content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["blocks"] });
},
});
const openThread = (event: any, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
addTempBlock(currentBlock, 2, "Thread", thread);
block.mutate({ kind: 2, title: "Thread", content: thread });
} else {
event.stopPropagation();
}

View File

@ -1,23 +1,12 @@
import { Image } from "@shared/image";
import { useOpenGraph } from "@utils/hooks/useOpenGraph";
function isValidURL(string: string) {
let url: URL;
try {
url = new URL(string);
} catch (_) {
return false;
}
return true;
}
export function LinkPreview({ urls }: { urls: string[] }) {
const domain = new URL(urls[0]);
const { status, data, error, isFetching } = useOpenGraph(urls[0]);
const { status, data, isFetching } = useOpenGraph(urls[0]);
return (
<div className="mt-3 max-w-[420px] overflow-hidden rounded-lg bg-zinc-800">
{error && <p>failed to load</p>}
{isFetching || status === "loading" ? (
<div className="flex flex-col">
<div className="w-full h-44 bg-zinc-700 animate-pulse" />
@ -29,20 +18,6 @@ export function LinkPreview({ urls }: { urls: string[] }) {
</span>
</div>
</div>
) : !data ? (
<a
className="flex flex-col px-3 py-3 rounded-lg border border-transparent hover:border-fuchsia-900"
href={urls[0]}
target="_blank"
rel="noreferrer"
>
<p className="leading-none text-sm text-zinc-400 line-clamp-3">
Can't fetch open graph, click to open website directly
</p>
<span className="mt-2.5 leading-none text-sm text-zinc-500">
{domain.hostname}
</span>
</a>
) : (
<a
className="flex flex-col rounded-lg border border-transparent hover:border-fuchsia-900"
@ -50,31 +25,20 @@ export function LinkPreview({ urls }: { urls: string[] }) {
target="_blank"
rel="noreferrer"
>
{isValidURL(data["og:image"]) ? (
<Image
src={data["og:image"]}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]}
className="w-full h-44 object-cover rounded-t-lg bg-white"
/>
) : (
<Image
src="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]}
className="w-full h-44 object-cover rounded-t-lg bg-white"
/>
)}
<Image
src={data.images[0]}
fallback="https://void.cat/d/XTmrMkpid8DGLjv1AzdvcW"
alt={urls[0]}
className="w-full h-44 object-cover rounded-t-lg"
/>
<div className="flex flex-col gap-2 px-3 py-3">
<h5 className="leading-none font-medium text-zinc-200">
{data["og:title"]}
<h5 className="leading-none font-medium text-zinc-200 line-clamp-1">
{data.title}
</h5>
{data["og:description"] ? (
<p className="leading-none text-sm text-zinc-400 line-clamp-3">
{data["og:description"]}
{data.description && (
<p className="text-sm text-zinc-400 break-all line-clamp-3">
{data.description}
</p>
) : (
<></>
)}
<span className="mt-2.5 leading-none text-sm text-zinc-500">
{domain.hostname}

View File

@ -1,9 +1,13 @@
import ReactPlayer from "react-player/es6";
export function VideoPreview({ urls }: { urls: string[] }) {
return (
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className="relative mt-3 max-w-[420px] flex w-full flex-col overflow-hidden rounded-lg bg-zinc-950"
/>
<div className="relative mt-3 max-w-[420px] flex w-full flex-col gap-2">
{urls.map((url) => (
<div key={url} className="aspect-video">
<ReactPlayer url={url} width="100%" height="100%" />
</div>
))}
</div>
);
}

View File

@ -2,16 +2,16 @@ import { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { Button } from "@shared/button";
import { Image } from "@shared/image";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { DEFAULT_AVATAR } from "@stores/constants";
import { dateToUnix } from "@utils/date";
import { useAccount } from "@utils/hooks/useAccount";
import { useProfile } from "@utils/hooks/useProfile";
import { useContext, useState } from "react";
export function NoteReplyForm({ id }: { id: string }) {
const ndk = useContext(RelayContext);
const account = useActiveAccount((state) => state.account);
const { account } = useAccount();
const { status, user } = useProfile(account.npub);
const [value, setValue] = useState("");

View File

@ -7,7 +7,7 @@ export function TitleBar({
return (
<div
data-tauri-drag-region
className="group overflow-hidden h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900"
className="group overflow-hidden shrink-0 h-11 w-full flex items-center justify-between px-3 border-b border-zinc-900"
>
<div className="w-6" />
<h3 className="text-sm font-medium text-zinc-200">{title}</h3>

View File

@ -1,22 +1,27 @@
import { Popover, Transition } from "@headlessui/react";
import { Image } from "@shared/image";
import { DEFAULT_AVATAR } from "@stores/constants";
import { formatCreatedAt } from "@utils/createdAt";
import { useProfile } from "@utils/hooks/useProfile";
import { shortenKey } from "@utils/shortenKey";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Fragment } from "react";
import { Link } from "react-router-dom";
dayjs.extend(relativeTime);
export function User({
pubkey,
time,
size,
repost,
}: { pubkey: string; time: number; size?: string; repost?: boolean }) {
isChat = false,
}: {
pubkey: string;
time: number;
size?: string;
repost?: boolean;
isChat?: boolean;
}) {
const { user } = useProfile(pubkey);
const createdAt = formatCreatedAt(time, isChat);
const avatarWidth = size === "small" ? "w-6" : "w-11";
const avatarHeight = size === "small" ? "h-6" : "h-11";
@ -54,9 +59,7 @@ export function User({
</span>
)}
<span className="leading-none text-zinc-500">·</span>
<span className="leading-none text-zinc-500">
{dayjs().to(dayjs.unix(time), true)}
</span>
<span className="leading-none text-zinc-500">{createdAt}</span>
</div>
<Transition
as={Fragment}

View File

@ -1,121 +0,0 @@
import {
addBlockToDB,
createAccount,
getActiveAccount,
getBlocks,
getLastLogin,
removeBlockFromDB,
updateAccount,
} from "@libs/storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export const useActiveAccount = create(
immer(
persist(
(set: any, get: any) => ({
tempProfile: {},
account: null,
blocks: null,
lastLogin: null,
createTempProfile: (data: any) => {
set({ tempProfile: data });
},
create: async (npub: string, pubkey: string, privkey: string) => {
const response = await createAccount(npub, pubkey, privkey, null, 1);
if (response) {
const activeAccount = await getActiveAccount();
await addBlockToDB(
activeAccount.id,
0,
"Lume ❤️ You",
"https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3",
);
set({
account: activeAccount,
});
}
},
fetch: async () => {
const response = await getActiveAccount();
set({ account: response });
},
fetchLastLogin: async () => {
const response = await getLastLogin();
set({ lastLogin: parseInt(response) });
},
fetchBlocks: async () => {
const account = get().account;
const response = await getBlocks(account.id);
set({ blocks: response });
},
addTempBlock: (
block: number,
kind: number,
title: string,
content: string,
) => {
const account = get().account;
const target = get().blocks.findIndex(
(b: { id: number }) => b.id === block,
);
// update state
set((state: any) => {
state.blocks.splice(target, 0, {
id: account.id + kind,
account_id: account.id,
kind,
title,
content,
});
});
},
addBlock: (kind: number, title: string, content: string) => {
const account = get().account;
// add to db
addBlockToDB(account.id, kind, title, content);
// update state
set((state: any) => ({
blocks: [
...state.blocks,
{
id: account.id + kind,
account_id: account.id,
kind,
title,
content,
},
],
}));
},
removeBlock: (id: string, db?: false) => {
if (db) {
// remove from db
removeBlockFromDB(id);
}
// update state
set((state: any) => {
const target = state.blocks.findIndex(
(b: { id: string }) => b.id === id,
);
state.blocks.splice(target, 1);
});
},
updateFollows: (list: any) => {
const account = get().account;
// update db
updateAccount("follows", list, account.pubkey);
// update state
set((state: any) => ({
account: { ...state.account, follows: JSON.stringify(list) },
}));
},
}),
{
name: "account",
storage: createJSONStorage(() => sessionStorage),
},
),
),
);

View File

@ -2,7 +2,66 @@ export const APP_VERSION = "1.0.0";
export const DEFAULT_AVATAR = "https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih";
export const OPENGRAPH_KEY = "9EJG4SY-19Q4M5J-H8R29C9-091XPCC";
export const OPENGRAPH = {
REGEX_VALID_URL: new RegExp(
"^" +
// protocol identifier
"(?:(?:https?|ftp)://)" +
// user:pass authentication
"(?:\\S+(?::\\S*)?@)?" +
"(?:" +
// IP address exclusion
// private & local networks
"(?!(?:10|127)(?:\\.\\d{1,3}){3})" +
"(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" +
"(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broacast addresses
// (first & last IP address of each class)
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
"|" +
// host name
"(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" +
// domain name
"(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" +
// TLD identifier
"(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" +
// TLD may end with dot
"\\.?" +
")" +
// port number
"(?::\\d{2,5})?" +
// resource path
"(?:[/?#]\\S*)?" +
"$",
"i",
),
REGEX_LOOPBACK: new RegExp(
"^" +
"(?:(?:10|127)(?:\\.\\d{1,3}){3})" +
"|" +
"(?:(?:169\\.254|192\\.168|192\\.0)(?:\\.\\d{1,3}){2})" +
"|" +
"(?:172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
"$",
"i",
),
REGEX_CONTENT_TYPE_IMAGE: new RegExp("image/.*", "i"),
REGEX_CONTENT_TYPE_AUDIO: new RegExp("audio/.*", "i"),
REGEX_CONTENT_TYPE_VIDEO: new RegExp("video/.*", "i"),
REGEX_CONTENT_TYPE_TEXT: new RegExp("text/.*", "i"),
REGEX_CONTENT_TYPE_APPLICATION: new RegExp("application/.*", "i"),
};
export const FULL_RELAYS = [
"wss://relay.damus.io",

43
src/utils/createdAt.tsx Normal file
View File

@ -0,0 +1,43 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import updateLocale from "dayjs/plugin/updateLocale";
dayjs.extend(relativeTime);
dayjs.extend(updateLocale);
dayjs.updateLocale("en", {
relativeTime: {
past: "%s ago",
s: "just now",
m: "1m",
mm: "%dm",
h: "1h",
hh: "%dh",
d: "1d",
dd: "%dd",
},
});
export function formatCreatedAt(time, message = false) {
let formated;
const now = dayjs();
const inputTime = dayjs.unix(time);
const diff = now.diff(inputTime, "hour");
if (message) {
if (diff < 12) {
formated = inputTime.format("HH:mm A");
} else {
formated = inputTime.format("MMM DD");
}
} else {
if (diff < 24) {
formated = inputTime.from(now, true);
} else {
formated = inputTime.format("MMM DD");
}
}
return formated;
}

View File

@ -1,42 +1,17 @@
import { OPENGRAPH_KEY } from "@stores/constants";
import { getLinkPreview } from "@libs/openGraph";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/api/http";
export function useOpenGraph(url: string) {
const { status, data, error, isFetching } = useQuery(
["preview", url],
async () => {
const result = await fetch(
`https://skrape.dev/api/opengraph/?url=${url}&key=${OPENGRAPH_KEY}`,
{
method: "GET",
timeout: 10,
},
);
if (result.ok) {
if (Object.keys(result.data).length === 0) {
const origin = new URL(url).origin;
const result = await fetch(
`https://skrape.dev/api/opengraph/?url=${origin}&key=${OPENGRAPH_KEY}`,
{
method: "GET",
timeout: 10,
},
);
if (result.ok) {
return result.data;
}
} else {
return result.data;
}
} else {
return null;
}
return await getLinkPreview(url);
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: Infinity,
},
);