From 1ead1e4a7cbf2be61ab2bd61ad8a947b417ffba6 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 12 Jul 2023 19:27:42 +0100 Subject: [PATCH] v2 start --- packages/app/public/icons.svg | 18 ++ packages/app/src/Element/HyperText.tsx | 11 +- packages/app/src/Element/LinkPreview.css | 4 +- packages/app/src/Element/MediaElement.tsx | 36 +-- packages/app/src/Element/Note.css | 51 ++-- packages/app/src/Element/Note.tsx | 90 ++++---- packages/app/src/Element/NoteContextMenu.tsx | 220 ++++++++++++++++++ packages/app/src/Element/NoteFooter.tsx | 211 +---------------- packages/app/src/Element/ProfileImage.css | 6 +- packages/app/src/Element/Text.css | 15 +- packages/app/src/Element/Text.tsx | 230 +++++-------------- packages/app/src/Pages/Layout.css | 9 +- packages/app/src/Pages/Layout.tsx | 7 +- packages/app/src/index.css | 27 +-- packages/system/src/const.ts | 21 ++ packages/system/src/event-builder.ts | 4 +- packages/system/src/index.ts | 1 + packages/system/src/text.ts | 204 ++++++++++++++++ packages/system/src/utils.ts | 35 +-- 19 files changed, 663 insertions(+), 537 deletions(-) create mode 100644 packages/app/src/Element/NoteContextMenu.tsx create mode 100644 packages/system/src/text.ts diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index 007d2888..382fecd3 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -178,5 +178,23 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app/src/Element/HyperText.tsx b/packages/app/src/Element/HyperText.tsx index 685911af..32b4548c 100644 --- a/packages/app/src/Element/HyperText.tsx +++ b/packages/app/src/Element/HyperText.tsx @@ -1,7 +1,6 @@ import { TwitterTweetEmbed } from "react-twitter-embed"; import { - FileExtensionRegex, YoutubeUrlRegex, TweetUrlRegex, TidalRegex, @@ -23,17 +22,14 @@ import AppleMusicEmbed from "Element/AppleMusicEmbed"; import WavlakeEmbed from "Element/WavlakeEmbed"; import LinkPreview from "Element/LinkPreview"; import NostrLink from "Element/NostrLink"; -import RevealMedia from "Element/RevealMedia"; import MagnetLink from "Element/MagnetLink"; interface HypeTextProps { link: string; - creator: string; depth?: number; - disableMediaSpotlight?: boolean; } -export default function HyperText({ link, creator, depth, disableMediaSpotlight }: HypeTextProps) { +export default function HyperText({ link, depth }: HypeTextProps) { const a = link; try { const url = new URL(a); @@ -47,10 +43,7 @@ export default function HyperText({ link, creator, depth, disableMediaSpotlight const isAppleMusicLink = AppleMusicRegex.test(a); const isNostrNestsLink = NostrNestsRegex.test(a); const isWavlakeLink = WavlakeRegex.test(a); - const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1; - if (extension && !isAppleMusicLink) { - return ; - } else if (tweetId) { + if (tweetId) { return (
diff --git a/packages/app/src/Element/LinkPreview.css b/packages/app/src/Element/LinkPreview.css index d7ab0e69..6e7ff60a 100644 --- a/packages/app/src/Element/LinkPreview.css +++ b/packages/app/src/Element/LinkPreview.css @@ -1,6 +1,6 @@ .link-preview-container { - border: 1px solid var(--gray); - border-radius: 10px; + border-radius: 0px 0px 12px 12px; + background: #151515; overflow: hidden; } diff --git a/packages/app/src/Element/MediaElement.tsx b/packages/app/src/Element/MediaElement.tsx index 779be25e..c0001364 100644 --- a/packages/app/src/Element/MediaElement.tsx +++ b/packages/app/src/Element/MediaElement.tsx @@ -52,25 +52,29 @@ export function MediaElement(props: MediaElementProps) { 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); + 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); + if (vals.invoice) { + const decoded = decodeInvoice(vals.invoice); + setInvoice(decoded); + } } } + } catch (e) { + console.error(e); } } diff --git a/packages/app/src/Element/Note.css b/packages/app/src/Element/Note.css index bd9157a7..091e90fe 100644 --- a/packages/app/src/Element/Note.css +++ b/packages/app/src/Element/Note.css @@ -19,16 +19,17 @@ text-decoration-color: var(--highlight); } -.note > .header > .info { +.note .header .info { font-size: var(--font-size); margin-left: 4px; white-space: nowrap; color: var(--font-secondary-color); display: flex; align-items: center; + gap: 8px; } -.note > .header > .info .saved { +.note .header .info .saved { margin-right: 12px; font-weight: 600; font-size: 10px; @@ -39,11 +40,11 @@ align-items: center; } -.note > .header > .info .saved svg { +.note .header .info .saved svg { margin-right: 8px; } -.note > .header > .pinned { +.note .header .pinned { font-size: var(--font-size-small); color: var(--font-secondary-color); font-weight: 500; @@ -53,7 +54,7 @@ align-items: center; } -.note > .header > .pinned svg { +.note .header .pinned svg { margin-right: 8px; } @@ -67,10 +68,11 @@ padding-left: 0; } -.note > .body { - margin-top: 4px; - margin-bottom: 24px; - padding-left: 56px; +.note > .body .text-frag { + padding-left: 61px; +} + +.note > .body .text-frag { text-overflow: ellipsis; white-space: pre-wrap; word-break: normal; @@ -78,8 +80,14 @@ overflow-y: visible; } +.note > .body img, +.note > .body video, +.note > .body audio { + margin-top: 16px; +} + .note > .footer { - padding-left: 46px; + padding: 16px 0 0px 61px; } .note .footer .footer-reactions { @@ -88,8 +96,7 @@ align-items: center; justify-content: center; margin-left: auto; - gap: 1em; - padding-left: 0.8em; + gap: 48px; } @media (min-width: 720px) { @@ -98,7 +105,7 @@ } } -.note > .footer .ctx-menu { +.note .ctx-menu { color: var(--font-secondary-color); background: transparent; box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.4); @@ -108,7 +115,7 @@ border-radius: 16px; } -.note > .footer .ctx-menu li { +.note .ctx-menu li { background: #1e1e1e; padding-top: 8px; padding-bottom: 8px; @@ -116,28 +123,28 @@ grid-template-columns: 2rem auto; } -.light .note > .footer .ctx-menu li { +.light .note .ctx-menu li { background: var(--note-bg); } -.note > .footer .ctx-menu li:first-of-type { +.note .ctx-menu li:first-of-type { padding-top: 12px; border-top-left-radius: 16px; border-top-right-radius: 16px; } -.note > .footer .ctx-menu li:last-of-type { +.note .ctx-menu li:last-of-type { padding-bottom: 12px; border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; } -.note > .footer .ctx-menu li:hover { +.note .ctx-menu li:hover { color: white; background: #2a2a2a; } -.light .note > .footer .ctx-menu li:hover { +.light .note .ctx-menu li:hover { color: white; background: var(--font-secondary-color); } @@ -196,11 +203,7 @@ user-select: none; color: var(--font-secondary-color); font-feature-settings: "tnum"; -} - -.reaction-pill .reaction-pill-number { - margin-left: 8px; - font-feature-settings: "tnum"; + gap: 5px; } .reaction-pill.reacted { diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index 4d61db22..ad880b9a 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -1,5 +1,5 @@ import "./Note.css"; -import React, { useMemo, useState, useLayoutEffect, ReactNode } from "react"; +import React, { useMemo, useState, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useInView } from "react-intersection-observer"; import { useIntl, FormattedMessage } from "react-intl"; @@ -20,7 +20,7 @@ import { Reaction, profileLink, } from "SnortUtils"; -import NoteFooter, { Translation } from "Element/NoteFooter"; +import NoteFooter from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; import Reveal from "Element/Reveal"; import useModeration from "Hooks/useModeration"; @@ -32,6 +32,8 @@ import { NostrFileElement } from "Element/NostrFileHeader"; import ZapstrEmbed from "Element/ZapstrEmbed"; import PubkeyList from "Element/PubkeyList"; import { LiveEvent } from "Element/LiveEvent"; +import { NoteContextMenu, NoteTranslation } from "Element/NoteContextMenu"; +import Reactions from "Element/Reactions"; import messages from "./messages"; @@ -97,12 +99,10 @@ export default function Note(props: NoteProps) { const { isMuted } = useModeration(); const isOpMuted = isMuted(ev?.pubkey); const { ref, inView, entry } = useInView({ triggerOnce: true }); - const [extendable, setExtendable] = useState(false); - const [showMore, setShowMore] = useState(false); const login = useLogin(); const { pinned, bookmarked } = login; const publisher = useEventPublisher(); - const [translated, setTranslated] = useState(); + const [translated, setTranslated] = useState(); const { formatMessage } = useIntl(); const reactions = useMemo(() => getReactions(related, ev.id, EventKind.Reaction), [related, ev]); const groupReactions = useMemo(() => { @@ -208,15 +208,6 @@ export default function Note(props: NoteProps) { return ; }; - useLayoutEffect(() => { - if (entry && inView && extendable === false) { - const h = (entry?.target as HTMLDivElement)?.offsetHeight ?? 0; - if (h > 650) { - setExtendable(true); - } - } - }, [inView, entry, extendable]); - function goToEvent( e: React.MouseEvent, eTarget: TaggedNostrEvent, @@ -342,21 +333,31 @@ export default function Note(props: NoteProps) { subHeader={replyTag() ?? undefined} link={opt?.canClick === undefined ? undefined : ""} /> - {(options.showTime || options.showBookmarked) && ( -
- {options.showBookmarked && ( -
unbookmark(ev.id)}> - -
- )} - {!options.showBookmarked && } -
- )} - {options.showPinned && ( -
unpin(ev.id)}> - -
- )} +
+ {(options.showTime || options.showBookmarked) && ( + <> + {options.showBookmarked && ( +
unbookmark(ev.id)}> + +
+ )} + {!options.showBookmarked && } + + )} + {options.showPinned && ( +
unpin(ev.id)}> + +
+ )} + {}} + onTranslated={t => setTranslated(t)} + setShowReactions={setShowReactions} + /> +
)}
goToEvent(e, ev, true)}> @@ -369,32 +370,21 @@ export default function Note(props: NoteProps) {
)} - {extendable && !showMore && ( - setShowMore(true)}> - - - )} - {options.showFooter && ( - setTranslated(t)} - showReactions={showReactions} - setShowReactions={setShowReactions} - /> - )} + {options.showFooter && } + ); } const note = ( -
goToEvent(e, ev)} - ref={ref}> +
goToEvent(e, ev)} ref={ref}> {content()}
); diff --git a/packages/app/src/Element/NoteContextMenu.tsx b/packages/app/src/Element/NoteContextMenu.tsx new file mode 100644 index 00000000..b32506b3 --- /dev/null +++ b/packages/app/src/Element/NoteContextMenu.tsx @@ -0,0 +1,220 @@ +import { FormattedMessage, useIntl } from "react-intl"; +import { HexKey, Lists, NostrPrefix, TaggedRawEvent, encodeTLV } from "@snort/system"; +import { Menu, MenuItem } from "@szhsin/react-menu"; +import { useDispatch, useSelector } from "react-redux"; + +import { TranslateHost } from "Const"; +import { System } from "index"; +import Icon from "Icons/Icon"; +import { setPinned, setBookmarked } from "Login"; +import { + setNote as setReBroadcastNote, + setShow as setReBroadcastShow, + reset as resetReBroadcast, +} from "State/ReBroadcast"; +import messages from "Element/messages"; +import useLogin from "Hooks/useLogin"; +import useModeration from "Hooks/useModeration"; +import useEventPublisher from "Feed/EventPublisher"; +import { RootState } from "State/Store"; +import { ReBroadcaster } from "./ReBroadcaster"; + +export interface NoteTranslation { + text: string; + fromLanguage: string; + confidence: number; +} + +interface NosteContextMenuProps { + ev: TaggedRawEvent; + setShowReactions(b: boolean): void; + react(content: string): Promise; + onTranslated?: (t: NoteTranslation) => void; +} + +export function NoteContextMenu({ ev, ...props }: NosteContextMenuProps) { + const dispatch = useDispatch(); + const { formatMessage } = useIntl(); + const login = useLogin(); + const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login; + const { mute, block } = useModeration(); + const publisher = useEventPublisher(); + const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show); + const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note); + const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id; + const lang = window.navigator.language; + const langNames = new Intl.DisplayNames([...window.navigator.languages], { + type: "language", + }); + const isMine = ev.pubkey === publicKey; + + async function deleteEvent() { + if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) { + const evDelete = await publisher.delete(ev.id); + System.BroadcastEvent(evDelete); + } + } + + async function share() { + const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays); + const url = `${window.location.protocol}//${window.location.host}/e/${link}`; + if ("share" in window.navigator) { + await window.navigator.share({ + title: "Snort", + url: url, + }); + } else { + await navigator.clipboard.writeText(url); + } + } + + async function translate() { + const res = await fetch(`${TranslateHost}/translate`, { + method: "POST", + body: JSON.stringify({ + q: ev.content, + source: "auto", + target: lang.split("-")[0], + }), + headers: { "Content-Type": "application/json" }, + }); + + if (res.ok) { + const result = await res.json(); + if (typeof props.onTranslated === "function" && result) { + props.onTranslated({ + text: result.translatedText, + fromLanguage: langNames.of(result.detectedLanguage.language), + confidence: result.detectedLanguage.confidence, + } as NoteTranslation); + } + } + } + + async function copyId() { + const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays); + await navigator.clipboard.writeText(link); + } + + async function pin(id: HexKey) { + if (publisher) { + const es = [...pinned.item, id]; + const ev = await publisher.noteList(es, Lists.Pinned); + System.BroadcastEvent(ev); + setPinned(login, es, ev.created_at * 1000); + } + } + + async function bookmark(id: HexKey) { + if (publisher) { + const es = [...bookmarked.item, id]; + const ev = await publisher.noteList(es, Lists.Bookmarked); + System.BroadcastEvent(ev); + setBookmarked(login, es, ev.created_at * 1000); + } + } + + async function copyEvent() { + await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " ")); + } + + const handleReBroadcastButtonClick = () => { + if (reBroadcastNote?.id !== ev.id) { + dispatch(resetReBroadcast()); + } + + dispatch(setReBroadcastNote(ev)); + dispatch(setReBroadcastShow(!showReBroadcastModal)); + }; + + function menuItems() { + return ( + <> +
+ {/* This menu item serves as a "close menu" button; + it allows the user to click anywhere nearby the menu to close it. */} + +
+ +
+ props.setShowReactions(true)}> + + + + share()}> + + + + {!pinned.item.includes(ev.id) && ( + pin(ev.id)}> + + + + )} + {!bookmarked.item.includes(ev.id) && ( + bookmark(ev.id)}> + + + + )} + copyId()}> + + + + mute(ev.pubkey)}> + + + + {prefs.enableReactions && ( + props.react("-")}> + + + + )} + {ev.pubkey === publicKey && ( + + + + + )} + {ev.pubkey !== publicKey && ( + block(ev.pubkey)}> + + + + )} + translate()}> + + + + {prefs.showDebugMenus && ( + copyEvent()}> + + + + )} + {isMine && ( + deleteEvent()}> + + + + )} + + ); + } + + return ( + <> + + +
+ } + menuClassName="ctx-menu"> + {menuItems()} + + {willRenderReBroadcast && } + + ); +} diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index 58ed5ae4..cadd5ffe 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useState } from "react"; import { useSelector, useDispatch } from "react-redux"; -import { useIntl, FormattedMessage } from "react-intl"; -import { Menu, MenuItem } from "@szhsin/react-menu"; +import { useIntl } from "react-intl"; import { useLongPress } from "use-long-press"; -import { TaggedNostrEvent, HexKey, u256, encodeTLV, NostrPrefix, Lists, ParsedZap } from "@snort/system"; +import { TaggedNostrEvent, HexKey, u256, ParsedZap } from "@snort/system"; import { LNURL } from "@snort/shared"; import { useUserProfile } from "@snort/system-react"; @@ -14,22 +13,13 @@ import { formatShort } from "Number"; import useEventPublisher from "Feed/EventPublisher"; import { delay, normalizeReaction, unwrap } from "SnortUtils"; import { NoteCreator } from "Element/NoteCreator"; -import { ReBroadcaster } from "Element/ReBroadcaster"; -import Reactions from "Element/Reactions"; import SendSats from "Element/SendSats"; import { ZapsSummary } from "Element/Zap"; import { RootState } from "State/Store"; import { setReplyTo, setShow, reset } from "State/NoteCreator"; -import { - setNote as setReBroadcastNote, - setShow as setReBroadcastShow, - reset as resetReBroadcast, -} from "State/ReBroadcast"; -import useModeration from "Hooks/useModeration"; -import { TranslateHost } from "Const"; + import { useWallet } from "Wallet"; import useLogin from "Hooks/useLogin"; -import { setBookmarked, setPinned } from "Login"; import { useInteractionCache } from "Hooks/useInteractionCache"; import { ZapPoolController } from "ZapPoolController"; import { System } from "index"; @@ -49,49 +39,31 @@ const barrierZapper = async (then: () => Promise): Promise => { } }; -export interface Translation { - text: string; - fromLanguage: string; - confidence: number; -} - export interface NoteFooterProps { reposts: TaggedNostrEvent[]; zaps: ParsedZap[]; positive: TaggedNostrEvent[]; - negative: TaggedNostrEvent[]; - showReactions: boolean; - setShowReactions(b: boolean): void; ev: TaggedNostrEvent; - onTranslated?: (content: Translation) => void; } export default function NoteFooter(props: NoteFooterProps) { - const { ev, showReactions, setShowReactions, positive, negative, reposts, zaps } = props; + const { ev, positive, reposts, zaps } = props; const dispatch = useDispatch(); const { formatMessage } = useIntl(); const login = useLogin(); - const { pinned, bookmarked, publicKey, preferences: prefs, relays } = login; - const { mute, block } = useModeration(); + const { publicKey, preferences: prefs, relays } = login; const author = useUserProfile(System, ev.pubkey); const interactionCache = useInteractionCache(publicKey, ev.id); const publisher = useEventPublisher(); const showNoteCreatorModal = useSelector((s: RootState) => s.noteCreator.show); - const showReBroadcastModal = useSelector((s: RootState) => s.reBroadcast.show); - const reBroadcastNote = useSelector((s: RootState) => s.reBroadcast.note); const replyTo = useSelector((s: RootState) => s.noteCreator.replyTo); const willRenderNoteCreator = showNoteCreatorModal && replyTo?.id === ev.id; - const willRenderReBroadcast = showReBroadcastModal && reBroadcastNote && reBroadcastNote?.id === ev.id; const [tip, setTip] = useState(false); const [zapping, setZapping] = useState(false); const walletState = useWallet(); const wallet = walletState.wallet; const isMine = ev.pubkey === publicKey; - const lang = window.navigator.language; - const langNames = new Intl.DisplayNames([...window.navigator.languages], { - type: "language", - }); const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0); const didZap = interactionCache.data.zapped || zaps.some(a => a.sender === publicKey); const longPress = useLongPress( @@ -123,13 +95,6 @@ export default function NoteFooter(props: NoteFooterProps) { } } - async function deleteEvent() { - if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.id.substring(0, 8) })) && publisher) { - const evDelete = await publisher.delete(ev.id); - System.BroadcastEvent(evDelete); - } - } - async function repost() { if (!hasReposted() && publisher) { if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.id }))) { @@ -248,145 +213,6 @@ export default function NoteFooter(props: NoteFooterProps) { ); } - async function share() { - const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays); - const url = `${window.location.protocol}//${window.location.host}/e/${link}`; - if ("share" in window.navigator) { - await window.navigator.share({ - title: "Snort", - url: url, - }); - } else { - await navigator.clipboard.writeText(url); - } - } - - async function translate() { - const res = await fetch(`${TranslateHost}/translate`, { - method: "POST", - body: JSON.stringify({ - q: ev.content, - source: "auto", - target: lang.split("-")[0], - }), - headers: { "Content-Type": "application/json" }, - }); - - if (res.ok) { - const result = await res.json(); - if (typeof props.onTranslated === "function" && result) { - props.onTranslated({ - text: result.translatedText, - fromLanguage: langNames.of(result.detectedLanguage.language), - confidence: result.detectedLanguage.confidence, - } as Translation); - } - } - } - - async function copyId() { - const link = encodeTLV(NostrPrefix.Event, ev.id, ev.relays); - await navigator.clipboard.writeText(link); - } - - async function pin(id: HexKey) { - if (publisher) { - const es = [...pinned.item, id]; - const ev = await publisher.noteList(es, Lists.Pinned); - System.BroadcastEvent(ev); - setPinned(login, es, ev.created_at * 1000); - } - } - - async function bookmark(id: HexKey) { - if (publisher) { - const es = [...bookmarked.item, id]; - const ev = await publisher.noteList(es, Lists.Bookmarked); - System.BroadcastEvent(ev); - setBookmarked(login, es, ev.created_at * 1000); - } - } - - async function copyEvent() { - await navigator.clipboard.writeText(JSON.stringify(ev, undefined, " ")); - } - - function menuItems() { - return ( - <> -
- {/* This menu item serves as a "close menu" button; - it allows the user to click anywhere nearby the menu to close it. */} - -
- -
- setShowReactions(true)}> - - - - share()}> - - - - {!pinned.item.includes(ev.id) && ( - pin(ev.id)}> - - - - )} - {!bookmarked.item.includes(ev.id) && ( - bookmark(ev.id)}> - - - - )} - copyId()}> - - - - mute(ev.pubkey)}> - - - - {prefs.enableReactions && ( - react("-")}> - - - - )} - {ev.pubkey === publicKey && ( - - - - - )} - {ev.pubkey !== publicKey && ( - block(ev.pubkey)}> - - - - )} - translate()}> - - - - {prefs.showDebugMenus && ( - copyEvent()}> - - - - )} - {isMine && ( - deleteEvent()}> - - - - )} - - ); - } - const handleReplyButtonClick = () => { if (replyTo?.id !== ev.id) { dispatch(reset()); @@ -396,15 +222,6 @@ export default function NoteFooter(props: NoteFooterProps) { dispatch(setShow(!showNoteCreatorModal)); }; - const handleReBroadcastButtonClick = () => { - if (reBroadcastNote?.id !== ev.id) { - dispatch(resetReBroadcast()); - } - - dispatch(setReBroadcastNote(ev)); - dispatch(setReBroadcastShow(!showReBroadcastModal)); - }; - return ( <>
@@ -415,26 +232,8 @@ export default function NoteFooter(props: NoteFooterProps) {
- - -
- } - menuClassName="ctx-menu"> - {menuItems()} -
{willRenderNoteCreator && } - {willRenderReBroadcast && } - setTip(false)} diff --git a/packages/app/src/Element/ProfileImage.css b/packages/app/src/Element/ProfileImage.css index 8f346215..4f3c3031 100644 --- a/packages/app/src/Element/ProfileImage.css +++ b/packages/app/src/Element/ProfileImage.css @@ -5,11 +5,7 @@ text-decoration: none; user-select: none; min-width: 0; -} - -.pfp .avatar-wrapper { - margin-right: 8px; - z-index: 2; + gap: 12px; } .pfp .avatar { diff --git a/packages/app/src/Element/Text.css b/packages/app/src/Element/Text.css index 3ede68b3..cf8f6a92 100644 --- a/packages/app/src/Element/Text.css +++ b/packages/app/src/Element/Text.css @@ -1,16 +1,18 @@ .text { font-size: var(--font-size); line-height: 24px; - white-space: pre-wrap; - word-break: break-word; } -.text > a { +.text .text-frag > a { color: var(--highlight); text-decoration: none; } -.text a:hover { +.text .text-frag > a:hover { + text-decoration: underline; +} + +.text .text-frag .hashtag:hover { text-decoration: underline; } @@ -65,11 +67,8 @@ .text video, .text iframe, .text audio { - max-width: 100%; - max-height: 500px; - margin: 10px auto; + width: 100%; display: block; - border-radius: 12px; } .text iframe, diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index 87585a92..6bc5253d 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -1,23 +1,13 @@ import "./Text.css"; import { useMemo } from "react"; -import { Link, useLocation } from "react-router-dom"; -import { HexKey, NostrPrefix, validateNostrLink } from "@snort/system"; +import { HexKey, ParsedFragment, transformText } from "@snort/system"; -import { MentionRegex, InvoiceRegex, HashtagRegex, CashuRegex } from "Const"; -import { eventLink, hexToBech32, splitByUrl } from "SnortUtils"; import Invoice from "Element/Invoice"; import Hashtag from "Element/Hashtag"; -import Mention from "Element/Mention"; import HyperText from "Element/HyperText"; import CashuNuts from "Element/CashuNuts"; -import { ProxyImg } from "Element/ProxyImg"; - -export type Fragment = string | React.ReactNode; - -export interface TextFragment { - body: React.ReactNode[]; - tags: Array>; -} +import RevealMedia from "./RevealMedia"; +import { ProxyImg } from "./ProxyImg"; export interface TextProps { content: string; @@ -29,168 +19,64 @@ export interface TextProps { } export default function Text({ content, tags, creator, disableMedia, depth, disableMediaSpotlight }: TextProps) { - const location = useLocation(); - - function extractLinks(fragments: Fragment[]) { - return fragments - .map(f => { - if (typeof f === "string") { - return splitByUrl(f).map(a => { - const validateLink = () => { - const normalizedStr = a.toLowerCase(); - - if (normalizedStr.startsWith("web+nostr:") || normalizedStr.startsWith("nostr:")) { - return validateNostrLink(normalizedStr); - } - - return ( - normalizedStr.startsWith("http:") || - normalizedStr.startsWith("https:") || - normalizedStr.startsWith("magnet:") - ); - }; - - if (validateLink()) { - if ((disableMedia ?? false) && !a.startsWith("nostr:")) { - return ( - e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> - {a} - - ); - } - return ( - - ); + function renderChunk(f: Array) { + if (f.every(a => a.type === "media") && f.length === 1) { + if (disableMedia ?? false) { + return ( + e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> + {f[0].content} + + ); + } + return ; + } else { + return ( +
+ {f.map(a => { + switch (a.type) { + case "invoice": + return ; + case "hashtag": + return ; + case "cashu": + return ; + case "media": + case "link": + return ; + case "custom_emoji": + return ; + default: + return <>{a.content}; } - return a; - }); + })} +
+ ); + } + } + const elements = useMemo(() => { + const frags = transformText(content, tags); + const chunked = frags.reduce((acc, v) => { + if (v.type === "media" && !(v.mimeType?.startsWith("unknown") ?? true)) { + if (acc.length === 0) { + acc.push([], [v]); + } else { + acc.push([v]); } - return f; - }) - .flat(); - } - - function extractCashuTokens(fragments: Fragment[]) { - return fragments - .map(f => { - if (typeof f === "string" && f.includes("cashuA")) { - return f.split(CashuRegex).map(a => { - return ; - }); + } else { + if (acc.length === 0) { + acc.push([v]); + } else { + acc[0].push(v); } - return f; - }) - .flat(); - } - - function extractMentions(frag: TextFragment) { - return frag.body - .map(f => { - if (typeof f === "string") { - return f.split(MentionRegex).map(match => { - const matchTag = match.match(/#\[(\d+)\]/); - if (matchTag && matchTag.length === 2) { - const idx = parseInt(matchTag[1]); - const ref = frag.tags?.[idx]; - if (ref) { - switch (ref[0]) { - case "p": { - return ; - } - case "e": { - const eText = hexToBech32(NostrPrefix.Event, ref[1]).substring(0, 12); - return ( - ref[1] && ( - e.stopPropagation()} - state={{ from: location.pathname }}> - #{eText} - - ) - ); - } - case "t": { - return ; - } - } - } - return {matchTag[0]}?; - } else { - return match; - } - }); - } - return f; - }) - .flat(); - } - - function extractInvoices(fragments: Fragment[]) { - return fragments - .map(f => { - if (typeof f === "string") { - return f.split(InvoiceRegex).map(i => { - if (i.toLowerCase().startsWith("lnbc")) { - return ; - } else { - return i; - } - }); - } - return f; - }) - .flat(); - } - - function extractHashtags(fragments: Fragment[]) { - return fragments - .map(f => { - if (typeof f === "string") { - return f.split(HashtagRegex).map(i => { - if (i.toLowerCase().startsWith("#")) { - return ; - } else { - return i; - } - }); - } - return f; - }) - .flat(); - } - - function extractCustomEmoji(fragments: Fragment[]) { - return fragments - .map(f => { - if (typeof f === "string") { - return f.split(/:(\w+):/g).map(i => { - const t = tags.find(a => a[0] === "emoji" && a[1] === i); - if (t) { - return ; - } else { - return i; - } - }); - } - return f; - }) - .flat(); - } - - function transformText(frag: TextFragment) { - let fragments = extractMentions(frag); - fragments = extractLinks(fragments); - fragments = extractInvoices(fragments); - fragments = extractHashtags(fragments); - fragments = extractCashuTokens(fragments); - fragments = extractCustomEmoji(fragments); - return fragments; - } - - const element = useMemo(() => { - return
{transformText({ body: [content], tags })}
; + } + return acc; + }, [] as Array>); + return chunked.reverse(); }, [content]); - return
{element}
; + return ( +
+ {elements.map(a => renderChunk(a))} +
+ ); } diff --git a/packages/app/src/Pages/Layout.css b/packages/app/src/Pages/Layout.css index 38f50842..19b281e0 100644 --- a/packages/app/src/Pages/Layout.css +++ b/packages/app/src/Pages/Layout.css @@ -12,14 +12,15 @@ header { display: flex; - flex-direction: row; - align-items: center; + padding: 10px 16px; justify-content: space-between; + align-items: center; + align-self: stretch; } .header-actions .avatar { - width: 48px; - height: 48px; + width: 40px; + height: 40px; cursor: pointer; } diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 667767e5..7f2e5533 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -12,7 +12,6 @@ import { RootState } from "State/Store"; import { setShow, reset } from "State/NoteCreator"; import { System } from "index"; import useLoginFeed from "Feed/LoginFeed"; -import useModeration from "Hooks/useModeration"; import { NoteCreator } from "Element/NoteCreator"; import { mapPlanName } from "./subscribe"; import useLogin from "Hooks/useLogin"; @@ -103,7 +102,7 @@ export default function Layout() { return (
{!shouldHideHeader && ( -
+
navigate("/")}> {currentSubscription && ( @@ -177,11 +176,11 @@ const AccountHeader = () => {
navigate("/messages")}> - + {unreadDms > 0 && }
- + {hasNotifications && }
div:not(.page) header { @media (min-width: 720px) { .page { - width: 586px; + width: 640px; margin-left: auto; margin-right: auto; } } .card { - margin-bottom: 12px; - border-radius: 16px; - background-color: var(--note-bg); - padding: 6px 12px; -} - -@media (min-width: 720px) { - .card { - margin-bottom: 16px; - padding: 12px 24px; - } + padding: 16px 12px; + border-bottom: 1px solid var(--border-primary); } html.light .card { @@ -560,14 +554,7 @@ small.xs { } .main-content { - padding: 0 12px; - position: relative; -} - -@media (min-width: 720px) { - .main-content { - padding: 0; - } + border: 1px solid var(--border-primary); } .bold { diff --git a/packages/system/src/const.ts b/packages/system/src/const.ts index 15889551..37a93a0f 100644 --- a/packages/system/src/const.ts +++ b/packages/system/src/const.ts @@ -13,3 +13,24 @@ export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/g; * How long profile cache should be considered valid for */ export const ProfileCacheExpire = 1_000 * 60 * 60 * 6; + +/** + * Extract file extensions regex + */ +// eslint-disable-next-line no-useless-escape +export const FileExtensionRegex = /\.([\w]{1,7})$/i; + +/** + * Simple lightning invoice regex + */ +export const InvoiceRegex = /(lnbc\w+)/i; + +/* + * Regex to match any base64 string + */ +export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i; + +/** + * Regex to match any npub/nevent/naddr/nprofile/note + */ +export const MentionNostrEntityRegex = /@n(pub|profile|event|ote|addr|)1[acdefghjklmnpqrstuvwxyz023456789]+/g; \ No newline at end of file diff --git a/packages/system/src/event-builder.ts b/packages/system/src/event-builder.ts index ac4980dd..f7f9300a 100644 --- a/packages/system/src/event-builder.ts +++ b/packages/system/src/event-builder.ts @@ -1,5 +1,5 @@ import { EventKind, HexKey, NostrPrefix, NostrEvent, EventSigner } from "."; -import { HashtagRegex } from "./const"; +import { HashtagRegex, MentionNostrEntityRegex } from "./const"; import { getPublicKey, unixNow } from "@snort/shared"; import { EventExt } from "./event-ext"; import { tryParseNostrLink } from "./nostr-link"; @@ -43,7 +43,7 @@ export class EventBuilder { */ processContent() { if (this.#content) { - this.#content = this.#content.replace(/@n(pub|profile|event|ote|addr|)1[acdefghjklmnpqrstuvwxyz023456789]+/g, m => + this.#content = this.#content.replace(MentionNostrEntityRegex, m => this.#replaceMention(m) ); diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index 2c6e00c1..e437d565 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -20,6 +20,7 @@ export * from "./nostr-link"; export * from "./profile-cache"; export * from "./zaps"; export * from "./signer"; +export * from "./text"; export * from "./impl/nip4"; export * from "./impl/nip44"; diff --git a/packages/system/src/text.ts b/packages/system/src/text.ts new file mode 100644 index 00000000..3c98ee5a --- /dev/null +++ b/packages/system/src/text.ts @@ -0,0 +1,204 @@ +import { unwrap } from "@snort/shared"; + +import { CashuRegex, FileExtensionRegex, HashtagRegex, InvoiceRegex, MentionNostrEntityRegex } from "./const"; +import { validateNostrLink } from "./nostr-link"; +import { splitByUrl } from "./utils"; + +export interface ParsedFragment { + type: "text" | "link" | "mention" | "invoice" | "media" | "cashu" | "hashtag" | "custom_emoji" + content: string + mimeType?: string +} + +export type Fragment = string | ParsedFragment; + +export interface TextFragment { + body: React.ReactNode[]; + tags: Array>; +} + +function extractLinks(fragments: Fragment[]) { + return fragments + .map(f => { + if (typeof f === "string") { + return splitByUrl(f).map(a => { + const validateLink = () => { + const normalizedStr = a.toLowerCase(); + + if (normalizedStr.startsWith("web+nostr:") || normalizedStr.startsWith("nostr:")) { + return validateNostrLink(normalizedStr); + } + + return ( + normalizedStr.startsWith("http:") || + normalizedStr.startsWith("https:") || + normalizedStr.startsWith("magnet:") + ); + }; + + if (validateLink()) { + const url = new URL(a); + const extension = url.pathname.match(FileExtensionRegex); + + if (extension && extension.length > 1) { + const mediaType = (() => { + switch (extension[1]) { + case "gif": + case "jpg": + case "jpeg": + case "jfif": + case "png": + case "bmp": + case "webp": + return "image"; + case "wav": + case "mp3": + case "ogg": + return "audio"; + case "mp4": + case "mov": + case "mkv": + case "avi": + case "m4v": + case "webm": + case "m3u8": + return "video"; + default: + return "unknown"; + } + })(); + return { + type: "media", + content: a, + mimeType: `${mediaType}/${extension[1]}` + } as ParsedFragment; + } else { + return { + type: "link", + content: a + } as ParsedFragment; + } + } + return a; + }); + } + return f; + }) + .flat(); +} + +function extractMentions(fragments: Fragment[]) { + return fragments + .map(f => { + if (typeof f === "string") { + return f.split(MentionNostrEntityRegex).map(i => { + if (MentionNostrEntityRegex.test(i)) { + return { + type: "mention", + content: i + } as ParsedFragment; + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractCashuTokens(fragments: Fragment[]) { + return fragments + .map(f => { + if (typeof f === "string" && f.includes("cashuA")) { + return f.split(CashuRegex).map(a => { + return { + type: "cashu", + content: a + } as ParsedFragment + }); + } + return f; + }) + .flat(); +} + +function extractInvoices(fragments: Fragment[]) { + return fragments + .map(f => { + if (typeof f === "string") { + return f.split(InvoiceRegex).map(i => { + if (i.toLowerCase().startsWith("lnbc")) { + return { + type: "invoice", + content: i + } as ParsedFragment + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractHashtags(fragments: Fragment[]) { + return fragments + .map(f => { + if (typeof f === "string") { + return f.split(HashtagRegex).map(i => { + if (i.toLowerCase().startsWith("#")) { + return { + type: "hashtag", + content: i.substring(1) + } as ParsedFragment; + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +function extractCustomEmoji(fragments: Fragment[], tags: Array>) { + return fragments + .map(f => { + if (typeof f === "string") { + return f.split(/:(\w+):/g).map(i => { + const t = tags.find(a => a[0] === "emoji" && a[1] === i); + if (t) { + return { + type: "custom_emoji", + content: t[2] + } as ParsedFragment + } else { + return i; + } + }); + } + return f; + }) + .flat(); +} + +export function transformText(body: string, tags: Array>) { + let fragments = extractLinks([body]); + fragments = extractMentions(fragments); + fragments = extractHashtags(fragments); + fragments = extractInvoices(fragments); + fragments = extractCashuTokens(fragments); + fragments = extractCustomEmoji(fragments, tags); + fragments = fragments.map(a => { + if (typeof a === "string") { + if (a.trim().length > 0) { + return { type: "text", content: a } as ParsedFragment; + } + } else { + return a; + } + }).filter(a => a).map(a => unwrap(a)); + return fragments as Array; +} \ No newline at end of file diff --git a/packages/system/src/utils.ts b/packages/system/src/utils.ts index 697a6b14..165cb6d3 100644 --- a/packages/system/src/utils.ts +++ b/packages/system/src/utils.ts @@ -27,19 +27,24 @@ export function reqFilterEq(a: FlatReqFilter | ReqFilter, b: FlatReqFilter | Req } export function flatFilterEq(a: FlatReqFilter, b: FlatReqFilter): boolean { - return ( - a.keys === b.keys && - a.since === b.since && - a.until === b.until && - a.limit === b.limit && - a.search === b.search && - a.ids === b.ids && - a.kinds === b.kinds && - a.authors === b.authors && - a["#e"] === b["#e"] && - a["#p"] === b["#p"] && - a["#t"] === b["#t"] && - a["#d"] === b["#d"] && - a["#r"] === b["#r"] - ); + return a.keys === b.keys + && a.since === b.since + && a.until === b.until + && a.limit === b.limit + && a.search === b.search + && a.ids === b.ids + && a.kinds === b.kinds + && a.authors === b.authors + && a["#e"] === b["#e"] + && a["#p"] === b["#p"] + && a["#t"] === b["#t"] + && a["#d"] === b["#d"] + && a["#r"] === b["#r"]; } + +export function splitByUrl(str: string) { + const urlRegex = + /((?:http|ftp|https|nostr|web\+nostr|magnet):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~()_|]))/i; + + return str.split(urlRegex); + }