mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-02 08:58:36 +02:00
Merge branch 'next' into lists
This commit is contained in:
commit
cabca407a3
@ -1,5 +0,0 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
replace momentjs with dayjs
|
19
CHANGELOG.md
19
CHANGELOG.md
@ -1,5 +1,24 @@
|
||||
# nostrudel
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 644c53e: Handle hashtags in search view
|
||||
- 0cc4059: Fetch open graph metadata for links
|
||||
- 2eeb79c: Display custom emojis
|
||||
- 214487e: Add relay icons to notes
|
||||
- f383903: replace momentjs with dayjs
|
||||
- 5d19861: Add multi relay selection to hashtag view
|
||||
- 39ef920: Improve editing and saving app settings
|
||||
- 0cc4059: Add CORS proxy
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9936c25: Add validation check to LNURL address in profile edit view
|
||||
- 7f162ac: Fix user app settings being cached
|
||||
- 17d5160: Use nevent when quote reposting note
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
@ -114,4 +114,14 @@ describe("Embeds", () => {
|
||||
cy.findByTitle("Tidal List Embed").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Emoji", () => {
|
||||
it("should embed emojis", () => {
|
||||
cy.visit(
|
||||
"#/n/nevent1qqsdj7k47uh4z0ypl2m29lvd4ar9zpf6dcy7ls0q6g6qctnxfj5n3pcpzpmhxue69uhkummnw3ezuamfdejszrthwden5te0dehhxtnvdakqdyqlpq"
|
||||
);
|
||||
|
||||
cy.findByRole("img", { name: /pepeD/i }).should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -56,4 +56,14 @@ describe("Search", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Hashtag", () => {
|
||||
it("should redirect to hashtag view", () => {
|
||||
cy.visit("#/search");
|
||||
cy.findByRole("searchbox").type("#bitcoin").type("{enter}");
|
||||
|
||||
cy.url().should("contain", "/t/bitcoin");
|
||||
cy.contains("#bitcoin");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nostrudel",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@ -16,6 +16,7 @@
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"bech32": "^2.0.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dayjs": "^1.11.8",
|
||||
"framer-motion": "^7.10.3",
|
||||
"idb": "^7.1.1",
|
||||
|
@ -3,6 +3,7 @@ import appSettings from "../../services/app-settings";
|
||||
import { ImageGalleryLink } from "../image-gallery";
|
||||
import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||
import { useTrusted } from "../note/trust";
|
||||
import OpenGraphCard from "../open-graph-card";
|
||||
|
||||
const BlurredImage = (props: ImageProps) => {
|
||||
const { isOpen, onOpen } = useDisclosure();
|
||||
@ -45,8 +46,12 @@ export function renderVideoUrl(match: URL) {
|
||||
|
||||
export function renderGenericUrl(match: URL) {
|
||||
return (
|
||||
<Link color="blue.500" href={match.toString()} target="_blank" isExternal>
|
||||
<Link href={match.toString()} isExternal color="blue.500">
|
||||
{match.toString()}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderOpenGraphUrl(match: URL) {
|
||||
return <OpenGraphCard url={match} maxW="lg" />;
|
||||
}
|
||||
|
21
src/components/embed-types/emoji.tsx
Normal file
21
src/components/embed-types/emoji.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Image } from "@chakra-ui/react";
|
||||
import { EmbedableContent, embedJSX } from "../../helpers/embeds";
|
||||
import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event";
|
||||
|
||||
export function embedEmoji(content: EmbedableContent, note: NostrEvent | DraftNostrEvent) {
|
||||
return embedJSX(content, {
|
||||
regexp: /:([a-zA-Z0-9]+):/i,
|
||||
render: (match) => {
|
||||
const emojiTag = note.tags.find(
|
||||
(tag) => tag[0] === "emoji" && tag[1].toLowerCase() === match[1].toLowerCase() && tag[2]
|
||||
);
|
||||
if (emojiTag) {
|
||||
return (
|
||||
<Image src={emojiTag[2]} height="1.5em" display="inline-block" verticalAlign="middle" title={match[1]} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
name: "emoji",
|
||||
});
|
||||
}
|
@ -4,3 +4,4 @@ export * from "./music";
|
||||
export * from "./common";
|
||||
export * from "./youtube";
|
||||
export * from "./nostr";
|
||||
export * from "./emoji";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { replaceDomain } from "../../helpers/url";
|
||||
import appSettings from "../../services/app-settings";
|
||||
import { TweetEmbed } from "../tweet-embed";
|
||||
import { renderGenericUrl } from "./common";
|
||||
import { renderOpenGraphUrl } from "./common";
|
||||
|
||||
// copied from https://github.com/SimonBrazell/privacy-redirect/blob/master/src/assets/javascripts/helpers/twitter.js
|
||||
export const TWITTER_DOMAINS = ["twitter.com", "www.twitter.com", "mobile.twitter.com", "pbs.twimg.com"];
|
||||
@ -10,6 +10,6 @@ export function renderTwitterUrl(match: URL) {
|
||||
if (!TWITTER_DOMAINS.includes(match.hostname)) return null;
|
||||
|
||||
const { twitterRedirect } = appSettings.value;
|
||||
if (twitterRedirect) return renderGenericUrl(replaceDomain(match, twitterRedirect));
|
||||
if (twitterRedirect) return renderOpenGraphUrl(replaceDomain(match, twitterRedirect));
|
||||
else return <TweetEmbed href={match.toString()} conversation={false} />;
|
||||
}
|
||||
|
@ -89,7 +89,7 @@ export const Note = React.memo(({ event, maxHeight, variant = "outline" }: NoteP
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
<NoteRelays event={event} size="sm" variant="link" />
|
||||
<NoteRelays event={event} />
|
||||
<NoteMenu event={event} size="sm" variant="link" aria-label="More Options" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
@ -11,13 +11,14 @@ import {
|
||||
embedNostrHashtags,
|
||||
renderWavlakeUrl,
|
||||
renderYoutubeUrl,
|
||||
renderGenericUrl,
|
||||
renderImageUrl,
|
||||
renderTwitterUrl,
|
||||
renderAppleMusicUrl,
|
||||
renderSpotifyUrl,
|
||||
renderTidalUrl,
|
||||
renderVideoUrl,
|
||||
embedEmoji,
|
||||
renderOpenGraphUrl,
|
||||
} from "../embed-types";
|
||||
import { ImageGalleryProvider } from "../image-gallery";
|
||||
import { useTrusted } from "./trust";
|
||||
@ -37,7 +38,7 @@ function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) {
|
||||
renderTidalUrl,
|
||||
renderImageUrl,
|
||||
renderVideoUrl,
|
||||
renderGenericUrl,
|
||||
renderOpenGraphUrl,
|
||||
]);
|
||||
|
||||
// bitcoin
|
||||
@ -47,6 +48,7 @@ function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) {
|
||||
content = embedNostrLinks(content);
|
||||
content = embedNostrMentions(content, event);
|
||||
content = embedNostrHashtags(content, event);
|
||||
content = embedEmoji(content, event);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
@ -5,12 +5,17 @@ import { Bech32Prefix, getSharableNoteId, normalizeToBech32 } from "../../helper
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
|
||||
|
||||
import { ClipboardIcon, CodeIcon, ExternalLinkIcon, LikeIcon, RepostIcon, TrashIcon } from "../icons";
|
||||
import { ClipboardIcon, CodeIcon, ExternalLinkIcon, LikeIcon, RelayIcon, RepostIcon, TrashIcon } from "../icons";
|
||||
import NoteReactionsModal from "./note-zaps-modal";
|
||||
import NoteDebugModal from "../debug-modals/note-debug-modal";
|
||||
import { useCurrentAccount } from "../../hooks/use-current-account";
|
||||
import { buildAppSelectUrl } from "../../helpers/nostr-apps";
|
||||
import { useDeleteEventContext } from "../../providers/delete-event-provider";
|
||||
import { useCallback } from "react";
|
||||
import { nostrPostAction } from "../../classes/nostr-post-action";
|
||||
import clientRelaysService from "../../services/client-relays";
|
||||
import { handleEventFromRelay } from "../../services/event-relays";
|
||||
import relayPoolService from "../../services/relay-pool";
|
||||
|
||||
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
|
||||
const account = useCurrentAccount();
|
||||
@ -22,6 +27,18 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
|
||||
const noteId = normalizeToBech32(event.id, Bech32Prefix.Note);
|
||||
|
||||
const broadcast = useCallback(() => {
|
||||
const missingRelays = clientRelaysService.getWriteUrls();
|
||||
|
||||
const { results, onComplete } = nostrPostAction(missingRelays, event, 5000);
|
||||
|
||||
results.subscribe((result) => {
|
||||
if (result.status) {
|
||||
handleEventFromRelay(relayPoolService.requestRelay(result.url, false), event);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuIconButton {...props}>
|
||||
@ -47,6 +64,9 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
|
||||
Delete Note
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={broadcast} icon={<RelayIcon />}>
|
||||
Broadcast
|
||||
</MenuItem>
|
||||
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
|
||||
View Raw
|
||||
</MenuItem>
|
||||
|
@ -1,76 +1,18 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Text,
|
||||
Flex,
|
||||
PopoverFooter,
|
||||
} from "@chakra-ui/react";
|
||||
import { nostrPostAction } from "../../classes/nostr-post-action";
|
||||
import { handleEventFromRelay } from "../../services/event-relays";
|
||||
import { memo } from "react";
|
||||
import { IconButtonProps } from "@chakra-ui/react";
|
||||
import { getEventRelays } from "../../services/event-relays";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { RelayIcon } from "../icons";
|
||||
import { RelayFavicon } from "../relay-favicon";
|
||||
import { useWriteRelayUrls } from "../../hooks/use-client-relays";
|
||||
import relayPoolService from "../../services/relay-pool";
|
||||
import useEventRelays from "../../hooks/use-event-relays";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { RelayIconStack } from "../relay-icon-stack";
|
||||
import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||
|
||||
export type NoteRelaysProps = Omit<IconButtonProps, "icon" | "aria-label"> & {
|
||||
export type NoteRelaysProps = {
|
||||
event: NostrEvent;
|
||||
};
|
||||
|
||||
export const NoteRelays = memo(({ event, ...props }: NoteRelaysProps) => {
|
||||
const eventRelays = useEventRelays();
|
||||
const writeRelays = useWriteRelayUrls();
|
||||
export const NoteRelays = memo(({ event }: NoteRelaysProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const eventRelays = useSubject(getEventRelays(event.id));
|
||||
|
||||
const [broadcasting, setBroadcasting] = useState(false);
|
||||
const broadcast = useCallback(() => {
|
||||
const missingRelays = writeRelays.filter((url) => !eventRelays.includes(url));
|
||||
if (missingRelays.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBroadcasting(true);
|
||||
const { results, onComplete } = nostrPostAction(missingRelays, event, 5000);
|
||||
|
||||
results.subscribe((result) => {
|
||||
if (result.status) {
|
||||
handleEventFromRelay(relayPoolService.requestRelay(result.url, false), event);
|
||||
}
|
||||
});
|
||||
|
||||
onComplete.then(() => setBroadcasting(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover isLazy>
|
||||
<PopoverTrigger>
|
||||
<IconButton title="Note Relays" icon={<RelayIcon />} aria-label="Note Relays" {...props} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverArrow />
|
||||
<PopoverBody>
|
||||
{eventRelays.map((url) => (
|
||||
<Flex alignItems="center" key={url}>
|
||||
<RelayFavicon relay={url} size="2xs" mr="2" />
|
||||
<Text isTruncated>{url}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</PopoverBody>
|
||||
<PopoverFooter>
|
||||
<Flex gap="2">
|
||||
<Button size="xs" onClick={broadcast} isLoading={broadcasting} leftIcon={<RelayIcon />}>
|
||||
Broadcast
|
||||
</Button>
|
||||
</Flex>
|
||||
</PopoverFooter>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
return <RelayIconStack relays={eventRelays} direction="row-reverse" maxRelays={isMobile ? 4 : undefined} />;
|
||||
});
|
||||
|
32
src/components/open-graph-card.tsx
Normal file
32
src/components/open-graph-card.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Box, CardProps, Code, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react";
|
||||
import useOpenGraphData from "../hooks/use-open-graph-data";
|
||||
|
||||
export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit<CardProps, "children">) {
|
||||
const { value: data, loading } = useOpenGraphData(url);
|
||||
|
||||
const link = (
|
||||
<Link href={url.toString()} isExternal color="blue.500">
|
||||
{url.toString()}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (!data) return link;
|
||||
|
||||
return (
|
||||
<LinkBox borderRadius="lg" borderWidth={1} overflow="hidden" {...props}>
|
||||
{data.ogImage?.map((ogImage) => (
|
||||
<Image src={ogImage.url} mx="auto" />
|
||||
))}
|
||||
|
||||
<Box m="2" mt="4">
|
||||
<Heading size="sm" my="2">
|
||||
<LinkOverlay href={url.toString()} isExternal>
|
||||
{data.ogTitle ?? data.dcTitle}
|
||||
</LinkOverlay>
|
||||
</Heading>
|
||||
<Text>{data.ogDescription || data.dcDescription}</Text>
|
||||
{link}
|
||||
</Box>
|
||||
</LinkBox>
|
||||
);
|
||||
}
|
56
src/components/relay-icon-stack.tsx
Normal file
56
src/components/relay-icon-stack.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Flex,
|
||||
FlexProps,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { RelayFavicon } from "./relay-favicon";
|
||||
import relayScoreboardService from "../services/relay-scoreboard";
|
||||
|
||||
export function RelayIconStack({ relays, maxRelays, ...props }: { relays: string[]; maxRelays?: number } & FlexProps) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const topRelays = relayScoreboardService.getRankedRelays(relays);
|
||||
const clamped = maxRelays ? topRelays.slice(0, maxRelays) : topRelays;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex alignItems="center" gap="-4" overflow="hidden" cursor="pointer" onClick={onOpen} {...props}>
|
||||
{clamped.map((url) => (
|
||||
<RelayFavicon key={url} relay={url} size="2xs" title={url} />
|
||||
))}
|
||||
{clamped.length !== topRelays.length && (
|
||||
<Text mx="1" fontSize="sm" lineHeight={0}>
|
||||
+{topRelays.length - clamped.length}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Relays</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Flex direction="column" gap="1">
|
||||
{topRelays.map((url) => (
|
||||
<Flex key={url}>
|
||||
<RelayFavicon relay={url} size="2xs" mr="2" />
|
||||
<Text>{url}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
17
src/helpers/cors.ts
Normal file
17
src/helpers/cors.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import appSettings from "../services/app-settings";
|
||||
import { convertToUrl } from "./url";
|
||||
|
||||
const corsFailedHosts = new Set();
|
||||
|
||||
export function fetchWithCorsFallback(url: URL | string, opts?: RequestInit) {
|
||||
if (!appSettings.value.corsProxy) return fetch(url, opts);
|
||||
|
||||
if (corsFailedHosts.has(convertToUrl(url).host)) {
|
||||
return fetch(appSettings.value.corsProxy + url, opts);
|
||||
}
|
||||
|
||||
return fetch(url, opts).catch((e) => {
|
||||
corsFailedHosts.add(convertToUrl(url).host);
|
||||
return fetch(appSettings.value.corsProxy + url, opts);
|
||||
});
|
||||
}
|
@ -5,6 +5,8 @@ import { RelayConfig, RelayMode } from "../classes/relay";
|
||||
import accountService from "../services/account";
|
||||
import { Kind, nip19 } from "nostr-tools";
|
||||
import { matchNostrLink } from "./regexp";
|
||||
import { getSharableNoteId } from "./nip19";
|
||||
import relayScoreboardService from "../services/relay-scoreboard";
|
||||
|
||||
export function isReply(event: NostrEvent | DraftNostrEvent) {
|
||||
return event.kind === 1 && !!getReferences(event).replyId;
|
||||
@ -154,13 +156,14 @@ export function buildReply(event: NostrEvent, account = accountService.current.v
|
||||
}
|
||||
|
||||
export function buildRepost(event: NostrEvent): DraftNostrEvent {
|
||||
const relay = getEventRelays(event.id).value?.[0] ?? "";
|
||||
const relays = getEventRelays(event.id).value;
|
||||
const topRelay = relayScoreboardService.getRankedRelays(relays)[0] ?? "";
|
||||
|
||||
const tags: NostrEvent["tags"] = [];
|
||||
tags.push(["e", event.id, relay]);
|
||||
tags.push(["e", event.id, topRelay]);
|
||||
|
||||
return {
|
||||
kind: 6, //Kind.Repost
|
||||
kind: Kind.Repost,
|
||||
tags,
|
||||
content: "",
|
||||
created_at: dayjs().unix(),
|
||||
@ -168,15 +171,12 @@ export function buildRepost(event: NostrEvent): DraftNostrEvent {
|
||||
}
|
||||
|
||||
export function buildQuoteRepost(event: NostrEvent): DraftNostrEvent {
|
||||
const relay = getEventRelays(event.id).value?.[0] ?? "";
|
||||
|
||||
const tags: NostrEvent["tags"] = [];
|
||||
tags.push(["e", event.id, relay, "mention"]);
|
||||
const nevent = getSharableNoteId(event.id);
|
||||
|
||||
return {
|
||||
kind: Kind.Text,
|
||||
tags,
|
||||
content: "#[0]",
|
||||
tags: [],
|
||||
content: "nostr:" + nevent,
|
||||
created_at: dayjs().unix(),
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
const convertToUrl = (url: string | URL) => (url instanceof URL ? url : new URL(url));
|
||||
export const convertToUrl = (url: string | URL) => (url instanceof URL ? url : new URL(url));
|
||||
|
||||
export function normalizeRelayUrl(relayUrl: string) {
|
||||
const url = new URL(relayUrl);
|
||||
|
13
src/hooks/use-open-graph-data.ts
Normal file
13
src/hooks/use-open-graph-data.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useAsync } from "react-use";
|
||||
import extractMetaTags from "../lib/open-graph-scraper/extract";
|
||||
import { fetchWithCorsFallback } from "../helpers/cors";
|
||||
|
||||
export default function useOpenGraphData(url: URL) {
|
||||
return useAsync(async () => {
|
||||
try {
|
||||
const html = await fetchWithCorsFallback(url).then((res) => res.text());
|
||||
return extractMetaTags(html);
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}, [url.toString()]);
|
||||
}
|
1
src/lib/open-graph-scraper/README.md
Normal file
1
src/lib/open-graph-scraper/README.md
Normal file
@ -0,0 +1 @@
|
||||
open graph parser code copied from https://github.com/jshemas/openGraphScraper/blob/master/lib/extract.ts and modified to work in the browser
|
54
src/lib/open-graph-scraper/extract.ts
Normal file
54
src/lib/open-graph-scraper/extract.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { load } from "cheerio";
|
||||
|
||||
import fallback from "./fallback";
|
||||
import fields from "./fields";
|
||||
import mediaSetup from "./media";
|
||||
|
||||
import type { OgObjectInteral } from "./types";
|
||||
|
||||
/**
|
||||
* extract all of the meta tags needed for ogs
|
||||
*
|
||||
* @param {sting} body - the body of the fetch request
|
||||
* @param {object} options - options for ogs
|
||||
* @return {object} object with ogs results
|
||||
*
|
||||
*/
|
||||
export default function extractMetaTags(body: string, useFallbacks = true) {
|
||||
let ogObject: OgObjectInteral = {};
|
||||
const $ = load(body);
|
||||
const metaFields = fields;
|
||||
|
||||
// find all of the open graph info in the meta tags
|
||||
$("meta").each((index, meta) => {
|
||||
if (!meta.attribs || (!meta.attribs.property && !meta.attribs.name)) return;
|
||||
const property = meta.attribs.property || meta.attribs.name;
|
||||
const content = meta.attribs.content || meta.attribs.value;
|
||||
metaFields.forEach((item) => {
|
||||
if (item && property.toLowerCase() === item.property.toLowerCase()) {
|
||||
if (!item.multiple) {
|
||||
// @ts-ignore
|
||||
ogObject[item.fieldName] = content;
|
||||
// @ts-ignore
|
||||
} else if (!ogObject[item.fieldName]) {
|
||||
// @ts-ignore
|
||||
ogObject[item.fieldName] = [content];
|
||||
// @ts-ignore
|
||||
} else if (Array.isArray(ogObject[item.fieldName])) {
|
||||
// @ts-ignore
|
||||
ogObject[item.fieldName].push(content);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// formats the multiple media values
|
||||
ogObject = mediaSetup(ogObject);
|
||||
|
||||
// if onlyGetOpenGraphInfo isn't set, run the open graph fallbacks
|
||||
if (useFallbacks) {
|
||||
ogObject = fallback(ogObject, $);
|
||||
}
|
||||
|
||||
return ogObject;
|
||||
}
|
172
src/lib/open-graph-scraper/fallback.ts
Normal file
172
src/lib/open-graph-scraper/fallback.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import type { CheerioAPI } from "cheerio";
|
||||
|
||||
import { findImageTypeFromUrl, isImageTypeValid, isUrlValid } from "./utils";
|
||||
import type { ImageObject, OgObjectInteral } from "./types";
|
||||
|
||||
const doesElementExist = (selector: string, attribute: string, $: CheerioAPI) =>
|
||||
$(selector).attr(attribute) && ($(selector).attr(attribute)?.length || 0) > 0;
|
||||
|
||||
/**
|
||||
* ogs fallbacks
|
||||
*
|
||||
* @param {object} ogObject - the current ogObject
|
||||
* @param {object} $ - cheerio.load() of the current html
|
||||
* @return {object} object with ogs results with updated fallback values
|
||||
*
|
||||
*/
|
||||
export function fallback(ogObject: OgObjectInteral, $: CheerioAPI) {
|
||||
// title fallback
|
||||
if (!ogObject.ogTitle) {
|
||||
if ($("title").text() && $("title").text().length > 0) {
|
||||
ogObject.ogTitle = $("title").first().text();
|
||||
} else if (
|
||||
$('head > meta[name="title"]').attr("content") &&
|
||||
($('head > meta[name="title"]').attr("content")?.length || 0) > 0
|
||||
) {
|
||||
ogObject.ogTitle = $('head > meta[name="title"]').attr("content");
|
||||
} else if ($(".post-title").text() && $(".post-title").text().length > 0) {
|
||||
ogObject.ogTitle = $(".post-title").text();
|
||||
} else if ($(".entry-title").text() && $(".entry-title").text().length > 0) {
|
||||
ogObject.ogTitle = $(".entry-title").text();
|
||||
} else if ($('h1[class*="title" i] a').text() && $('h1[class*="title" i] a').text().length > 0) {
|
||||
ogObject.ogTitle = $('h1[class*="title" i] a').text();
|
||||
} else if ($('h1[class*="title" i]').text() && $('h1[class*="title" i]').text().length > 0) {
|
||||
ogObject.ogTitle = $('h1[class*="title" i]').text();
|
||||
}
|
||||
}
|
||||
|
||||
// Get meta description tag if og description was not provided
|
||||
if (!ogObject.ogDescription) {
|
||||
if (doesElementExist('head > meta[name="description"]', "content", $)) {
|
||||
ogObject.ogDescription = $('head > meta[name="description"]').attr("content");
|
||||
} else if (doesElementExist('head > meta[itemprop="description"]', "content", $)) {
|
||||
ogObject.ogDescription = $('head > meta[itemprop="description"]').attr("content");
|
||||
} else if ($("#description").text() && $("#description").text().length > 0) {
|
||||
ogObject.ogDescription = $("#description").text();
|
||||
}
|
||||
}
|
||||
|
||||
// Get all of images if there is no og:image info
|
||||
if (!ogObject.ogImage) {
|
||||
ogObject.ogImage = [];
|
||||
$("img").map((index, imageElement) => {
|
||||
const source: string = $(imageElement).attr("src") || "";
|
||||
if (!source) return false;
|
||||
const type = findImageTypeFromUrl(source);
|
||||
if (!isUrlValid(source) || !isImageTypeValid(type)) return false;
|
||||
const fallbackImage: ImageObject = {
|
||||
url: source,
|
||||
type,
|
||||
};
|
||||
if ($(imageElement).attr("width") && Number($(imageElement).attr("width")))
|
||||
fallbackImage.width = Number($(imageElement).attr("width"));
|
||||
if ($(imageElement).attr("height") && Number($(imageElement).attr("height")))
|
||||
fallbackImage.height = Number($(imageElement).attr("height"));
|
||||
ogObject.ogImage?.push(fallbackImage);
|
||||
return false;
|
||||
});
|
||||
ogObject.ogImage = ogObject.ogImage
|
||||
.filter((value) => value.url !== undefined && value.url !== "")
|
||||
.filter((value, index) => index < 10);
|
||||
if (ogObject.ogImage.length === 0) delete ogObject.ogImage;
|
||||
} else if (ogObject.ogImage) {
|
||||
ogObject.ogImage.map((image) => {
|
||||
if (image.url && !image.type) {
|
||||
const type = findImageTypeFromUrl(image.url);
|
||||
if (isImageTypeValid(type)) image.type = type;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// audio fallback
|
||||
if (!ogObject.ogAudioURL && !ogObject.ogAudioSecureURL) {
|
||||
const audioElementValue: string = $("audio").attr("src") || "";
|
||||
const audioSourceElementValue: string = $("audio > source").attr("src") || "";
|
||||
if (doesElementExist("audio", "src", $)) {
|
||||
if (audioElementValue.startsWith("https")) {
|
||||
ogObject.ogAudioSecureURL = audioElementValue;
|
||||
} else {
|
||||
ogObject.ogAudioURL = audioElementValue;
|
||||
}
|
||||
const audioElementTypeValue: string = $("audio").attr("type") || "";
|
||||
if (!ogObject.ogAudioType && doesElementExist("audio", "type", $)) ogObject.ogAudioType = audioElementTypeValue;
|
||||
} else if (doesElementExist("audio > source", "src", $)) {
|
||||
if (audioSourceElementValue.startsWith("https")) {
|
||||
ogObject.ogAudioSecureURL = audioSourceElementValue;
|
||||
} else {
|
||||
ogObject.ogAudioURL = audioSourceElementValue;
|
||||
}
|
||||
const audioSourceElementTypeValue: string = $("audio > source").attr("type") || "";
|
||||
if (!ogObject.ogAudioType && doesElementExist("audio > source", "type", $))
|
||||
ogObject.ogAudioType = audioSourceElementTypeValue;
|
||||
}
|
||||
}
|
||||
|
||||
// locale fallback
|
||||
if (!ogObject.ogLocale) {
|
||||
if (doesElementExist("html", "lang", $)) {
|
||||
ogObject.ogLocale = $("html").attr("lang");
|
||||
} else if (doesElementExist('head > meta[itemprop="inLanguage"]', "content", $)) {
|
||||
ogObject.ogLocale = $('head > meta[itemprop="inLanguage"]').attr("content");
|
||||
}
|
||||
}
|
||||
|
||||
// logo fallback
|
||||
if (!ogObject.ogLogo) {
|
||||
if (doesElementExist('meta[itemprop="logo"]', "content", $)) {
|
||||
ogObject.ogLogo = $('meta[itemprop="logo"]').attr("content");
|
||||
} else if (doesElementExist('img[itemprop="logo"]', "src", $)) {
|
||||
ogObject.ogLogo = $('img[itemprop="logo"]').attr("src");
|
||||
}
|
||||
}
|
||||
|
||||
// url fallback
|
||||
if (!ogObject.ogUrl) {
|
||||
if (doesElementExist('link[rel="canonical"]', "href", $)) {
|
||||
ogObject.ogUrl = $('link[rel="canonical"]').attr("href");
|
||||
} else if (doesElementExist('link[rel="alternate"][hreflang="x-default"]', "href", $)) {
|
||||
ogObject.ogUrl = $('link[rel="alternate"][hreflang="x-default"]').attr("href");
|
||||
}
|
||||
}
|
||||
|
||||
// date fallback
|
||||
if (!ogObject.ogDate) {
|
||||
if (doesElementExist('head > meta[name="date"]', "content", $)) {
|
||||
ogObject.ogDate = $('head > meta[name="date"]').attr("content");
|
||||
} else if (doesElementExist('[itemprop*="datemodified" i]', "content", $)) {
|
||||
ogObject.ogDate = $('[itemprop*="datemodified" i]').attr("content");
|
||||
} else if (doesElementExist('[itemprop="datepublished" i]', "content", $)) {
|
||||
ogObject.ogDate = $('[itemprop="datepublished" i]').attr("content");
|
||||
} else if (doesElementExist('[itemprop*="date" i]', "content", $)) {
|
||||
ogObject.ogDate = $('[itemprop*="date" i]').attr("content");
|
||||
} else if (doesElementExist('time[itemprop*="date" i]', "datetime", $)) {
|
||||
ogObject.ogDate = $('time[itemprop*="date" i]').attr("datetime");
|
||||
} else if (doesElementExist("time[datetime]", "datetime", $)) {
|
||||
ogObject.ogDate = $("time[datetime]").attr("datetime");
|
||||
}
|
||||
}
|
||||
|
||||
// favicon fallback
|
||||
if (!ogObject.favicon) {
|
||||
if (doesElementExist('link[rel="shortcut icon"]', "href", $)) {
|
||||
ogObject.favicon = $('link[rel="shortcut icon"]').attr("href");
|
||||
} else if (doesElementExist('link[rel="icon"]', "href", $)) {
|
||||
ogObject.favicon = $('link[rel="icon"]').attr("href");
|
||||
} else if (doesElementExist('link[rel="mask-icon"]', "href", $)) {
|
||||
ogObject.favicon = $('link[rel="mask-icon"]').attr("href");
|
||||
} else if (doesElementExist('link[rel="apple-touch-icon"]', "href", $)) {
|
||||
ogObject.favicon = $('link[rel="apple-touch-icon"]').attr("href");
|
||||
} else if (doesElementExist('link[type="image/png"]', "href", $)) {
|
||||
ogObject.favicon = $('link[type="image/png"]').attr("href");
|
||||
} else if (doesElementExist('link[type="image/ico"]', "href", $)) {
|
||||
ogObject.favicon = $('link[type="image/ico"]').attr("href");
|
||||
} else if (doesElementExist('link[type="image/x-icon"]', "href", $)) {
|
||||
ogObject.favicon = $('link[type="image/x-icon"]').attr("href");
|
||||
}
|
||||
}
|
||||
|
||||
return ogObject;
|
||||
}
|
||||
|
||||
export default fallback;
|
855
src/lib/open-graph-scraper/fields.ts
Normal file
855
src/lib/open-graph-scraper/fields.ts
Normal file
@ -0,0 +1,855 @@
|
||||
/**
|
||||
* array of meta tags ogs is looking for
|
||||
*
|
||||
* @return {array} array of meta tags
|
||||
*
|
||||
*/
|
||||
const fields = [
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:title",
|
||||
fieldName: "ogTitle",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:type",
|
||||
fieldName: "ogType",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:logo",
|
||||
fieldName: "ogLogo",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:image",
|
||||
fieldName: "ogImageProperty",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:image:url",
|
||||
fieldName: "ogImageURL",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:image:secure_url",
|
||||
fieldName: "ogImageSecureURL",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:image:width",
|
||||
fieldName: "ogImageWidth",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:image:height",
|
||||
fieldName: "ogImageHeight",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:image:type",
|
||||
fieldName: "ogImageType",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:url",
|
||||
fieldName: "ogUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:audio",
|
||||
fieldName: "ogAudio",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:audio:url",
|
||||
fieldName: "ogAudioURL",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:audio:secure_url",
|
||||
fieldName: "ogAudioSecureURL",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:audio:type",
|
||||
fieldName: "ogAudioType",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:description",
|
||||
fieldName: "ogDescription",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:determiner",
|
||||
fieldName: "ogDeterminer",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:locale",
|
||||
fieldName: "ogLocale",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:locale:alternate",
|
||||
fieldName: "ogLocaleAlternate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:site_name",
|
||||
fieldName: "ogSiteName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:product:retailer_item_id",
|
||||
fieldName: "ogProductRetailerItemId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:product:price:amount",
|
||||
fieldName: "ogProductPriceAmount",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:product:price:currency",
|
||||
fieldName: "ogProductPriceCurrency",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:product:availability",
|
||||
fieldName: "ogProductAvailability",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:product:condition",
|
||||
fieldName: "ogProductCondition",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:price:amount",
|
||||
fieldName: "ogPriceAmount",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:price:currency",
|
||||
fieldName: "ogPriceCurrency",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:availability",
|
||||
fieldName: "ogAvailability",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:video",
|
||||
fieldName: "ogVideoProperty",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:video:url", // An alternative to 'og:video'
|
||||
fieldName: "ogVideoProperty",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:video:secure_url",
|
||||
fieldName: "ogVideoSecureURL",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:video:actor:id",
|
||||
fieldName: "ogVideoActorId",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:video:width",
|
||||
fieldName: "ogVideoWidth",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:video:height",
|
||||
fieldName: "ogVideoHeight",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "og:video:type",
|
||||
fieldName: "ogVideoType",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:card",
|
||||
fieldName: "twitterCard",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:url",
|
||||
fieldName: "twitterUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:site",
|
||||
fieldName: "twitterSite",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:site:id",
|
||||
fieldName: "twitterSiteId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:creator",
|
||||
fieldName: "twitterCreator",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:creator:id",
|
||||
fieldName: "twitterCreatorId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:title",
|
||||
fieldName: "twitterTitle",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:description",
|
||||
fieldName: "twitterDescription",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:image",
|
||||
fieldName: "twitterImageProperty",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:image:height",
|
||||
fieldName: "twitterImageHeight",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:image:width",
|
||||
fieldName: "twitterImageWidth",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:image:src",
|
||||
fieldName: "twitterImageSrc",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:image:alt",
|
||||
fieldName: "twitterImageAlt",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:player",
|
||||
fieldName: "twitterPlayerProperty",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:player:width",
|
||||
fieldName: "twitterPlayerWidth",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:player:height",
|
||||
fieldName: "twitterPlayerHeight",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:player:stream",
|
||||
fieldName: "twitterPlayerStream",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "twitter:player:stream:content_type",
|
||||
fieldName: "twitterPlayerStreamContentType",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:name:iphone",
|
||||
fieldName: "twitterAppNameiPhone",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:id:iphone",
|
||||
fieldName: "twitterAppIdiPhone",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:url:iphone",
|
||||
fieldName: "twitterAppUrliPhone",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:name:ipad",
|
||||
fieldName: "twitterAppNameiPad",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:id:ipad",
|
||||
fieldName: "twitterAppIdiPad",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:url:ipad",
|
||||
fieldName: "twitterAppUrliPad",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:name:googleplay",
|
||||
fieldName: "twitterAppNameGooglePlay",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:id:googleplay",
|
||||
fieldName: "twitterAppIdGooglePlay",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "twitter:app:url:googleplay",
|
||||
fieldName: "twitterAppUrlGooglePlay",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "music:song",
|
||||
fieldName: "musicSongProperty",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "music:song:disc",
|
||||
fieldName: "musicSongDisc",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "music:song:track",
|
||||
fieldName: "musicSongTrack",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "music:song:url",
|
||||
fieldName: "musicSongUrl",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "music:musician",
|
||||
fieldName: "musicMusician",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "music:release_date",
|
||||
fieldName: "musicReleaseDate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "music:duration",
|
||||
fieldName: "musicDuration",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "music:creator",
|
||||
fieldName: "musicCreator",
|
||||
},
|
||||
{
|
||||
multiple: true,
|
||||
property: "music:album",
|
||||
fieldName: "musicAlbum",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "music:album:disc",
|
||||
fieldName: "musicAlbumDisc",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "music:album:track",
|
||||
fieldName: "musicAlbumTrack",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "music:album:url",
|
||||
fieldName: "musicAlbumUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:published_date",
|
||||
fieldName: "articlePublishedDate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:published_time",
|
||||
fieldName: "articlePublishedTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:modified_date",
|
||||
fieldName: "articleModifiedDate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:modified_time",
|
||||
fieldName: "articleModifiedTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:expiration_time",
|
||||
fieldName: "articleExpirationTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:author",
|
||||
fieldName: "articleAuthor",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:section",
|
||||
fieldName: "articleSection",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:tag",
|
||||
fieldName: "articleTag",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "article:publisher",
|
||||
fieldName: "articlePublisher",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:article:published_time",
|
||||
fieldName: "ogArticlePublishedTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:article:modified_time",
|
||||
fieldName: "ogArticleModifiedTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:article:expiration_time",
|
||||
fieldName: "ogArticleExpirationTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:article:author",
|
||||
fieldName: "ogArticleAuthor",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:article:section",
|
||||
fieldName: "ogArticleSection",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:article:tag",
|
||||
fieldName: "ogArticleTag",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:article:publisher",
|
||||
fieldName: "ogArticlePublisher",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "books:book",
|
||||
fieldName: "booksBook",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "book:author",
|
||||
fieldName: "bookAuthor",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "book:isbn",
|
||||
fieldName: "bookIsbn",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "book:release_date",
|
||||
fieldName: "bookReleaseDate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "book:canonical_name",
|
||||
fieldName: "bookCanonicalName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "book:tag",
|
||||
fieldName: "bookTag",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "books:rating:value",
|
||||
fieldName: "booksRatingValue",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "books:rating:scale",
|
||||
fieldName: "booksRatingScale",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "profile:first_name",
|
||||
fieldName: "profileFirstName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "profile:last_name",
|
||||
fieldName: "profileLastName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "profile:username",
|
||||
fieldName: "profileUsername",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "profile:gender",
|
||||
fieldName: "profileGender",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "business:contact_data:street_address",
|
||||
fieldName: "businessContactDataStreetAddress",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "business:contact_data:locality",
|
||||
fieldName: "businessContactDataLocality",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "business:contact_data:region",
|
||||
fieldName: "businessContactDataRegion",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "business:contact_data:postal_code",
|
||||
fieldName: "businessContactDataPostalCode",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "business:contact_data:country_name",
|
||||
fieldName: "businessContactDataCountryName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:menu",
|
||||
fieldName: "restaurantMenu",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:restaurant",
|
||||
fieldName: "restaurantRestaurant",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:section",
|
||||
fieldName: "restaurantSection",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:variation:price:amount",
|
||||
fieldName: "restaurantVariationPriceAmount",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:variation:price:currency",
|
||||
fieldName: "restaurantVariationPriceCurrency",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:website",
|
||||
fieldName: "restaurantContactInfoWebsite",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:street_address",
|
||||
fieldName: "restaurantContactInfoStreetAddress",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:locality",
|
||||
fieldName: "restaurantContactInfoLocality",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:region",
|
||||
fieldName: "restaurantContactInfoRegion",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:postal_code",
|
||||
fieldName: "restaurantContactInfoPostalCode",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:country_name",
|
||||
fieldName: "restaurantContactInfoCountryName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:email",
|
||||
fieldName: "restaurantContactInfoEmail",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "restaurant:contact_info:phone_number",
|
||||
fieldName: "restaurantContactInfoPhoneNumber",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "place:location:latitude",
|
||||
fieldName: "placeLocationLatitude",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "place:location:longitude",
|
||||
fieldName: "placeLocationLongitude",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "og:date",
|
||||
fieldName: "ogDate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "author",
|
||||
fieldName: "author",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "updated_time",
|
||||
fieldName: "updatedTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "modified_time",
|
||||
fieldName: "modifiedTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "published_time",
|
||||
fieldName: "publishedTime",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "release_date",
|
||||
fieldName: "releaseDate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.source",
|
||||
fieldName: "dcSource",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.subject",
|
||||
fieldName: "dcSubject",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.title",
|
||||
fieldName: "dcTitle",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.type",
|
||||
fieldName: "dcType",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.creator",
|
||||
fieldName: "dcCreator",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.coverage",
|
||||
fieldName: "dcCoverage",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.language",
|
||||
fieldName: "dcLanguage",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.contributor",
|
||||
fieldName: "dcContributor",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.date",
|
||||
fieldName: "dcDate",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.date.issued",
|
||||
fieldName: "dcDateIssued",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.date.created",
|
||||
fieldName: "dcDateCreated",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.description",
|
||||
fieldName: "dcDescription",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.identifier",
|
||||
fieldName: "dcIdentifier",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.publisher",
|
||||
fieldName: "dcPublisher",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.rights",
|
||||
fieldName: "dcRights",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.relation",
|
||||
fieldName: "dcRelation",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.format.media",
|
||||
fieldName: "dcFormatMedia",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "dc.format.size",
|
||||
fieldName: "dcFormatSize",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:ios:url",
|
||||
fieldName: "alIosUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:ios:app_store_id",
|
||||
fieldName: "alIosAppStoreId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:ios:app_name",
|
||||
fieldName: "alIosAppName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:iphone:url",
|
||||
fieldName: "alIphoneUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:iphone:app_store_id",
|
||||
fieldName: "alIphoneAppStoreId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:iphone:app_name",
|
||||
fieldName: "alIphoneAppName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:ipad:url",
|
||||
fieldName: "alIpadUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:ipad:app_store_id",
|
||||
fieldName: "alIpadAppStoreId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:ipad:app_name",
|
||||
fieldName: "alIpadAppName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:android:url",
|
||||
fieldName: "alAndroidUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:android:package",
|
||||
fieldName: "alAndroidPackage",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:android:class",
|
||||
fieldName: "alAndroidClass",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:android:app_name",
|
||||
fieldName: "alAndroidAppName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows_phone:url",
|
||||
fieldName: "alWindowsPhoneUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows_phone:app_id",
|
||||
fieldName: "alWindowsPhoneAppId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows_phone:app_name",
|
||||
fieldName: "alWindowsPhoneAppName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows:url",
|
||||
fieldName: "alWindowsUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows:app_id",
|
||||
fieldName: "alWindowsAppId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows:app_name",
|
||||
fieldName: "alWindowsAppName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows_universal:url",
|
||||
fieldName: "alWindowsUniversalUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows_universal:app_id",
|
||||
fieldName: "alWindowsUniversalAppId",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:windows_universal:app_name",
|
||||
fieldName: "alWindowsUniversalAppName",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:web:url",
|
||||
fieldName: "alWebUrl",
|
||||
},
|
||||
{
|
||||
multiple: false,
|
||||
property: "al:web:should_fallback",
|
||||
fieldName: "alWebShouldFallback",
|
||||
},
|
||||
];
|
||||
|
||||
export default fields;
|
234
src/lib/open-graph-scraper/media.ts
Normal file
234
src/lib/open-graph-scraper/media.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import fields from "./fields";
|
||||
import { removeNestedUndefinedValues } from "./utils";
|
||||
import type {
|
||||
ImageObject,
|
||||
MusicSongObject,
|
||||
OgObjectInteral,
|
||||
TwitterImageObject,
|
||||
TwitterPlayerObject,
|
||||
VideoObject,
|
||||
} from "./types";
|
||||
|
||||
const mediaMapperTwitterImage = (item: TwitterImageObject[]) => ({
|
||||
alt: item[3],
|
||||
height: item[2],
|
||||
url: item[0],
|
||||
width: item[1],
|
||||
});
|
||||
|
||||
const mediaMapperTwitterPlayer = (item: TwitterPlayerObject[]) => ({
|
||||
height: item[2],
|
||||
stream: item[3],
|
||||
url: item[0],
|
||||
width: item[1],
|
||||
});
|
||||
|
||||
const mediaMapperMusicSong = (item: MusicSongObject[]) => ({
|
||||
disc: item[2],
|
||||
track: item[1],
|
||||
url: item[0],
|
||||
});
|
||||
|
||||
const mediaMapper = (item: ImageObject[] | VideoObject[]) => ({
|
||||
height: item[2],
|
||||
type: item[3],
|
||||
url: item[0],
|
||||
width: item[1],
|
||||
});
|
||||
|
||||
const mediaSorter = (
|
||||
a: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject,
|
||||
b: ImageObject | TwitterImageObject | VideoObject | TwitterPlayerObject
|
||||
) => {
|
||||
if (!(a.url && b.url)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aRes = a.url.match(/\.(\w{2,5})$/);
|
||||
const aExt = (aRes && aRes[1].toLowerCase()) || null;
|
||||
const bRes = b.url.match(/\.(\w{2,5})$/);
|
||||
const bExt = (bRes && bRes[1].toLowerCase()) || null;
|
||||
|
||||
if (aExt === "gif" && bExt !== "gif") {
|
||||
return -1;
|
||||
}
|
||||
if (aExt !== "gif" && bExt === "gif") {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(b.width || 0, b.height || 0) - Math.max(a.width || 0, a.height || 0);
|
||||
};
|
||||
|
||||
const mediaSorterMusicSong = (a: MusicSongObject, b: MusicSongObject) => {
|
||||
if (!(a.track && b.track)) {
|
||||
return 0;
|
||||
}
|
||||
if ((a.disc || 0) > (b.disc || 0)) {
|
||||
return 1;
|
||||
}
|
||||
if ((a.disc || 0) < (b.disc || 0)) {
|
||||
return -1;
|
||||
}
|
||||
return a.track - b.track;
|
||||
};
|
||||
|
||||
// lodash zip replacement
|
||||
const zip = (array: any, ...args: any) => {
|
||||
if (array === undefined) return [];
|
||||
return array.map((value: any, idx: number) => [value, ...args.map((arr: []) => arr[idx])]);
|
||||
};
|
||||
|
||||
/**
|
||||
* formats the multiple media values
|
||||
*
|
||||
* @param {object} ogObject - the current ogObject
|
||||
* @param {object} options - options for ogs
|
||||
* @return {object} object with ogs results with updated media values
|
||||
*
|
||||
*/
|
||||
export function mediaSetup(ogObject: OgObjectInteral) {
|
||||
// sets ogImage property/width/height/type to empty array if one these exists
|
||||
if (
|
||||
ogObject.ogImageSecureURL ||
|
||||
ogObject.ogImageURL ||
|
||||
ogObject.ogImageProperty ||
|
||||
ogObject.ogImageWidth ||
|
||||
ogObject.ogImageHeight ||
|
||||
ogObject.ogImageType
|
||||
) {
|
||||
ogObject.ogImageSecureURL = ogObject.ogImageSecureURL ? ogObject.ogImageSecureURL : [];
|
||||
ogObject.ogImageURL = ogObject.ogImageURL ? ogObject.ogImageURL : [];
|
||||
ogObject.ogImageProperty = ogObject.ogImageProperty ? ogObject.ogImageProperty : [];
|
||||
// set ogImageProperty to ogImageSecureURL if it exists
|
||||
// eslint-disable-next-line max-len
|
||||
ogObject.ogImageProperty =
|
||||
ogObject.ogImageSecureURL.length !== 0 ? ogObject.ogImageSecureURL : ogObject.ogImageProperty;
|
||||
// fall back to ogImageURL if ogImageProperty isn't set
|
||||
ogObject.ogImageProperty = ogObject.ogImageProperty.length !== 0 ? ogObject.ogImageProperty : ogObject.ogImageURL;
|
||||
ogObject.ogImageWidth = ogObject.ogImageWidth ? ogObject.ogImageWidth : [];
|
||||
ogObject.ogImageHeight = ogObject.ogImageHeight ? ogObject.ogImageHeight : [];
|
||||
ogObject.ogImageType = ogObject.ogImageType ? ogObject.ogImageType : [];
|
||||
}
|
||||
|
||||
// format images and limit to 10
|
||||
const ogImages: ImageObject[] = zip(
|
||||
ogObject.ogImageProperty,
|
||||
ogObject.ogImageWidth,
|
||||
ogObject.ogImageHeight,
|
||||
ogObject.ogImageType
|
||||
)
|
||||
.map(mediaMapper)
|
||||
.filter((value: ImageObject) => value.url !== undefined && value.url !== "")
|
||||
.filter((value: ImageObject, index: number) => index < 10)
|
||||
.sort(mediaSorter);
|
||||
|
||||
// sets ogVideo property/width/height/type to empty array if one these exists
|
||||
if (ogObject.ogVideoProperty || ogObject.ogVideoWidth || ogObject.ogVideoHeight || ogObject.ogVideoType) {
|
||||
ogObject.ogVideoProperty = ogObject.ogVideoProperty ? ogObject.ogVideoProperty : [];
|
||||
ogObject.ogVideoWidth = ogObject.ogVideoWidth ? ogObject.ogVideoWidth : [];
|
||||
ogObject.ogVideoHeight = ogObject.ogVideoHeight ? ogObject.ogVideoHeight : [];
|
||||
ogObject.ogVideoType = ogObject.ogVideoType ? ogObject.ogVideoType : [];
|
||||
}
|
||||
|
||||
// format videos and limit to 10
|
||||
const ogVideos: VideoObject[] = zip(
|
||||
ogObject.ogVideoProperty,
|
||||
ogObject.ogVideoWidth,
|
||||
ogObject.ogVideoHeight,
|
||||
ogObject.ogVideoType
|
||||
)
|
||||
.map(mediaMapper)
|
||||
.filter((value: VideoObject) => value.url !== undefined && value.url !== "")
|
||||
.filter((value: VideoObject, index: number) => index < 10)
|
||||
.sort(mediaSorter);
|
||||
|
||||
// sets twitter image src/property/width/height/alt to empty array if one these exists
|
||||
if (
|
||||
ogObject.twitterImageSrc ||
|
||||
ogObject.twitterImageProperty ||
|
||||
ogObject.twitterImageWidth ||
|
||||
ogObject.twitterImageHeight ||
|
||||
ogObject.twitterImageAlt
|
||||
) {
|
||||
ogObject.twitterImageSrc = ogObject.twitterImageSrc ? ogObject.twitterImageSrc : [];
|
||||
// eslint-disable-next-line max-len
|
||||
ogObject.twitterImageProperty = ogObject.twitterImageProperty
|
||||
? ogObject.twitterImageProperty
|
||||
: ogObject.twitterImageSrc; // deafult to twitterImageSrc
|
||||
ogObject.twitterImageWidth = ogObject.twitterImageWidth ? ogObject.twitterImageWidth : [];
|
||||
ogObject.twitterImageHeight = ogObject.twitterImageHeight ? ogObject.twitterImageHeight : [];
|
||||
ogObject.twitterImageAlt = ogObject.twitterImageAlt ? ogObject.twitterImageAlt : [];
|
||||
}
|
||||
|
||||
// format twitter images and limit to 10
|
||||
const twitterImages: TwitterImageObject[] = zip(
|
||||
ogObject.twitterImageProperty,
|
||||
ogObject.twitterImageWidth,
|
||||
ogObject.twitterImageHeight,
|
||||
ogObject.twitterImageAlt
|
||||
)
|
||||
.map(mediaMapperTwitterImage)
|
||||
.filter((value: TwitterImageObject) => value.url !== undefined && value.url !== "")
|
||||
.filter((value: TwitterImageObject, index: number) => index < 10)
|
||||
.sort(mediaSorter);
|
||||
|
||||
// sets twitter property/width/height/stream to empty array if one these exists
|
||||
if (
|
||||
ogObject.twitterPlayerProperty ||
|
||||
ogObject.twitterPlayerWidth ||
|
||||
ogObject.twitterPlayerHeight ||
|
||||
ogObject.twitterPlayerStream
|
||||
) {
|
||||
ogObject.twitterPlayerProperty = ogObject.twitterPlayerProperty ? ogObject.twitterPlayerProperty : [];
|
||||
ogObject.twitterPlayerWidth = ogObject.twitterPlayerWidth ? ogObject.twitterPlayerWidth : [];
|
||||
ogObject.twitterPlayerHeight = ogObject.twitterPlayerHeight ? ogObject.twitterPlayerHeight : [];
|
||||
ogObject.twitterPlayerStream = ogObject.twitterPlayerStream ? ogObject.twitterPlayerStream : [];
|
||||
}
|
||||
|
||||
// format twitter player and limit to 10
|
||||
const twitterPlayers: TwitterPlayerObject[] = zip(
|
||||
ogObject.twitterPlayerProperty,
|
||||
ogObject.twitterPlayerWidth,
|
||||
ogObject.twitterPlayerHeight,
|
||||
ogObject.twitterPlayerStream
|
||||
)
|
||||
.map(mediaMapperTwitterPlayer)
|
||||
.filter((value: TwitterPlayerObject) => value.url !== undefined && value.url !== "")
|
||||
.filter((value: TwitterPlayerObject, index: number) => index < 10)
|
||||
.sort(mediaSorter);
|
||||
|
||||
// sets music property/songTrack/songDisc to empty array if one these exists
|
||||
if (ogObject.musicSongProperty || ogObject.musicSongTrack || ogObject.musicSongDisc || ogObject.musicSongUrl) {
|
||||
ogObject.musicSongUrl = ogObject.musicSongUrl ? ogObject.musicSongUrl : [];
|
||||
ogObject.musicSongProperty = ogObject.musicSongProperty ? ogObject.musicSongProperty : ogObject.musicSongUrl; // deafult to musicSongUrl
|
||||
ogObject.musicSongTrack = ogObject.musicSongTrack ? ogObject.musicSongTrack : [];
|
||||
ogObject.musicSongDisc = ogObject.musicSongDisc ? ogObject.musicSongDisc : [];
|
||||
}
|
||||
|
||||
// format music songs and limit to 10
|
||||
const musicSongs: MusicSongObject[] = zip(ogObject.musicSongProperty, ogObject.musicSongTrack, ogObject.musicSongDisc)
|
||||
.map(mediaMapperMusicSong)
|
||||
.filter((value: MusicSongObject) => value.url !== undefined && value.url !== "")
|
||||
.filter((value: MusicSongObject, index: number) => index < 10)
|
||||
.sort(mediaSorterMusicSong);
|
||||
|
||||
// remove old values since everything will live under the main property
|
||||
fields
|
||||
.filter((item) => item.multiple && item.fieldName && item.fieldName.match("(ogImage|ogVideo|twitter|musicSong).*"))
|
||||
.forEach((item) => {
|
||||
// @ts-ignore
|
||||
delete ogObject[item.fieldName];
|
||||
});
|
||||
|
||||
if (ogImages.length) ogObject.ogImage = ogImages;
|
||||
if (ogVideos.length) ogObject.ogVideo = ogVideos;
|
||||
if (twitterImages.length) ogObject.twitterImage = twitterImages;
|
||||
if (twitterPlayers.length) ogObject.twitterPlayer = twitterPlayers;
|
||||
if (musicSongs.length) ogObject.musicSong = musicSongs;
|
||||
|
||||
// removes any undefs
|
||||
ogObject = removeNestedUndefinedValues(ogObject);
|
||||
|
||||
return ogObject;
|
||||
}
|
||||
|
||||
export default mediaSetup;
|
240
src/lib/open-graph-scraper/types.ts
Normal file
240
src/lib/open-graph-scraper/types.ts
Normal file
@ -0,0 +1,240 @@
|
||||
export type TwitterImageObject = {
|
||||
alt?: string;
|
||||
height?: number;
|
||||
url: string;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export type TwitterPlayerObject = {
|
||||
height?: number;
|
||||
stream?: string;
|
||||
url: string;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export type ImageObject = {
|
||||
height?: number;
|
||||
type: string;
|
||||
url: string;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export type VideoObject = {
|
||||
height?: number;
|
||||
type?: string;
|
||||
url: string;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export type MusicSongObject = {
|
||||
disc?: string;
|
||||
track?: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type OgObjectInteral = {
|
||||
alAndroidAppName?: string;
|
||||
alAndroidClass?: string;
|
||||
alAndroidPackage?: string;
|
||||
alAndroidUrl?: string;
|
||||
alIosAppName?: string;
|
||||
alIosAppStoreId?: string;
|
||||
alIosUrl?: string;
|
||||
alIpadAppName?: string;
|
||||
alIpadAppStoreId?: string;
|
||||
alIpadUrl?: string;
|
||||
alIphoneAppName?: string;
|
||||
alIphoneAppStoreId?: string;
|
||||
alIphoneUrl?: string;
|
||||
alWebShouldFallback?: string;
|
||||
alWebUrl?: string;
|
||||
alWindowsAppId?: string;
|
||||
alWindowsAppName?: string;
|
||||
alWindowsPhoneAppId?: string;
|
||||
alWindowsPhoneAppName?: string;
|
||||
alWindowsPhoneUrl?: string;
|
||||
alWindowsUniversalAppId?: string;
|
||||
alWindowsUniversalAppName?: string;
|
||||
alWindowsUniversalUrl?: string;
|
||||
alWindowsUrl?: string;
|
||||
articleAuthor?: string;
|
||||
articleExpirationTime?: string;
|
||||
articleModifiedTime?: string;
|
||||
articlePublishedTime?: string;
|
||||
articlePublisher?: string;
|
||||
articleSection?: string;
|
||||
articleTag?: string;
|
||||
author?: string;
|
||||
bookAuthor?: string;
|
||||
bookCanonicalName?: string;
|
||||
bookIsbn?: string;
|
||||
bookReleaseDate?: string;
|
||||
booksBook?: string;
|
||||
booksRatingScale?: string;
|
||||
booksRatingValue?: string;
|
||||
bookTag?: string;
|
||||
businessContactDataCountryName?: string;
|
||||
businessContactDataLocality?: string;
|
||||
businessContactDataPostalCode?: string;
|
||||
businessContactDataRegion?: string;
|
||||
businessContactDataStreetAddress?: string;
|
||||
charset?: string;
|
||||
dcContributor?: string;
|
||||
dcCoverage?: string;
|
||||
dcCreator?: string;
|
||||
dcDate?: string;
|
||||
dcDateCreated?: string;
|
||||
dcDateIssued?: string;
|
||||
dcDescription?: string;
|
||||
dcFormatMedia?: string;
|
||||
dcFormatSize?: string;
|
||||
dcIdentifier?: string;
|
||||
dcLanguage?: string;
|
||||
dcPublisher?: string;
|
||||
dcRelation?: string;
|
||||
dcRights?: string;
|
||||
dcSource?: string;
|
||||
dcSubject?: string;
|
||||
dcTitle?: string;
|
||||
dcType?: string;
|
||||
error?: string;
|
||||
errorDetails?: Error;
|
||||
favicon?: string;
|
||||
modifiedTime?: string;
|
||||
musicAlbum?: string;
|
||||
musicAlbumDisc?: string;
|
||||
musicAlbumTrack?: string;
|
||||
musicAlbumUrl?: string;
|
||||
musicCreator?: string;
|
||||
musicDuration?: string;
|
||||
musicMusician?: string;
|
||||
musicReleaseDate?: string;
|
||||
musicSong?: MusicSongObject[];
|
||||
musicSongDisc?: string | null[];
|
||||
musicSongProperty?: string | null[];
|
||||
musicSongTrack?: number | null[];
|
||||
musicSongUrl?: string | null[];
|
||||
ogArticleAuthor?: string;
|
||||
ogArticleExpirationTime?: string;
|
||||
ogArticleModifiedTime?: string;
|
||||
ogArticlePublishedTime?: string;
|
||||
ogArticlePublisher?: string;
|
||||
ogArticleSection?: string;
|
||||
ogArticleTag?: string;
|
||||
ogAudio?: string;
|
||||
ogAudioSecureURL?: string;
|
||||
ogAudioType?: string;
|
||||
ogAudioURL?: string;
|
||||
ogAvailability?: string;
|
||||
ogDate?: string;
|
||||
ogDescription?: string;
|
||||
ogDeterminer?: string;
|
||||
ogImage?: ImageObject[];
|
||||
ogImageHeight?: string | null[];
|
||||
ogImageProperty?: string | null[];
|
||||
ogImageSecureURL?: string | null[];
|
||||
ogImageType?: string | null[];
|
||||
ogImageURL?: string | null[];
|
||||
ogImageWidth?: string | null[];
|
||||
ogLocale?: string;
|
||||
ogLocaleAlternate?: string;
|
||||
ogLogo?: string;
|
||||
ogPriceAmount?: string;
|
||||
ogPriceCurrency?: string;
|
||||
ogProductAvailability?: string;
|
||||
ogProductCondition?: string;
|
||||
ogProductPriceAmount?: string;
|
||||
ogProductPriceCurrency?: string;
|
||||
ogProductRetailerItemId?: string;
|
||||
ogSiteName?: string;
|
||||
ogTitle?: string;
|
||||
ogType?: string;
|
||||
ogUrl?: string;
|
||||
ogVideo?: VideoObject[];
|
||||
ogVideoActorId?: string;
|
||||
ogVideoHeight?: string | null[];
|
||||
ogVideoProperty?: string | null[];
|
||||
ogVideoSecureURL?: string;
|
||||
ogVideoType?: string | null[];
|
||||
ogVideoWidth?: string | null[];
|
||||
placeLocationLatitude?: string;
|
||||
placeLocationLongitude?: string;
|
||||
profileFirstName?: string;
|
||||
profileGender?: string;
|
||||
profileLastName?: string;
|
||||
profileUsername?: string;
|
||||
publishedTime?: string;
|
||||
releaseDate?: string;
|
||||
requestUrl?: string;
|
||||
restaurantContactInfoCountryName?: string;
|
||||
restaurantContactInfoEmail?: string;
|
||||
restaurantContactInfoLocality?: string;
|
||||
restaurantContactInfoPhoneNumber?: string;
|
||||
restaurantContactInfoPostalCode?: string;
|
||||
restaurantContactInfoRegion?: string;
|
||||
restaurantContactInfoStreetAddress?: string;
|
||||
restaurantContactInfoWebsite?: string;
|
||||
restaurantMenu?: string;
|
||||
restaurantRestaurant?: string;
|
||||
restaurantSection?: string;
|
||||
restaurantVariationPriceAmount?: string;
|
||||
restaurantVariationPriceCurrency?: string;
|
||||
success?: boolean;
|
||||
twitterAppIdGooglePlay?: string;
|
||||
twitterAppIdiPad?: string;
|
||||
twitterAppIdiPhone?: string;
|
||||
twitterAppNameGooglePlay?: string;
|
||||
twitterAppNameiPad?: string;
|
||||
twitterAppNameiPhone?: string;
|
||||
twitterAppUrlGooglePlay?: string;
|
||||
twitterAppUrliPad?: string;
|
||||
twitterAppUrliPhone?: string;
|
||||
twitterCard?: string;
|
||||
twitterCreator?: string;
|
||||
twitterCreatorId?: string;
|
||||
twitterDescription?: string;
|
||||
twitterImage?: TwitterImageObject[];
|
||||
twitterImageAlt?: string | null[];
|
||||
twitterImageHeight?: string | null[];
|
||||
twitterImageProperty?: string | null[];
|
||||
twitterImageSrc?: string | null[];
|
||||
twitterImageWidth?: string | null[];
|
||||
twitterPlayer?: TwitterPlayerObject[];
|
||||
twitterPlayerHeight?: string | null[];
|
||||
twitterPlayerProperty?: string | null[];
|
||||
twitterPlayerStream?: string | null[];
|
||||
twitterPlayerStreamContentType?: string;
|
||||
twitterPlayerWidth?: string | null[];
|
||||
twitterSite?: string;
|
||||
twitterSiteId?: string;
|
||||
twitterTitle?: string;
|
||||
twitterUrl?: string;
|
||||
updatedTime?: string;
|
||||
};
|
||||
|
||||
export type OgObject = Omit<
|
||||
OgObjectInteral,
|
||||
| "musicSongDisc"
|
||||
| "musicSongProperty"
|
||||
| "musicSongTrack"
|
||||
| "musicSongUrl"
|
||||
| "ogImageHeight"
|
||||
| "ogImageProperty"
|
||||
| "ogImageSecureURL"
|
||||
| "ogImageType"
|
||||
| "ogImageURL"
|
||||
| "ogImageWidth"
|
||||
| "ogVideoHeight"
|
||||
| "ogVideoProperty"
|
||||
| "ogVideoType"
|
||||
| "ogVideoWidth"
|
||||
| "twitterImageAlt"
|
||||
| "twitterImageHeight"
|
||||
| "twitterImageProperty"
|
||||
| "twitterImageSrc"
|
||||
| "twitterImageWidth"
|
||||
| "twitterPlayerHeight"
|
||||
| "twitterPlayerProperty"
|
||||
| "twitterPlayerStream"
|
||||
| "twitterPlayerWidth"
|
||||
>;
|
139
src/lib/open-graph-scraper/utils.ts
Normal file
139
src/lib/open-graph-scraper/utils.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Checks if URL is valid
|
||||
*
|
||||
* @param {string} url - url to be checked
|
||||
* @return {boolean} boolean value if the url is valid
|
||||
*
|
||||
*/
|
||||
export function isUrlValid(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch (e) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces url to start with http:// if it doesn't
|
||||
*
|
||||
* @param {string} url - url to be updated
|
||||
* @return {string} url that starts with http
|
||||
*
|
||||
*/
|
||||
const coerceUrl = (url: string): string => (/^(f|ht)tps?:\/\//i.test(url) ? url : `http://${url}`);
|
||||
|
||||
/**
|
||||
* Validates and formats url
|
||||
*
|
||||
* @param {string} url - url to be checked and formatted
|
||||
* @return {string} proper url or null
|
||||
*
|
||||
*/
|
||||
export function validateAndFormatURL(url: string): { url: string | null } {
|
||||
return { url: isUrlValid(url) ? coerceUrl(url) : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the image type from a given url
|
||||
*
|
||||
* @param {string} url - url to be checked
|
||||
* @return {string} image type from url
|
||||
*
|
||||
*/
|
||||
export function findImageTypeFromUrl(url: string): string {
|
||||
let type: string = url.split(".").pop() || "";
|
||||
[type] = type.split("?");
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if image type is valid
|
||||
*
|
||||
* @param {string} type - type to be checked
|
||||
* @return {boolean} boolean value if type is value
|
||||
*
|
||||
*/
|
||||
export function isImageTypeValid(type: string): boolean {
|
||||
const validImageTypes: string[] = [
|
||||
"apng",
|
||||
"bmp",
|
||||
"gif",
|
||||
"ico",
|
||||
"cur",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"jfif",
|
||||
"pjpeg",
|
||||
"pjp",
|
||||
"png",
|
||||
"svg",
|
||||
"tif",
|
||||
"tiff",
|
||||
"webp",
|
||||
];
|
||||
return validImageTypes.includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if URL is a non html page
|
||||
*
|
||||
* @param {string} url - url to be checked
|
||||
* @return {boolean} boolean value if url is non html
|
||||
*
|
||||
*/
|
||||
export function isThisANonHTMLUrl(url: string): boolean {
|
||||
const invalidImageTypes: string[] = [
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".3gp",
|
||||
".avi",
|
||||
".mov",
|
||||
".mp4",
|
||||
".m4v",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".mkv",
|
||||
".ogv",
|
||||
".ogm",
|
||||
".ogg",
|
||||
".oga",
|
||||
".webm",
|
||||
".wav",
|
||||
".bmp",
|
||||
".gif",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".zip",
|
||||
".rar",
|
||||
".tar",
|
||||
".tar.gz",
|
||||
".tgz",
|
||||
".tar.bz2",
|
||||
".tbz2",
|
||||
".txt",
|
||||
".pdf",
|
||||
];
|
||||
const extension: string = findImageTypeFromUrl(url);
|
||||
return invalidImageTypes.some((type: string): boolean => `.${extension}`.includes(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and delete nested undefs
|
||||
*
|
||||
* @param {object} object - object to be cleaned
|
||||
* @return {object} object without nested undefs
|
||||
*
|
||||
*/
|
||||
export function removeNestedUndefinedValues(object: { [key: string]: any }): { [key: string]: any } {
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
if (value && typeof value === "object") removeNestedUndefinedValues(value);
|
||||
else if (value === undefined) delete object[key];
|
||||
});
|
||||
return object;
|
||||
}
|
@ -35,7 +35,7 @@ export async function loadSettings() {
|
||||
appSettings.next(account.localSettings);
|
||||
}
|
||||
} else {
|
||||
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls());
|
||||
const subject = userAppSettings.requestAppSettings(account.pubkey, clientRelaysService.getReadUrls(), true);
|
||||
appSettings.connect(subject);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import db from "./db";
|
||||
import { fetchWithCorsFallback } from "../helpers/cors";
|
||||
|
||||
function parseAddress(address: string): { name?: string; domain?: string } {
|
||||
const parts = address.trim().toLowerCase().split("@");
|
||||
@ -26,7 +27,9 @@ function getIdentityFromJson(name: string, domain: string, json: IdentityJson):
|
||||
}
|
||||
|
||||
async function fetchAllIdentities(domain: string) {
|
||||
const json = await fetch(`//${domain}/.well-known/nostr.json`).then((res) => res.json() as Promise<IdentityJson>);
|
||||
const json = await fetchWithCorsFallback(`//${domain}/.well-known/nostr.json`).then(
|
||||
(res) => res.json() as Promise<IdentityJson>
|
||||
);
|
||||
|
||||
await addToCache(domain, json);
|
||||
}
|
||||
@ -35,7 +38,7 @@ async function fetchIdentity(address: string) {
|
||||
const { name, domain } = parseAddress(address);
|
||||
if (!name || !domain) throw new Error("invalid address");
|
||||
|
||||
const json = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
const json = await fetchWithCorsFallback(`https://${domain}/.well-known/nostr.json?name=${name}`)
|
||||
.then((res) => res.json() as Promise<IdentityJson>)
|
||||
.then((json) => {
|
||||
// convert all keys in names, and relays to lower case
|
||||
|
@ -26,6 +26,7 @@ export type AppSettings = {
|
||||
zapAmounts: number[];
|
||||
primaryColor: string;
|
||||
imageProxy: string;
|
||||
corsProxy: string;
|
||||
showContentWarning: boolean;
|
||||
twitterRedirect?: string;
|
||||
redditRedirect?: string;
|
||||
@ -43,6 +44,7 @@ export const defaultSettings: AppSettings = {
|
||||
zapAmounts: [50, 200, 500, 1000],
|
||||
primaryColor: "#8DB600",
|
||||
imageProxy: "",
|
||||
corsProxy: "",
|
||||
showContentWarning: true,
|
||||
twitterRedirect: undefined,
|
||||
redditRedirect: undefined,
|
||||
@ -61,15 +63,8 @@ class UserAppSettings {
|
||||
requester: CachedPubkeyEventRequester;
|
||||
constructor() {
|
||||
this.requester = new CachedPubkeyEventRequester(30078, "user-app-data", DTAG);
|
||||
this.requester.readCache = this.readCache;
|
||||
this.requester.writeCache = this.writeCache;
|
||||
}
|
||||
|
||||
readCache(pubkey: string) {
|
||||
return db.get("settings", pubkey);
|
||||
}
|
||||
writeCache(pubkey: string, event: NostrEvent) {
|
||||
return db.put("settings", event);
|
||||
this.requester.readCache = (pubkey) => db.get("settings", pubkey);
|
||||
this.requester.writeCache = (pubkey, event) => db.put("settings", event);
|
||||
}
|
||||
|
||||
private parsedSubjects = new SuperMap<string, PersistentSubject<AppSettings>>(
|
||||
|
@ -1,41 +1,59 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Editable,
|
||||
EditableInput,
|
||||
EditablePreview,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Select,
|
||||
IconButton,
|
||||
Input,
|
||||
Spinner,
|
||||
Switch,
|
||||
useDisclosure,
|
||||
useEditableControls,
|
||||
} from "@chakra-ui/react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { CloseIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useAppTitle } from "../../hooks/use-app-title";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { useTimelineLoader } from "../../hooks/use-timeline-loader";
|
||||
import { unique } from "../../helpers/array";
|
||||
import { isReply } from "../../helpers/nostr-event";
|
||||
import { Note } from "../../components/note";
|
||||
import { CheckIcon, EditIcon, RelayIcon } from "../../components/icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import RelaySelectionModal from "./relay-selection-modal";
|
||||
|
||||
function EditableControls() {
|
||||
const { isEditing, getSubmitButtonProps, getCancelButtonProps, getEditButtonProps } = useEditableControls();
|
||||
|
||||
return isEditing ? (
|
||||
<ButtonGroup justifyContent="center" size="md">
|
||||
<IconButton icon={<CheckIcon />} {...getSubmitButtonProps()} aria-label="Save" />
|
||||
<IconButton icon={<CloseIcon />} {...getCancelButtonProps()} aria-label="Cancel" />
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
<IconButton size="md" icon={<EditIcon />} {...getEditButtonProps()} aria-label="Edit" />
|
||||
);
|
||||
}
|
||||
|
||||
export default function HashTagView() {
|
||||
const navigate = useNavigate();
|
||||
const { hashtag } = useParams() as { hashtag: string };
|
||||
const [editableHashtag, setEditableHashtag] = useState(hashtag);
|
||||
useEffect(() => setEditableHashtag(hashtag), [hashtag]);
|
||||
|
||||
useAppTitle("#" + hashtag);
|
||||
|
||||
const defaultRelays = useReadRelayUrls();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedRelay = searchParams.get("relay") ?? "";
|
||||
const setSelectedRelay = (url: string) => {
|
||||
if (url) {
|
||||
setSearchParams({ relay: url });
|
||||
} else setSearchParams({});
|
||||
};
|
||||
|
||||
const availableRelays = unique([...defaultRelays, selectedRelay]).filter(Boolean);
|
||||
const [selectedRelays, setSelectedRelays] = useState(defaultRelays);
|
||||
|
||||
const relaysModal = useDisclosure();
|
||||
const { isOpen: showReplies, onToggle } = useDisclosure();
|
||||
const { events, loading, loadMore, loader } = useTimelineLoader(
|
||||
`${hashtag}-hashtag`,
|
||||
selectedRelay ? [selectedRelay] : defaultRelays,
|
||||
selectedRelays,
|
||||
{ kinds: [1], "#t": [hashtag] },
|
||||
{ pageSize: 60 * 10 }
|
||||
);
|
||||
@ -43,41 +61,59 @@ export default function HashTagView() {
|
||||
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1">
|
||||
<Heading>#{hashtag}</Heading>
|
||||
<Flex gap="2">
|
||||
<Select
|
||||
placeholder="All Relays"
|
||||
maxWidth="250"
|
||||
value={selectedRelay}
|
||||
onChange={(e) => {
|
||||
setSelectedRelay(e.target.value);
|
||||
<>
|
||||
<Flex direction="column" gap="4" overflow="auto" flex={1} pb="4" pt="4" pl="1" pr="1">
|
||||
<Flex gap="4" alignItems="center" wrap="wrap">
|
||||
<Editable
|
||||
value={editableHashtag}
|
||||
onChange={(v) => setEditableHashtag(v)}
|
||||
fontSize="3xl"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
gap="2"
|
||||
alignItems="center"
|
||||
selectAllOnFocus
|
||||
onSubmit={(v) => navigate("/t/" + String(v).toLowerCase())}
|
||||
flexShrink={0}
|
||||
>
|
||||
<div>
|
||||
#<EditablePreview p={0} />
|
||||
</div>
|
||||
<Input as={EditableInput} maxW="md" />
|
||||
<EditableControls />
|
||||
</Editable>
|
||||
<Button leftIcon={<RelayIcon />} onClick={relaysModal.onOpen}>
|
||||
{selectedRelays.length} Relays
|
||||
</Button>
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
|
||||
<FormLabel htmlFor="show-replies" mb="0">
|
||||
Show Replies
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
{timeline.map((event) => (
|
||||
<Note key={event.id} event={event} maxHeight={600} />
|
||||
))}
|
||||
{loading ? (
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
|
||||
) : (
|
||||
<Button onClick={() => loadMore()} flexShrink={0}>
|
||||
Load More
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{relaysModal.isOpen && (
|
||||
<RelaySelectionModal
|
||||
selected={selectedRelays}
|
||||
onSubmit={(relays) => {
|
||||
setSelectedRelays(relays);
|
||||
loader.forgetEvents();
|
||||
}}
|
||||
>
|
||||
{availableRelays.map((url) => (
|
||||
<option key={url} value={url}>
|
||||
{url}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<Switch id="show-replies" isChecked={showReplies} onChange={onToggle} mr="2" />
|
||||
<FormLabel htmlFor="show-replies" mb="0">
|
||||
Show Replies
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
{timeline.map((event) => (
|
||||
<Note key={event.id} event={event} maxHeight={600} />
|
||||
))}
|
||||
{loading ? (
|
||||
<Spinner ml="auto" mr="auto" mt="8" mb="8" flexShrink={0} />
|
||||
) : (
|
||||
<Button onClick={() => loadMore()} flexShrink={0}>
|
||||
Load More
|
||||
</Button>
|
||||
onClose={relaysModal.onClose}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
117
src/views/hashtag/relay-selection-modal.tsx
Normal file
117
src/views/hashtag/relay-selection-modal.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||
import { RelayFavicon } from "../../components/relay-favicon";
|
||||
import { RelayUrlInput } from "../../components/relay-url-input";
|
||||
import { normalizeRelayUrl } from "../../helpers/url";
|
||||
import { unique } from "../../helpers/array";
|
||||
import relayScoreboardService from "../../services/relay-scoreboard";
|
||||
|
||||
function AddRelayForm({ onSubmit }: { onSubmit: (relay: string) => void }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const toast = useToast();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
as="form"
|
||||
onSubmit={(e) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
onSubmit(normalizeRelayUrl(url));
|
||||
setUrl("");
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
toast({ status: "error", description: err.message });
|
||||
}
|
||||
}
|
||||
}}
|
||||
gap="2"
|
||||
mb="4"
|
||||
>
|
||||
<RelayUrlInput value={url} onChange={(v) => setUrl(v)} />
|
||||
<Button type="submit">Add</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const manuallyAddedRelays = new Set<string>();
|
||||
|
||||
export default function RelaySelectionModal({
|
||||
selected,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
selected: string[];
|
||||
onSubmit: (relays: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [newSelected, setSelected] = useState<string[]>(selected);
|
||||
const relays = useReadRelayUrls([...selected, ...newSelected, ...Array.from(manuallyAddedRelays)]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} closeOnOverlayClick={false}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Select Relays</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody py="0">
|
||||
<AddRelayForm
|
||||
onSubmit={(newRelay) => {
|
||||
setSelected(unique([newRelay, ...newSelected]));
|
||||
manuallyAddedRelays.add(newRelay);
|
||||
}}
|
||||
/>
|
||||
<CheckboxGroup value={newSelected} onChange={(urls) => setSelected(urls.map(String))}>
|
||||
<Flex direction="column" gap="2" mb="2">
|
||||
{relays.map((url) => (
|
||||
<Checkbox key={url} value={url}>
|
||||
<RelayFavicon relay={url} size="xs" /> {url}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
</CheckboxGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
<Button onClick={() => setSelected(relays)} size="sm">
|
||||
All
|
||||
</Button>
|
||||
<Button onClick={() => setSelected([])} size="sm">
|
||||
None
|
||||
</Button>
|
||||
<Button onClick={() => setSelected(relayScoreboardService.getRankedRelays(relays).slice(0, 4))} size="sm">
|
||||
4 Fastest
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose} mr="2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="brand"
|
||||
onClick={() => {
|
||||
onSubmit(newSelected);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -25,7 +25,7 @@ export default function GlobalTab() {
|
||||
`global`,
|
||||
selectedRelay ? [selectedRelay] : [],
|
||||
{ kinds: [1] },
|
||||
{ pageSize: 60 * 10 }
|
||||
{ pageSize: 60*10 }
|
||||
);
|
||||
|
||||
const timeline = showReplies ? events : events.filter((e) => !isReply(e));
|
||||
|
@ -25,6 +25,7 @@ import dnsIdentityService from "../../services/dns-identity";
|
||||
import signingService from "../../services/signing";
|
||||
import userMetadataService from "../../services/user-metadata";
|
||||
import { DraftNostrEvent } from "../../types/nostr-event";
|
||||
import lnurlMetadataService from "../../services/lnurl-metadata";
|
||||
|
||||
const isEmail =
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
@ -160,10 +161,15 @@ const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
|
||||
autoComplete="off"
|
||||
isDisabled={isSubmitting}
|
||||
{...register("lightningAddress", {
|
||||
validate: (v) => {
|
||||
if (v && !isLNURL(v) && !isLightningAddress(v)) {
|
||||
validate: async (v) => {
|
||||
if (!v) return true;
|
||||
if (!isLNURL(v) && !isLightningAddress(v)) {
|
||||
return "Must be lightning address or LNURL";
|
||||
}
|
||||
const metadata = await lnurlMetadataService.requestMetadata(v);
|
||||
if (!metadata) {
|
||||
return "Incorrect or broken LNURL address";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
|
@ -91,6 +91,8 @@ export default function SearchView() {
|
||||
const handleSearchText = (text: string) => {
|
||||
if (text.startsWith("nostr:") || text.startsWith("web+nostr:") || safeDecode(search)) {
|
||||
navigate({ pathname: "/l/" + encodeURIComponent(text) }, { replace: true });
|
||||
} else if (text.trim().match(/^#(\w+)/i)) {
|
||||
navigate({ pathname: "/t/" + text.toLowerCase().trim().replace(/^#/, "") });
|
||||
} else {
|
||||
setSearchParams({ q: text }, { replace: true });
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
AccordionItem,
|
||||
@ -7,7 +8,6 @@ import {
|
||||
AccordionIcon,
|
||||
ButtonGroup,
|
||||
} from "@chakra-ui/react";
|
||||
import { useState } from "react";
|
||||
import { clearCacheData, deleteDatabase } from "../../services/db";
|
||||
|
||||
export default function DatabaseSettings() {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
@ -10,41 +11,11 @@ import {
|
||||
AccordionIcon,
|
||||
FormHelperText,
|
||||
Input,
|
||||
InputProps,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
|
||||
function ColorPicker({ value, onPickColor, ...props }: { onPickColor?: (color: string) => void } & InputProps) {
|
||||
const [tmpColor, setTmpColor] = useState(value);
|
||||
const ref = useRef<HTMLInputElement>();
|
||||
|
||||
useEffect(() => setTmpColor(value), [value]);
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.onchange = () => {
|
||||
if (onPickColor && ref.current?.value) {
|
||||
onPickColor(ref.current.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
value={tmpColor}
|
||||
onChange={(e) => {
|
||||
setTmpColor(e.target.value);
|
||||
if (props.onChange) props.onChange(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import { AppSettings } from "../../services/user-app-settings";
|
||||
|
||||
export default function DisplaySettings() {
|
||||
const { blurImages, colorMode, primaryColor, updateSettings, showContentWarning } = useAppSettings();
|
||||
const { register } = useFormContext<AppSettings>();
|
||||
|
||||
return (
|
||||
<AccordionItem>
|
||||
@ -60,14 +31,10 @@ export default function DisplaySettings() {
|
||||
<Flex direction="column" gap="4">
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="use-dark-theme" mb="0">
|
||||
<FormLabel htmlFor="colorMode" mb="0">
|
||||
Use dark theme
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="use-dark-theme"
|
||||
isChecked={colorMode === "dark"}
|
||||
onChange={(v) => updateSettings({ colorMode: v.target.checked ? "dark" : "light" })}
|
||||
/>
|
||||
<Switch id="colorMode" {...register("colorMode")} />
|
||||
</Flex>
|
||||
<FormHelperText>
|
||||
<span>Enables hacker mode</span>
|
||||
@ -75,17 +42,10 @@ export default function DisplaySettings() {
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="primary-color" mb="0">
|
||||
<FormLabel htmlFor="primaryColor" mb="0">
|
||||
Primary Color
|
||||
</FormLabel>
|
||||
<ColorPicker
|
||||
id="primary-color"
|
||||
type="color"
|
||||
value={primaryColor}
|
||||
onPickColor={(color) => updateSettings({ primaryColor: color })}
|
||||
maxW="120"
|
||||
size="sm"
|
||||
/>
|
||||
<Input id="primaryColor" type="color" maxW="120" size="sm" {...register("primaryColor")} />
|
||||
</Flex>
|
||||
<FormHelperText>
|
||||
<span>The primary color of the theme</span>
|
||||
@ -93,14 +53,10 @@ export default function DisplaySettings() {
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="blur-images" mb="0">
|
||||
<FormLabel htmlFor="blurImages" mb="0">
|
||||
Blur images from strangers
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="blur-images"
|
||||
isChecked={blurImages}
|
||||
onChange={(v) => updateSettings({ blurImages: v.target.checked })}
|
||||
/>
|
||||
<Switch id="blurImages" {...register("blurImages")} />
|
||||
</Flex>
|
||||
<FormHelperText>
|
||||
<span>Enabled: blur images for people you aren't following</span>
|
||||
@ -111,31 +67,12 @@ export default function DisplaySettings() {
|
||||
<FormLabel htmlFor="show-content-warning" mb="0">
|
||||
Show content warning
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="show-content-warning"
|
||||
isChecked={showContentWarning}
|
||||
onChange={(v) => updateSettings({ showContentWarning: v.target.checked })}
|
||||
/>
|
||||
<Switch id="show-content-warning" {...register("showContentWarning")} />
|
||||
</Flex>
|
||||
<FormHelperText>
|
||||
<span>Enabled: shows a warning for notes with NIP-36 Content Warning</span>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="show-ads" mb="0">
|
||||
Show Ads
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="show-ads"
|
||||
isChecked={false}
|
||||
onChange={(v) => alert("Sorry, that feature will never be finished.")}
|
||||
/>
|
||||
</Flex>
|
||||
<FormHelperText>
|
||||
<span>Enabled: shows ads so I can steal your data</span>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
@ -1,38 +1,56 @@
|
||||
import { Button, Flex, Accordion, Link } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import accountService from "../../services/account";
|
||||
import { GithubIcon, LogoutIcon, ToolsIcon } from "../../components/icons";
|
||||
import { GithubIcon, ToolsIcon } from "../../components/icons";
|
||||
import LightningSettings from "./lightning-settings";
|
||||
import DatabaseSettings from "./database-settings";
|
||||
import DisplaySettings from "./display-settings";
|
||||
import PerformanceSettings from "./performance-settings";
|
||||
import PrivacySettings from "./privacy-settings";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
|
||||
export default function SettingsView() {
|
||||
const { updateSettings, ...settings } = useAppSettings();
|
||||
|
||||
const form = useForm({
|
||||
mode: "all",
|
||||
values: settings,
|
||||
});
|
||||
|
||||
const saveSettings = form.handleSubmit(async (values) => {
|
||||
await updateSettings(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex direction="column" pt="2" pb="2" overflow="auto">
|
||||
<Accordion defaultIndex={[0]} allowMultiple>
|
||||
<DisplaySettings />
|
||||
|
||||
<PerformanceSettings />
|
||||
|
||||
<PrivacySettings />
|
||||
|
||||
<LightningSettings />
|
||||
|
||||
<DatabaseSettings />
|
||||
</Accordion>
|
||||
<Flex gap="2" padding="4" alignItems="center">
|
||||
<Button leftIcon={<LogoutIcon />} onClick={() => accountService.logout()}>
|
||||
Logout
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/tools" leftIcon={<ToolsIcon />}>
|
||||
Tools
|
||||
</Button>
|
||||
<Link isExternal href="https://github.com/hzrd149/nostrudel" ml="auto">
|
||||
<GithubIcon /> Github
|
||||
</Link>
|
||||
</Flex>
|
||||
<form onSubmit={saveSettings}>
|
||||
<FormProvider {...form}>
|
||||
<Accordion defaultIndex={[0]} allowMultiple>
|
||||
<DisplaySettings />
|
||||
<PerformanceSettings />
|
||||
<PrivacySettings />
|
||||
<LightningSettings />
|
||||
<DatabaseSettings />
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
<Flex gap="4" padding="4" alignItems="center">
|
||||
<Button as={RouterLink} to="/tools" leftIcon={<ToolsIcon />}>
|
||||
Tools
|
||||
</Button>
|
||||
<Link isExternal href="https://github.com/hzrd149/nostrudel">
|
||||
<GithubIcon /> Github
|
||||
</Link>
|
||||
<Button
|
||||
ml="auto"
|
||||
isLoading={form.formState.isLoading || form.formState.isValidating}
|
||||
isDisabled={!form.formState.isDirty}
|
||||
colorScheme="brand"
|
||||
type="submit"
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@ -11,18 +11,12 @@ import {
|
||||
Input,
|
||||
Select,
|
||||
} from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import appSettings, { replaceSettings } from "../../services/app-settings";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { LightningIcon } from "../../components/icons";
|
||||
import { LightningPayMode } from "../../services/user-app-settings";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import { AppSettings } from "../../services/user-app-settings";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
export default function LightningSettings() {
|
||||
const { lightningPayMode, zapAmounts, updateSettings } = useAppSettings();
|
||||
|
||||
const [zapInput, setZapInput] = useState(zapAmounts.join(","));
|
||||
useEffect(() => setZapInput(zapAmounts.join(",")), [zapAmounts.join(",")]);
|
||||
const { register } = useFormContext<AppSettings>();
|
||||
|
||||
return (
|
||||
<AccordionItem>
|
||||
@ -37,14 +31,10 @@ export default function LightningSettings() {
|
||||
<AccordionPanel>
|
||||
<Flex direction="column" gap="4">
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="lightning-payment-mode" mb="0">
|
||||
<FormLabel htmlFor="lightningPayMode" mb="0">
|
||||
Payment mode
|
||||
</FormLabel>
|
||||
<Select
|
||||
id="lightning-payment-mode"
|
||||
value={lightningPayMode}
|
||||
onChange={(e) => updateSettings({ lightningPayMode: e.target.value as LightningPayMode })}
|
||||
>
|
||||
<Select id="lightningPayMode" {...register("lightningPayMode")}>
|
||||
<option value="prompt">Prompt</option>
|
||||
<option value="webln">WebLN</option>
|
||||
<option value="external">External</option>
|
||||
@ -64,18 +54,20 @@ export default function LightningSettings() {
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="zap-amounts"
|
||||
value={zapInput}
|
||||
onChange={(e) => setZapInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
const amounts = zapInput
|
||||
.split(",")
|
||||
.map((v) => parseInt(v))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
updateSettings({ zapAmounts: amounts });
|
||||
setZapInput(amounts.join(","));
|
||||
}}
|
||||
autoComplete="off"
|
||||
{...register("zapAmounts", {
|
||||
setValueAs: (value: number[] | string) => {
|
||||
if (Array.isArray(value)) {
|
||||
return Array.from(value).join(",");
|
||||
} else {
|
||||
return value
|
||||
.split(",")
|
||||
.map((v) => parseInt(v))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<FormHelperText>
|
||||
<span>Comma separated list of custom zap amounts</span>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
@ -11,16 +12,13 @@ import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Link,
|
||||
FormErrorMessage,
|
||||
} from "@chakra-ui/react";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppSettings } from "../../services/user-app-settings";
|
||||
import { safeUrl } from "../../helpers/parse";
|
||||
|
||||
export default function PerformanceSettings() {
|
||||
const { autoShowMedia, proxyUserMedia, showReactions, showSignatureVerification, updateSettings, imageProxy } =
|
||||
useAppSettings();
|
||||
|
||||
const [proxyInput, setProxyInput] = useState(imageProxy);
|
||||
useEffect(() => setProxyInput(imageProxy), [imageProxy]);
|
||||
const { register, formState } = useFormContext<AppSettings>();
|
||||
|
||||
return (
|
||||
<AccordionItem>
|
||||
@ -39,11 +37,7 @@ export default function PerformanceSettings() {
|
||||
<FormLabel htmlFor="proxy-user-media" mb="0">
|
||||
Proxy user media
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="proxy-user-media"
|
||||
isChecked={proxyUserMedia}
|
||||
onChange={(v) => updateSettings({ proxyUserMedia: v.target.checked })}
|
||||
/>
|
||||
<Switch id="proxy-user-media" {...register("proxyUserMedia")} />
|
||||
</Flex>
|
||||
<FormHelperText>
|
||||
<span>Enabled: Use media.nostr.band to get smaller profile pictures (saves ~50Mb of data)</span>
|
||||
@ -52,24 +46,17 @@ export default function PerformanceSettings() {
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel htmlFor="image-proxy" mb="0">
|
||||
<FormLabel htmlFor="imageProxy" mb="0">
|
||||
Image proxy service
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="image-proxy"
|
||||
id="imageProxy"
|
||||
type="url"
|
||||
value={proxyInput}
|
||||
onChange={(e) => setProxyInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
try {
|
||||
const url = proxyInput ? new URL(proxyInput).toString() : "";
|
||||
if (url !== imageProxy) {
|
||||
updateSettings({ imageProxy: url });
|
||||
setProxyInput(url);
|
||||
}
|
||||
} catch (e) {}
|
||||
}}
|
||||
{...register("imageProxy", {
|
||||
setValueAs: (v) => safeUrl(v) || v,
|
||||
})}
|
||||
/>
|
||||
{formState.errors.imageProxy && <FormErrorMessage>{formState.errors.imageProxy.message}</FormErrorMessage>}
|
||||
<FormHelperText>
|
||||
<span>
|
||||
A URL to an instance of{" "}
|
||||
@ -81,40 +68,28 @@ export default function PerformanceSettings() {
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="auto-show-embeds" mb="0">
|
||||
Automatically show media
|
||||
<FormLabel htmlFor="autoShowMedia" mb="0">
|
||||
Show embeds
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="auto-show-embeds"
|
||||
isChecked={autoShowMedia}
|
||||
onChange={(v) => updateSettings({ autoShowMedia: v.target.checked })}
|
||||
/>
|
||||
<Switch id="autoShowMedia" {...register("autoShowMedia")} />
|
||||
</Flex>
|
||||
<FormHelperText>Disabled: Images and videos will show expandable buttons</FormHelperText>
|
||||
<FormHelperText>Disabled: Embeds will show an expandable button</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="show-reactions" mb="0">
|
||||
<FormLabel htmlFor="showReactions" mb="0">
|
||||
Show reactions
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="show-reactions"
|
||||
isChecked={showReactions}
|
||||
onChange={(v) => updateSettings({ showReactions: v.target.checked })}
|
||||
/>
|
||||
<Switch id="showReactions" {...register("showReactions")} />
|
||||
</Flex>
|
||||
<FormHelperText>Enabled: Show reactions on notes</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="show-sig-verify" mb="0">
|
||||
<FormLabel htmlFor="showSignatureVerification" mb="0">
|
||||
Show signature verification
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="show-sig-verify"
|
||||
isChecked={showSignatureVerification}
|
||||
onChange={(v) => updateSettings({ showSignatureVerification: v.target.checked })}
|
||||
/>
|
||||
<Switch id="showSignatureVerification" {...register("showSignatureVerification")} />
|
||||
</Flex>
|
||||
<FormHelperText>Enabled: show signature verification on notes</FormHelperText>
|
||||
</FormControl>
|
||||
|
@ -2,7 +2,6 @@ import {
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
AccordionButton,
|
||||
@ -11,38 +10,36 @@ import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Link,
|
||||
Button,
|
||||
FormErrorMessage,
|
||||
} from "@chakra-ui/react";
|
||||
import useAppSettings from "../../hooks/use-app-settings";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useAsync } from "react-use";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { AppSettings } from "../../services/user-app-settings";
|
||||
import { safeUrl } from "../../helpers/parse";
|
||||
|
||||
async function validateInvidiousUrl(url?: string) {
|
||||
if (!url) return true;
|
||||
try {
|
||||
const res = await fetch(new URL("/api/v1/stats", url));
|
||||
return res.ok || "Catch reach instance";
|
||||
return res.ok || "Cant reach instance";
|
||||
} catch (e) {
|
||||
return "Catch reach instance";
|
||||
return "Cant reach instance";
|
||||
}
|
||||
}
|
||||
|
||||
async function validateCorsProxy(url?: string) {
|
||||
if (!url) return true;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
const res = await fetch(new URL("/https://example.com", url), { signal: controller.signal });
|
||||
return res.ok || "Cant reach instance";
|
||||
} catch (e) {
|
||||
return "Cant reach instance";
|
||||
}
|
||||
}
|
||||
|
||||
export default function PrivacySettings() {
|
||||
const { youtubeRedirect, twitterRedirect, redditRedirect, updateSettings } = useAppSettings();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
youtubeRedirect,
|
||||
twitterRedirect,
|
||||
redditRedirect,
|
||||
},
|
||||
});
|
||||
|
||||
const save = handleSubmit(async (values) => {
|
||||
await updateSettings(values);
|
||||
});
|
||||
const { register, formState } = useFormContext<AppSettings>();
|
||||
|
||||
return (
|
||||
<AccordionItem>
|
||||
@ -55,75 +52,89 @@ export default function PrivacySettings() {
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel>
|
||||
<form onSubmit={save}>
|
||||
<Flex direction="column" gap="4">
|
||||
<FormControl isInvalid={!!formState.errors.twitterRedirect}>
|
||||
<FormLabel>Nitter instance</FormLabel>
|
||||
<Input type="url" placeholder="https://nitter.net/" {...register("twitterRedirect")} />
|
||||
{formState.errors.twitterRedirect && (
|
||||
<FormErrorMessage>{formState.errors.twitterRedirect.message}</FormErrorMessage>
|
||||
)}
|
||||
<FormHelperText>
|
||||
Nitter is a privacy focused UI for twitter.{" "}
|
||||
<Link href="https://github.com/zedeus/nitter/wiki/Instances" isExternal color="blue.500">
|
||||
Nitter instances
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<Flex direction="column" gap="4">
|
||||
<FormControl isInvalid={!!formState.errors.twitterRedirect}>
|
||||
<FormLabel>Nitter instance</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://nitter.net/"
|
||||
{...register("twitterRedirect", { setValueAs: safeUrl })}
|
||||
/>
|
||||
{formState.errors.twitterRedirect && (
|
||||
<FormErrorMessage>{formState.errors.twitterRedirect.message}</FormErrorMessage>
|
||||
)}
|
||||
<FormHelperText>
|
||||
Nitter is a privacy focused UI for twitter.{" "}
|
||||
<Link href="https://github.com/zedeus/nitter/wiki/Instances" isExternal color="blue.500">
|
||||
Nitter instances
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!formState.errors.youtubeRedirect}>
|
||||
<FormLabel>Invidious instance</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="Invidious instance url"
|
||||
{...register("youtubeRedirect", {
|
||||
validate: validateInvidiousUrl,
|
||||
})}
|
||||
/>
|
||||
{formState.errors.youtubeRedirect && (
|
||||
<FormErrorMessage>{formState.errors.youtubeRedirect.message}</FormErrorMessage>
|
||||
)}
|
||||
<FormHelperText>
|
||||
Invidious is a privacy focused UI for youtube.{" "}
|
||||
<Link href="https://docs.invidious.io/instances" isExternal color="blue.500">
|
||||
Invidious instances
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!formState.errors.youtubeRedirect}>
|
||||
<FormLabel>Invidious instance</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="Invidious instance url"
|
||||
{...register("youtubeRedirect", {
|
||||
validate: validateInvidiousUrl,
|
||||
setValueAs: safeUrl,
|
||||
})}
|
||||
/>
|
||||
{formState.errors.youtubeRedirect && (
|
||||
<FormErrorMessage>{formState.errors.youtubeRedirect.message}</FormErrorMessage>
|
||||
)}
|
||||
<FormHelperText>
|
||||
Invidious is a privacy focused UI for youtube.{" "}
|
||||
<Link href="https://docs.invidious.io/instances" isExternal color="blue.500">
|
||||
Invidious instances
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!formState.errors.redditRedirect}>
|
||||
<FormLabel>Teddit / Libreddit instance</FormLabel>
|
||||
<Input type="url" placeholder="https://nitter.net/" {...register("redditRedirect")} />
|
||||
{formState.errors.redditRedirect && (
|
||||
<FormErrorMessage>{formState.errors.redditRedirect.message}</FormErrorMessage>
|
||||
)}
|
||||
<FormHelperText>
|
||||
Libreddit and Teddit are both privacy focused UIs for reddit.{" "}
|
||||
<Link
|
||||
href="https://github.com/libreddit/libreddit-instances/blob/master/instances.md"
|
||||
isExternal
|
||||
color="blue.500"
|
||||
>
|
||||
Libreddit instances
|
||||
</Link>
|
||||
{", "}
|
||||
<Link href="https://codeberg.org/teddit/teddit#instances" isExternal color="blue.500">
|
||||
Teddit instances
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!formState.errors.redditRedirect}>
|
||||
<FormLabel>Teddit / Libreddit instance</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://nitter.net/"
|
||||
{...register("redditRedirect", { setValueAs: safeUrl })}
|
||||
/>
|
||||
{formState.errors.redditRedirect && (
|
||||
<FormErrorMessage>{formState.errors.redditRedirect.message}</FormErrorMessage>
|
||||
)}
|
||||
<FormHelperText>
|
||||
Libreddit and Teddit are both privacy focused UIs for reddit.{" "}
|
||||
<Link
|
||||
href="https://github.com/libreddit/libreddit-instances/blob/master/instances.md"
|
||||
isExternal
|
||||
color="blue.500"
|
||||
>
|
||||
Libreddit instances
|
||||
</Link>
|
||||
{", "}
|
||||
<Link href="https://codeberg.org/teddit/teddit#instances" isExternal color="blue.500">
|
||||
Teddit instances
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
colorScheme="brand"
|
||||
ml="auto"
|
||||
isLoading={formState.isSubmitting}
|
||||
type="submit"
|
||||
isDisabled={!formState.isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
<FormControl isInvalid={!!formState.errors.corsProxy}>
|
||||
<FormLabel>CORS Proxy</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://cors.example.com/"
|
||||
{...register("corsProxy", { setValueAs: safeUrl, validate: validateCorsProxy })}
|
||||
/>
|
||||
{formState.errors.corsProxy && <FormErrorMessage>{formState.errors.corsProxy.message}</FormErrorMessage>}
|
||||
<FormHelperText>
|
||||
This is used as a fallback when verifying NIP-05 ids and fetching open-graph metadata. URL to an instance
|
||||
of{" "}
|
||||
<Link href="https://github.com/Rob--W/cors-anywhere" isExternal color="blue.500">
|
||||
cors-anywhere
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Box, Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
|
||||
import { Button, Flex, FormControl, FormLabel, Spinner, Switch, useDisclosure } from "@chakra-ui/react";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { Note } from "../../components/note";
|
||||
import RepostNote from "../../components/repost-note";
|
||||
@ -8,10 +8,11 @@ import userTimelineService from "../../services/user-timeline";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import useSubject from "../../hooks/use-subject";
|
||||
import { useMount, useUnmount } from "react-use";
|
||||
import { RelayIconStack } from "../../components/relay-icon-stack";
|
||||
|
||||
const UserNotesTab = () => {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const contextRelays = useAdditionalRelayContext();
|
||||
const readRelays = useAdditionalRelayContext();
|
||||
|
||||
const { isOpen: showReplies, onToggle: toggleReplies } = useDisclosure();
|
||||
const { isOpen: hideReposts, onToggle: toggleReposts } = useDisclosure();
|
||||
@ -22,8 +23,8 @@ const UserNotesTab = () => {
|
||||
const loading = useSubject(timeline.loading);
|
||||
|
||||
useEffect(() => {
|
||||
timeline.setRelays(contextRelays);
|
||||
}, [timeline, contextRelays.join("|")]);
|
||||
timeline.setRelays(readRelays);
|
||||
}, [timeline, readRelays.join("|")]);
|
||||
|
||||
useMount(() => timeline.open());
|
||||
useUnmount(() => timeline.close());
|
||||
@ -45,7 +46,7 @@ const UserNotesTab = () => {
|
||||
<FormLabel htmlFor="reposts" mb="0">
|
||||
Reposts
|
||||
</FormLabel>
|
||||
<Box flexGrow={1} />
|
||||
<RelayIconStack ml="auto" relays={readRelays} direction="row-reverse" mr="4" maxRelays={4} />
|
||||
</FormControl>
|
||||
{filteredEvents.map((event) =>
|
||||
event.kind === 6 ? (
|
||||
|
114
yarn.lock
114
yarn.lock
@ -3027,6 +3027,11 @@ bluebird@^3.7.2:
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
||||
|
||||
boolbase@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||
@ -3158,6 +3163,31 @@ check-more-types@^2.24.0:
|
||||
resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
|
||||
integrity sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==
|
||||
|
||||
cheerio-select@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4"
|
||||
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.0.1"
|
||||
|
||||
cheerio@^1.0.0-rc.12:
|
||||
version "1.0.0-rc.12"
|
||||
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683"
|
||||
integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==
|
||||
dependencies:
|
||||
cheerio-select "^2.1.0"
|
||||
dom-serializer "^2.0.0"
|
||||
domhandler "^5.0.3"
|
||||
domutils "^3.0.1"
|
||||
htmlparser2 "^8.0.1"
|
||||
parse5 "^7.0.0"
|
||||
parse5-htmlparser2-tree-adapter "^7.0.0"
|
||||
|
||||
ci-info@^3.1.0, ci-info@^3.2.0:
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91"
|
||||
@ -3353,6 +3383,17 @@ css-in-js-utils@^3.1.0:
|
||||
dependencies:
|
||||
hyphenate-style-name "^1.0.3"
|
||||
|
||||
css-select@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6"
|
||||
integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
css-what "^6.1.0"
|
||||
domhandler "^5.0.2"
|
||||
domutils "^3.0.1"
|
||||
nth-check "^2.0.1"
|
||||
|
||||
css-tree@^1.1.2:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
|
||||
@ -3361,6 +3402,11 @@ css-tree@^1.1.2:
|
||||
mdn-data "2.0.14"
|
||||
source-map "^0.6.1"
|
||||
|
||||
css-what@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
|
||||
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
|
||||
|
||||
csstype@^3.0.11, csstype@^3.0.2, csstype@^3.0.6:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||
@ -3554,6 +3600,36 @@ dom-accessibility-api@^0.5.9:
|
||||
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453"
|
||||
integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==
|
||||
|
||||
dom-serializer@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
|
||||
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.2"
|
||||
entities "^4.2.0"
|
||||
|
||||
domelementtype@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
|
||||
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
|
||||
|
||||
domhandler@^5.0.2, domhandler@^5.0.3:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
|
||||
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
domutils@^3.0.1:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
|
||||
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
|
||||
dependencies:
|
||||
dom-serializer "^2.0.0"
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.3"
|
||||
|
||||
ecc-jsbn@~0.1.1:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||
@ -3593,6 +3669,11 @@ enquirer@^2.3.0, enquirer@^2.3.6:
|
||||
dependencies:
|
||||
ansi-colors "^4.1.1"
|
||||
|
||||
entities@^4.2.0, entities@^4.4.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
|
||||
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
|
||||
|
||||
error-ex@^1.3.1:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
|
||||
@ -4209,6 +4290,16 @@ hosted-git-info@^2.1.4:
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
|
||||
integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
|
||||
|
||||
htmlparser2@^8.0.1:
|
||||
version "8.0.2"
|
||||
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
|
||||
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
domhandler "^5.0.3"
|
||||
domutils "^3.0.1"
|
||||
entities "^4.4.0"
|
||||
|
||||
http-signature@~1.3.6:
|
||||
version "1.3.6"
|
||||
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9"
|
||||
@ -5001,6 +5092,13 @@ npm-run-path@^4.0.0:
|
||||
dependencies:
|
||||
path-key "^3.0.0"
|
||||
|
||||
nth-check@^2.0.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
|
||||
integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
|
||||
dependencies:
|
||||
boolbase "^1.0.0"
|
||||
|
||||
object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
@ -5132,6 +5230,21 @@ parse-json@^5.0.0:
|
||||
json-parse-even-better-errors "^2.3.0"
|
||||
lines-and-columns "^1.1.6"
|
||||
|
||||
parse5-htmlparser2-tree-adapter@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1"
|
||||
integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==
|
||||
dependencies:
|
||||
domhandler "^5.0.2"
|
||||
parse5 "^7.0.0"
|
||||
|
||||
parse5@^7.0.0:
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32"
|
||||
integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==
|
||||
dependencies:
|
||||
entities "^4.4.0"
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
@ -6657,6 +6770,7 @@ wrap-ansi@^6.2.0:
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
name wrap-ansi-cjs
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
|
Loading…
x
Reference in New Issue
Block a user