diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9b77ea71..6771ee2d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,9 +7,11 @@ assignees: "" --- **Describe the bug** + A clear and concise description of what the bug is. **To Reproduce** + Steps to reproduce the behavior: 1. Go to '...' @@ -18,23 +20,25 @@ Steps to reproduce the behavior: 4. See error **Expected behavior** + A clear and concise description of what you expected to happen. **Screenshots** + If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] -- Browser [e.g. chrome, safari] -- Version [e.g. 22] +- Browser: [e.g. chrome, safari] +- Version: [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] -- Browser [e.g. stock browser, safari] -- Version [e.g. 22] +- Browser: [e.g. stock browser, safari] +- Version: [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/packages/app/_headers b/packages/app/_headers index aa6bc2c8..bb210fcf 100644 --- a/packages/app/_headers +++ b/packages/app/_headers @@ -1,2 +1,2 @@ /* - Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com; \ No newline at end of file + Content-Security-Policy: default-src 'self'; manifest-src *; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com https://nostrnests.com https://embed.wavlake.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src *; img-src * data: blob:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com; \ No newline at end of file diff --git a/packages/app/src/Cache/PaymentsCache.ts b/packages/app/src/Cache/PaymentsCache.ts new file mode 100644 index 00000000..a52543b0 --- /dev/null +++ b/packages/app/src/Cache/PaymentsCache.ts @@ -0,0 +1,18 @@ +import { Payment, db } from "Db"; +import FeedCache from "./FeedCache"; + +class Payments extends FeedCache { + constructor() { + super("PaymentsCache", db.payments); + } + + key(of: Payment): string { + return of.url; + } + + takeSnapshot(): Array { + return [...this.cache.values()]; + } +} + +export const PaymentsCache = new Payments(); diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index cb501536..e8d1724c 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie"; import { FullRelaySettings, HexKey, NostrEvent, u256, MetadataCache } from "@snort/system"; export const NAME = "snortDB"; -export const VERSION = 8; +export const VERSION = 9; export interface SubCache { id: string; @@ -33,6 +33,13 @@ export interface EventInteraction { reposted: boolean; } +export interface Payment { + url: string; + pr: string; + preimage: string; + macaroon: string; +} + const STORES = { users: "++pubkey, name, display_name, picture, nip05, npub", relays: "++addr", @@ -40,6 +47,7 @@ const STORES = { events: "++id, pubkey, created_at", dms: "++id, pubkey", eventInteraction: "++id", + payments: "++url", }; export class SnortDB extends Dexie { @@ -50,6 +58,7 @@ export class SnortDB extends Dexie { events!: Table; dms!: Table; eventInteraction!: Table; + payments!: Table; constructor() { super(NAME); diff --git a/packages/app/src/Element/AsyncButton.tsx b/packages/app/src/Element/AsyncButton.tsx index 808b6557..c4118afa 100644 --- a/packages/app/src/Element/AsyncButton.tsx +++ b/packages/app/src/Element/AsyncButton.tsx @@ -12,6 +12,7 @@ export default function AsyncButton(props: AsyncButtonProps) { const [loading, setLoading] = useState(false); async function handle(e: React.MouseEvent) { + e.stopPropagation(); if (loading || props.disabled) return; setLoading(true); try { diff --git a/packages/app/src/Element/FollowListBase.tsx b/packages/app/src/Element/FollowListBase.tsx index fc0b09dc..dfc7fdc5 100644 --- a/packages/app/src/Element/FollowListBase.tsx +++ b/packages/app/src/Element/FollowListBase.tsx @@ -10,12 +10,21 @@ import messages from "./messages"; export interface FollowListBaseProps { pubkeys: HexKey[]; - title?: ReactNode | string; + title?: ReactNode; showFollowAll?: boolean; showAbout?: boolean; className?: string; + actions?: ReactNode; } -export default function FollowListBase({ pubkeys, title, showFollowAll, showAbout, className }: FollowListBaseProps) { + +export default function FollowListBase({ + pubkeys, + title, + showFollowAll, + showAbout, + className, + actions, +}: FollowListBaseProps) { const publisher = useEventPublisher(); const { follows, relays } = useLogin(); @@ -31,6 +40,7 @@ export default function FollowListBase({ pubkeys, title, showFollowAll, showAbou {(showFollowAll ?? true) && (
{title}
+ {actions} diff --git a/packages/app/src/Element/LinkPreview.tsx b/packages/app/src/Element/LinkPreview.tsx index 83512264..2398fa81 100644 --- a/packages/app/src/Element/LinkPreview.tsx +++ b/packages/app/src/Element/LinkPreview.tsx @@ -4,6 +4,7 @@ import { CSSProperties, useEffect, useState } from "react"; import Spinner from "Icons/Spinner"; import SnortApi, { LinkPreviewData } from "SnortApi"; import useImgProxy from "Hooks/useImgProxy"; +import { MediaElement } from "Element/MediaElement"; async function fetchUrlPreviewInfo(url: string) { const api = new SnortApi(); @@ -21,11 +22,16 @@ const LinkPreview = ({ url }: { url: string }) => { useEffect(() => { (async () => { const data = await fetchUrlPreviewInfo(url); - if (data && data.image) { - setPreview(data); - } else { - setPreview(null); + if (data) { + const type = data.og_tags?.find(a => a[0].toLowerCase() === "og:type"); + const canPreviewType = type?.[1].startsWith("image") || type?.[1].startsWith("video") || false; + if (canPreviewType || data.image) { + setPreview(data); + return; + } } + + setPreview(null); })(); }, [url]); @@ -36,14 +42,37 @@ const LinkPreview = ({ url }: { url: string }) => { ); - const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : ""; - const style = { "--img-url": backgroundImage } as CSSProperties; + function previewElement() { + const type = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:type")?.[1]; + if (type?.startsWith("video")) { + const urlTags = ["og:video:secure_url", "og:video:url", "og:video"]; + const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1]; + const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:video:type")?.[1] ?? "video/mp4"; + if (link) { + return ; + } + } + if (type?.startsWith("image")) { + const urlTags = ["og:image:secure_url", "og:image:url", "og:image"]; + const link = preview?.og_tags?.find(a => urlTags.includes(a[0].toLowerCase()))?.[1]; + const videoType = preview?.og_tags?.find(a => a[0].toLowerCase() === "og:image:type")?.[1] ?? "image/png"; + if (link) { + return ; + } + } + if (preview?.image) { + const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : ""; + const style = { "--img-url": backgroundImage } as CSSProperties; + return
; + } + return null; + } return (
{preview && ( e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> - {preview?.image &&
} + {previewElement()}

{preview?.title} {preview?.description && ( diff --git a/packages/app/src/Element/MediaElement.tsx b/packages/app/src/Element/MediaElement.tsx index 15110254..efbfb755 100644 --- a/packages/app/src/Element/MediaElement.tsx +++ b/packages/app/src/Element/MediaElement.tsx @@ -1,10 +1,17 @@ import { ProxyImg } from "Element/ProxyImg"; -import React, { MouseEvent, useState } from "react"; +import React, { MouseEvent, useEffect, useState } from "react"; +import { FormattedMessage, FormattedNumber } from "react-intl"; +import { Link } from "react-router-dom"; import "./MediaElement.css"; import Modal from "Element/Modal"; import Icon from "Icons/Icon"; - +import { decodeInvoice, InvoiceDetails, kvToObject } from "Util"; +import AsyncButton from "Element/AsyncButton"; +import { useWallet } from "Wallet"; +import { PaymentsCache } from "Cache/PaymentsCache"; +import { Payment } from "Db"; +import PageSpinner from "Element/PageSpinner"; /* [ "imeta", @@ -21,19 +28,153 @@ interface MediaElementProps { blurHash?: string; } +interface L402Object { + macaroon: string; + invoice: string; +} + export function MediaElement(props: MediaElementProps) { + const [invoice, setInvoice] = useState(); + const [l402, setL402] = useState(); + const [auth, setAuth] = useState(); + const [error, setError] = useState(""); + const [url, setUrl] = useState(props.url); + const [loading, setLoading] = useState(false); + const wallet = useWallet(); + + async function probeFor402() { + const cached = await PaymentsCache.get(props.url); + if (cached) { + setAuth(cached); + return; + } + + const req = new Request(props.url, { + method: "OPTIONS", + headers: { + accept: "L402", + }, + }); + const rsp = await fetch(req); + if (rsp.status === 402) { + const auth = rsp.headers.get("www-authenticate"); + if (auth?.startsWith("L402")) { + const vals = kvToObject(auth.substring(5)); + console.debug(vals); + setL402(vals); + + if (vals.invoice) { + const decoded = decodeInvoice(vals.invoice); + setInvoice(decoded); + } + } + } + } + + async function payInvoice() { + if (wallet.wallet && l402) { + try { + const res = await wallet.wallet.payInvoice(l402.invoice); + console.debug(res); + if (res.preimage) { + const pmt = { + pr: l402.invoice, + url: props.url, + macaroon: l402.macaroon, + preimage: res.preimage, + }; + await PaymentsCache.set(pmt); + setAuth(pmt); + } + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } + } + } + } + + async function loadMedia() { + if (!auth) return; + setLoading(true); + + const mediaReq = new Request(props.url, { + headers: { + Authorization: `L402 ${auth.macaroon}:${auth.preimage}`, + }, + }); + const rsp = await fetch(mediaReq); + if (rsp.ok) { + const buf = await rsp.blob(); + setUrl(URL.createObjectURL(buf)); + } + setLoading(false); + } + + useEffect(() => { + if (auth) { + loadMedia().catch(console.error); + } + }, [auth]); + + if (auth && loading) { + return ; + } + + if (invoice) { + return ( +

+

+ +

+
+
+ , + }} + /> +
+
+ {wallet.wallet && ( + payInvoice()}> + + + )} +
+
+ {!wallet.wallet && ( + + e.stopPropagation()}> + + + ), + }} + /> + + )} + {error && {error}} +
+ ); + } + if (props.mime.startsWith("image/")) { return ( - + probeFor402()} /> ); } else if (props.mime.startsWith("audio/")) { - return