diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index 2e204b46..1aa8ce5b 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -282,6 +282,11 @@ + + + + + \ No newline at end of file diff --git a/packages/app/src/Element/MediaElement.css b/packages/app/src/Element/MediaElement.css deleted file mode 100644 index 47480d0a..00000000 --- a/packages/app/src/Element/MediaElement.css +++ /dev/null @@ -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; -} diff --git a/packages/app/src/Element/MediaElement.tsx b/packages/app/src/Element/MediaElement.tsx index 2abe27b9..3db43944 100644 --- a/packages/app/src/Element/MediaElement.tsx +++ b/packages/app/src/Element/MediaElement.tsx @@ -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) => void; } 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; - } - - 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(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 ; - } - - if (invoice) { - return ( - - - - - - - , - }} - /> - - - {wallet.wallet && ( - payInvoice()}> - - - )} - - - {!wallet.wallet && ( - - e.stopPropagation()}> - - - ), - }} - /> - - )} - {error && {error}} - - ); - } - if (props.mime.startsWith("image/")) { - if (!(props.disableSpotlight ?? false)) { - return ( - - probeFor402()} /> - - ); - } else { - return probeFor402()} />; - } + return ; } else if (props.mime.startsWith("audio/")) { - return probeFor402()} />; + return ; } else if (props.mime.startsWith("video/")) { - return probeFor402()} />; + return ; } else { return ( ) { - e.stopPropagation(); - e.preventDefault(); - setShowModal(s => !s); - } - - function onClose(e: MouseEvent) { - e.stopPropagation(); - e.preventDefault(); - setShowModal(false); - } - - return ( - <> - {showModal && ( - - - - - {children} - - )} - {children} - > - ); -} diff --git a/packages/app/src/Element/RevealMedia.tsx b/packages/app/src/Element/RevealMedia.tsx index 76a2830b..a0f472bb 100644 --- a/packages/app/src/Element/RevealMedia.tsx +++ b/packages/app/src/Element/RevealMedia.tsx @@ -8,7 +8,7 @@ import { MediaElement } from "Element/MediaElement"; interface RevealMediaProps { creator: string; link: string; - disableSpotlight?: boolean; + onMediaClick?: (e: React.MouseEvent) => void; } export default function RevealMedia(props: RevealMediaProps) { @@ -53,12 +53,10 @@ export default function RevealMedia(props: RevealMediaProps) { return ( }> - + ); } else { - return ( - - ); + return ; } } diff --git a/packages/app/src/Element/SpotlightMedia.css b/packages/app/src/Element/SpotlightMedia.css new file mode 100644 index 00000000..4e5862e2 --- /dev/null +++ b/packages/app/src/Element/SpotlightMedia.css @@ -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; +} diff --git a/packages/app/src/Element/SpotlightMedia.tsx b/packages/app/src/Element/SpotlightMedia.tsx new file mode 100644 index 00000000..f617fe48 --- /dev/null +++ b/packages/app/src/Element/SpotlightMedia.tsx @@ -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; + 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 ( + + + + {idx + 1}/{props.images.length} + + + {props.images.length > 1 && ( + <> + dec()} /> + inc()} /> + > + )} + + ); +} diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index 44d96800..1ec1b6d4 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -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 ); } - return ; + return ( + { + 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 ( {elements.map(a => renderChunk(a))} + {showSpotlight && setShowSpotlight(false)} idx={imageIdx} />} ); } diff --git a/packages/app/src/Pages/Layout.css b/packages/app/src/Pages/Layout.css index 52c2a5f4..d67e5016 100644 --- a/packages/app/src/Pages/Layout.css +++ b/packages/app/src/Pages/Layout.css @@ -10,6 +10,10 @@ margin: 0; } +.logo:hover { + text-decoration: none; +} + header { display: flex; padding: 10px 16px; diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index a528f7ae..11a7418f 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -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() { {!shouldHideHeader && ( - navigate("/")}> + Snort {currentSubscription && ( @@ -111,7 +111,7 @@ export default function Layout() { {mapPlanName(currentSubscription.type)} )} - + {publicKey ? ( @@ -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 = () => { - navigate("/messages")}> + {unreadDms > 0 && } - - + + {hasNotifications && } - + {