Merge branch 'next' into lists

This commit is contained in:
hzrd149 2023-06-29 11:14:29 -05:00
commit cabca407a3
42 changed files with 2462 additions and 416 deletions

View File

@ -1,5 +0,0 @@
---
"nostrudel": minor
---
replace momentjs with dayjs

View File

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

View File

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

View File

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

View File

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

View File

@ -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" />;
}

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

View File

@ -4,3 +4,4 @@ export * from "./music";
export * from "./common";
export * from "./youtube";
export * from "./nostr";
export * from "./emoji";

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View 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()]);
}

View 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

View 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;
}

View 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;

View 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;

View 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;

View 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"
>;

View 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;
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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