New spotlight media

This commit is contained in:
Kieran 2023-07-27 15:31:34 +01:00
parent df5fe7ca4d
commit 340bb45327
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
9 changed files with 151 additions and 230 deletions

View File

@ -282,6 +282,11 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.49466 2.78774C7.73973 1.25408 5.14439 0.940234 3.12891 2.6623C0.948817 4.52502 0.63207 7.66213 2.35603 9.88052C3.01043 10.7226 4.28767 11.9877 5.51513 13.1462C6.75696 14.3184 7.99593 15.426 8.60692 15.9671C8.61074 15.9705 8.61463 15.9739 8.61859 15.9774C8.67603 16.0283 8.74753 16.0917 8.81608 16.1433C8.89816 16.2052 9.01599 16.2819 9.17334 16.3288C9.38253 16.3912 9.60738 16.3912 9.81656 16.3288C9.97391 16.2819 10.0917 16.2052 10.1738 16.1433C10.2424 16.0917 10.3139 16.0283 10.3713 15.9774C10.3753 15.9739 10.3792 15.9705 10.383 15.9671C10.994 15.426 12.2329 14.3184 13.4748 13.1462C14.7022 11.9877 15.9795 10.7226 16.6339 9.88052C18.3512 7.67065 18.0834 4.50935 15.8532 2.65572C13.8153 0.961905 11.2476 1.25349 9.49466 2.78774Z" fill="currentColor"/>
</g>
</symbol>
<symbol id="x-close" viewBox="0 0 24 24" fill="none">
<g>
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -1,15 +0,0 @@
.modal.spotlight .modal-body {
max-width: 100vw;
width: unset;
}
.modal.spotlight img,
.modal.spotlight video {
max-width: 90vw;
max-height: 90vh;
aspect-ratio: unset;
}
.modal.spotlight .close {
text-align: right;
}

View File

