Merge remote-tracking branch 'origin/main' into gossip-model

This commit is contained in:
2023-06-13 10:05:53 +01:00
40 changed files with 604 additions and 151 deletions

View File

@ -12,6 +12,7 @@ export default function AsyncButton(props: AsyncButtonProps) {
const [loading, setLoading] = useState<boolean>(false);
async function handle(e: React.MouseEvent) {
e.stopPropagation();
if (loading || props.disabled) return;
setLoading(true);
try {

View File

@ -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) && (
<div className="flex mt10 mb10">
<div className="f-grow bold">{title}</div>
{actions}
<button className="transparent" type="button" onClick={() => followAll()}>
<FormattedMessage {...messages.FollowAll} />
</button>

View File

@ -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 }) => {
</a>
);
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 <MediaElement url={link} mime={videoType} />;
}
}
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 <MediaElement url={link} mime={videoType} />;
}
}
if (preview?.image) {
const backgroundImage = preview?.image ? `url(${proxy(preview?.image)})` : "";
const style = { "--img-url": backgroundImage } as CSSProperties;
return <div className="link-preview-image" style={style}></div>;
}
return null;
}
return (
<div className="link-preview-container">
{preview && (
<a href={url} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
{preview?.image && <div className="link-preview-image" style={style} />}
{previewElement()}
<p className="link-preview-title">
{preview?.title}
{preview?.description && (

View File

@ -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<InvoiceDetails>();
const [l402, setL402] = useState<L402Object>();
const [auth, setAuth] = useState<Payment>();
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<L402Object>(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 <PageSpinner />;
}
if (invoice) {
return (
<div className="note-invoice">
<h3>
<FormattedMessage defaultMessage="Payment Required" />
</h3>
<div className="flex f-row">
<div className="f-grow">
<FormattedMessage
defaultMessage="You must pay {n} sats to access this file."
values={{
n: <FormattedNumber value={(invoice.amount ?? 0) / 1000} />,
}}
/>
</div>
<div>
{wallet.wallet && (
<AsyncButton onClick={() => payInvoice()}>
<FormattedMessage defaultMessage="Pay Now" />
</AsyncButton>
)}
</div>
</div>
{!wallet.wallet && (
<b>
<FormattedMessage
defaultMessage="Please connect a wallet {here} to be able to pay this invoice"
values={{
here: (
<Link to="/settings/wallet" onClick={e => e.stopPropagation()}>
<FormattedMessage defaultMessage="here" description="Inline link text pointing to another page" />
</Link>
),
}}
/>
</b>
)}
{error && <b className="error">{error}</b>}
</div>
);
}
if (props.mime.startsWith("image/")) {
return (
<SpotlightMedia>
<ProxyImg key={props.url} src={props.url} />
<ProxyImg key={props.url} src={url} onError={() => probeFor402()} />
</SpotlightMedia>
);
} else if (props.mime.startsWith("audio/")) {
return <audio key={props.url} src={props.url} controls />;
return <audio key={props.url} src={url} controls onError={() => probeFor402()} />;
} else if (props.mime.startsWith("video/")) {
return (
<SpotlightMedia>
<video key={props.url} src={props.url} controls />
<video key={props.url} src={url} controls onError={() => probeFor402()} />
</SpotlightMedia>
);
} else {

View File

@ -8,22 +8,13 @@ interface ProxyImgProps extends React.DetailedHTMLProps<React.ImgHTMLAttributes<
}
export const ProxyImg = (props: ProxyImgProps) => {
const { src, size, ...rest } = props;
const [url, setUrl] = useState<string>();
const { proxy } = useImgProxy();
const [loadFailed, setLoadFailed] = useState(false);
const [bypass, setBypass] = useState(false);
const { proxy } = useImgProxy();
useEffect(() => {
if (src) {
const url = proxy(src, size);
setUrl(url);
}
}, [src]);
if (loadFailed) {
if (bypass) {
return <img src={src} {...rest} />;
return <img {...props} />;
}
return (
<div
@ -35,11 +26,23 @@ export const ProxyImg = (props: ProxyImgProps) => {
<FormattedMessage
defaultMessage="Failed to proxy image from {host}, click here to load directly"
values={{
host: getUrlHostname(src),
host: getUrlHostname(props.src),
}}
/>
</div>
);
}
return <img src={url} {...rest} onError={() => setLoadFailed(true)} />;
return (
<img
{...props}
src={props.src ? proxy(props.src, props.size) : ""}
onError={e => {
if (props.onError) {
props.onError(e);
} else {
setLoadFailed(true);
}
}}
/>
);
};

View File

@ -1,8 +1,84 @@
import { NostrEvent } from "@snort/system";
import { dedupe } from "SnortUtils";
import FollowListBase from "./FollowListBase";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { dedupe, hexToBech32, unixNow } from "SnortUtils";
import FollowListBase from "Element/FollowListBase";
import AsyncButton from "Element/AsyncButton";
import { useWallet } from "Wallet";
import { Toastore } from "Toaster";
import { getDisplayName } from "Element/ProfileImage";
import { UserCache } from "Cache";
import useLogin from "Hooks/useLogin";
import { LNURL } from "LNURL";
import useEventPublisher from "Feed/EventPublisher";
import { WalletInvoiceState } from "Wallet";
export default function PubkeyList({ ev, className }: { ev: NostrEvent; className?: string }) {
const wallet = useWallet();
const login = useLogin();
const publisher = useEventPublisher();
const ids = dedupe(ev.tags.filter(a => a[0] === "p").map(a => a[1]));
return <FollowListBase pubkeys={ids} showAbout={true} className={className} />;
async function zapAll() {
for (const pk of ids) {
try {
const profile = await UserCache.get(pk);
const amtSend = login.preferences.defaultZapAmount;
const lnurl = profile?.lud16 || profile?.lud06;
if (lnurl) {
const svc = new LNURL(lnurl);
await svc.load();
const zap = await publisher?.zap(
amtSend * 1000,
pk,
Object.keys(login.relays.item),
undefined,
`Zap from ${hexToBech32("note", ev.id)}`
);
const invoice = await svc.getInvoice(amtSend, undefined, zap);
if (invoice.pr) {
const rsp = await wallet.wallet?.payInvoice(invoice.pr);
if (rsp?.state === WalletInvoiceState.Paid) {
Toastore.push({
element: (
<FormattedMessage
defaultMessage="Sent {n} sats to {name}"
values={{
n: amtSend,
name: getDisplayName(profile, pk),
}}
/>
),
icon: "zap",
});
}
}
}
} catch (e) {
console.debug("Failed to zap", pk, e);
}
}
}
return (
<FollowListBase
pubkeys={ids}
showAbout={true}
className={className}
title={ev.tags.find(a => a[0] === "d")?.[1]}
actions={
<>
<AsyncButton className="mr5 transparent" onClick={() => zapAll()}>
<FormattedMessage
defaultMessage="Zap All {n} sats"
values={{
n: <FormattedNumber value={login.preferences.defaultZapAmount * ids.length} />,
}}
/>
</AsyncButton>
</>
}
/>
);
}