diff --git a/packages/app/public/icons.svg b/packages/app/public/icons.svg index 27b9b9d12..43c7b523c 100644 --- a/packages/app/public/icons.svg +++ b/packages/app/public/icons.svg @@ -151,6 +151,8 @@ - + + + \ No newline at end of file diff --git a/packages/app/public/index.html b/packages/app/public/index.html index adaf597e9..14de62464 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -10,6 +10,7 @@ http-equiv="Content-Security-Policy" content="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;" /> + diff --git a/packages/app/src/Element/DM.css b/packages/app/src/Element/DM.css index dbec697f8..9c5a17ba2 100644 --- a/packages/app/src/Element/DM.css +++ b/packages/app/src/Element/DM.css @@ -1,23 +1,37 @@ .dm { - padding: 8px; - background-color: var(--gray); - margin-bottom: 5px; - border-radius: 5px; - width: fit-content; + margin-top: 16px; min-width: 100px; max-width: 90%; - overflow: hidden; - min-height: 40px; white-space: pre-wrap; + color: var(--font-color); } -.dm > div:first-child { +.dm a { + color: var(--font-color) !important; +} + +.dm > div:last-child { color: var(--gray-light); font-size: small; - margin-bottom: 3px; + margin-top: 3px; +} + +.dm.other > div:first-child { + padding: 12px 16px; + background: var(--gray-secondary); + border-radius: 16px 16px 16px 0px; } .dm.me { align-self: flex-end; - background-color: var(--gray-secondary); +} + +.dm.me > div:first-child { + padding: 12px 16px; + background: var(--dm-gradient); + border-radius: 16px 16px 0px 16px; +} + +.dm.me > div:last-child { + text-align: end; } diff --git a/packages/app/src/Element/DM.tsx b/packages/app/src/Element/DM.tsx index 9c4bf72f0..dfc8911b5 100644 --- a/packages/app/src/Element/DM.tsx +++ b/packages/app/src/Element/DM.tsx @@ -45,13 +45,13 @@ export default function DM(props: DMProps) { }, [inView, props.data]); return ( -
+
+
+ +
-
- -
); } diff --git a/packages/app/src/Element/DmWindow.css b/packages/app/src/Element/DmWindow.css new file mode 100644 index 000000000..2fe1afee0 --- /dev/null +++ b/packages/app/src/Element/DmWindow.css @@ -0,0 +1,19 @@ +.dm-window { + display: flex; + flex-direction: column; + height: 100%; +} + +.dm-window > div:nth-child(2) { + overflow-y: auto; + padding: 0 10px 10px 10px; + flex-grow: 1; +} + +.dm-window > div:nth-child(3) { + display: flex; + align-items: center; + background-color: var(--bg-color); + gap: 10px; + padding: 5px 10px; +} diff --git a/packages/app/src/Element/DmWindow.tsx b/packages/app/src/Element/DmWindow.tsx new file mode 100644 index 000000000..d68296ea0 --- /dev/null +++ b/packages/app/src/Element/DmWindow.tsx @@ -0,0 +1,84 @@ +import "./DmWindow.css"; +import { useEffect, useMemo, useRef } from "react"; +import { TaggedRawEvent } from "@snort/nostr"; + +import ProfileImage from "Element/ProfileImage"; +import DM from "Element/DM"; +import { dmsForLogin, dmsInChat, isToSelf } from "Pages/MessagesPage"; +import NoteToSelf from "Element/NoteToSelf"; +import { useDmCache } from "Hooks/useDmsCache"; +import useLogin from "Hooks/useLogin"; +import WriteDm from "Element/WriteDm"; +import { unwrap } from "Util"; + +export default function DmWindow({ id }: { id: string }) { + const pubKey = useLogin().publicKey; + const dmListRef = useRef(null); + + function resize(chatList: HTMLDivElement) { + const scrollWrap = unwrap(chatList.parentElement); + const h = scrollWrap.scrollHeight; + const s = scrollWrap.clientHeight + scrollWrap.scrollTop; + const pos = Math.abs(h - s); + const atBottom = pos === 0; + //console.debug("Resize", h, s, pos, atBottom); + if (atBottom) { + scrollWrap.scrollTo(0, scrollWrap.scrollHeight); + } + } + + useEffect(() => { + if (dmListRef.current) { + const scrollWrap = dmListRef.current; + const chatList = unwrap(scrollWrap.parentElement); + chatList.onscroll = () => { + resize(dmListRef.current as HTMLDivElement); + }; + new ResizeObserver(() => resize(dmListRef.current as HTMLDivElement)).observe(scrollWrap); + return () => { + chatList.onscroll = null; + new ResizeObserver(() => resize(dmListRef.current as HTMLDivElement)).unobserve(scrollWrap); + }; + } + }, [dmListRef]); + + return ( +
+
+ {(id === pubKey && ) || ( + + )} +
+
+
+ +
+
+
+ +
+
+ ); +} + +function DmChatSelected({ chatPubKey }: { chatPubKey: string }) { + const dms = useDmCache(); + const { publicKey: myPubKey } = useLogin(); + const sortedDms = useMemo(() => { + if (myPubKey) { + const myDms = dmsForLogin(dms, myPubKey); + // filter dms in this chat, or dms to self + const thisDms = myPubKey === chatPubKey ? myDms.filter(d => isToSelf(d, myPubKey)) : myDms; + return [...dmsInChat(thisDms, chatPubKey)].sort((a, b) => a.created_at - b.created_at); + } + return []; + }, [dms, myPubKey, chatPubKey]); + + return ( + <> + {sortedDms.map(a => ( + + ))} + + ); +} diff --git a/packages/app/src/Element/Nip05.tsx b/packages/app/src/Element/Nip05.tsx index 740299052..0583837a3 100644 --- a/packages/app/src/Element/Nip05.tsx +++ b/packages/app/src/Element/Nip05.tsx @@ -68,7 +68,7 @@ const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => { const { isVerified, couldNotVerify } = useIsVerified(pubkey, nip05, !verifyNip); return ( -
ev.stopPropagation()}> +
{!isDefaultUser && isVerified && {`${name}@`}} {isVerified && ( <> diff --git a/packages/app/src/Element/NoteTime.tsx b/packages/app/src/Element/NoteTime.tsx index 26d1bcba5..72d0e94b6 100644 --- a/packages/app/src/Element/NoteTime.tsx +++ b/packages/app/src/Element/NoteTime.tsx @@ -28,7 +28,6 @@ export default function NoteTime(props: NoteTimeProps) { year: "2-digit", month: "short", day: "2-digit", - weekday: "short", }); } else if (absAgo > HourInMs) { return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate.getMinutes().toString().padStart(2, "0")}`; diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index eb9c5608a..ec3e017c0 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -1,6 +1,6 @@ import "./ProfileImage.css"; -import { useMemo } from "react"; +import React, { useMemo } from "react"; import { HexKey, NostrPrefix } from "@snort/nostr"; import { useUserProfile } from "Hooks/useUserProfile"; @@ -38,8 +38,18 @@ export default function ProfileImage({ return overrideUsername ?? getDisplayName(user, pubkey); }, [user, pubkey, overrideUsername]); + function handleClick(e: React.MouseEvent) { + if (link === "") { + e.preventDefault(); + } + } + return ( - +
diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index 7eb5d56cd..36c57b612 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -1,13 +1,10 @@ import "./Text.css"; import { useMemo } from "react"; import { Link, useLocation } from "react-router-dom"; -import ReactMarkdown from "react-markdown"; -import { visit, SKIP } from "unist-util-visit"; -import * as unist from "unist"; import { HexKey, NostrPrefix } from "@snort/nostr"; import { MentionRegex, InvoiceRegex, HashtagRegex, CashuRegex } from "Const"; -import { eventLink, hexToBech32, splitByUrl, unwrap, validateNostrLink } from "Util"; +import { eventLink, hexToBech32, splitByUrl, validateNostrLink } from "Util"; import Invoice from "Element/Invoice"; import Hashtag from "Element/Hashtag"; import Mention from "Element/Mention"; @@ -159,19 +156,6 @@ export default function Text({ content, tags, creator, disableMedia, depth }: Te .flat(); } - function transformLi(frag: TextFragment) { - const fragments = transformText(frag); - return
  • {fragments}
  • ; - } - - function transformParagraph(frag: TextFragment) { - const fragments = transformText(frag); - if (fragments.every(f => typeof f === "string")) { - return

    {fragments}

    ; - } - return <>{fragments}; - } - function transformText(frag: TextFragment) { let fragments = extractMentions(frag); fragments = extractLinks(fragments); @@ -181,41 +165,8 @@ export default function Text({ content, tags, creator, disableMedia, depth }: Te return fragments; } - const components = { - p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags }), - a: (x: { href?: string }) => , - li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags }), - }; - - interface Node extends unist.Node { - value: string; - } - - const disableMarkdownLinks = () => (tree: Node) => { - visit(tree, (node, index, parent) => { - if ( - parent && - typeof index === "number" && - (node.type === "link" || - node.type === "linkReference" || - node.type === "image" || - node.type === "imageReference" || - node.type === "definition") - ) { - node.type = "text"; - const position = unwrap(node.position); - node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )"); - return SKIP; - } - }); - }; - const element = useMemo(() => { - return ( - - {content} - - ); + return
    {transformText({ body: [content], tags })}
    ; }, [content]); return
    {element}
    ; diff --git a/packages/app/src/Element/UnreadCount.css b/packages/app/src/Element/UnreadCount.css index 59459f502..adeca5df5 100644 --- a/packages/app/src/Element/UnreadCount.css +++ b/packages/app/src/Element/UnreadCount.css @@ -3,7 +3,7 @@ font-size: var(--font-size-small); display: inline-block; background-color: var(--gray-secondary); - padding: 2px 10px; + padding: 2px 8px; border-radius: 10px; user-select: none; margin: 2px 5px; @@ -17,3 +17,7 @@ .pill:hover { cursor: pointer; } + +.light .pill.unread { + color: white; +} diff --git a/packages/app/src/Element/WriteDm.tsx b/packages/app/src/Element/WriteDm.tsx new file mode 100644 index 000000000..26a92cb62 --- /dev/null +++ b/packages/app/src/Element/WriteDm.tsx @@ -0,0 +1,102 @@ +import { encodeTLV, NostrPrefix, RawEvent } from "@snort/nostr"; +import useEventPublisher from "Feed/EventPublisher"; +import Icon from "Icons/Icon"; +import Spinner from "Icons/Spinner"; +import { useState } from "react"; +import useFileUpload from "Upload"; +import { openFile } from "Util"; +import Textarea from "./Textarea"; + +export default function WriteDm({ chatPubKey }: { chatPubKey: string }) { + const [msg, setMsg] = useState(""); + const [sending, setSending] = useState(false); + const [uploading, setUploading] = useState(false); + const [otherEvents, setOtherEvents] = useState>([]); + const [error, setError] = useState(""); + const publisher = useEventPublisher(); + const uploader = useFileUpload(); + + async function attachFile() { + try { + const file = await openFile(); + if (file) { + uploadFile(file); + } + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } + } + } + + async function uploadFile(file: File | Blob) { + setUploading(true); + try { + if (file) { + const rx = await uploader.upload(file, file.name); + if (rx.header) { + const link = `nostr:${encodeTLV(rx.header.id, NostrPrefix.Event, undefined, rx.header.kind)}`; + setMsg(`${msg ? `${msg}\n` : ""}${link}`); + setOtherEvents([...otherEvents, rx.header]); + } else if (rx.url) { + setMsg(`${msg ? `${msg}\n` : ""}${rx.url}`); + } else if (rx?.error) { + setError(rx.error); + } + } + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } + } finally { + setUploading(false); + } + } + + async function sendDm() { + if (msg && publisher) { + setSending(true); + const ev = await publisher.sendDm(msg, chatPubKey); + publisher.broadcast(ev); + setMsg(""); + setSending(false); + } + } + + function onChange(e: React.ChangeEvent) { + if (!sending) { + setMsg(e.target.value); + } + } + + async function onEnter(e: React.KeyboardEvent) { + const isEnter = e.code === "Enter"; + if (isEnter && !e.shiftKey) { + await sendDm(); + } + } + + return ( + <> + +
    + - -
    -
    - +
    + +
    ); } diff --git a/packages/app/src/Pages/Layout.tsx b/packages/app/src/Pages/Layout.tsx index 3828af896..bfe5a0a16 100644 --- a/packages/app/src/Pages/Layout.tsx +++ b/packages/app/src/Pages/Layout.tsx @@ -57,7 +57,8 @@ export default function Layout() { }, [location]); useEffect(() => { - if (location.pathname.startsWith("/login")) { + const widePage = ["/login", "/messages"]; + if (widePage.some(a => location.pathname.startsWith(a))) { setPageClass(""); } else { setPageClass("page"); @@ -162,7 +163,7 @@ export default function Layout() { {!shouldHideNoteCreator && ( <> - diff --git a/packages/app/src/Pages/MessagesPage.css b/packages/app/src/Pages/MessagesPage.css new file mode 100644 index 000000000..3f1c4d406 --- /dev/null +++ b/packages/app/src/Pages/MessagesPage.css @@ -0,0 +1,39 @@ +.dm-page { + display: grid; + grid-template-columns: 350px auto; + height: calc(100vh - 57px); + /* 100vh - header - padding */ + overflow: hidden; +} + +/* These should match what is in code too */ +@media (max-width: 768px) { + .dm-page { + grid-template-columns: 100vw; + } + .dm-page > div:nth-child(1) { + margin: 0 !important; + } +} +@media (min-width: 1500px) { + .dm-page { + grid-template-columns: 350px auto 350px; + } +} + +/* User list */ +.dm-page > div:nth-child(1) { + overflow-y: auto; + margin: 0 10px; + padding: 0 10px 0 0; +} + +/* Chat window */ +.dm-page > div:nth-child(2) { + height: calc(100vh - 57px); +} + +/* Profile pannel */ +.dm-page > div:nth-child(3) { + margin: 0 10px; +} diff --git a/packages/app/src/Pages/MessagesPage.tsx b/packages/app/src/Pages/MessagesPage.tsx index c0c1c0d3f..493444ca5 100644 --- a/packages/app/src/Pages/MessagesPage.tsx +++ b/packages/app/src/Pages/MessagesPage.tsx @@ -1,6 +1,7 @@ -import { useMemo } from "react"; -import { FormattedMessage } from "react-intl"; -import { HexKey, RawEvent } from "@snort/nostr"; +import React, { useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useNavigate } from "react-router-dom"; +import { HexKey, RawEvent, NostrPrefix } from "@snort/nostr"; import UnreadCount from "Element/UnreadCount"; import ProfileImage from "Element/ProfileImage"; @@ -9,9 +10,16 @@ import NoteToSelf from "Element/NoteToSelf"; import useModeration from "Hooks/useModeration"; import { useDmCache } from "Hooks/useDmsCache"; import useLogin from "Hooks/useLogin"; +import usePageWidth from "Hooks/usePageWidth"; +import NoteTime from "Element/NoteTime"; +import DmWindow from "Element/DmWindow"; +import "./MessagesPage.css"; import messages from "./messages"; +const TwoCol = 768; +const ThreeCol = 1500; + type DmChat = { pubkey: HexKey; unreadMessages: number; @@ -21,7 +29,11 @@ type DmChat = { export default function MessagesPage() { const login = useLogin(); const { isMuted } = useModeration(); + const { formatMessage } = useIntl(); + const navigate = useNavigate(); const dms = useDmCache(); + const [chat, setChat] = useState(); + const pageWidth = usePageWidth(); const chats = useMemo(() => { if (login.publicKey) { @@ -35,25 +47,39 @@ export default function MessagesPage() { const unreadCount = useMemo(() => chats.reduce((p, c) => p + c.unreadMessages, 0), [chats]); + function openChat(e: React.MouseEvent, pubkey: string) { + e.stopPropagation(); + e.preventDefault(); + if (pageWidth < TwoCol) { + navigate(`/messages/${hexToBech32(NostrPrefix.PublicKey, pubkey)}`); + } else { + setChat(pubkey); + } + } + function noteToSelf(chat: DmChat) { return ( -
    - +
    openChat(e, chat.pubkey)}> +
    ); } function person(chat: DmChat) { + if (!login.publicKey) return null; if (chat.pubkey === login.publicKey) return noteToSelf(chat); return ( -
    - - +
    openChat(e, chat.pubkey)}> + +
    + + + + {chat.unreadMessages > 0 && } +
    ); } @@ -65,24 +91,28 @@ export default function MessagesPage() { } return ( -
    -
    -

    - -

    - +
    +
    +
    +

    + +

    + +
    + {chats + .sort((a, b) => { + return a.pubkey === login.publicKey + ? -1 + : b.pubkey === login.publicKey + ? 1 + : b.newestMessage - a.newestMessage; + }) + .map(person)}
    - {chats - .sort((a, b) => { - return a.pubkey === login.publicKey - ? -1 - : b.pubkey === login.publicKey - ? 1 - : b.newestMessage - a.newestMessage; - }) - .map(person)} + {pageWidth >= TwoCol && chat && } + {pageWidth >= ThreeCol &&
    }
    ); } @@ -126,7 +156,7 @@ function unreadDms(dms: RawEvent[], myPubKey: HexKey, pk: HexKey) { return dmsInChat(dms, pk).filter(a => a.created_at >= lastRead && a.pubkey !== myPubKey).length; } -function newestMessage(dms: RawEvent[], myPubKey: HexKey, pk: HexKey) { +function newestMessage(dms: readonly RawEvent[], myPubKey: HexKey, pk: HexKey) { if (pk === myPubKey) { return dmsInChat( dms.filter(d => isToSelf(d, myPubKey)), diff --git a/packages/app/src/index.css b/packages/app/src/index.css index d5be32895..2f6c771ee 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -23,7 +23,8 @@ --gray-dark: #2b2b2b; --gray-superdark: #1a1a1a; --gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light)); - --snort-gradient: linear-gradient(90deg, #a178ff 0%, #ff6baf 108.33%); + --snort-gradient: linear-gradient(90deg, #a178ff 0%, #ff6baf 100%); + --dm-gradient: linear-gradient(90deg, #5722d2 0%, #db1771 100%); --invoice-gradient: linear-gradient( 45deg, var(--note-bg) 50%, @@ -65,8 +66,9 @@ html.light { --gray-dark: #2b2b2b; --gray-superdark: #eee; - --invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, rgb(247, 183, 51, 0.2), rgb(252, 74, 26, 0.2)); - --paid-invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, rgb(247, 183, 51, 0.6), rgb(252, 74, 26, 0.6)); + --dm-gradient: var(--gray); + --invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, #f7b73333, #fc4a1a33); + --paid-invoice-gradient: linear-gradient(45deg, var(--note-bg) 50%, #f7b73399, #fc4a1a99); } body { @@ -89,6 +91,9 @@ code { margin-right: auto; } +body #root > div:not(.page) header { + padding: 2px 10px; +} @media (min-width: 720px) { .page { width: 586px; @@ -258,22 +263,13 @@ button.icon:hover { } .btn-rnd { - border: none; border-radius: 100%; - width: 21px; - height: 21px; + aspect-ratio: 1; display: flex; align-items: center; justify-content: center; } -@media (min-width: 520px) { - .btn-rnd { - width: 32px; - height: 32px; - } -} - textarea { font: inherit; } @@ -358,6 +354,11 @@ input:disabled { align-items: flex-start !important; } +.f-col-end { + flex-direction: column; + align-items: flex-end !important; +} + .f-end { justify-content: flex-end; } @@ -615,3 +616,8 @@ button.tall { opacity: 1; } } + +.rta__textarea { + /* Fix width calculation to account for 12px padding on input */ + width: calc(100% - 24px) !important; +}