@ -1,18 +1,5 @@
import { ProxyImg } from "Element/ProxyImg";
import React, { MouseEvent, useEffect, useState } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { Link } from "react-router-dom";
import { decodeInvoice, InvoiceDetails } from "@snort/shared";
import "./MediaElement.css";
import Modal from "Element/Modal";
import Icon from "Icons/Icon";
import { kvToObject } from "SnortUtils";
import AsyncButton from "Element/AsyncButton";
import { useWallet } from "Wallet";
import { PaymentsCache } from "Cache";
import { Payment } from "Db";
import PageSpinner from "Element/PageSpinner";
import React from "react";
/*
[
@ -28,162 +15,16 @@ interface MediaElementProps {
magnet?: string;
sha256?: string;
blurHash?: string;
disableSpotlight?: boolean;
}
interface L402Object {
macaroon: string;
invoice: string;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
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;
}
try {
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);
}
}
}
} catch (e) {
console.error(e);
}
}
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/")) {
if (!(props.disableSpotlight ?? false)) {
return (
<SpotlightMedia>
<ProxyImg key={props.url} src={url} onError={() => probeFor402()} />
</SpotlightMedia>
);
} else {
return <ProxyImg key={props.url} src={url} onError={() => probeFor402()} />;
}
return <ProxyImg key={props.url} src={props.url} onClick={props.onMediaClick} />;
} else if (props.mime.startsWith("audio/")) {
return <audio key={props.url} src={url} controls onError={() => probeFor402()} />;
return <audio key={props.url} src={props.url} controls />;
} else if (props.mime.startsWith("video/")) {
return <video key={props.url} src={url} controls onError={() => probeFor402()} />;
return <video key={props.url} src={props.url} controls />;
} else {
return (
<a
@ -198,33 +39,3 @@ export function MediaElement(props: MediaElementProps) {
);
}
}
export function SpotlightMedia({ children }: { children: React.ReactNode }) {
const [showModal, setShowModal] = useState(false);
function onClick(e: MouseEvent<HTMLDivElement>) {
e.stopPropagation();
e.preventDefault();
setShowModal(s => !s);
}
function onClose(e: MouseEvent<HTMLDivElement>) {
e.stopPropagation();
e.preventDefault();
setShowModal(false);
}
return (
<>
{showModal && (
<Modal onClose={onClose} className="spotlight">
<div className="close" onClick={onClose}>
<Icon name="close" />
</div>
{children}
</Modal>
)}
<div onClick={onClick}>{children}</div>
</>
);
}

View File

@ -8,7 +8,7 @@ import { MediaElement } from "Element/MediaElement";
interface RevealMediaProps {
creator: string;
link: string;
disableSpotlight?: boolean;
onMediaClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
}
export default function RevealMedia(props: RevealMediaProps) {
@ -53,12 +53,10 @@ export default function RevealMedia(props: RevealMediaProps) {
return (
<Reveal
message={<FormattedMessage defaultMessage="Click to load content from {link}" values={{ link: hostname }} />}>
<MediaElement mime={`${type}/${extension}`} url={url.toString()} disableSpotlight={props.disableSpotlight} />
<MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />
</Reveal>
);
} else {
return (
<MediaElement mime={`${type}/${extension}`} url={url.toString()} disableSpotlight={props.disableSpotlight} />
);
return <MediaElement mime={`${type}/${extension}`} url={url.toString()} onMediaClick={props.onMediaClick} />;
}
}

View File

@ -0,0 +1,45 @@
.modal.spotlight .modal-body {
border: none;
border-radius: unset;
width: unset;
height: unset;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.modal.spotlight img,
.modal.spotlight video {
max-width: 100vw;
max-height: 100vh;
aspect-ratio: unset;
width: unset;
}
.modal.spotlight .details {
text-align: right;
position: absolute;
top: 28px;
right: 28px;
gap: 18px;
display: flex;
font-size: 15px;
font-weight: 400;
line-height: 24px;
align-items: center;
}
.modal.spotlight .left {
position: absolute;
left: 24px;
top: 50vh;
transform: rotate(180deg);
}
.modal.spotlight .right {
position: absolute;
right: 24px;
top: 50vh;
}

View File

@ -0,0 +1,54 @@
import "./SpotlightMedia.css";
import { useMemo, useState } from "react";
import Modal from "Element/Modal";
import Icon from "Icons/Icon";
import { ProxyImg } from "Element/ProxyImg";
interface SpotlightMediaProps {
images: Array<string>;
idx: number;
onClose: () => void;
}
export function SpotlightMedia(props: SpotlightMediaProps) {
const [idx, setIdx] = useState(props.idx);
const image = useMemo(() => {
return props.images.at(idx % props.images.length);
}, [idx, props]);
function dec() {
setIdx(s => {
if (s - 1 === -1) {
return props.images.length - 1;
} else {
return s - 1;
}
});
}
function inc() {
setIdx(s => {
if (s + 1 === props.images.length) {
return 0;
} else {
return s + 1;
}
});
}
return (
<Modal onClose={props.onClose} className="spotlight">
<ProxyImg src={image} />
<div className="details">
{idx + 1}/{props.images.length}
<Icon name="x-close" size={24} onClick={props.onClose} />
</div>
{props.images.length > 1 && (
<>
<Icon className="left" name="arrowFront" size={24} onClick={() => dec()} />
<Icon className="right" name="arrowFront" size={24} onClick={() => inc()} />
</>
)}
</Modal>
);
}

View File

@ -1,5 +1,5 @@
import "./Text.css";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { HexKey, ParsedFragment, transformText } from "@snort/system";
import Invoice from "Element/Invoice";
@ -8,6 +8,7 @@ import HyperText from "Element/HyperText";
import CashuNuts from "Element/CashuNuts";
import RevealMedia from "./RevealMedia";
import { ProxyImg } from "./ProxyImg";
import { SpotlightMedia } from "./SpotlightMedia";
export interface TextProps {
content: string;
@ -19,6 +20,15 @@ export interface TextProps {
}
export default function Text({ content, tags, creator, disableMedia, depth, disableMediaSpotlight }: TextProps) {
const [showSpotlight, setShowSpotlight] = useState(false);
const [imageIdx, setImageIdx] = useState(0);
const elements = useMemo(() => {
return transformText(content, tags);
}, [content]);
const images = elements.filter(a => a.type === "media" && a.mimeType?.startsWith("image")).map(a => a.content);
function renderChunk(a: ParsedFragment) {
if (a.type === "media" && !a.mimeType?.startsWith("unknown")) {
if (disableMedia ?? false) {
@ -28,7 +38,21 @@ export default function Text({ content, tags, creator, disableMedia, depth, disa
</a>
);
}
return <RevealMedia link={a.content} creator={creator} disableSpotlight={disableMediaSpotlight} />;
return (
<RevealMedia
link={a.content}
creator={creator}
onMediaClick={e => {
if (!disableMediaSpotlight) {
e.stopPropagation();
e.preventDefault();
setShowSpotlight(true);
const selected = images.findIndex(b => b === a.content);
setImageIdx(selected === -1 ? 0 : selected);
}
}}
/>
);
} else {
switch (a.type) {
case "invoice":
@ -48,13 +72,10 @@ export default function Text({ content, tags, creator, disableMedia, depth, disa
}
}
const elements = useMemo(() => {
return transformText(content, tags);
}, [content]);
return (
<div dir="auto" className="text">
{elements.map(a => renderChunk(a))}
{showSpotlight && <SpotlightMedia images={images} onClose={() => setShowSpotlight(false)} idx={imageIdx} />}
</div>
);
}

View File

@ -10,6 +10,10 @@
margin: 0;
}
.logo:hover {
text-decoration: none;
}
header {
display: flex;
padding: 10px 16px;

View File

@ -1,7 +1,7 @@
import "./Layout.css";
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { useUserProfile } from "@snort/system-react";
@ -103,7 +103,7 @@ export default function Layout() {
<div className={pageClass}>
{!shouldHideHeader && (
<header className="main-content">
<div className="logo" onClick={() => navigate("/")}>
<Link to="/" className="logo">
<h1>Snort</h1>
{currentSubscription && (
<small className="flex">
@ -111,7 +111,7 @@ export default function Layout() {
{mapPlanName(currentSubscription.type)}
</small>
)}
</div>
</Link>
{publicKey ? (
<AccountHeader />
@ -150,8 +150,7 @@ const AccountHeader = () => {
);
const unreadDms = useMemo(() => (publicKey ? 0 : 0), [publicKey]);
async function goToNotifications(e: React.MouseEvent) {
e.stopPropagation();
async function goToNotifications() {
// request permissions to send notifications
if ("Notification" in window) {
try {
@ -163,7 +162,6 @@ const AccountHeader = () => {
console.error(e);
}
}
navigate("/notifications");
}
return (
@ -172,14 +170,14 @@ const AccountHeader = () => {
<input type="text" placeholder={formatMessage({ defaultMessage: "Search" })} className="w-max" />
<Icon name="search" size={24} />
</div>
<div className="btn" onClick={() => navigate("/messages")}>
<Link className="btn" to="/messages">
<Icon name="mail" size={24} />
{unreadDms > 0 && <span className="has-unread"></span>}
</div>
<div className="btn" onClick={goToNotifications}>
</Link>
<Link className="btn" to="/notifications" onClick={goToNotifications}>
<Icon name="bell-v2" size={24} />
{hasNotifications && <span className="has-unread"></span>}
</div>
</Link>
<Avatar
user={profile}
onClick={() => {