From 9ca3b547c21eb2ecd05896406bfd362e693e5787 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Wed, 25 Jan 2023 19:08:53 +0100 Subject: [PATCH 01/36] UI updates (#135) --- d.ts | 5 + src/Element/Avatar.css | 4 +- src/Element/Copy.css | 12 +- src/Element/Copy.tsx | 4 +- src/Element/FollowButton.tsx | 12 +- src/Element/FollowListBase.tsx | 8 +- src/Element/FollowsYou.css | 2 +- src/Element/LNURLTip.css | 12 -- src/Element/Modal.css | 17 ++- src/Element/Modal.tsx | 4 +- src/Element/Nip05.css | 45 +------ src/Element/Nip05.tsx | 22 ++-- src/Element/Note.css | 10 +- src/Element/Note.tsx | 54 ++++++-- src/Element/NoteCreator.css | 36 ++++- src/Element/NoteCreator.tsx | 45 +++++-- src/Element/NoteFooter.tsx | 38 +++--- src/Element/NoteReaction.css | 2 +- src/Element/ProfileImage.css | 17 +-- src/Element/ProfileImage.tsx | 19 ++- src/Element/Textarea.css | 6 +- src/Element/Timeline.css | 2 +- src/Element/Timeline.tsx | 6 +- src/Icons/Bell.tsx | 9 ++ src/Icons/Dislike.tsx | 9 ++ src/Icons/Dots.tsx | 11 ++ src/Icons/Envelope.tsx | 9 ++ src/Icons/Heart.tsx | 9 ++ src/Icons/Link.tsx | 9 ++ src/Icons/Plus.tsx | 9 ++ src/Icons/Reply.tsx | 9 ++ src/Icons/Zap.tsx | 9 ++ src/Pages/ChatPage.tsx | 4 +- src/Pages/DonatePage.tsx | 2 +- src/Pages/Layout.css | 47 ++++--- src/Pages/Layout.tsx | 28 ++-- src/Pages/Login.tsx | 12 +- src/Pages/MessagesPage.tsx | 8 +- src/Pages/NewUserPage.tsx | 4 +- src/Pages/ProfilePage.css | 233 +++++++++++++++------------------ src/Pages/ProfilePage.tsx | 108 ++++++++------- src/Pages/Root.tsx | 15 ++- src/Pages/SettingsPage.tsx | 4 +- src/Pages/Verification.tsx | 2 +- src/Pages/settings/Profile.tsx | 8 +- src/Pages/settings/Relays.tsx | 8 +- src/index.css | 113 +++++++++++----- 47 files changed, 628 insertions(+), 433 deletions(-) create mode 100644 src/Icons/Bell.tsx create mode 100644 src/Icons/Dislike.tsx create mode 100644 src/Icons/Dots.tsx create mode 100644 src/Icons/Envelope.tsx create mode 100644 src/Icons/Heart.tsx create mode 100644 src/Icons/Link.tsx create mode 100644 src/Icons/Plus.tsx create mode 100644 src/Icons/Reply.tsx create mode 100644 src/Icons/Zap.tsx diff --git a/d.ts b/d.ts index 63fda725..457c08e1 100644 --- a/d.ts +++ b/d.ts @@ -2,3 +2,8 @@ declare module "*.jpg" { const value: any export default value } + +declare module "*.svg" { + const value: any + export default value +} diff --git a/src/Element/Avatar.css b/src/Element/Avatar.css index a474f224..b3bca95e 100644 --- a/src/Element/Avatar.css +++ b/src/Element/Avatar.css @@ -2,8 +2,8 @@ border-radius: 50%; height: 210px; width: 210px; - background-image: var(--img-url), var(--gray-gradient); - border: 2px solid transparent; + background-image: var(--img-url); + border: 1px solid transparent; background-origin: border-box; background-clip: content-box, border-box; background-size: cover; diff --git a/src/Element/Copy.css b/src/Element/Copy.css index 35af72ab..2c99df14 100644 --- a/src/Element/Copy.css +++ b/src/Element/Copy.css @@ -1,15 +1,9 @@ .copy { - user-select: none; - cursor: pointer; - -webkit-tap-highlight-color: transparent; + cursor: pointer; } .copy .body { - font-family: monospace; - font-size: 14px; - background: var(--note-bg); + font-size: var(--font-size-small); color: var(--font-color); - padding: 2px 4px; - border-radius: 10px; - margin: 0 4px 0 0; + margin-right: 8px; } diff --git a/src/Element/Copy.tsx b/src/Element/Copy.tsx index 0168447c..a2e557b4 100644 --- a/src/Element/Copy.tsx +++ b/src/Element/Copy.tsx @@ -10,7 +10,7 @@ export interface CopyProps { export default function Copy({ text, maxSize = 32 }: CopyProps) { const { copy, copied, error } = useCopy(); const sliceLength = maxSize / 2 - const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}:${text.slice(-sliceLength)}` : text + const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text return (
copy(text)}> @@ -20,7 +20,7 @@ export default function Copy({ text, maxSize = 32 }: CopyProps) {
) diff --git a/src/Element/FollowButton.tsx b/src/Element/FollowButton.tsx index 747813bb..7609d5ad 100644 --- a/src/Element/FollowButton.tsx +++ b/src/Element/FollowButton.tsx @@ -7,14 +7,12 @@ import { RootState } from "State/Store"; export interface FollowButtonProps { pubkey: HexKey, - className?: string, + className?: string } export default function FollowButton(props: FollowButtonProps) { const pubkey = props.pubkey; const publiser = useEventPublisher(); const isFollowing = useSelector(s => s.login.follows?.includes(pubkey) ?? false); - const baseClassName = isFollowing ? `btn btn-warn follow-button` : `btn btn-success follow-button` - const className = props.className ? `${baseClassName} ${props.className}` : `${baseClassName}`; async function follow(pubkey: HexKey) { let ev = await publiser.addFollow(pubkey); @@ -27,8 +25,8 @@ export default function FollowButton(props: FollowButtonProps) { } return ( -
isFollowing ? unfollow(pubkey) : follow(pubkey)}> - -
+ ) -} \ No newline at end of file +} diff --git a/src/Element/FollowListBase.tsx b/src/Element/FollowListBase.tsx index 7b8dc85d..c8692f36 100644 --- a/src/Element/FollowListBase.tsx +++ b/src/Element/FollowListBase.tsx @@ -15,12 +15,12 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) } return ( - <> +
{title}
-
followAll()}>Follow All
+
{pubkeys?.map(a => )} - +
) -} \ No newline at end of file +} diff --git a/src/Element/FollowsYou.css b/src/Element/FollowsYou.css index 23fb9b9e..744aa217 100644 --- a/src/Element/FollowsYou.css +++ b/src/Element/FollowsYou.css @@ -1,5 +1,5 @@ .follows-you { - color: var(--font-color); + color: var(--gray-light); font-size: var(--font-size-tiny); margin-left: .2em; font-weight: normal diff --git a/src/Element/LNURLTip.css b/src/Element/LNURLTip.css index 74e4cd25..20fb44ab 100644 --- a/src/Element/LNURLTip.css +++ b/src/Element/LNURLTip.css @@ -1,10 +1,5 @@ .lnurl-tip { - background-color: var(--note-bg); - padding: 10px; - border-radius: 10px; - width: 500px; text-align: center; - min-height: 10vh; } .lnurl-tip .btn { @@ -62,10 +57,3 @@ align-items: center; justify-content: center; } - -@media(max-width: 720px) { - .lnurl-tip { - width: 100vw; - margin: 0 10px; - } -} diff --git a/src/Element/Modal.css b/src/Element/Modal.css index 3d5ebee2..37dd7ec4 100644 --- a/src/Element/Modal.css +++ b/src/Element/Modal.css @@ -8,5 +8,20 @@ display: flex; justify-content: center; align-items: center; - z-index: 9999999; + z-index: 42; +} + +.modal-body { + background-color: var(--note-bg); + padding: 10px; + border-radius: 10px; + width: 500px; + min-height: 10vh; +} + +@media(max-width: 720px) { + .modal-body { + width: 100vw; + margin: 0 10px; + } } diff --git a/src/Element/Modal.tsx b/src/Element/Modal.tsx index d91ad504..0fefb877 100644 --- a/src/Element/Modal.tsx +++ b/src/Element/Modal.tsx @@ -17,7 +17,9 @@ export default function Modal(props: ModalProps) { return (
{ e.stopPropagation(); onClose(); }}> +
{props.children} +
) -} \ No newline at end of file +} diff --git a/src/Element/Nip05.css b/src/Element/Nip05.css index 2cc3ef46..866e62a8 100644 --- a/src/Element/Nip05.css +++ b/src/Element/Nip05.css @@ -1,7 +1,6 @@ .nip05 { justify-content: flex-start; align-items: center; - font-size: 14px; margin: .2em; } @@ -10,50 +9,8 @@ } .nip05 .nick { - color: var(--gray-light); + color: var(--font-secondary-color); font-weight: bold; - margin-right: .2em; -} - -.nip05 .domain { - color: var(--gray-superlight); - font-weight: bold; -} - -.nip05 .text-gradient { - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - text-fill-color: transparent; - background-color: var(--gray-superlight); -} - -.nip05 .domain[data-domain="snort.social"] { - background-image: var(--snort-gradient); -} - -.nip05 .domain[data-domain="nostrplebs.com"] { - background-image: var(--nostrplebs-gradient); -} - -.nip05 .domain[data-domain="nostrpurple.com"] { - background-image: var(--nostrplebs-gradient); -} - -.nip05 .domain[data-domain="nostr.fan"] { - background-image: var(--nostrplebs-gradient); -} - -.nip05 .domain[data-domain="nostrich.zone"] { - background-image: var(--nostrplebs-gradient); -} - -.nip05 .domain[data-domain="nostriches.net"] { - background-image: var(--nostrplebs-gradient); -} - -.nip05 .domain[data-domain="strike.army"] { - background-image: var(--strike-army-gradient); } .nip05 .badge { diff --git a/src/Element/Nip05.tsx b/src/Element/Nip05.tsx index d68332e2..99e860a6 100644 --- a/src/Element/Nip05.tsx +++ b/src/Element/Nip05.tsx @@ -1,7 +1,7 @@ import { useQuery } from "react-query"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { faCircleCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import './Nip05.css' import { HexKey } from "Nostr"; @@ -57,16 +57,20 @@ const Nip05 = (props: Nip05Params) => { const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05) return ( -
- {!isDefaultUser && ( -
- {name} -
- )} -
- {domain} +
ev.stopPropagation()}> +
+ {isDefaultUser ? ( + `${domain}` + ) : `@${name}`}
+ {isVerified && ( + + )} {!isVerified && !couldNotVerify && ( .header .reply a { + color: var(--highlight); +} + +.note>.header .reply a:hover { + text-decoration-color: var(--highlight); +} + .note>.header>.info { font-size: var(--font-size); white-space: nowrap; @@ -18,7 +26,7 @@ } .note>.body { - margin-top: 12px; + margin-top: 4px; padding-left: 56px; text-overflow: ellipsis; white-space: pre-wrap; diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index d68d1383..68a54288 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -1,6 +1,6 @@ import "./Note.css"; -import { useCallback, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useMemo, ReactNode } from "react"; +import { useNavigate, Link } from "react-router-dom"; import { default as NEvent } from "Nostr/Event"; import ProfileImage from "Element/ProfileImage"; @@ -64,21 +64,57 @@ export default function Note(props: NoteProps) { const maxMentions = 2; let replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; - let mentions: string[] = []; + let mentions: {pk: string, name: string, link: ReactNode}[] = []; for (let pk of ev.Thread?.PubKeys) { - let u = users?.get(pk); + const u = users?.get(pk); + const npub = hexToBech32("npub", pk) + const shortNpub = npub.substring(0, 12); if (u) { - mentions.push(u.name ?? hexToBech32("npub", pk).substring(0, 12)); + mentions.push({ + pk, + name: u.name ?? shortNpub, + link: ( + + {u.name ? `@${u.name}` : shortNpub} + + ) + }); } else { - mentions.push(hexToBech32("npub", pk).substring(0, 12)); + mentions.push({ + pk, + name: shortNpub, + link: ( + + {shortNpub} + + ) + }); } } - mentions.sort((a, b) => a.startsWith("npub") ? 1 : -1); + mentions.sort((a, b) => a.name.startsWith("npub") ? 1 : -1); let othersLength = mentions.length - maxMentions - let pubMentions = mentions.length > maxMentions ? `${mentions?.slice(0, maxMentions).join(", ")} & ${othersLength} other${othersLength > 1 ? 's' : ''}` : mentions?.join(", "); + const renderMention = (m: any, idx: number) => { + return ( + <> + {idx > 0 && ", "} + {m.link} + + ) + } + const pubMentions = mentions.length > maxMentions ? ( + mentions?.slice(0, maxMentions).map(renderMention) + ) : mentions?.map(renderMention); + const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : '' return (
- {(pubMentions?.length ?? 0) > 0 ? pubMentions : replyId ? hexToBech32("note", replyId)?.substring(0, 12) : ""} + {(mentions?.length ?? 0) > 0 ? ( + <> + {pubMentions} + {others} + + ) : replyId ? ( + hexToBech32("note", replyId)?.substring(0, 12) // todo: link + ) : ""}
) } diff --git a/src/Element/NoteCreator.css b/src/Element/NoteCreator.css index 9dce9434..2a4ad48a 100644 --- a/src/Element/NoteCreator.css +++ b/src/Element/NoteCreator.css @@ -13,23 +13,28 @@ .note-creator textarea { outline: none; resize: none; - min-height: 40px; background-color: var(--note-bg); border-radius: 10px 10px 0 0; max-width: stretch; min-width: stretch; } -.note-creator .actions { +.note-creator-actions { width: 100%; + display: flex; + flex-direction: row; + align-items: center; justify-content: flex-end; margin-bottom: 5px; } .note-creator .attachment { cursor: pointer; - padding: 5px 10px; - border-radius: 10px; + margin-left: auto; +} + +.note-creator-actions button:not(:last-child) { + margin-right: 4px; } .note-creator .attachment .error { @@ -45,3 +50,26 @@ color: var(--font-color); font-size: var(--font-size); } + +.note-create-button { + width: 48px; + height: 48px; + background-color: var(--highlight); + border: none; + border-radius: 100%; + position: fixed; + bottom: 50px; + right: 16px; +} + +@media (min-width: 520px) { + .note-create-button { + right: 10vw; + } +} + +@media (min-width: 1020px) { + .note-create-button { + right: 25vw; + } +} diff --git a/src/Element/NoteCreator.tsx b/src/Element/NoteCreator.tsx index 426bfce1..3dfda16e 100644 --- a/src/Element/NoteCreator.tsx +++ b/src/Element/NoteCreator.tsx @@ -4,21 +4,26 @@ import { faPaperclip } from "@fortawesome/free-solid-svg-icons"; import "./NoteCreator.css"; +import Plus from "Icons/Plus"; import useEventPublisher from "Feed/EventPublisher"; import { openFile } from "Util"; import VoidUpload from "Feed/VoidUpload"; import { FileExtensionRegex } from "Const"; import Textarea from "Element/Textarea"; -import Event, { default as NEvent } from "Nostr/Event"; +import Modal from "Element/Modal"; +import { default as NEvent } from "Nostr/Event"; export interface NoteCreatorProps { + show: boolean + setShow: (s: boolean) => void replyTo?: NEvent, onSend?: Function, - show: boolean, + onClose?(): void autoFocus: boolean } export function NoteCreator(props: NoteCreatorProps) { + const { show, setShow } = props const publisher = useEventPublisher(); const [note, setNote] = useState(); const [error, setError] = useState(); @@ -68,14 +73,23 @@ export function NoteCreator(props: NoteCreatorProps) { } } + function cancel(ev: any) { + setShow(false) + setNote("") + } + function onSubmit(ev: React.MouseEvent) { ev.stopPropagation(); sendNote().catch(console.warn); } - if (!props.show) return null; return ( <> + + {show && ( +
-
sendDm()}>Send
+
) -} \ No newline at end of file +} diff --git a/src/Pages/DonatePage.tsx b/src/Pages/DonatePage.tsx index b5be933c..8421bcfd 100644 --- a/src/Pages/DonatePage.tsx +++ b/src/Pages/DonatePage.tsx @@ -44,7 +44,7 @@ const DonatePage = () => { } return ( -
+

Help fund the development of Snort

Snort is an open source project built by passionate people in their free time diff --git a/src/Pages/Layout.css b/src/Pages/Layout.css index 76462f49..d8664c1a 100644 --- a/src/Pages/Layout.css +++ b/src/Pages/Layout.css @@ -1,20 +1,37 @@ .logo { cursor: pointer; + font-weight: bold; + font-size: 29px; } -.unread-count { - width: 20px; - height: 20px; - border: 1px solid; - border-radius: 100%; - position: relative; - padding: 3px; - line-height: 1.5em; - top: -10px; - left: -10px; - font-size: var(--font-size-small); - background-color: var(--error); - color: var(--note-bg); - font-weight: bold; - text-align: center; +header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 72px; + padding: 0 12px; +} + +header .pfp .avatar-wrapper { + margin-right: 0; +} + +.header-actions { + display: flex; + flex-direction: row; +} + +.header-actions .btn-rnd { + position: relative; +} + +.header-actions .btn-rnd .has-unread { + background: var(--highlight); + border-radius: 100%; + width: 9px; + height: 9px; + position: absolute; + top: 0; + right: 0; } diff --git a/src/Pages/Layout.tsx b/src/Pages/Layout.tsx index dffdc48a..4243189e 100644 --- a/src/Pages/Layout.tsx +++ b/src/Pages/Layout.tsx @@ -2,8 +2,8 @@ import "./Layout.css"; import { useEffect } from "react" import { useDispatch, useSelector } from "react-redux"; import { Outlet, useNavigate } from "react-router-dom"; -import { faBell, faMessage } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Envelope from "Icons/Envelope" +import Bell from "Icons/Bell" import { RootState } from "State/Store"; import { init, setPreferences, UserPreferences } from "State/Login"; @@ -84,21 +84,17 @@ export default function Layout() { const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length; const unreadDms = key ? totalUnread(dms, key) : 0; return ( - <> +

navigate("/messages")}> - + + {unreadDms > 0 && ()}
- {unreadDms > 0 && ( - {unreadDms > 100 ? ">99" : unreadDms} - )}
goToNotifications(e)}> - + + {unreadNotifications > 0 && ()}
- {unreadNotifications > 0 && ( - {unreadNotifications > 100 ? ">99" : unreadNotifications} - )} - +
) } @@ -108,14 +104,14 @@ export default function Layout() { return (
-
-
navigate("/")}>snort
+
+
navigate("/")}>Snort
{key ? accountHeader() : -
navigate("/login")}>Login
+ }
-
+
diff --git a/src/Pages/Login.tsx b/src/Pages/Login.tsx index cf117b86..0768af51 100644 --- a/src/Pages/Login.tsx +++ b/src/Pages/Login.tsx @@ -85,24 +85,24 @@ export default function LoginPage() { <>

Other Login Methods

-
doNip07Login()}>Login with Extension (NIP-07)
+
) } return ( - <> +

Login

setKey(e.target.value)} />
{error.length > 0 ? {error} : null}
-
doLogin()}>Login
-
makeRandomKey()}>Generate Key
+ +
{altLogins()} - +
); -} \ No newline at end of file +} diff --git a/src/Pages/MessagesPage.tsx b/src/Pages/MessagesPage.tsx index 5142f016..f0936fb4 100644 --- a/src/Pages/MessagesPage.tsx +++ b/src/Pages/MessagesPage.tsx @@ -51,17 +51,17 @@ export default function MessagesPage() { } return ( - <> +

Messages

-
markAllRead()}>Mark All Read
+
{chats.sort((a, b) => { return a.pubkey === myPubKey ? -1 : b.pubkey === myPubKey ? 1 : b.newestMessage - a.newestMessage }).map(person)} - +
) } @@ -122,4 +122,4 @@ export function extractChats(dms: RawEvent[], myPubKey: HexKey) { newestMessage: newestMessage(dms, myPubKey, a) } as DmChat; }) -} \ No newline at end of file +} diff --git a/src/Pages/NewUserPage.tsx b/src/Pages/NewUserPage.tsx index f268856f..fdd8c61e 100644 --- a/src/Pages/NewUserPage.tsx +++ b/src/Pages/NewUserPage.tsx @@ -67,9 +67,9 @@ export default function NewUserPage() { } return ( - <> +
{importTwitterFollows()} {followSomebody()} - +
); } \ No newline at end of file diff --git a/src/Pages/ProfilePage.css b/src/Pages/ProfilePage.css index 5479f191..a97143c3 100644 --- a/src/Pages/ProfilePage.css +++ b/src/Pages/ProfilePage.css @@ -1,26 +1,29 @@ .profile { + display: flex; flex-direction: column; + align-items: flex-start; } .profile .banner { - width: 100%; - height: 210px; - margin-bottom: -80px; - object-fit: cover; - mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0)); - -webkit-mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0)); - z-index: 0; + width: 100%; + height: 160px; + object-fit: cover; + margin-bottom: -60px; + mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0)); + -webkit-mask-image: linear-gradient(to bottom, var(--bg-color) 60%, rgba(0,0,0,0)); + z-index: 0; } -@media (min-width: 720px) { - .profile .banner { - width: 100%; - max-width: 720px; - height: 300px; - margin-bottom: -120px; - } +.profile .profile-wrapper { + margin: 0 16px; + width: calc(100% - 32px); + display: flex; + flex-direction: column; + align-items: flex-start; + position: relative; } + .profile p { white-space: pre-wrap; } @@ -29,35 +32,37 @@ margin: 0; } -@media (min-width: 720px) { - .profile .banner { - width: 100%; - max-width: 720px; - height: 300px; - margin-bottom: -120px; - } +.profile .nip05 { + display: flex; + margin: 4px 0 12px 0; +} + +.profile .nip05 .nick { + font-weight: normal; + color: var(--gray-light); } .profile .avatar-wrapper { - align-self: flex-start; - z-index: 1; - margin-left: 4px; + z-index: 1; +} + +.profile .avatar-wrapper .avatar { + width: 120px; + height: 120px; } .profile .name { - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.profile .name h2 { - margin: 0; + display: flex; + flex-direction: column; } .profile .details { - max-width: 680px; width: 100%; margin-top: 12px; + background-color: var(--note-bg); + padding: 12px 16px; + margin: 0 auto; + border-radius: 16px; } .profile .details p { @@ -76,129 +81,63 @@ .profile .btn-icon { color: var(--font-color); padding: 6px; - margin-left: 4px; } .profile .details-wrapper { display: flex; flex-direction: column; - align-items: flex-start; justify-content: space-between; - position: relative; - width: 100%; - margin-left: 4px; -} - -.profile .copy .body { font-size: 12px } - -@media (min-width: 360px) { - .profile .copy .body { font-size: 14px } - .profile .details-wrapper, .profile .avatar-wrapper { margin-left: 21px; } - .profile .details { width: calc(100% - 21px); } -} - -@media (min-width: 720px) { - .profile .details-wrapper, .profile .avatar-wrapper { margin-left: 30px; } - .profile .details { width: calc(100% - 30px); } -} - -.profile .follow-button { - position: absolute; - top: -30px; - right: 20px; -} - -.profile .message-button { - position: absolute; - top: -30px; - right: 74px; -} - -.profile .no-banner .follow-button { - right: 0px; -} -.profile .no-banner .message-button { - right: 54px; -} - -.tabs { - display: flex; - justify-content: flex-start; - width: 100%; - margin: 10px 0; + width: calc(100% - 32px); } .tabs > div { margin-right: 0; } -.tab { - margin: 0; - padding: 8px 0; - border-bottom: 3px solid var(--gray-secondary); -} -.tab.active { - border-bottom: 3px solid var(--highlight); -} - -.profile .no-banner { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; -} - -.profile .no-banner .avatar { - height: 256px; - width: 256px; - margin-bottom: 30px; -} - -.profile .no-banner .avatar-wrapper, .profile .no-banner .details-wrapper { - margin: 0 auto; -} - -@media (min-width: 720px) { - .profile .no-banner { - width: 100%; - flex-direction: row; - justify-content: space-around; - margin-top: 21px; - } - .profile .no-banner .avatar-wrapper { - margin: auto 10px; - } - .profile .no-banner .details-wrapper { - margin-left: 10px; - margin-top: 21px; - max-width: 420px; - } -} - .profile .links { - margin: 8px 12px; + margin-top: 4px; + margin-left: 2px; +} + +.profile h3 { + color: var(--font-secondary-color); + font-size: 10px; + letter-spacing: .11em; + font-weight: 600; + line-height: 12px; + text-transform: uppercase; + margin-left: 16px; } .profile .website { - color: var(--highlight); - margin: 6px 0; + margin: 4px 0; + display: flex; + flex-direction: row; + align-items: center; +} + +.profile .website a { + color: var(--font-color); } .profile .website a { text-decoration: none; } -.profile .website::before { - content: '🔗 '; +.profile .website a:hover { + text-decoration: underline; } .profile .lnurl { - color: var(--highlight); - margin: 6px 0; cursor: pointer; } +.profile .ln-address { + display: flex; + flex-direction: row; + align-items: center; +} + .profile .lnurl:hover { text-decoration: underline; } @@ -208,6 +147,42 @@ text-overflow: ellipsis; } -.profile .zap { - margin-right: .3em; +.profile .link-icon { + color: var(--highlight); + margin-right: 8px; } + +.profile .link-icon svg { + width: 12px; + height: 12px; +} + +.profile .profile-actions { + position: absolute; + top: 80px; + right: 0; +} + +@media (min-width: 520px) { + .profile .profile-actions { + top: 120px; + } +} + +.profile .profile-actions button:not(:last-child) { + margin-right: 8px; +} + +@media (min-width: 520px) { + .profile .banner { + width: 100%; + max-width: 720px; + height: 300px; + margin-bottom: -100px; + } + .profile .avatar-wrapper .avatar { + width: 210px; + height: 210px; + } +} + diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 8ad0e744..d92101fa 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -2,10 +2,10 @@ import "./ProfilePage.css"; import { useEffect, useMemo, useState } from "react"; import { useSelector } from "react-redux"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faGear, faEnvelope } from "@fortawesome/free-solid-svg-icons"; import { useNavigate, useParams } from "react-router-dom"; +import Link from "Icons/Link"; +import Zap from "Icons/Zap"; import useProfile from "Feed/ProfileFeed"; import FollowButton from "Element/FollowButton"; import { extractLnAddress, parseId, hexToBech32 } from "Util"; @@ -39,7 +39,9 @@ export default function ProfilePage() { const isMe = loginPubKey === id; const [showLnQr, setShowLnQr] = useState(false); const [tab, setTab] = useState(ProfileTab.Notes); + const aboutText = user?.about || '' const about = Text({ content: user?.about || '', tags: [], users: new Map() }) + const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || ""); useEffect(() => { setTab(ProfileTab.Notes); @@ -52,36 +54,48 @@ export default function ProfilePage() { {user?.display_name || user?.name || 'Nostrich'} - {user?.nip05 && } + + {links()}
) } + function links() { + return ( +
+ {user?.website && ( +
+ + + + {user.website} +
+ )} + + {lnurl && ( +
setShowLnQr(true)}> + + + + + {lnurl} + +
+ )} + setShowLnQr(false)} /> +
+ ) + } + function bio() { - const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || ""); - return ( -
-
{about}
- -
- {user?.website && ( - - )} - - {lnurl && ( -
setShowLnQr(true)}> - ⚡️ - - {lnurl} - -
- )} -
- setShowLnQr(false)} /> -
+ return aboutText.length > 0 && ( + <> +

Bio

+
+ {about} +
+ ) } @@ -119,17 +133,20 @@ export default function ProfilePage() { return (
{username()} - {isMe ? ( -
navigate("/settings")}> - -
- ) : <> -
navigate(`/messages/${hexToBech32("npub", id)}`)}> - -
- - - } +
+ {isMe ? ( + + ) : ( + <> + + + + )} +
{bio()}
) @@ -138,18 +155,11 @@ export default function ProfilePage() { return ( <>
- {user?.banner && banner} - {user?.banner ? ( - <> - {avatar()} - {userDetails()} - - ) : ( -
- {avatar()} - {userDetails()} -
- )} + {user?.banner && banner} +
+ {avatar()} + {userDetails()} +
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => { diff --git a/src/Pages/Root.tsx b/src/Pages/Root.tsx index 75383348..739e8e24 100644 --- a/src/Pages/Root.tsx +++ b/src/Pages/Root.tsx @@ -16,6 +16,7 @@ const RootTab = { }; export default function RootPage() { + const [show, setShow] = useState(false) const [loggedOut, pubKey, follows] = useSelector(s => [s.login.loggedOut, s.login.publicKey, s.login.follows]); const [tab, setTab] = useState(RootTab.Posts); @@ -32,20 +33,20 @@ export default function RootPage() { return ( <> {pubKey ? <> - -
-
setTab(RootTab.Posts)}> +
+
setTab(RootTab.Posts)}> Posts
-
setTab(RootTab.PostsAndReplies)}> - Posts & Replies +
setTab(RootTab.PostsAndReplies)}> + Conversations
-
setTab(RootTab.Global)}> +
setTab(RootTab.Global)}> Global
: null} {followHints()} + ); -} \ No newline at end of file +} diff --git a/src/Pages/SettingsPage.tsx b/src/Pages/SettingsPage.tsx index aa60cd87..6af73c9d 100644 --- a/src/Pages/SettingsPage.tsx +++ b/src/Pages/SettingsPage.tsx @@ -9,10 +9,10 @@ export default function SettingsPage() { const navigate = useNavigate(); return ( - <> +

navigate("/settings")} className="pointer">Settings

- +
); } diff --git a/src/Pages/Verification.tsx b/src/Pages/Verification.tsx index 683c9025..3732bca1 100644 --- a/src/Pages/Verification.tsx +++ b/src/Pages/Verification.tsx @@ -24,7 +24,7 @@ export default function VerificationPage() { ]; return ( -
+

Get Verified

NIP-05 is a DNS based verification spec which helps to validate you as a real user. diff --git a/src/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx index 482340da..bd96889f 100644 --- a/src/Pages/settings/Profile.tsx +++ b/src/Pages/settings/Profile.tsx @@ -128,11 +128,11 @@ export default function ProfileSettings() {

NIP-05:
setNip05(e.target.value)} /> -
navigate("/verification")}> +
+
@@ -143,10 +143,10 @@ export default function ProfileSettings() {
-
{ dispatch(logout()); navigate("/"); }}>Logout
+
-
saveProfile()}>Save
+
diff --git a/src/Pages/settings/Relays.tsx b/src/Pages/settings/Relays.tsx index b5e2d241..f8c83383 100644 --- a/src/Pages/settings/Relays.tsx +++ b/src/Pages/settings/Relays.tsx @@ -27,7 +27,7 @@ const RelaySettingsPage = () => {
setNewRelay(e.target.value)} />
-
addNewRelay()}>Add
+ ) } @@ -49,16 +49,16 @@ const RelaySettingsPage = () => { return ( <>

Relays

-
+
{Object.keys(relays || {}).map(a => )}
-
saveRelays()}>Save
+
{addRelay()} ) } -export default RelaySettingsPage; \ No newline at end of file +export default RelaySettingsPage; diff --git a/src/index.css b/src/index.css index 8556f0db..e0153880 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Be+Bietnam+Pro:wght@400;600;700&display=swap'); :root { --bg-color: #000; @@ -9,10 +9,8 @@ --font-size-small: 14px; --font-size-tiny: 12px; --modal-bg-color: rgba(0, 0, 0, 0.8); - --note-bg: #111; - --highlight-light: #ffd342; - --highlight: #ffc400; - --highlight-dark: #dba800; + --note-bg: #0C0C0C; + --highlight: #8B5CF6; --error: #FF6053; --success: #2AD544; @@ -22,8 +20,10 @@ --gray: #333; --gray-secondary: #222; --gray-tertiary: #444; + --gray-dark: #2B2B2B; + --gray-superdark: #171717; --gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light)); - --snort-gradient: linear-gradient(to bottom right, var(--highlight-light), var(--highlight), var(--highlight-dark)); + --snort-gradient: linear-gradient(180deg, #FFC7B7 0%, #4F1B73 100%); --nostrplebs-gradient: linear-gradient(to bottom right, #ff3cac, #2b86c5); --strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900); } @@ -46,11 +46,13 @@ html.light { --gray-tertiary: #EEE; --gray-superlight: #333; --gray-light: #555; + --gray-dark: #2B2B2B; + --gray-superdark: #171717; } body { margin: 0; - font-family: 'Montserrat', sans-serif; + font-family: 'Be Vietnam Pro', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: var(--bg-color); @@ -63,25 +65,17 @@ code { } .page { - width: 720px; + width: 100vw; margin-left: auto; margin-right: auto; } -.page>.header { - display: flex; - align-items: center; - margin: 10px 0; -} - -.page>.header>div:nth-child(1) { - font-size: x-large; - flex-grow: 1; -} - -.page>.header>div:nth-child(2) { - display: flex; - align-items: center; +@media (min-width: 720px) { + .page { + width: 720px; + margin-left: auto; + margin-right: auto; + } } .card { @@ -115,10 +109,42 @@ html.light .card { margin-top: 12px; } +button { + cursor: pointer; + padding: 6px 12px; + font-weight: bold; + color: white; + font-size: var(--font-size); + background-color: var(--highlight); + border: none; + border-radius: 16px; + outline: none; +} +button:hover { + background-color: var(--font-color); + color: var(--bg-color); +} + +button.secondary { + color: var(--font-color); + background-color: var(--gray-dark); +} +.light button.secondary { + background-color: var(--gray); +} +button.secondary:hover { + color: var(--font-color); + background-color: var(--gray-superdark); +} +.light button.secondary:hover { + background-color: var(--gray-secondary); +} + .btn { padding: 10px; border-radius: 5px; cursor: pointer; + color: var(--font-color); user-select: none; background-color: var(--bg-color); color: var(--font-color); @@ -155,6 +181,17 @@ html.light .card { .btn-rnd { border-radius: 100%; + border-color: var(--gray-superdark); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 16px; +} + +.light .btn-rnd { + border-color: var(--gray); } textarea { @@ -365,18 +402,23 @@ body.scroll-lock { background-color: var(--success); } -.root-tabs { +.tabs { padding: 0; align-items: center; justify-content: flex-start; + margin-bottom: 16px; } -.root-tab { - border-bottom: 3px solid var(--gray-secondary); +.tab { + border-bottom: 1px solid var(--gray-secondary); + font-weight: bold; + color: var(--font-secondary-color); + padding: 8px 0; } -.root-tab.active { - border-bottom: 3px solid var(--highlight); +.tab.active { + border-bottom: 1px solid var(--highlight); + color: var(--font-color); } .tweet { @@ -398,10 +440,6 @@ body.scroll-lock { } @media(max-width: 720px) { - .page { - width: calc(100vw - 8px); - } - div.form-group { flex-direction: column; align-items: flex-start; @@ -410,4 +448,15 @@ body.scroll-lock { .highlight { color: var(--highlight); -} \ No newline at end of file +} + +.main-content { + padding: 0 12px; +} + +@media (min-width: 720px) { + .main-content { + padding: 0; + } +} + From 2b4bb776fcf7d58cbce903c1e77080088ece6bae Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Wed, 25 Jan 2023 19:16:24 +0100 Subject: [PATCH 02/36] fix: hide note creator on send --- src/Element/NoteCreator.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Element/NoteCreator.tsx b/src/Element/NoteCreator.tsx index 3dfda16e..b4c07f18 100644 --- a/src/Element/NoteCreator.tsx +++ b/src/Element/NoteCreator.tsx @@ -35,6 +35,7 @@ export function NoteCreator(props: NoteCreatorProps) { console.debug("Sending note: ", ev); publisher.broadcast(ev); setNote(""); + setShow(false); if (typeof props.onSend === "function") { props.onSend(); } From 421baf8916909efd16fefba59403780105138346 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Wed, 25 Jan 2023 19:31:44 +0100 Subject: [PATCH 03/36] fix: add bottom margin to thread --- src/Element/Thread.css | 3 +++ src/Element/Thread.tsx | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 src/Element/Thread.css diff --git a/src/Element/Thread.css b/src/Element/Thread.css new file mode 100644 index 00000000..c6267f18 --- /dev/null +++ b/src/Element/Thread.css @@ -0,0 +1,3 @@ +.thread-container { + margin: 12px 0 150px 0; +} diff --git a/src/Element/Thread.tsx b/src/Element/Thread.tsx index cfaa0b82..bc3fbef9 100644 --- a/src/Element/Thread.tsx +++ b/src/Element/Thread.tsx @@ -1,3 +1,4 @@ +import "./Thread.css"; import { useMemo } from "react"; import { Link } from "react-router-dom"; import { TaggedRawEvent, u256 } from "Nostr"; @@ -82,7 +83,7 @@ export default function Thread(props: ThreadProps) { } return ( - <> +
{renderRoot()} {root ? renderChain(root.Id) : null} {root ? null : <> @@ -98,6 +99,6 @@ export default function Thread(props: ThreadProps) { ) })} } - +
); } \ No newline at end of file From 8d523a9e6d9051d3991550c30105df8f957ba55b Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Wed, 25 Jan 2023 22:19:50 +0100 Subject: [PATCH 04/36] fix: put domain back --- src/Element/Nip05.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Element/Nip05.tsx b/src/Element/Nip05.tsx index 99e860a6..ef508137 100644 --- a/src/Element/Nip05.tsx +++ b/src/Element/Nip05.tsx @@ -58,11 +58,14 @@ const Nip05 = (props: Nip05Params) => { return (
ev.stopPropagation()}> -
- {isDefaultUser ? ( - `${domain}` - ) : `@${name}`} -
+ {!isDefaultUser && ( +
+ `@${name}`} +
+ )} + + {domain} + {isVerified && ( Date: Wed, 25 Jan 2023 22:41:01 +0100 Subject: [PATCH 05/36] fix: nip05 domains --- src/Element/Nip05.css | 40 +++++++++++++++++++++++++++++------- src/Element/Nip05.tsx | 4 ++-- src/Element/ProfileImage.css | 4 ++-- src/Element/ProfileImage.tsx | 2 +- src/Pages/ProfilePage.tsx | 4 ++-- src/index.css | 1 + 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/Element/Nip05.css b/src/Element/Nip05.css index 866e62a8..d9d6f0ec 100644 --- a/src/Element/Nip05.css +++ b/src/Element/Nip05.css @@ -1,18 +1,44 @@ .nip05 { - justify-content: flex-start; - align-items: center; - margin: .2em; + justify-content: flex-start; + align-items: center; } .nip05.failed { - text-decoration: line-through; + text-decoration: line-through; } .nip05 .nick { - color: var(--font-secondary-color); - font-weight: bold; + color: var(--font-secondary-color); + font-weight: bold; +} + +.nip05 .domain { + color: var(--gray-light); + font-weight: bold; + margin-left: .2em; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; + background-color: var(--gray-light); +} + +.nip05 .domain[data-domain="snort.social"] { + background-image: var(--nostrplebs-gradient); +} + +.nip05 .domain[data-domain="nostrplebs.com"] { + background-image: var(--nostrplebs-gradient); +} + +.nip05 .domain[data-domain="nostrpurple.com"] { + background-image: var(--nostrplebs-gradient); +} + +.nip05 .domain[data-domain="nostr.fan"] { + background-image: var(--nostrplebs-gradient); } .nip05 .badge { - margin: .1em .2em; + margin: .1em .2em; } diff --git a/src/Element/Nip05.tsx b/src/Element/Nip05.tsx index ef508137..0d4ac65a 100644 --- a/src/Element/Nip05.tsx +++ b/src/Element/Nip05.tsx @@ -60,10 +60,10 @@ const Nip05 = (props: Nip05Params) => {
ev.stopPropagation()}> {!isDefaultUser && (
- `@${name}`} + {`@${name}`}
)} - + {domain} diff --git a/src/Element/ProfileImage.css b/src/Element/ProfileImage.css index 718f9342..65ff650e 100644 --- a/src/Element/ProfileImage.css +++ b/src/Element/ProfileImage.css @@ -24,8 +24,8 @@ .pfp .username { display: flex; - flex-direction: row; - align-items: center; + flex-direction: column; + align-items: flex-start; font-weight: bold; } diff --git a/src/Element/ProfileImage.tsx b/src/Element/ProfileImage.tsx index 32412aa8..542e6d6d 100644 --- a/src/Element/ProfileImage.tsx +++ b/src/Element/ProfileImage.tsx @@ -35,8 +35,8 @@ export default function ProfileImage({ pubkey, subHeader, showUsername = true, c
{name} + {user?.nip05 && } - {user?.nip05 && }
{subHeader} diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index d92101fa..c84ddd61 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -106,10 +106,10 @@ export default function ProfilePage() { case ProfileTab.Follows: { if (isMe) { return ( - <> +

Following {follows.length}

{follows.map(a => )} - +
); } else { return ; diff --git a/src/index.css b/src/index.css index e0153880..526ea45b 100644 --- a/src/index.css +++ b/src/index.css @@ -25,6 +25,7 @@ --gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light)); --snort-gradient: linear-gradient(180deg, #FFC7B7 0%, #4F1B73 100%); --nostrplebs-gradient: linear-gradient(to bottom right, #ff3cac, #2b86c5); + --snort-text-gradient: linear-gradient(to bottom right, #8B5CF6, #0284C7); --strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900); } From 5fa7a57c9a28d467efb4451bafddd1e497eb625f Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 07:13:09 +0100 Subject: [PATCH 06/36] fix: font weight and line heights --- src/Element/Nip05.css | 3 +-- src/Element/Note.css | 1 + src/Element/ProfileImage.css | 3 +-- src/Element/Text.css | 5 +++++ src/Pages/Layout.css | 3 ++- src/index.css | 11 ++++++----- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Element/Nip05.css b/src/Element/Nip05.css index d9d6f0ec..1cd799ae 100644 --- a/src/Element/Nip05.css +++ b/src/Element/Nip05.css @@ -1,6 +1,7 @@ .nip05 { justify-content: flex-start; align-items: center; + font-weight: normal; } .nip05.failed { @@ -9,12 +10,10 @@ .nip05 .nick { color: var(--font-secondary-color); - font-weight: bold; } .nip05 .domain { color: var(--gray-light); - font-weight: bold; margin-left: .2em; -webkit-background-clip: text; -webkit-text-fill-color: transparent; diff --git a/src/Element/Note.css b/src/Element/Note.css index 965090c8..fe2d9293 100644 --- a/src/Element/Note.css +++ b/src/Element/Note.css @@ -148,6 +148,7 @@ .note.active>.header>.info { color: var(--font-tertiary-color); + font-weight: 500; } .note.active>.footer>.reaction-pill { diff --git a/src/Element/ProfileImage.css b/src/Element/ProfileImage.css index 65ff650e..7ae74f3b 100644 --- a/src/Element/ProfileImage.css +++ b/src/Element/ProfileImage.css @@ -26,11 +26,10 @@ display: flex; flex-direction: column; align-items: flex-start; - font-weight: bold; + font-weight: 600; } .pfp .profile-name { display: flex; flex-direction: column; } - diff --git a/src/Element/Text.css b/src/Element/Text.css index dc5c2afb..0b13975f 100644 --- a/src/Element/Text.css +++ b/src/Element/Text.css @@ -1,3 +1,8 @@ +.text { + font-size: var(--font-size); + line-height: 24px; +} + .text a { color: var(--highlight); text-decoration: none; diff --git a/src/Pages/Layout.css b/src/Pages/Layout.css index d8664c1a..26a193ce 100644 --- a/src/Pages/Layout.css +++ b/src/Pages/Layout.css @@ -1,7 +1,8 @@ .logo { cursor: pointer; - font-weight: bold; + font-weight: 700; font-size: 29px; + line-height: 23px; } header { diff --git a/src/index.css b/src/index.css index 526ea45b..33e6348a 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Be+Bietnam+Pro:wght@400;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Be+Bietnam+Pro:wght@400;500;600;700&display=swap'); :root { --bg-color: #000; @@ -113,7 +113,7 @@ html.light .card { button { cursor: pointer; padding: 6px 12px; - font-weight: bold; + font-weight: 700; color: white; font-size: var(--font-size); background-color: var(--highlight); @@ -165,7 +165,7 @@ button.secondary:hover { border: 2px solid; background-color: var(--gray-secondary); color: var(--font-color); - font-weight: bold; + font-weight: 700; } .btn.disabled { @@ -388,7 +388,7 @@ body.scroll-lock { } .tabs .active { - font-weight: bold; + font-weight: 700; } .error { @@ -412,7 +412,8 @@ body.scroll-lock { .tab { border-bottom: 1px solid var(--gray-secondary); - font-weight: bold; + font-weight: 700; + line-height: 19px; color: var(--font-secondary-color); padding: 8px 0; } From a27730c3d391268b480f5262bec27de6da447990 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 07:13:25 +0100 Subject: [PATCH 07/36] fix: card spacing --- src/Element/Note.css | 12 +----------- src/Pages/Layout.css | 6 ++++++ src/index.css | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/Element/Note.css b/src/Element/Note.css index fe2d9293..ee705518 100644 --- a/src/Element/Note.css +++ b/src/Element/Note.css @@ -27,6 +27,7 @@ .note>.body { margin-top: 4px; + margin-bottom: 24px; padding-left: 56px; text-overflow: ellipsis; white-space: pre-wrap; @@ -65,17 +66,6 @@ margin-left: 56px; } -@media (min-width: 720px) { - .note>.footer { - margin-top: 24px; - } - - .note>.note-creator { - margin-top: 24px; - } -} - - .thread.note { border-bottom-left-radius: 0; border-bottom-right-radius: 0; diff --git a/src/Pages/Layout.css b/src/Pages/Layout.css index 26a193ce..a445f316 100644 --- a/src/Pages/Layout.css +++ b/src/Pages/Layout.css @@ -14,6 +14,12 @@ header { padding: 0 12px; } +@media (min-width: 720px) { + header { + padding: 0; + } +} + header .pfp .avatar-wrapper { margin-right: 0; } diff --git a/src/index.css b/src/index.css index 33e6348a..42a26fdc 100644 --- a/src/index.css +++ b/src/index.css @@ -88,7 +88,7 @@ code { @media (min-width: 720px) { .card { - margin-bottom: 24px; + margin-bottom: 16px; padding: 12px 24px; } } From 52ddac5ef5f1303ec03ed330e8c7271c6fd2a3df Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 07:51:03 +0100 Subject: [PATCH 08/36] note reaction adjustments --- src/Element/Note.css | 12 ++++++++++++ src/Element/NoteFooter.tsx | 27 ++++++++++++++------------- src/Element/NoteReaction.css | 1 - src/index.css | 3 +-- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/Element/Note.css b/src/Element/Note.css index ee705518..e58114dd 100644 --- a/src/Element/Note.css +++ b/src/Element/Note.css @@ -40,6 +40,18 @@ padding-left: 46px; } +.note .footer .footer-reactions { + display: flex; + flex-direction: row; + margin-left: auto; +} + +@media (min-width: 720px) { + .note .footer .footer-reactions { + margin-left: 0; + } +} + .note>.footer .ctx-menu { background-color: var(--note-bg); color: var(--font-secondary-color); diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 08fec331..a715e85b 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -186,23 +186,24 @@ export default function NoteFooter(props: NoteFooterProps) { } return ( - <> -
- -
- -
-
} menuClassName="ctx-menu"> - {menuItems()} - +
+
+ {tipButton()} + {reactionIcons()}
setReply(s => !s)}>
- - {reactionIcons()} - {tipButton()} + +
+ +
+
} + menuClassName="ctx-menu" + > + {menuItems()} +
setTip(false)} show={tip} /> - +
) } diff --git a/src/Element/NoteReaction.css b/src/Element/NoteReaction.css index 2c7d6237..42cd356c 100644 --- a/src/Element/NoteReaction.css +++ b/src/Element/NoteReaction.css @@ -1,5 +1,4 @@ .reaction { - margin-bottom: 24px; } .reaction > .note { diff --git a/src/index.css b/src/index.css index 42a26fdc..8bbcf73f 100644 --- a/src/index.css +++ b/src/index.css @@ -106,8 +106,7 @@ html.light .card { .card>.footer { display: flex; - flex-direction: row-reverse; - margin-top: 12px; + flex-direction: row; } button { From 67772c099a5f5c5d8ee5aa4a88d0fa9b1dfb458b Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 07:51:38 +0100 Subject: [PATCH 09/36] profile fixes --- src/Element/FollowListBase.tsx | 2 +- src/Element/NoteCreator.css | 3 +++ src/Pages/ProfilePage.css | 26 ++++++++++++++++++-------- src/Pages/ProfilePage.tsx | 15 +++++++++------ src/index.css | 3 +++ 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/Element/FollowListBase.tsx b/src/Element/FollowListBase.tsx index c8692f36..e65d4ec4 100644 --- a/src/Element/FollowListBase.tsx +++ b/src/Element/FollowListBase.tsx @@ -17,7 +17,7 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) return (
-
{title}
+
{title}
{pubkeys?.map(a => )} diff --git a/src/Element/NoteCreator.css b/src/Element/NoteCreator.css index 2a4ad48a..b3f675f0 100644 --- a/src/Element/NoteCreator.css +++ b/src/Element/NoteCreator.css @@ -60,6 +60,9 @@ position: fixed; bottom: 50px; right: 16px; + display: flex; + align-items: center; + justify-content: center; } @media (min-width: 520px) { diff --git a/src/Pages/ProfilePage.css b/src/Pages/ProfilePage.css index a97143c3..bd3b73b7 100644 --- a/src/Pages/ProfilePage.css +++ b/src/Pages/ProfilePage.css @@ -29,12 +29,16 @@ } .profile .name h2 { - margin: 0; + margin: 12px 0 0 0; + font-weight: 600; + font-size: 19px; + line-height: 23px; } .profile .nip05 { display: flex; - margin: 4px 0 12px 0; + font-size: 16px; + margin: 0 0 12px 0; } .profile .nip05 .nick { @@ -42,6 +46,10 @@ color: var(--gray-light); } +.profile .name .copy { + margin-bottom: 4px; +} + .profile .avatar-wrapper { z-index: 1; } @@ -61,8 +69,9 @@ margin-top: 12px; background-color: var(--note-bg); padding: 12px 16px; - margin: 0 auto; border-radius: 16px; + margin: 0 auto; + margin-bottom: 12px; } .profile .details p { @@ -90,6 +99,10 @@ width: calc(100% - 32px); } +.profile .details .text { + font-size: 14px; +} + .tabs > div { margin-right: 0; } @@ -97,6 +110,7 @@ .profile .links { margin-top: 4px; margin-left: 2px; + margin-bottom: 12px; } .profile h3 { @@ -106,7 +120,7 @@ font-weight: 600; line-height: 12px; text-transform: uppercase; - margin-left: 16px; + margin-left: 12px; } .profile .website { @@ -180,9 +194,5 @@ height: 300px; margin-bottom: -100px; } - .profile .avatar-wrapper .avatar { - width: 210px; - height: 210px; - } } diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index c84ddd61..8a04440f 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -34,6 +34,7 @@ export default function ProfilePage() { const navigate = useNavigate(); const id = useMemo(() => parseId(params.id!), [params]); const user = useProfile(id)?.get(id); + const loggedOut = useSelector(s => s.login.loggedOut); const loginPubKey = useSelector(s => s.login.publicKey); const follows = useSelector(s => s.login.follows); const isMe = loginPubKey === id; @@ -139,12 +140,14 @@ export default function ProfilePage() { Settings ) : ( - <> - - - + !loggedOut && ( + <> + + + + ) )}
{bio()} diff --git a/src/index.css b/src/index.css index 8bbcf73f..f75d7a74 100644 --- a/src/index.css +++ b/src/index.css @@ -461,3 +461,6 @@ body.scroll-lock { } } +.bold { + font-weight: 700; +} From dd1a556eace786dd10d8d03f8cef1e964b5aa2c8 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 07:53:48 +0100 Subject: [PATCH 10/36] adjust margin --- src/Pages/ProfilePage.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pages/ProfilePage.css b/src/Pages/ProfilePage.css index bd3b73b7..ac743911 100644 --- a/src/Pages/ProfilePage.css +++ b/src/Pages/ProfilePage.css @@ -47,7 +47,7 @@ } .profile .name .copy { - margin-bottom: 4px; + margin-bottom: 8px; } .profile .avatar-wrapper { From d60b636d348cf348e3fef3bb331c0e3a5e1ccde8 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 08:25:05 +0100 Subject: [PATCH 11/36] add back button --- src/Element/BackButton.css | 21 +++++++++++++++++++++ src/Element/BackButton.tsx | 17 +++++++++++++++++ src/Element/FollowListBase.tsx | 2 +- src/Element/Thread.tsx | 7 ++++++- src/Icons/ArrowBack.tsx | 9 +++++++++ src/index.css | 15 +++++++++++++++ 6 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/Element/BackButton.css create mode 100644 src/Element/BackButton.tsx create mode 100644 src/Icons/ArrowBack.tsx diff --git a/src/Element/BackButton.css b/src/Element/BackButton.css new file mode 100644 index 00000000..0ebb52b8 --- /dev/null +++ b/src/Element/BackButton.css @@ -0,0 +1,21 @@ +.back-button { + background: none; + padding: 0; + color: var(--highlight); + font-weight: 400; + font-size: var(--font-size); +} + +.back-button svg { + margin-right: .5em; +} + +.back-button:hover { + text-decoration: underline; +} + +.back-button:hover { + background: none; + color: var(--font-color); + text-decoration: underline; +} diff --git a/src/Element/BackButton.tsx b/src/Element/BackButton.tsx new file mode 100644 index 00000000..25564c41 --- /dev/null +++ b/src/Element/BackButton.tsx @@ -0,0 +1,17 @@ +import "./BackButton.css" + +import { useNavigate } from "react-router-dom"; + +import ArrowBack from "Icons/ArrowBack"; + +const BackButton = () => { + const navigate = useNavigate() + + return ( + + ) +} + +export default BackButton diff --git a/src/Element/FollowListBase.tsx b/src/Element/FollowListBase.tsx index e65d4ec4..99e063e9 100644 --- a/src/Element/FollowListBase.tsx +++ b/src/Element/FollowListBase.tsx @@ -18,7 +18,7 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps)
{title}
- +
{pubkeys?.map(a => )}
diff --git a/src/Element/Thread.tsx b/src/Element/Thread.tsx index bc3fbef9..70ec56d4 100644 --- a/src/Element/Thread.tsx +++ b/src/Element/Thread.tsx @@ -1,10 +1,12 @@ import "./Thread.css"; import { useMemo } from "react"; import { Link } from "react-router-dom"; + import { TaggedRawEvent, u256 } from "Nostr"; import { default as NEvent } from "Nostr/Event"; import EventKind from "Nostr/EventKind"; import { eventLink } from "Util"; +import BackButton from "Element/BackButton"; import Note from "Element/Note"; import NoteGhost from "Element/NoteGhost"; @@ -83,6 +85,8 @@ export default function Thread(props: ThreadProps) { } return ( + <> +
{renderRoot()} {root ? renderChain(root.Id) : null} @@ -100,5 +104,6 @@ export default function Thread(props: ThreadProps) { })} }
+ ); -} \ No newline at end of file +} diff --git a/src/Icons/ArrowBack.tsx b/src/Icons/ArrowBack.tsx new file mode 100644 index 00000000..78c0cc3d --- /dev/null +++ b/src/Icons/ArrowBack.tsx @@ -0,0 +1,9 @@ +const ArrowBack = () => { + return ( + + + + ) +} + +export default ArrowBack diff --git a/src/index.css b/src/index.css index f75d7a74..fb3dbbf0 100644 --- a/src/index.css +++ b/src/index.css @@ -120,6 +120,7 @@ button { border-radius: 16px; outline: none; } + button:hover { background-color: var(--font-color); color: var(--bg-color); @@ -129,13 +130,27 @@ button.secondary { color: var(--font-color); background-color: var(--gray-dark); } + +button.transparent { + background-color: transparent; + border: 1px solid var(--gray-superdark); +} + .light button.secondary { background-color: var(--gray); } + button.secondary:hover { + border: none; color: var(--font-color); background-color: var(--gray-superdark); } + +button.transparent:hover { + color: var(--bg-color); + background-color: var(--font-color); +} + .light button.secondary:hover { background-color: var(--gray-secondary); } From c70f03cea28e1f0e3066ec39263f54f232a61fb1 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 08:30:23 +0100 Subject: [PATCH 12/36] fix: classname for notifications --- src/Pages/Layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pages/Layout.tsx b/src/Pages/Layout.tsx index 4243189e..ce98a535 100644 --- a/src/Pages/Layout.tsx +++ b/src/Pages/Layout.tsx @@ -91,7 +91,7 @@ export default function Layout() {
goToNotifications(e)}> - {unreadNotifications > 0 && ()} + {unreadNotifications > 0 && ()}
From 0554600d361e75ea43924926be94d6f8f4fb8cef Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 08:33:01 +0100 Subject: [PATCH 13/36] add back strike army gradient --- src/Element/Nip05.css | 4 ++++ src/index.css | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Element/Nip05.css b/src/Element/Nip05.css index 1cd799ae..e050a763 100644 --- a/src/Element/Nip05.css +++ b/src/Element/Nip05.css @@ -26,6 +26,10 @@ background-image: var(--nostrplebs-gradient); } +.nip05 .domain[data-domain="strike.army"] { + background-image: var(--strike-army-gradient); +} + .nip05 .domain[data-domain="nostrplebs.com"] { background-image: var(--nostrplebs-gradient); } diff --git a/src/index.css b/src/index.css index fb3dbbf0..d2b7da4d 100644 --- a/src/index.css +++ b/src/index.css @@ -25,7 +25,6 @@ --gray-gradient: linear-gradient(to bottom right, var(--gray-superlight), var(--gray), var(--gray-light)); --snort-gradient: linear-gradient(180deg, #FFC7B7 0%, #4F1B73 100%); --nostrplebs-gradient: linear-gradient(to bottom right, #ff3cac, #2b86c5); - --snort-text-gradient: linear-gradient(to bottom right, #8B5CF6, #0284C7); --strike-army-gradient: linear-gradient(to bottom right, #CCFF00, #a1c900); } From d156a44f0cb8bc7d294535719719bfc4d03065ba Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 08:56:13 +0100 Subject: [PATCH 14/36] feat: improve webln UX --- src/Element/LNURLTip.tsx | 45 ++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/Element/LNURLTip.tsx b/src/Element/LNURLTip.tsx index e02cf7d5..9d197644 100644 --- a/src/Element/LNURLTip.tsx +++ b/src/Element/LNURLTip.tsx @@ -5,6 +5,20 @@ import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; +const useWebLn = (enable = true) => { + const maybeWebLn = "webln" in window ? window.webln : null + + useEffect(() => { + if (maybeWebLn && !maybeWebLn.enabled && enable) { + maybeWebLn.enable().catch((error) => { + console.debug("Couldn't enable WebLN") + }) + } + }, [maybeWebLn, enable]) + + return maybeWebLn +} + declare global { interface Window { webln?: { @@ -54,6 +68,7 @@ export default function LNURLTip(props: LNURLTipProps) { const [comment, setComment] = useState(); const [error, setError] = useState(); const [success, setSuccess] = useState(); + const webln = useWebLn(show); useEffect(() => { if (show && !props.invoice) { @@ -136,6 +151,7 @@ export default function LNURLTip(props: LNURLTipProps) { } else { setInvoice(data); setError(""); + payWebLNIfEnabled(data); } } else { setError("Failed to load invoice"); @@ -156,29 +172,19 @@ export default function LNURLTip(props: LNURLTipProps) { ); } - async function payWebLN() { + async function payWebLNIfEnabled(invoice: LNURLInvoice) { try { - if (!window.webln!.enabled) { - await window.webln!.enable(); + if (webln?.enabled) { + let res = await webln.sendPayment(invoice!.pr); + console.log(res); + setSuccess(invoice!.successAction || {}); } - let res = await window.webln!.sendPayment(invoice!.pr); - console.log(res); - setSuccess(invoice!.successAction || {}); } catch (e: any) { setError(e.toString()); console.warn(e); } } - function webLn() { - if ("webln" in window) { - return ( -
payWebLN()}>Pay with WebLN
- ) - } - return null; - } - function invoiceForm() { if (invoice) return null; return ( @@ -198,7 +204,7 @@ export default function LNURLTip(props: LNURLTipProps) { : null}
{amount === -1 ? custom() : null} - {(amount ?? 0) > 0 ?
loadInvoice()}>Get Invoice
: null} + {(amount ?? 0) > 0 && } ) } @@ -218,10 +224,9 @@ export default function LNURLTip(props: LNURLTipProps) {
-
window.open(`lightning:${pr}`)}> +
-
{webLn()}
+
)} @@ -236,7 +241,7 @@ export default function LNURLTip(props: LNURLTipProps) { return ( <>

{success?.description ?? "Paid!"}

- {success.url ? {success.url} : null} + {success.url ? {success.url} : null} ) } From 05b6b35ca4cbc052347e9fccf510fd28f076da6e Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 15:28:26 +0100 Subject: [PATCH 15/36] fix: margin --- src/Element/Nip05.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Element/Nip05.css b/src/Element/Nip05.css index e050a763..2a2ec381 100644 --- a/src/Element/Nip05.css +++ b/src/Element/Nip05.css @@ -10,11 +10,11 @@ .nip05 .nick { color: var(--font-secondary-color); + margin-right: .2em; } .nip05 .domain { color: var(--gray-light); - margin-left: .2em; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; From 802c6b331cbca0110febcc6a19c6b07cd02dca82 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 16:57:36 +0100 Subject: [PATCH 16/36] pay inline invoices with webln if enabled --- src/Element/Invoice.tsx | 21 ++++++++++++++++++++- src/Element/LNURLTip.tsx | 27 ++------------------------- src/Hooks/useWebln.ts | 25 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 26 deletions(-) create mode 100644 src/Hooks/useWebln.ts diff --git a/src/Element/Invoice.tsx b/src/Element/Invoice.tsx index 7f90d1cd..ffeae1f4 100644 --- a/src/Element/Invoice.tsx +++ b/src/Element/Invoice.tsx @@ -5,12 +5,14 @@ import { decode as invoiceDecode } from "light-bolt11-decoder"; import { useMemo } from "react"; import NoteTime from "Element/NoteTime"; import LNURLTip from "Element/LNURLTip"; +import useWebln from "Hooks/useWebln"; export interface InvoiceProps { invoice: string } export default function Invoice(props: InvoiceProps) { const invoice = props.invoice; + const webln = useWebln(); const [showInvoice, setShowInvoice] = useState(false); const info = useMemo(() => { @@ -55,6 +57,19 @@ export default function Invoice(props: InvoiceProps) { } } + function payInvoice(e: any) { + e.stopPropagation(); + if (webln?.enabled) { + try { + webln.sendPayment(invoice); + } catch (error) { + setShowInvoice(true); + } + } else { + setShowInvoice(true); + } + } + return ( <>
@@ -63,7 +78,11 @@ export default function Invoice(props: InvoiceProps) { {info?.expire ? {info?.expired ? "Expired" : "Expires"} : null}
- {info?.expired ?
Expired
:
{ e.stopPropagation(); setShowInvoice(true); }}>Pay
} + {info?.expired ?
Expired
: ( +
+ Pay +
+ )}
diff --git a/src/Element/LNURLTip.tsx b/src/Element/LNURLTip.tsx index 9d197644..3770e405 100644 --- a/src/Element/LNURLTip.tsx +++ b/src/Element/LNURLTip.tsx @@ -4,30 +4,7 @@ import { bech32ToText } from "Util"; import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; - -const useWebLn = (enable = true) => { - const maybeWebLn = "webln" in window ? window.webln : null - - useEffect(() => { - if (maybeWebLn && !maybeWebLn.enabled && enable) { - maybeWebLn.enable().catch((error) => { - console.debug("Couldn't enable WebLN") - }) - } - }, [maybeWebLn, enable]) - - return maybeWebLn -} - -declare global { - interface Window { - webln?: { - enabled: boolean, - enable: () => Promise, - sendPayment: (pr: string) => Promise - } - } -} +import useWebln from "Hooks/useWebln"; interface LNURLService { minSendable?: number, @@ -68,7 +45,7 @@ export default function LNURLTip(props: LNURLTipProps) { const [comment, setComment] = useState(); const [error, setError] = useState(); const [success, setSuccess] = useState(); - const webln = useWebLn(show); + const webln = useWebln(show); useEffect(() => { if (show && !props.invoice) { diff --git a/src/Hooks/useWebln.ts b/src/Hooks/useWebln.ts new file mode 100644 index 00000000..f348092c --- /dev/null +++ b/src/Hooks/useWebln.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; + +declare global { + interface Window { + webln?: { + enabled: boolean, + enable: () => Promise, + sendPayment: (pr: string) => Promise + } + } +} + +export default function useWebln(enable = true) { + const maybeWebLn = "webln" in window ? window.webln : null + + useEffect(() => { + if (maybeWebLn && !maybeWebLn.enabled && enable) { + maybeWebLn.enable().catch((error) => { + console.debug("Couldn't enable WebLN") + }) + } + }, [maybeWebLn, enable]) + + return maybeWebLn +} From 661f2936e610454d8d4ddc4f8c45bc3d039cfdc5 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 08:15:22 +0100 Subject: [PATCH 17/36] fix: nip05 fix --- src/Element/Nip05.css | 1 - src/Element/Nip05.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Element/Nip05.css b/src/Element/Nip05.css index 2a2ec381..bdde8a85 100644 --- a/src/Element/Nip05.css +++ b/src/Element/Nip05.css @@ -10,7 +10,6 @@ .nip05 .nick { color: var(--font-secondary-color); - margin-right: .2em; } .nip05 .domain { diff --git a/src/Element/Nip05.tsx b/src/Element/Nip05.tsx index 0d4ac65a..3b25f4fa 100644 --- a/src/Element/Nip05.tsx +++ b/src/Element/Nip05.tsx @@ -60,7 +60,7 @@ const Nip05 = (props: Nip05Params) => {
ev.stopPropagation()}> {!isDefaultUser && (
- {`@${name}`} + {`${name}@`}
)} From 83df146716663a13c7d7d5a4cb5043bb8bd6b7ad Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 11:22:35 +0100 Subject: [PATCH 18/36] light disabled button --- src/index.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.css b/src/index.css index d2b7da4d..3b953bcb 100644 --- a/src/index.css +++ b/src/index.css @@ -120,6 +120,15 @@ button { outline: none; } +button:disabled { + cursor: not-allowed; + color: var(--gray); +} +.light button:disabled { + color: var(--font-color); +} + + button:hover { background-color: var(--font-color); color: var(--bg-color); From cfbf24495598f48041811042b8d1afaf9c06a508 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Thu, 26 Jan 2023 12:34:18 +0100 Subject: [PATCH 19/36] feat: NIP-51 --- README.md | 3 +- src/Element/Invoice.tsx | 4 +-- src/Element/LogoutButton.tsx | 14 +++++++++ src/Element/MuteButton.tsx | 21 +++++++++++++ src/Element/MutedList.tsx | 39 +++++++++++++++++++++++ src/Element/Note.tsx | 5 ++- src/Element/NoteFooter.tsx | 8 ++++- src/Element/NoteReaction.tsx | 5 ++- src/Element/Timeline.tsx | 21 ++++++++----- src/Feed/EventPublisher.ts | 13 ++++++++ src/Feed/LoginFeed.ts | 29 +++++++++++++++--- src/Feed/MuteList.ts | 51 +++++++++++++++++++++++++++++++ src/Hooks/useModeration.tsx | 56 ++++++++++++++++++++++++++++++++++ src/Nostr/EventKind.ts | 3 +- src/Nostr/Subscriptions.ts | 13 ++++++-- src/Nostr/index.ts | 3 +- src/Pages/MessagesPage.tsx | 6 ++-- src/Pages/ProfilePage.tsx | 15 +++++++-- src/Pages/settings/Profile.tsx | 7 ++--- src/State/Login.ts | 25 +++++++++++++-- src/index.css | 8 ++--- 21 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 src/Element/LogoutButton.tsx create mode 100644 src/Element/MuteButton.tsx create mode 100644 src/Element/MutedList.tsx create mode 100644 src/Feed/MuteList.ts create mode 100644 src/Hooks/useModeration.tsx diff --git a/README.md b/README.md index eb77f2b1..ffd7801c 100644 --- a/README.md +++ b/README.md @@ -25,4 +25,5 @@ Snort supports the following NIP's - [ ] NIP-28: Public Chat - [ ] NIP-36: Sensitive Content - [ ] NIP-40: Expiration Timestamp -- [ ] NIP-42: Authentication of clients to relays \ No newline at end of file +- [ ] NIP-42: Authentication of clients to relays +- [ ] NIP-51: Lists diff --git a/src/Element/Invoice.tsx b/src/Element/Invoice.tsx index ffeae1f4..80ad2cb5 100644 --- a/src/Element/Invoice.tsx +++ b/src/Element/Invoice.tsx @@ -79,9 +79,9 @@ export default function Invoice(props: InvoiceProps) {
{info?.expired ?
Expired
: ( -
+
+ )}
diff --git a/src/Element/LogoutButton.tsx b/src/Element/LogoutButton.tsx new file mode 100644 index 00000000..bb25af72 --- /dev/null +++ b/src/Element/LogoutButton.tsx @@ -0,0 +1,14 @@ +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +import { logout } from "State/Login"; + +export default function LogoutButton(){ + const dispatch = useDispatch() + const navigate = useNavigate() + return ( + + ) +} diff --git a/src/Element/MuteButton.tsx b/src/Element/MuteButton.tsx new file mode 100644 index 00000000..8d344751 --- /dev/null +++ b/src/Element/MuteButton.tsx @@ -0,0 +1,21 @@ +import { HexKey } from "Nostr"; +import useModeration from "Hooks/useModeration"; + +interface MuteButtonProps { + pubkey: HexKey +} + +const MuteButton = ({ pubkey }: MuteButtonProps) => { + const { mute, unmute, isMuted } = useModeration() + return isMuted(pubkey) ? ( + + ) : ( + + ) +} + +export default MuteButton diff --git a/src/Element/MutedList.tsx b/src/Element/MutedList.tsx new file mode 100644 index 00000000..f4c2182c --- /dev/null +++ b/src/Element/MutedList.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { HexKey } from "Nostr"; import type { RootState } from "State/Store"; +import MuteButton from "Element/MuteButton"; +import ProfilePreview from "Element/ProfilePreview"; +import useMutedFeed, { getMuted } from "Feed/MuteList"; +import useModeration from "Hooks/useModeration"; + +export interface MutedListProps { + pubkey: HexKey +} + +export default function MutedList({ pubkey }: MutedListProps) { + const { publicKey } = useSelector((s: RootState) => s.login) + const { muted, isMuted, mute, unmute, muteAll } = useModeration(); + const feed = useMutedFeed(pubkey) + const pubkeys = useMemo(() => { + return publicKey === pubkey ? muted : getMuted(feed.store, pubkey); + }, [feed, pubkey]); + const hasAllMuted = pubkeys.every(isMuted) + + return ( +
+
+
{`${pubkeys?.length} muted`}
+ +
+ {pubkeys?.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} +
+ ) +} diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 68a54288..82a67fd1 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -12,6 +12,7 @@ import EventKind from "Nostr/EventKind"; import useProfile from "Feed/ProfileFeed"; import { TaggedRawEvent, u256 } from "Nostr"; import { useInView } from "react-intersection-observer"; +import useModeration from "Hooks/useModeration"; export interface NoteProps { data?: TaggedRawEvent, @@ -33,6 +34,8 @@ export default function Note(props: NoteProps) { const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); const users = useProfile(pubKeys); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); + const { isMuted } = useModeration() + const muted = isMuted(ev.PubKey) const { ref, inView } = useInView({ triggerOnce: true }); const options = { @@ -150,7 +153,7 @@ export default function Note(props: NoteProps) { ) } - return ( + return muted ? null : (
{content()}
diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index a715e85b..72b73600 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useSelector } from "react-redux"; -import { faTrash, faRepeat, faShareNodes, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Menu, MenuItem } from '@szhsin/react-menu'; @@ -20,6 +20,7 @@ import { RootState } from "State/Store"; import { HexKey, TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import { UserPreferences } from "State/Login"; +import useModeration from "Hooks/useModeration"; export interface NoteFooterProps { related: TaggedRawEvent[], @@ -30,6 +31,7 @@ export default function NoteFooter(props: NoteFooterProps) { const { related, ev } = props; const login = useSelector(s => s.login.publicKey); + const { mute } = useModeration(); const prefs = useSelector(s => s.login.preferences); const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); const publisher = useEventPublisher(); @@ -169,6 +171,10 @@ export default function NoteFooter(props: NoteFooterProps) { Copy ID + mute(ev.PubKey)}> + + Mute author + {prefs.showDebugMenus && ( copyEvent()}> diff --git a/src/Element/NoteReaction.tsx b/src/Element/NoteReaction.tsx index dc5bd62f..56de9de6 100644 --- a/src/Element/NoteReaction.tsx +++ b/src/Element/NoteReaction.tsx @@ -9,6 +9,7 @@ import { default as NEvent } from "Nostr/Event"; import { eventLink, hexToBech32 } from "Util"; import NoteTime from "Element/NoteTime"; import { RawEvent, TaggedRawEvent } from "Nostr"; +import useModeration from "Hooks/useModeration"; export interface NoteReactionProps { data?: TaggedRawEvent, @@ -18,6 +19,7 @@ export interface NoteReactionProps { export default function NoteReaction(props: NoteReactionProps) { const { ["data-ev"]: dataEv, data } = props; const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]) + const { isMuted } = useModeration(); const refEvent = useMemo(() => { if (ev) { @@ -53,8 +55,9 @@ export default function NoteReaction(props: NoteReactionProps) { showHeader: ev?.Kind === EventKind.Repost, showFooter: false, }; + const isOpMuted = root && isMuted(root.pubkey) - return ( + return isOpMuted ? null : (
diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index 89e04cbf..447b06cb 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -1,13 +1,16 @@ import "./Timeline.css"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faForward } from "@fortawesome/free-solid-svg-icons"; import { useCallback, useMemo } from "react"; +import { useSelector } from "react-redux"; + import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import { TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import LoadMore from "Element/LoadMore"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faForward } from "@fortawesome/free-solid-svg-icons"; +import type { RootState } from "State/Store"; export interface TimelineProps { postsOnly: boolean, @@ -19,21 +22,22 @@ export interface TimelineProps { * A list of notes by pubkeys */ export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { + const muted = useSelector((s: RootState) => s.login.muted) const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, { method }); const filterPosts = useCallback((nts: TaggedRawEvent[]) => { - return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true); - }, [postsOnly]); + return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => !muted.includes(a.pubkey)); + }, [postsOnly, muted]); const mainFeed = useMemo(() => { return filterPosts(main.notes); }, [main, filterPosts]); const latestFeed = useMemo(() => { - return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)); - }, [latest, mainFeed, filterPosts]); + return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)) + }, [latest, mainFeed, filterPosts, muted]); function eventElement(e: TaggedRawEvent) { switch (e.kind) { @@ -43,7 +47,10 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin case EventKind.Reaction: case EventKind.Repost: { let eRef = e.tags.find(a => a[0] === "e")?.at(1); - return a.id === eRef)}/> + let pRef = e.tags.find(a => a[0] === "p")?.at(1); + return !muted.includes(pRef || '') ? ( + a.id === eRef)}/> + ) : null } } } diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 125cf17b..842aad45 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -95,6 +95,19 @@ export default function useEventPublisher() { } } }, + muted: async (keys: HexKey[]) => { + if (pubKey) { + let ev = NEvent.ForPubKey(pubKey); + ev.Kind = EventKind.Lists; + ev.Tags.push(new Tag(["d", "mute"], ev.Tags.length)) + keys.forEach(p => { + ev.Tags.push(new Tag(["p", p], ev.Tags.length)) + }) + // todo: public/private block + ev.Content = ""; + return await signEvent(ev); + } + }, metadata: async (obj: UserMetadata) => { if (pubKey) { let ev = NEvent.ForPubKey(pubKey); diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 7c4521f2..7a762451 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -5,10 +5,11 @@ import { useDispatch, useSelector } from "react-redux"; import { HexKey, TaggedRawEvent } from "Nostr"; import EventKind from "Nostr/EventKind"; import { Subscriptions } from "Nostr/Subscriptions"; -import { addDirectMessage, addNotifications, setFollows, setRelays } from "State/Login"; +import { addDirectMessage, addNotifications, setFollows, setRelays, setMuted } from "State/Login"; import { RootState } from "State/Store"; import { db } from "Db"; import useSubscription from "Feed/Subscription"; +import { MUTE_LIST_TAG, getMutedKeys } from "Feed/MuteList"; import { mapEventToProfile, MetadataCache } from "Db/User"; import { getDisplayName } from "Element/ProfileImage"; import { MentionRegex } from "Const"; @@ -18,7 +19,7 @@ import { MentionRegex } from "Const"; */ export default function useLoginFeed() { const dispatch = useDispatch(); - const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); + const [pubKey, readNotifications, muted] = useSelector(s => [s.login.publicKey, s.login.readNotifications, s.login.muted]); const subMetadata = useMemo(() => { if (!pubKey) return null; @@ -42,6 +43,20 @@ export default function useLoginFeed() { return sub; }, [pubKey]); + const subMuted = useMemo(() => { + if (!pubKey) return null; + + let sub = new Subscriptions(); + sub.Id = "login:muted"; + sub.Kinds = new Set([EventKind.Lists]); + sub.Authors = new Set([pubKey]); + // TODO: not sure relay support this atm, don't seem to return results + // sub.DTags = new Set([MUTE_LIST_TAG]) + sub.Limit = 1; + + return sub; + }, [pubKey]); + const subDms = useMemo(() => { if (!pubKey) return null; @@ -61,6 +76,7 @@ export default function useLoginFeed() { const metadataFeed = useSubscription(subMetadata, { leaveOpen: true }); const notificationFeed = useSubscription(subNotification, { leaveOpen: true }); const dmsFeed = useSubscription(subDms, { leaveOpen: true }); + const mutedFeed = useSubscription(subMuted, { leaveOpen: true }); useEffect(() => { let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList); @@ -96,7 +112,7 @@ export default function useLoginFeed() { }, [metadataFeed.store]); useEffect(() => { - let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote); + let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !muted.includes(a.pubkey)) if ("Notification" in window && Notification.permission === "granted") { for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { @@ -108,6 +124,11 @@ export default function useLoginFeed() { dispatch(addNotifications(notifications)); }, [notificationFeed.store]); + useEffect(() => { + const ps = getMutedKeys(mutedFeed.store.notes) + dispatch(setMuted(ps)) + }, [mutedFeed.store]) + useEffect(() => { let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage); dispatch(addDirectMessage(dms)); @@ -159,4 +180,4 @@ async function sendNotification(ev: TaggedRawEvent) { vibrate: [500] }); } -} \ No newline at end of file +} diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts new file mode 100644 index 00000000..092d1233 --- /dev/null +++ b/src/Feed/MuteList.ts @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { HexKey, TaggedRawEvent } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import { Subscriptions } from "Nostr/Subscriptions"; +import type { RootState } from "State/Store"; +import useSubscription, { NoteStore } from "Feed/Subscription"; + +export const MUTE_LIST_TAG = "mute" + +export default function useMutedFeed(pubkey: HexKey) { + const loginPubkey = useSelector((s: RootState) => s.login.publicKey) + const sub = useMemo(() => { + if (pubkey === loginPubkey) return null + + let sub = new Subscriptions(); + sub.Id = `muted:${pubkey}`; + sub.Kinds = new Set([EventKind.Lists]); + sub.Authors = new Set([pubkey]); + // TODO: not sure relay support this atm, don't seem to return results + //sub.DTags = new Set([MUTE_LIST_TAG]) + sub.Limit = 1; + + return sub; + }, [pubkey]); + + return useSubscription(sub); +} + +export function getMutedKeys(rawNotes: TaggedRawEvent[]): { at: number, keys: HexKey[] } { + const notes = [...rawNotes] + notes.sort((a, b) => a.created_at - b.created_at) + const newest = notes && notes[0] + if (newest) { + const { tags } = newest + const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG) + if (mutedIndex !== -1) { + return { + at: newest.created_at, + keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1]) + } + } + } + return { at: 0, keys: [] } +} + +export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] { + let lists = feed?.notes.filter(a => a.kind === EventKind.Lists && a.pubkey === pubkey); + return getMutedKeys(lists).keys; +} diff --git a/src/Hooks/useModeration.tsx b/src/Hooks/useModeration.tsx new file mode 100644 index 00000000..b8aebb12 --- /dev/null +++ b/src/Hooks/useModeration.tsx @@ -0,0 +1,56 @@ +import { useSelector, useDispatch } from "react-redux"; + +import type { RootState } from "State/Store"; +import { HexKey } from "Nostr"; +import useEventPublisher from "Feed/EventPublisher"; +import { setMuted } from "State/Login"; + + +export default function useModeration() { + const dispatch = useDispatch() + const { muted } = useSelector((s: RootState) => s.login) + const publisher = useEventPublisher() + + async function setMutedList(ids: HexKey[]) { + try { + const ev = await publisher.muted(ids) + console.debug(ev); + publisher.broadcast(ev) + } catch (error) { + console.debug("Couldn't change mute list") + } + } + + function isMuted(id: HexKey) { + return muted.includes(id) + } + + function unmute(id: HexKey) { + const newMuted = muted.filter(p => p !== id) + dispatch(setMuted({ + at: new Date().getTime(), + keys: newMuted + })) + setMutedList(newMuted) + } + + function mute(id: HexKey) { + const newMuted = muted.concat([id]) + setMutedList(newMuted) + dispatch(setMuted({ + at: new Date().getTime(), + keys: newMuted + })) + } + + function muteAll(ids: HexKey[]) { + const newMuted = Array.from(new Set(muted.concat(ids))) + setMutedList(newMuted) + dispatch(setMuted({ + at: new Date().getTime(), + keys: newMuted + })) + } + + return { muted, mute, muteAll, unmute, isMuted } +} diff --git a/src/Nostr/EventKind.ts b/src/Nostr/EventKind.ts index d12012b9..b8a0de04 100644 --- a/src/Nostr/EventKind.ts +++ b/src/Nostr/EventKind.ts @@ -7,7 +7,8 @@ const enum EventKind { DirectMessage = 4, // NIP-04 Deletion = 5, // NIP-09 Repost = 6, // NIP-18 - Reaction = 7 // NIP-25 + Reaction = 7, // NIP-25 + Lists = 30000, // NIP-51 }; export default EventKind; \ No newline at end of file diff --git a/src/Nostr/Subscriptions.ts b/src/Nostr/Subscriptions.ts index b9af235a..47854f8b 100644 --- a/src/Nostr/Subscriptions.ts +++ b/src/Nostr/Subscriptions.ts @@ -42,6 +42,11 @@ export class Subscriptions { */ HashTags?: Set; + /** + * A list of "d" tags to search + */ + DTags?: Set; + /** * a timestamp, events must be newer than this to pass */ @@ -89,6 +94,7 @@ export class Subscriptions { this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined; this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined; this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined; + this.DTags = sub?.["#d"] ? new Set(sub["#d"]) : undefined; this.Since = sub?.since ?? undefined; this.Until = sub?.until ?? undefined; this.Limit = sub?.limit ?? undefined; @@ -130,9 +136,12 @@ export class Subscriptions { if (this.PTags) { ret["#p"] = Array.from(this.PTags); } - if(this.HashTags) { + if (this.HashTags) { ret["#t"] = Array.from(this.HashTags); } + if (this.DTags) { + ret["#d"] = Array.from(this.DTags); + } if (this.Since !== null) { ret.since = this.Since; } @@ -144,4 +153,4 @@ export class Subscriptions { } return ret; } -} \ No newline at end of file +} diff --git a/src/Nostr/index.ts b/src/Nostr/index.ts index cac6946e..3b016337 100644 --- a/src/Nostr/index.ts +++ b/src/Nostr/index.ts @@ -35,6 +35,7 @@ export type RawReqFilter = { "#e"?: u256[], "#p"?: u256[], "#t"?: string[], + "#d"?: string[], since?: number, until?: number, limit?: number @@ -53,4 +54,4 @@ export type UserMetadata = { nip05?: string, lud06?: string, lud16?: string -} \ No newline at end of file +} diff --git a/src/Pages/MessagesPage.tsx b/src/Pages/MessagesPage.tsx index f0936fb4..9895e067 100644 --- a/src/Pages/MessagesPage.tsx +++ b/src/Pages/MessagesPage.tsx @@ -8,6 +8,7 @@ import { hexToBech32 } from "../Util"; import { incDmInteraction } from "State/Login"; import { RootState } from "State/Store"; import NoteToSelf from "Element/NoteToSelf"; +import useModeration from "Hooks/useModeration"; type DmChat = { pubkey: HexKey, @@ -20,10 +21,11 @@ export default function MessagesPage() { const myPubKey = useSelector(s => s.login.publicKey); const dms = useSelector(s => s.login.dms); const dmInteraction = useSelector(s => s.login.dmInteraction); + const { muted, isMuted } = useModeration(); const chats = useMemo(() => { - return extractChats(dms, myPubKey!); - }, [dms, myPubKey, dmInteraction]); + return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!); + }, [dms, myPubKey, dmInteraction, muted]); function noteToSelf(chat: DmChat) { return ( diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 8a04440f..728d2773 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -10,6 +10,7 @@ import useProfile from "Feed/ProfileFeed"; import FollowButton from "Element/FollowButton"; import { extractLnAddress, parseId, hexToBech32 } from "Util"; import Avatar from "Element/Avatar"; +import LogoutButton from "Element/LogoutButton"; import Timeline from "Element/Timeline"; import Text from 'Element/Text' import LNURLTip from "Element/LNURLTip"; @@ -17,6 +18,7 @@ import Nip05 from "Element/Nip05"; import Copy from "Element/Copy"; import ProfilePreview from "Element/ProfilePreview"; import FollowersList from "Element/FollowersList"; +import MutedList from "Element/MutedList"; import FollowsList from "Element/FollowsList"; import { RootState } from "State/Store"; import { HexKey } from "Nostr"; @@ -26,7 +28,8 @@ enum ProfileTab { Notes = "Notes", Reactions = "Reactions", Followers = "Followers", - Follows = "Follows" + Follows = "Follows", + Muted = "Muted" }; export default function ProfilePage() { @@ -35,6 +38,8 @@ export default function ProfilePage() { const id = useMemo(() => parseId(params.id!), [params]); const user = useProfile(id)?.get(id); const loggedOut = useSelector(s => s.login.loggedOut); + const muted = useSelector(s => s.login.muted); + const isMuted = useMemo(() => muted.includes(id), [muted, id]) const loginPubKey = useSelector(s => s.login.publicKey); const follows = useSelector(s => s.login.follows); const isMe = loginPubKey === id; @@ -119,6 +124,9 @@ export default function ProfilePage() { case ProfileTab.Followers: { return } + case ProfileTab.Muted: { + return + } } } @@ -136,9 +144,12 @@ export default function ProfilePage() { {username()}
{isMe ? ( + <> + + ) : ( !loggedOut && ( <> @@ -165,7 +176,7 @@ export default function ProfilePage() {
- {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => { + {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(v => { return
setTab(v)}>{v}
})}
diff --git a/src/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx index bd96889f..5e95d27e 100644 --- a/src/Pages/settings/Profile.tsx +++ b/src/Pages/settings/Profile.tsx @@ -2,7 +2,7 @@ import "./Profile.css"; import Nostrich from "nostrich.jpg"; import { useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faShop } from "@fortawesome/free-solid-svg-icons"; @@ -10,7 +10,7 @@ import { faShop } from "@fortawesome/free-solid-svg-icons"; import useEventPublisher from "Feed/EventPublisher"; import useProfile from "Feed/ProfileFeed"; import VoidUpload from "Feed/VoidUpload"; -import { logout } from "State/Login"; +import LogoutButton from "Element/LogoutButton"; import { hexToBech32, openFile } from "Util"; import Copy from "Element/Copy"; import { RootState } from "State/Store"; @@ -20,7 +20,6 @@ export default function ProfileSettings() { const navigate = useNavigate(); const id = useSelector(s => s.login.publicKey); const privKey = useSelector(s => s.login.privateKey); - const dispatch = useDispatch(); const user = useProfile(id)?.get(id || ""); const publisher = useEventPublisher(); @@ -143,7 +142,7 @@ export default function ProfileSettings() {
- +
diff --git a/src/State/Login.ts b/src/State/Login.ts index ca009db4..e3fdb8f7 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -72,6 +72,16 @@ export interface LoginStore { */ follows: HexKey[], + /** + * A list of pubkeys this user has muted + */ + muted: HexKey[], + + /** + * Last seen mute list event timestamp + */ + lastMutedSeenAt: number, + /** * Notifications for this login session */ @@ -105,6 +115,8 @@ const InitState = { relays: {}, latestRelays: 0, follows: [], + lastMutedSeenAt: 0, + muted: [], notifications: [], readNotifications: new Date().getTime(), dms: [], @@ -207,6 +219,14 @@ const LoginSlice = createSlice({ state.follows = Array.from(existing); } }, + setMuted(state, action: PayloadAction<{at: number, keys: HexKey[]}>) { + const { at, keys } = action.payload + if (at > state.lastMutedSeenAt) { + const muted = new Set([...keys]) + state.muted = Array.from(muted) + state.lastMutedSeenAt = at + } + }, addNotifications: (state, action: PayloadAction) => { let n = action.payload; if (!Array.isArray(n)) { @@ -273,10 +293,11 @@ export const { removeRelay, setFollows, addNotifications, + setMuted, addDirectMessage, incDmInteraction, logout, markNotificationsRead, - setPreferences + setPreferences, } = LoginSlice.actions; -export const reducer = LoginSlice.reducer; \ No newline at end of file +export const reducer = LoginSlice.reducer; diff --git a/src/index.css b/src/index.css index 3b953bcb..81aefb4f 100644 --- a/src/index.css +++ b/src/index.css @@ -124,10 +124,6 @@ button:disabled { cursor: not-allowed; color: var(--gray); } -.light button:disabled { - color: var(--font-color); -} - button:hover { background-color: var(--font-color); @@ -487,3 +483,7 @@ body.scroll-lock { .bold { font-weight: 700; } + +.blurred { + filter: blur(5px); +} From a500c040f9d7597a17814147bf07a0506e72d803 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 08:16:06 +0100 Subject: [PATCH 20/36] remove unused code --- src/Pages/ProfilePage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 728d2773..3f80d216 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -38,8 +38,6 @@ export default function ProfilePage() { const id = useMemo(() => parseId(params.id!), [params]); const user = useProfile(id)?.get(id); const loggedOut = useSelector(s => s.login.loggedOut); - const muted = useSelector(s => s.login.muted); - const isMuted = useMemo(() => muted.includes(id), [muted, id]) const loginPubKey = useSelector(s => s.login.publicKey); const follows = useSelector(s => s.login.follows); const isMe = loginPubKey === id; From ba2fde425faf072a1f3f46d99cae1759e3adab3f Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 08:21:48 +0100 Subject: [PATCH 21/36] refactor: clean up --- src/Element/Timeline.tsx | 7 ++++--- src/Feed/LoginFeed.ts | 6 ++++-- src/index.css | 4 ---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index 447b06cb..2e95feb9 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -11,6 +11,7 @@ import LoadMore from "Element/LoadMore"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; import type { RootState } from "State/Store"; +import useModeration from "Hooks/useModeration"; export interface TimelineProps { postsOnly: boolean, @@ -22,13 +23,13 @@ export interface TimelineProps { * A list of notes by pubkeys */ export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { - const muted = useSelector((s: RootState) => s.login.muted) + const { muted, isMuted } = useModeration(); const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, { method }); const filterPosts = useCallback((nts: TaggedRawEvent[]) => { - return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => !muted.includes(a.pubkey)); + return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => !isMuted(a.pubkey)); }, [postsOnly, muted]); const mainFeed = useMemo(() => { @@ -37,7 +38,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin const latestFeed = useMemo(() => { return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)) - }, [latest, mainFeed, filterPosts, muted]); + }, [latest, mainFeed, filterPosts]); function eventElement(e: TaggedRawEvent) { switch (e.kind) { diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 7a762451..9ca50ffc 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -12,6 +12,7 @@ import useSubscription from "Feed/Subscription"; import { MUTE_LIST_TAG, getMutedKeys } from "Feed/MuteList"; import { mapEventToProfile, MetadataCache } from "Db/User"; import { getDisplayName } from "Element/ProfileImage"; +import useModeration from "Hooks/useModeration"; import { MentionRegex } from "Const"; /** @@ -19,7 +20,8 @@ import { MentionRegex } from "Const"; */ export default function useLoginFeed() { const dispatch = useDispatch(); - const [pubKey, readNotifications, muted] = useSelector(s => [s.login.publicKey, s.login.readNotifications, s.login.muted]); + const { isMuted } = useModeration(); + const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); const subMetadata = useMemo(() => { if (!pubKey) return null; @@ -112,7 +114,7 @@ export default function useLoginFeed() { }, [metadataFeed.store]); useEffect(() => { - let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !muted.includes(a.pubkey)) + let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey)) if ("Notification" in window && Notification.permission === "granted") { for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { diff --git a/src/index.css b/src/index.css index 81aefb4f..f6b76757 100644 --- a/src/index.css +++ b/src/index.css @@ -483,7 +483,3 @@ body.scroll-lock { .bold { font-weight: 700; } - -.blurred { - filter: blur(5px); -} From 613665c18709025e5413da70cc030f44bcf3af56 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 11:47:05 +0100 Subject: [PATCH 22/36] fix: correctly filter with tag --- src/Feed/EventPublisher.ts | 4 +++- src/Feed/LoginFeed.ts | 3 +-- src/Feed/MuteList.ts | 3 +-- src/Nostr/Tag.ts | 8 ++++++++ src/index.css | 4 ++++ 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 842aad45..f621cf7c 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -1,10 +1,12 @@ import { useSelector } from "react-redux"; + import { System } from "Nostr/System"; import { default as NEvent } from "Nostr/Event"; import EventKind from "Nostr/EventKind"; import Tag from "Nostr/Tag"; import { RootState } from "State/Store"; import { HexKey, RawEvent, u256, UserMetadata } from "Nostr"; +import { MUTE_LIST_TAG } from "Feed/MuteList"; import { bech32ToHex } from "Util" import { DefaultRelays, HashtagRegex } from "Const"; @@ -99,7 +101,7 @@ export default function useEventPublisher() { if (pubKey) { let ev = NEvent.ForPubKey(pubKey); ev.Kind = EventKind.Lists; - ev.Tags.push(new Tag(["d", "mute"], ev.Tags.length)) + ev.Tags.push(new Tag(["d", MUTE_LIST_TAG], ev.Tags.length)) keys.forEach(p => { ev.Tags.push(new Tag(["p", p], ev.Tags.length)) }) diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 9ca50ffc..71ff6c2b 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -52,8 +52,7 @@ export default function useLoginFeed() { sub.Id = "login:muted"; sub.Kinds = new Set([EventKind.Lists]); sub.Authors = new Set([pubKey]); - // TODO: not sure relay support this atm, don't seem to return results - // sub.DTags = new Set([MUTE_LIST_TAG]) + sub.DTags = new Set([MUTE_LIST_TAG]) sub.Limit = 1; return sub; diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 092d1233..4b71f5f2 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -18,8 +18,7 @@ export default function useMutedFeed(pubkey: HexKey) { sub.Id = `muted:${pubkey}`; sub.Kinds = new Set([EventKind.Lists]); sub.Authors = new Set([pubkey]); - // TODO: not sure relay support this atm, don't seem to return results - //sub.DTags = new Set([MUTE_LIST_TAG]) + sub.DTags = new Set([MUTE_LIST_TAG]) sub.Limit = 1; return sub; diff --git a/src/Nostr/Tag.ts b/src/Nostr/Tag.ts index e10af861..6ee98eec 100644 --- a/src/Nostr/Tag.ts +++ b/src/Nostr/Tag.ts @@ -8,6 +8,7 @@ export default class Tag { Relay?: string; Marker?: string; Hashtag?: string; + DTag?: string; Index: number; Invalid: boolean; @@ -36,6 +37,10 @@ export default class Tag { } break; } + case "d": { + this.DTag = tag[1]; + break; + } case "t": { this.Hashtag = tag[1]; break; @@ -61,6 +66,9 @@ export default class Tag { case "t": { return ["t", this.Hashtag!]; } + case "d": { + return ["t", this.DTag!]; + } default: { return this.Original; } diff --git a/src/index.css b/src/index.css index f6b76757..74bed443 100644 --- a/src/index.css +++ b/src/index.css @@ -124,6 +124,10 @@ button:disabled { cursor: not-allowed; color: var(--gray); } +.light button:disabled { + color: var(--font-secondary-color); + border-color: var(--font-secondary-color); +} button:hover { background-color: var(--font-color); From 5817c36af12539d336909f68f0ee2c6acd9ffad1 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 14:46:32 +0100 Subject: [PATCH 23/36] fix color --- src/index.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.css b/src/index.css index 74bed443..7968fb22 100644 --- a/src/index.css +++ b/src/index.css @@ -124,6 +124,11 @@ button:disabled { cursor: not-allowed; color: var(--gray); } + +.light button.transparent { + color: var(--font-color); +} + .light button:disabled { color: var(--font-secondary-color); border-color: var(--font-secondary-color); From c183633efc434ec988ae40a06ce8748bfe537731 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 14:46:59 +0100 Subject: [PATCH 24/36] fix: use standard mute list tag --- src/Feed/MuteList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 4b71f5f2..40134593 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -7,7 +7,7 @@ import { Subscriptions } from "Nostr/Subscriptions"; import type { RootState } from "State/Store"; import useSubscription, { NoteStore } from "Feed/Subscription"; -export const MUTE_LIST_TAG = "mute" +export const MUTE_LIST_TAG = "p:mute" export default function useMutedFeed(pubkey: HexKey) { const loginPubkey = useSelector((s: RootState) => s.login.publicKey) From 592a8b04c0bde1f62d5150ec7582fe865911c02b Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 18:35:55 +0100 Subject: [PATCH 25/36] fix: dont take into account old contact lists --- src/Feed/EventPublisher.ts | 2 +- src/Feed/LoginFeed.ts | 3 ++- src/Feed/MuteList.ts | 6 +++--- src/Hooks/useModeration.tsx | 6 +++--- src/State/Login.ts | 33 +++++++++++++++++++++++++-------- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index f621cf7c..5d2a4f66 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -304,4 +304,4 @@ const barierNip07 = async (then: () => Promise) => { } finally { isNip07Busy = false; } -}; \ No newline at end of file +}; diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index 71ff6c2b..c5b99963 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -30,6 +30,7 @@ export default function useLoginFeed() { sub.Id = `login:meta`; sub.Authors = new Set([pubKey]); sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]); + sub.Limit = 2 return sub; }, [pubKey]); @@ -92,7 +93,7 @@ export default function useLoginFeed() { dispatch(setRelays({ relays, createdAt: cl.created_at })); } let pTags = cl.tags.filter(a => a[0] === "p").map(a => a[1]); - dispatch(setFollows(pTags)); + dispatch(setFollows({ keys: pTags, createdAt: cl.created_at })); } (async () => { diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index 40134593..f5fd2d7c 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -27,7 +27,7 @@ export default function useMutedFeed(pubkey: HexKey) { return useSubscription(sub); } -export function getMutedKeys(rawNotes: TaggedRawEvent[]): { at: number, keys: HexKey[] } { +export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { const notes = [...rawNotes] notes.sort((a, b) => a.created_at - b.created_at) const newest = notes && notes[0] @@ -36,12 +36,12 @@ export function getMutedKeys(rawNotes: TaggedRawEvent[]): { at: number, keys: He const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG) if (mutedIndex !== -1) { return { - at: newest.created_at, + createdAt: newest.created_at, keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1]) } } } - return { at: 0, keys: [] } + return { createdAt: 0, keys: [] } } export function getMuted(feed: NoteStore, pubkey: HexKey): HexKey[] { diff --git a/src/Hooks/useModeration.tsx b/src/Hooks/useModeration.tsx index b8aebb12..ab529491 100644 --- a/src/Hooks/useModeration.tsx +++ b/src/Hooks/useModeration.tsx @@ -28,7 +28,7 @@ export default function useModeration() { function unmute(id: HexKey) { const newMuted = muted.filter(p => p !== id) dispatch(setMuted({ - at: new Date().getTime(), + createdAt: new Date().getTime(), keys: newMuted })) setMutedList(newMuted) @@ -38,7 +38,7 @@ export default function useModeration() { const newMuted = muted.concat([id]) setMutedList(newMuted) dispatch(setMuted({ - at: new Date().getTime(), + createdAt: new Date().getTime(), keys: newMuted })) } @@ -47,7 +47,7 @@ export default function useModeration() { const newMuted = Array.from(new Set(muted.concat(ids))) setMutedList(newMuted) dispatch(setMuted({ - at: new Date().getTime(), + createdAt: new Date().getTime(), keys: newMuted })) } diff --git a/src/State/Login.ts b/src/State/Login.ts index e3fdb8f7..cc6dec7f 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -72,6 +72,11 @@ export interface LoginStore { */ follows: HexKey[], + /** + * Newest relay list timestamp + */ + latestFollows: number, + /** * A list of pubkeys this user has muted */ @@ -80,7 +85,7 @@ export interface LoginStore { /** * Last seen mute list event timestamp */ - lastMutedSeenAt: number, + latestMuted: number, /** * Notifications for this login session @@ -115,8 +120,9 @@ const InitState = { relays: {}, latestRelays: 0, follows: [], - lastMutedSeenAt: 0, + latestFollows: 0, muted: [], + latestMuted: 0, notifications: [], readNotifications: new Date().getTime(), dms: [], @@ -136,6 +142,11 @@ export interface SetRelaysPayload { createdAt: number }; +export interface SetFollowsPayload { + keys: HexKey[] + createdAt: number +}; + const LoginSlice = createSlice({ name: "Login", initialState: InitState, @@ -204,9 +215,14 @@ const LoginSlice = createSlice({ delete state.relays[action.payload]; state.relays = { ...state.relays }; }, - setFollows: (state, action: PayloadAction) => { + setFollows: (state, action: PayloadAction) => { + const { keys, createdAt } = action.payload + if (state.latestFollows > createdAt) { + return; + } + let existing = new Set(state.follows); - let update = Array.isArray(action.payload) ? action.payload : [action.payload]; + let update = Array.isArray(keys) ? keys : [keys]; let changes = false; for (let pk of update) { @@ -217,14 +233,15 @@ const LoginSlice = createSlice({ } if (changes) { state.follows = Array.from(existing); + state.latestFollows = createdAt; } }, - setMuted(state, action: PayloadAction<{at: number, keys: HexKey[]}>) { - const { at, keys } = action.payload - if (at > state.lastMutedSeenAt) { + setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { + const { createdAt, keys } = action.payload + if (createdAt > state.latestMuted) { const muted = new Set([...keys]) state.muted = Array.from(muted) - state.lastMutedSeenAt = at + state.latestMuted = createdAt } }, addNotifications: (state, action: PayloadAction) => { From 456aa5fb797e40056583d34ce2c32422cfd19b94 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Fri, 27 Jan 2023 22:10:14 +0100 Subject: [PATCH 26/36] address review comments and add private blocking --- src/Element/BlockButton.tsx | 21 +++++++ src/Element/BlockList.tsx | 27 ++++++++ src/Element/MutedList.tsx | 3 +- src/Element/Note.tsx | 31 +++++++-- src/Element/NoteFooter.tsx | 10 ++- src/Element/NoteReaction.tsx | 4 +- src/Element/Text.css | 7 +++ src/Element/Timeline.tsx | 7 +-- src/Feed/EventPublisher.ts | 22 ++++--- src/Feed/LoginFeed.ts | 119 ++++++++++++++--------------------- src/Feed/MuteList.ts | 36 +++++------ src/Hooks/useModeration.tsx | 54 +++++++++++----- src/Nostr/Event.ts | 32 +++++++--- src/Nostr/Subscriptions.ts | 10 +-- src/Nostr/Tag.ts | 4 +- src/Nostr/index.ts | 9 ++- src/Notifications.ts | 43 +++++++++++++ src/Pages/Login.tsx | 8 +-- src/Pages/MessagesPage.tsx | 8 +-- src/Pages/ProfilePage.tsx | 3 +- src/Pages/settings/Index.tsx | 22 ++++++- src/State/Login.ts | 81 ++++++++++++++---------- src/index.css | 42 ++++++------- 23 files changed, 385 insertions(+), 218 deletions(-) create mode 100644 src/Element/BlockButton.tsx create mode 100644 src/Element/BlockList.tsx create mode 100644 src/Notifications.ts diff --git a/src/Element/BlockButton.tsx b/src/Element/BlockButton.tsx new file mode 100644 index 00000000..f2cd5426 --- /dev/null +++ b/src/Element/BlockButton.tsx @@ -0,0 +1,21 @@ +import { HexKey } from "Nostr"; +import useModeration from "Hooks/useModeration"; + +interface BlockButtonProps { + pubkey: HexKey +} + +const BlockButton = ({ pubkey }: BlockButtonProps) => { + const { block, unblock, isBlocked } = useModeration() + return isBlocked(pubkey) ? ( + + ) : ( + + ) +} + +export default BlockButton diff --git a/src/Element/BlockList.tsx b/src/Element/BlockList.tsx new file mode 100644 index 00000000..7125f0f5 --- /dev/null +++ b/src/Element/BlockList.tsx @@ -0,0 +1,27 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { HexKey } from "Nostr"; import type { RootState } from "State/Store"; +import MuteButton from "Element/MuteButton"; +import BlockButton from "Element/BlockButton"; +import ProfilePreview from "Element/ProfilePreview"; +import useMutedFeed, { getMuted } from "Feed/MuteList"; +import useModeration from "Hooks/useModeration"; + +export default function BlockList() { + const { publicKey } = useSelector((s: RootState) => s.login) + const { blocked, muted } = useModeration(); + + return ( +
+

Muted ({muted.length})

+ {muted.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} +

Blocked ({blocked.length})

+ {blocked.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} +
+ ) +} diff --git a/src/Element/MutedList.tsx b/src/Element/MutedList.tsx index f4c2182c..23657ab6 100644 --- a/src/Element/MutedList.tsx +++ b/src/Element/MutedList.tsx @@ -12,11 +12,10 @@ export interface MutedListProps { } export default function MutedList({ pubkey }: MutedListProps) { - const { publicKey } = useSelector((s: RootState) => s.login) const { muted, isMuted, mute, unmute, muteAll } = useModeration(); const feed = useMutedFeed(pubkey) const pubkeys = useMemo(() => { - return publicKey === pubkey ? muted : getMuted(feed.store, pubkey); + return getMuted(feed.store, pubkey); }, [feed, pubkey]); const hasAllMuted = pubkeys.every(isMuted) diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 82a67fd1..2094424c 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -1,5 +1,5 @@ import "./Note.css"; -import { useCallback, useMemo, ReactNode } from "react"; +import { useCallback, useMemo, useState, ReactNode } from "react"; import { useNavigate, Link } from "react-router-dom"; import { default as NEvent } from "Nostr/Event"; @@ -27,6 +27,23 @@ export interface NoteProps { ["data-ev"]?: NEvent } +const HiddenNote = ({ children }: any) => { + const [show, setShow] = useState(false) + return show ? children : ( +
+
+

+ This note was hidden because of your moderation settings +

+ +
+
+ ) +} + + export default function Note(props: NoteProps) { const navigate = useNavigate(); const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props @@ -35,7 +52,7 @@ export default function Note(props: NoteProps) { const users = useProfile(pubKeys); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); const { isMuted } = useModeration() - const muted = isMuted(ev.PubKey) + const isOpMuted = isMuted(ev.PubKey) const { ref, inView } = useInView({ triggerOnce: true }); const options = { @@ -153,9 +170,11 @@ export default function Note(props: NoteProps) { ) } - return muted ? null : ( -
- {content()} -
+ const note = ( +
+ {content()} +
) + + return isOpMuted ? {note} : note } diff --git a/src/Element/NoteFooter.tsx b/src/Element/NoteFooter.tsx index 72b73600..42db172e 100644 --- a/src/Element/NoteFooter.tsx +++ b/src/Element/NoteFooter.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useSelector } from "react-redux"; -import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash } from "@fortawesome/free-solid-svg-icons"; +import { faTrash, faRepeat, faShareNodes, faCopy, faCommentSlash, faBan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Menu, MenuItem } from '@szhsin/react-menu'; @@ -31,7 +31,7 @@ export default function NoteFooter(props: NoteFooterProps) { const { related, ev } = props; const login = useSelector(s => s.login.publicKey); - const { mute } = useModeration(); + const { mute, block } = useModeration(); const prefs = useSelector(s => s.login.preferences); const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); const publisher = useEventPublisher(); @@ -173,7 +173,11 @@ export default function NoteFooter(props: NoteFooterProps) { mute(ev.PubKey)}> - Mute author + Mute + + block(ev.PubKey)}> + + Block {prefs.showDebugMenus && ( copyEvent()}> diff --git a/src/Element/NoteReaction.tsx b/src/Element/NoteReaction.tsx index 56de9de6..771b72a8 100644 --- a/src/Element/NoteReaction.tsx +++ b/src/Element/NoteReaction.tsx @@ -20,6 +20,8 @@ export default function NoteReaction(props: NoteReactionProps) { const { ["data-ev"]: dataEv, data } = props; const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]) const { isMuted } = useModeration(); + const pRef = data?.tags.find((a: any) => a[0] === "p")?.at(1); + const isRefMuted = pRef && isMuted(pRef) const refEvent = useMemo(() => { if (ev) { @@ -57,7 +59,7 @@ export default function NoteReaction(props: NoteReactionProps) { }; const isOpMuted = root && isMuted(root.pubkey) - return isOpMuted ? null : ( + return isOpMuted || isRefMuted ? null : (
diff --git a/src/Element/Text.css b/src/Element/Text.css index 0b13975f..8903a429 100644 --- a/src/Element/Text.css +++ b/src/Element/Text.css @@ -69,3 +69,10 @@ width: -webkit-fill-available; aspect-ratio: 16 / 9; } + +.text blockquote { + margin: 0; + color: var(--font-secondary-color); + border-left: 2px solid var(--font-secondary-color); + padding-left: 12px; +} diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index 2e95feb9..abf5ba56 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -2,7 +2,6 @@ import "./Timeline.css"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faForward } from "@fortawesome/free-solid-svg-icons"; import { useCallback, useMemo } from "react"; -import { useSelector } from "react-redux"; import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import { TaggedRawEvent } from "Nostr"; @@ -10,7 +9,6 @@ import EventKind from "Nostr/EventKind"; import LoadMore from "Element/LoadMore"; import Note from "Element/Note"; import NoteReaction from "Element/NoteReaction"; -import type { RootState } from "State/Store"; import useModeration from "Hooks/useModeration"; export interface TimelineProps { @@ -48,10 +46,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin case EventKind.Reaction: case EventKind.Repost: { let eRef = e.tags.find(a => a[0] === "e")?.at(1); - let pRef = e.tags.find(a => a[0] === "p")?.at(1); - return !muted.includes(pRef || '') ? ( - a.id === eRef)}/> - ) : null + return a.id === eRef)}/> } } } diff --git a/src/Feed/EventPublisher.ts b/src/Feed/EventPublisher.ts index 5d2a4f66..c4d44a36 100644 --- a/src/Feed/EventPublisher.ts +++ b/src/Feed/EventPublisher.ts @@ -5,8 +5,7 @@ import { default as NEvent } from "Nostr/Event"; import EventKind from "Nostr/EventKind"; import Tag from "Nostr/Tag"; import { RootState } from "State/Store"; -import { HexKey, RawEvent, u256, UserMetadata } from "Nostr"; -import { MUTE_LIST_TAG } from "Feed/MuteList"; +import { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr"; import { bech32ToHex } from "Util" import { DefaultRelays, HashtagRegex } from "Const"; @@ -97,16 +96,25 @@ export default function useEventPublisher() { } } }, - muted: async (keys: HexKey[]) => { + muted: async (keys: HexKey[], priv: HexKey[]) => { if (pubKey) { let ev = NEvent.ForPubKey(pubKey); ev.Kind = EventKind.Lists; - ev.Tags.push(new Tag(["d", MUTE_LIST_TAG], ev.Tags.length)) + ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length)) keys.forEach(p => { ev.Tags.push(new Tag(["p", p], ev.Tags.length)) }) - // todo: public/private block - ev.Content = ""; + let content = "" + if (priv.length > 0) { + const ps = priv.map(p => ["p", p]) + const plaintext = JSON.stringify(ps) + if (hasNip07 && !privKey) { + content = await barierNip07(() => window.nostr.nip04.encrypt(pubKey, plaintext)); + } else if (privKey) { + content = await ev.EncryptData(plaintext, pubKey, privKey) + } + } + ev.Content = content; return await signEvent(ev); } }, @@ -294,7 +302,7 @@ const delay = (t: number) => { }); } -const barierNip07 = async (then: () => Promise) => { +export const barierNip07 = async (then: () => Promise) => { while (isNip07Busy) { await delay(10); } diff --git a/src/Feed/LoginFeed.ts b/src/Feed/LoginFeed.ts index c5b99963..cfc8a2a5 100644 --- a/src/Feed/LoginFeed.ts +++ b/src/Feed/LoginFeed.ts @@ -1,27 +1,27 @@ -import Nostrich from "nostrich.jpg"; import { useEffect, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { HexKey, TaggedRawEvent } from "Nostr"; +import { makeNotification } from "Notifications"; +import { TaggedRawEvent, HexKey, Lists } from "Nostr"; import EventKind from "Nostr/EventKind"; +import Event from "Nostr/Event"; import { Subscriptions } from "Nostr/Subscriptions"; -import { addDirectMessage, addNotifications, setFollows, setRelays, setMuted } from "State/Login"; -import { RootState } from "State/Store"; +import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login"; +import type { RootState } from "State/Store"; import { db } from "Db"; +import { barierNip07 } from "Feed/EventPublisher"; import useSubscription from "Feed/Subscription"; -import { MUTE_LIST_TAG, getMutedKeys } from "Feed/MuteList"; +import { getMutedKeys, getNewest } from "Feed/MuteList"; import { mapEventToProfile, MetadataCache } from "Db/User"; -import { getDisplayName } from "Element/ProfileImage"; import useModeration from "Hooks/useModeration"; -import { MentionRegex } from "Const"; /** * Managed loading data for the current logged in user */ export default function useLoginFeed() { const dispatch = useDispatch(); - const { isMuted } = useModeration(); - const [pubKey, readNotifications] = useSelector(s => [s.login.publicKey, s.login.readNotifications]); + const { publicKey: pubKey, privateKey: privKey } = useSelector((s: RootState) => s.login); + const { isMuted } = useModeration(); const subMetadata = useMemo(() => { if (!pubKey) return null; @@ -53,7 +53,7 @@ export default function useLoginFeed() { sub.Id = "login:muted"; sub.Kinds = new Set([EventKind.Lists]); sub.Authors = new Set([pubKey]); - sub.DTags = new Set([MUTE_LIST_TAG]) + sub.DTag = Lists.Muted; sub.Limit = 1; return sub; @@ -103,7 +103,7 @@ export default function useLoginFeed() { acc.created = v.created; } return acc; - }, { created: 0, profile: null }); + }, { created: 0, profile: null as MetadataCache | null }); if (maxProfile.profile) { let existing = await db.users.get(maxProfile.profile.pubkey); if ((existing?.created ?? 0) < maxProfile.created) { @@ -111,75 +111,52 @@ export default function useLoginFeed() { } } })().catch(console.warn); - }, [metadataFeed.store]); + }, [dispatch, metadataFeed.store]); useEffect(() => { - let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey)) - - if ("Notification" in window && Notification.permission === "granted") { - for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) { - sendNotification(nx) - .catch(console.warn); + const replies = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote && !isMuted(a.pubkey)) + replies.forEach(nx => { + makeNotification(nx).then(notification => { + if (notification) { + // @ts-ignore + dispatch(sendNotification(notification)) } - } - - dispatch(addNotifications(notifications)); - }, [notificationFeed.store]); + }) + }) + }, [dispatch, notificationFeed.store]); useEffect(() => { - const ps = getMutedKeys(mutedFeed.store.notes) - dispatch(setMuted(ps)) - }, [mutedFeed.store]) + const muted = getMutedKeys(mutedFeed.store.notes) + dispatch(setMuted(muted)) + + const newest = getNewest(mutedFeed.store.notes) + if (newest && newest.content.length > 0 && pubKey) { + decryptBlocked(newest, pubKey, privKey).then((plaintext) => { + try { + const blocked = JSON.parse(plaintext) + const keys = blocked.filter((p:any) => p && p.length === 2 && p[0] === "p").map((p: any) => p[1]) + dispatch(setBlocked({ + keys, + createdAt: newest.created_at, + })) + } catch(error) { + console.debug("Couldn't parse JSON") + } + }).catch((error) => console.warn(error)) + } + }, [dispatch, mutedFeed.store]) useEffect(() => { let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage); dispatch(addDirectMessage(dms)); - }, [dmsFeed.store]); + }, [dispatch, dmsFeed.store]); } -async function makeNotification(ev: TaggedRawEvent) { - switch (ev.kind) { - case EventKind.TextNote: { - const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]); - const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!); - const fromUser = users.find(a => a?.pubkey === ev.pubkey); - const name = getDisplayName(fromUser, ev.pubkey); - const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture; - return { - title: `Reply from ${name}`, - body: replaceTagsWithUser(ev, users).substring(0, 50), - icon: avatarUrl - } - } - } - return null; -} - -function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) { - return ev.content.split(MentionRegex).map(match => { - let matchTag = match.match(/#\[(\d+)\]/); - if (matchTag && matchTag.length === 2) { - let idx = parseInt(matchTag[1]); - let ref = ev.tags[idx]; - if (ref && ref[0] === "p" && ref.length > 1) { - let u = users.find(a => a.pubkey === ref[1]); - return `@${getDisplayName(u, ref[1])}`; - } - } - return match; - }).join(); -} - -async function sendNotification(ev: TaggedRawEvent) { - let n = await makeNotification(ev); - if (n != null && Notification.permission === "granted") { - let worker = await navigator.serviceWorker.ready; - worker.showNotification(n.title, { - body: n.body, - icon: n.icon, - tag: "notification", - timestamp: ev.created_at * 1000, - vibrate: [500] - }); - } +async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) { + const ev = new Event(raw) + if (pubKey && privKey) { + return await ev.DecryptData(raw.content, privKey, pubKey) + } else { + return await barierNip07(() => window.nostr.nip04.decrypt(pubKey, raw.content)); + } } diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index f5fd2d7c..d18e0948 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -1,45 +1,41 @@ import { useMemo } from "react"; -import { useSelector } from "react-redux"; -import { HexKey, TaggedRawEvent } from "Nostr"; +import { HexKey, TaggedRawEvent, Lists } from "Nostr"; import EventKind from "Nostr/EventKind"; import { Subscriptions } from "Nostr/Subscriptions"; -import type { RootState } from "State/Store"; import useSubscription, { NoteStore } from "Feed/Subscription"; -export const MUTE_LIST_TAG = "p:mute" - export default function useMutedFeed(pubkey: HexKey) { - const loginPubkey = useSelector((s: RootState) => s.login.publicKey) const sub = useMemo(() => { - if (pubkey === loginPubkey) return null - let sub = new Subscriptions(); sub.Id = `muted:${pubkey}`; sub.Kinds = new Set([EventKind.Lists]); sub.Authors = new Set([pubkey]); - sub.DTags = new Set([MUTE_LIST_TAG]) + sub.DTag = Lists.Muted; sub.Limit = 1; - return sub; }, [pubkey]); return useSubscription(sub); } -export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { +export function getNewest(rawNotes: TaggedRawEvent[]){ const notes = [...rawNotes] notes.sort((a, b) => a.created_at - b.created_at) - const newest = notes && notes[0] + if (notes.length > 0) { + return notes[0] + } +} + +export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } { + const newest = getNewest(rawNotes) if (newest) { - const { tags } = newest - const mutedIndex = tags.findIndex(t => t[0] === "d" && t[1] === MUTE_LIST_TAG) - if (mutedIndex !== -1) { - return { - createdAt: newest.created_at, - keys: tags.slice(mutedIndex).filter(t => t[0] === "p").map(t => t[1]) - } - } + const { created_at, tags } = newest + const keys = tags.filter(t => t[0] === "p").map(t => t[1]) + return { + keys, + createdAt: created_at, + } } return { createdAt: 0, keys: [] } } diff --git a/src/Hooks/useModeration.tsx b/src/Hooks/useModeration.tsx index ab529491..a09316ad 100644 --- a/src/Hooks/useModeration.tsx +++ b/src/Hooks/useModeration.tsx @@ -3,26 +3,30 @@ import { useSelector, useDispatch } from "react-redux"; import type { RootState } from "State/Store"; import { HexKey } from "Nostr"; import useEventPublisher from "Feed/EventPublisher"; -import { setMuted } from "State/Login"; +import { setMuted, setBlocked } from "State/Login"; export default function useModeration() { const dispatch = useDispatch() - const { muted } = useSelector((s: RootState) => s.login) + const { blocked, muted } = useSelector((s: RootState) => s.login) const publisher = useEventPublisher() - async function setMutedList(ids: HexKey[]) { - try { - const ev = await publisher.muted(ids) - console.debug(ev); - publisher.broadcast(ev) - } catch (error) { - console.debug("Couldn't change mute list") - } + async function setMutedList(pub: HexKey[], priv: HexKey[]) { + try { + const ev = await publisher.muted(pub, priv) + console.debug(ev); + publisher.broadcast(ev) + } catch (error) { + console.debug("Couldn't change mute list") } + } function isMuted(id: HexKey) { - return muted.includes(id) + return muted.includes(id) || blocked.includes(id) + } + + function isBlocked(id: HexKey) { + return blocked.includes(id) } function unmute(id: HexKey) { @@ -31,26 +35,44 @@ export default function useModeration() { createdAt: new Date().getTime(), keys: newMuted })) - setMutedList(newMuted) + setMutedList(newMuted, blocked) + } + + function unblock(id: HexKey) { + const newBlocked = blocked.filter(p => p !== id) + dispatch(setBlocked({ + createdAt: new Date().getTime(), + keys: newBlocked + })) + setMutedList(muted, newBlocked) } function mute(id: HexKey) { - const newMuted = muted.concat([id]) - setMutedList(newMuted) + const newMuted = muted.includes(id) ? muted : muted.concat([id]) + setMutedList(newMuted, blocked) dispatch(setMuted({ createdAt: new Date().getTime(), keys: newMuted })) } + function block(id: HexKey) { + const newBlocked = blocked.includes(id) ? blocked : blocked.concat([id]) + setMutedList(muted, newBlocked) + dispatch(setBlocked({ + createdAt: new Date().getTime(), + keys: newBlocked + })) + } + function muteAll(ids: HexKey[]) { const newMuted = Array.from(new Set(muted.concat(ids))) - setMutedList(newMuted) + setMutedList(newMuted, blocked) dispatch(setMuted({ createdAt: new Date().getTime(), keys: newMuted })) } - return { muted, mute, muteAll, unmute, isMuted } + return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked } } diff --git a/src/Nostr/Event.ts b/src/Nostr/Event.ts index baf7aa3d..b19eb27a 100644 --- a/src/Nostr/Event.ts +++ b/src/Nostr/Event.ts @@ -139,26 +139,33 @@ export default class Event { } /** - * Encrypt the message content in place + * Encrypt the given message content */ - async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) { + async EncryptData(content: string, pubkey: HexKey, privkey: HexKey) { let key = await this._GetDmSharedKey(pubkey, privkey); let iv = window.crypto.getRandomValues(new Uint8Array(16)); - let data = new TextEncoder().encode(this.Content); + let data = new TextEncoder().encode(content); let result = await window.crypto.subtle.encrypt({ name: "AES-CBC", iv: iv }, key, data); let uData = new Uint8Array(result); - this.Content = `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`; + return `${base64.encode(uData, 0, result.byteLength)}?iv=${base64.encode(iv, 0, 16)}`; } /** - * Decrypt the content of this message in place + * Encrypt the message content in place */ - async DecryptDm(privkey: HexKey, pubkey: HexKey) { + async EncryptDmForPubkey(pubkey: HexKey, privkey: HexKey) { + this.Content = await this.EncryptData(this.Content, pubkey, privkey); + } + + /** + * Decrypt the content of the message + */ + async DecryptData(cyphertext: string, privkey: HexKey, pubkey: HexKey) { let key = await this._GetDmSharedKey(pubkey, privkey); - let cSplit = this.Content.split("?iv="); + let cSplit = cyphertext.split("?iv="); let data = new Uint8Array(base64.length(cSplit[0])); base64.decode(cSplit[0], data, 0); @@ -169,7 +176,14 @@ export default class Event { name: "AES-CBC", iv: iv }, key, data); - this.Content = new TextDecoder().decode(result); + return new TextDecoder().decode(result); + } + + /** + * Decrypt the content of this message in place + */ + async DecryptDm(privkey: HexKey, pubkey: HexKey) { + this.Content = await this.DecryptData(this.Content, privkey, pubkey) } async _GetDmSharedKey(pubkey: HexKey, privkey: HexKey) { @@ -177,4 +191,4 @@ export default class Event { let sharedX = sharedPoint.slice(1, 33); return await window.crypto.subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt", "decrypt"]) } -} \ No newline at end of file +} diff --git a/src/Nostr/Subscriptions.ts b/src/Nostr/Subscriptions.ts index 47854f8b..bc6fe068 100644 --- a/src/Nostr/Subscriptions.ts +++ b/src/Nostr/Subscriptions.ts @@ -43,9 +43,9 @@ export class Subscriptions { HashTags?: Set; /** - * A list of "d" tags to search + * A "d" tag to search */ - DTags?: Set; + DTag?: string; /** * a timestamp, events must be newer than this to pass @@ -94,7 +94,7 @@ export class Subscriptions { this.Kinds = sub?.kinds ? new Set(sub.kinds) : undefined; this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined; this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined; - this.DTags = sub?.["#d"] ? new Set(sub["#d"]) : undefined; + this.DTag = sub?.["#d"] ? sub["#d"] : undefined; this.Since = sub?.since ?? undefined; this.Until = sub?.until ?? undefined; this.Limit = sub?.limit ?? undefined; @@ -139,8 +139,8 @@ export class Subscriptions { if (this.HashTags) { ret["#t"] = Array.from(this.HashTags); } - if (this.DTags) { - ret["#d"] = Array.from(this.DTags); + if (this.DTag) { + ret["#d"] = this.DTag; } if (this.Since !== null) { ret.since = this.Since; diff --git a/src/Nostr/Tag.ts b/src/Nostr/Tag.ts index 6ee98eec..fb961ae9 100644 --- a/src/Nostr/Tag.ts +++ b/src/Nostr/Tag.ts @@ -67,11 +67,11 @@ export default class Tag { return ["t", this.Hashtag!]; } case "d": { - return ["t", this.DTag!]; + return ["d", this.DTag!]; } default: { return this.Original; } } } -} \ No newline at end of file +} diff --git a/src/Nostr/index.ts b/src/Nostr/index.ts index 3b016337..1d8c4384 100644 --- a/src/Nostr/index.ts +++ b/src/Nostr/index.ts @@ -35,7 +35,7 @@ export type RawReqFilter = { "#e"?: u256[], "#p"?: u256[], "#t"?: string[], - "#d"?: string[], + "#d"?: string, since?: number, until?: number, limit?: number @@ -55,3 +55,10 @@ export type UserMetadata = { lud06?: string, lud16?: string } + +/** + * NIP-51 list types + */ +export enum Lists { + Muted = "mute" +} diff --git a/src/Notifications.ts b/src/Notifications.ts new file mode 100644 index 00000000..c92b6040 --- /dev/null +++ b/src/Notifications.ts @@ -0,0 +1,43 @@ +import Nostrich from "nostrich.jpg"; + +import { TaggedRawEvent } from "Nostr"; +import EventKind from "Nostr/EventKind"; +import type { NotificationRequest } from "State/Login"; +import { db } from "Db"; +import { MetadataCache } from "Db/User"; +import { getDisplayName } from "Element/ProfileImage"; +import { MentionRegex } from "Const"; + +export async function makeNotification(ev: TaggedRawEvent): Promise { + switch (ev.kind) { + case EventKind.TextNote: { + const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]); + const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!); + const fromUser = users.find(a => a?.pubkey === ev.pubkey); + const name = getDisplayName(fromUser, ev.pubkey); + const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture; + return { + title: `Reply from ${name}`, + body: replaceTagsWithUser(ev, users).substring(0, 50), + icon: avatarUrl, + timestamp: ev.created_at * 1000, + } + } + } + return null; +} + +function replaceTagsWithUser(ev: TaggedRawEvent, users: MetadataCache[]) { + return ev.content.split(MentionRegex).map(match => { + let matchTag = match.match(/#\[(\d+)\]/); + if (matchTag && matchTag.length === 2) { + let idx = parseInt(matchTag[1]); + let ref = ev.tags[idx]; + if (ref && ref[0] === "p" && ref.length > 1) { + let u = users.find(a => a.pubkey === ref[1]); + return `@${getDisplayName(u, ref[1])}`; + } + } + return match; + }).join(); +} diff --git a/src/Pages/Login.tsx b/src/Pages/Login.tsx index 0768af51..d32ce7c8 100644 --- a/src/Pages/Login.tsx +++ b/src/Pages/Login.tsx @@ -20,7 +20,7 @@ export default function LoginPage() { if (publicKey) { navigate("/"); } - }, [publicKey]); + }, [publicKey, navigate]); async function getNip05PubKey(addr: string) { let [username, domain] = addr.split("@"); @@ -32,7 +32,7 @@ export default function LoginPage() { return pKey; } } - throw "User key not found" + throw new Error("User key not found") } async function doLogin() { @@ -43,7 +43,7 @@ export default function LoginPage() { if (secp.utils.isValidPrivateKey(hexKey)) { dispatch(setPrivateKey(hexKey)); } else { - throw "INVALID PRIVATE KEY"; + throw new Error("INVALID PRIVATE KEY"); } } else if (key.startsWith("npub")) { let hexKey = bech32ToHex(key); @@ -55,7 +55,7 @@ export default function LoginPage() { if (secp.utils.isValidPrivateKey(key)) { dispatch(setPrivateKey(key)); } else { - throw "INVALID PRIVATE KEY"; + throw new Error("INVALID PRIVATE KEY"); } } } catch (e) { diff --git a/src/Pages/MessagesPage.tsx b/src/Pages/MessagesPage.tsx index 9895e067..01c6b392 100644 --- a/src/Pages/MessagesPage.tsx +++ b/src/Pages/MessagesPage.tsx @@ -21,11 +21,11 @@ export default function MessagesPage() { const myPubKey = useSelector(s => s.login.publicKey); const dms = useSelector(s => s.login.dms); const dmInteraction = useSelector(s => s.login.dmInteraction); - const { muted, isMuted } = useModeration(); + const { isMuted } = useModeration(); const chats = useMemo(() => { - return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!); - }, [dms, myPubKey, dmInteraction, muted]); + return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!) + }, [dms, myPubKey, dmInteraction]); function noteToSelf(chat: DmChat) { return ( @@ -93,7 +93,7 @@ export function isToSelf(e: RawEvent, pk: HexKey) { } export function dmsInChat(dms: RawEvent[], pk: HexKey) { - return dms.filter(a => a.pubkey === pk || dmTo(a) == pk); + return dms.filter(a => a.pubkey === pk || dmTo(a) === pk); } export function totalUnread(dms: RawEvent[], myPubKey: HexKey) { diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 3f80d216..adddf75c 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -18,6 +18,7 @@ import Nip05 from "Element/Nip05"; import Copy from "Element/Copy"; import ProfilePreview from "Element/ProfilePreview"; import FollowersList from "Element/FollowersList"; +import BlockList from "Element/BlockList"; import MutedList from "Element/MutedList"; import FollowsList from "Element/FollowsList"; import { RootState } from "State/Store"; @@ -123,7 +124,7 @@ export default function ProfilePage() { return } case ProfileTab.Muted: { - return + return isMe ? : } } } diff --git a/src/Pages/settings/Index.tsx b/src/Pages/settings/Index.tsx index 5fc6ae1b..d6b9877d 100644 --- a/src/Pages/settings/Index.tsx +++ b/src/Pages/settings/Index.tsx @@ -1,12 +1,23 @@ -import { faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useNavigate } from "react-router-dom"; import "./Index.css"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { faRightFromBracket, faCircleDollarToSlot, faGear, faPlug, faUser } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { logout } from "State/Login"; + const SettingsIndex = () => { + const dispatch = useDispatch(); const navigate = useNavigate(); + function handleLogout() { + dispatch(logout()) + navigate("/") + } + return ( + <>
navigate("profile")}> @@ -24,7 +35,12 @@ const SettingsIndex = () => { Donate
+
+ + Log Out +
+ ) } diff --git a/src/State/Login.ts b/src/State/Login.ts index cc6dec7f..80f904c9 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -3,12 +3,20 @@ import * as secp from '@noble/secp256k1'; import { DefaultRelays } from 'Const'; import { HexKey, TaggedRawEvent } from 'Nostr'; import { RelaySettings } from 'Nostr/Connection'; +import type { AppDispatch, RootState } from "State/Store"; const PrivateKeyItem = "secret"; const PublicKeyItem = "pubkey"; const NotificationsReadItem = "notifications-read"; const UserPreferencesKey = "preferences"; +export interface NotificationRequest { + title: string + body: string + icon: string + timestamp: number +} + export interface UserPreferences { /** * Enable reactions / reposts / zaps @@ -87,6 +95,11 @@ export interface LoginStore { */ latestMuted: number, + /** + * A list of pubkeys this user has muted privately + */ + blocked: HexKey[], + /** * Notifications for this login session */ @@ -122,6 +135,7 @@ const InitState = { follows: [], latestFollows: 0, muted: [], + blocked: [], latestMuted: 0, notifications: [], readNotifications: new Date().getTime(), @@ -238,49 +252,26 @@ const LoginSlice = createSlice({ }, setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { const { createdAt, keys } = action.payload - if (createdAt > state.latestMuted) { + if (createdAt >= state.latestMuted) { const muted = new Set([...keys]) state.muted = Array.from(muted) state.latestMuted = createdAt } }, - addNotifications: (state, action: PayloadAction) => { - let n = action.payload; - if (!Array.isArray(n)) { - n = [n]; - } - - let didChange = false; - for (let x of n) { - if (!state.notifications.some(a => a.id === x.id)) { - state.notifications.push(x); - didChange = true; - } - } - if (didChange) { - state.notifications = [ - ...state.notifications - ]; - } + setBlocked(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { + const { createdAt, keys } = action.payload + if (createdAt >= state.latestMuted) { + const blocked = new Set([...keys]) + state.blocked = Array.from(blocked) + state.latestMuted = createdAt + } }, addDirectMessage: (state, action: PayloadAction>) => { let n = action.payload; if (!Array.isArray(n)) { n = [n]; } - - let didChange = false; - for (let x of n) { - if (!state.dms.some(a => a.id === x.id)) { - state.dms.push(x); - didChange = true; - } - } - if (didChange) { - state.dms = [ - ...state.dms - ]; - } + state.dms = n; }, incDmInteraction: (state) => { state.dmInteraction += 1; @@ -309,12 +300,36 @@ export const { setRelays, removeRelay, setFollows, - addNotifications, setMuted, + setBlocked, addDirectMessage, incDmInteraction, logout, markNotificationsRead, setPreferences, } = LoginSlice.actions; + +export function sendNotification({ title, body, icon, timestamp }: NotificationRequest) { + return async (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState() + const { readNotifications } = state.login + const hasPermission = "Notification" in window && Notification.permission === "granted" + const shouldShowNotification = hasPermission && timestamp > readNotifications + if (shouldShowNotification) { + try { + let worker = await navigator.serviceWorker.ready; + worker.showNotification(title, { + tag: "notification", + vibrate: [500], + body, + icon, + timestamp, + }); + } catch (error) { + console.warn(error) + } + } + } +} + export const reducer = LoginSlice.reducer; diff --git a/src/index.css b/src/index.css index 7968fb22..afe45b1b 100644 --- a/src/index.css +++ b/src/index.css @@ -397,27 +397,6 @@ body.scroll-lock { margin-right: auto; } -.tabs { - display: flex; - align-content: center; - text-align: center; - margin: 10px 0; - overflow-x: auto; -} - -.tabs>div { - margin-right: 10px; - cursor: pointer; -} - -.tabs>div:last-child { - margin: 0; -} - -.tabs .active { - font-weight: 700; -} - .error { color: var(--error); } @@ -431,12 +410,27 @@ body.scroll-lock { } .tabs { - padding: 0; - align-items: center; - justify-content: flex-start; + display: flex; + align-content: center; + text-align: center; + margin-top: 10px; + overflow-x: auto; margin-bottom: 16px; } +.tabs > * { + margin-right: 10px; + cursor: pointer; +} + +.tabs > *:last-child { + margin: 0; +} + +.tabs .active { + font-weight: 700; +} + .tab { border-bottom: 1px solid var(--gray-secondary); font-weight: 700; From e245a3931bef977f69c1129a536db08cf5a31a21 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 12:57:09 +0100 Subject: [PATCH 27/36] fix: remove unintended change --- src/State/Login.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/State/Login.ts b/src/State/Login.ts index 80f904c9..a7fac58c 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -271,7 +271,20 @@ const LoginSlice = createSlice({ if (!Array.isArray(n)) { n = [n]; } - state.dms = n; + + let didChange = false; + for (let x of n) { + if (!state.dms.some(a => a.id === x.id)) { + state.dms.push(x); + didChange = true; + } + } + + if (didChange) { + state.dms = [ + ...state.dms + ]; + } }, incDmInteraction: (state) => { state.dmInteraction += 1; From 01c15c30a47c1982e6bc22a64b3be3a0f4958ab6 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 19:30:39 +0100 Subject: [PATCH 28/36] separate mute and blocked tabs --- src/Element/BlockList.tsx | 30 +++++++++++++++++++++--------- src/Pages/ProfilePage.tsx | 17 ++++++++++++----- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/Element/BlockList.tsx b/src/Element/BlockList.tsx index 7125f0f5..bcf91d5e 100644 --- a/src/Element/BlockList.tsx +++ b/src/Element/BlockList.tsx @@ -8,20 +8,32 @@ import ProfilePreview from "Element/ProfilePreview"; import useMutedFeed, { getMuted } from "Feed/MuteList"; import useModeration from "Hooks/useModeration"; -export default function BlockList() { +interface BlockListProps { + variant: "muted" | "blocked" +} + +export default function BlockList({ variant }: BlockListProps) { const { publicKey } = useSelector((s: RootState) => s.login) const { blocked, muted } = useModeration(); return (
-

Muted ({muted.length})

- {muted.map(a => { - return } pubkey={a} options={{ about: false }} key={a} /> - })} -

Blocked ({blocked.length})

- {blocked.map(a => { - return } pubkey={a} options={{ about: false }} key={a} /> - })} + {variant === "muted" && ( + <> +

{muted.length} muted

+ {muted.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} + + )} + {variant === "blocked" && ( + <> +

{blocked.length} blocked

+ {blocked.map(a => { + return } pubkey={a} options={{ about: false }} key={a} /> + })} + + )}
) } diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index adddf75c..3724f55a 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -30,7 +30,8 @@ enum ProfileTab { Reactions = "Reactions", Followers = "Followers", Follows = "Follows", - Muted = "Muted" + Muted = "Muted", + Blocked = "Blocked" }; export default function ProfilePage() { @@ -124,7 +125,10 @@ export default function ProfilePage() { return } case ProfileTab.Muted: { - return isMe ? : + return isMe ? : + } + case ProfileTab.Blocked: { + return isMe ? : null } } } @@ -165,6 +169,10 @@ export default function ProfilePage() { ) } + function renderTab(v: ProfileTab) { + return
setTab(v)}>{v}
+ } + return ( <>
@@ -175,9 +183,8 @@ export default function ProfilePage() {
- {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(v => { - return
setTab(v)}>{v}
- })} + {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)} + {isMe && renderTab(ProfileTab.Blocked)}
{tabContent()} From 152970288d0cec9b1b91f83e9578478d0e98948e Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 19:51:08 +0100 Subject: [PATCH 29/36] hidden note styles --- src/Element/Note.css | 13 +++++++++++++ src/Element/Note.tsx | 2 +- src/Element/NoteReaction.tsx | 6 ++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Element/Note.css b/src/Element/Note.css index e58114dd..0d1c87ee 100644 --- a/src/Element/Note.css +++ b/src/Element/Note.css @@ -181,3 +181,16 @@ .light .note.active>.footer>.reaction-pill.reacted { color: var(--highlight); } + +.hidden-note .header { + display: flex; + align-items: center; +} + +.card.note.hidden-note { + min-height: unset; +} + +.hidden-note button { + max-height: 30px; +} diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 2094424c..e6ff52eb 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -30,7 +30,7 @@ export interface NoteProps { const HiddenNote = ({ children }: any) => { const [show, setShow] = useState(false) return show ? children : ( -
+

This note was hidden because of your moderation settings diff --git a/src/Element/NoteReaction.tsx b/src/Element/NoteReaction.tsx index 771b72a8..2f7db624 100644 --- a/src/Element/NoteReaction.tsx +++ b/src/Element/NoteReaction.tsx @@ -20,8 +20,6 @@ export default function NoteReaction(props: NoteReactionProps) { const { ["data-ev"]: dataEv, data } = props; const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]) const { isMuted } = useModeration(); - const pRef = data?.tags.find((a: any) => a[0] === "p")?.at(1); - const isRefMuted = pRef && isMuted(pRef) const refEvent = useMemo(() => { if (ev) { @@ -53,13 +51,13 @@ export default function NoteReaction(props: NoteReactionProps) { } const root = extractRoot(); + const isOpMuted = root && isMuted(root.pubkey) const opt = { showHeader: ev?.Kind === EventKind.Repost, showFooter: false, }; - const isOpMuted = root && isMuted(root.pubkey) - return isOpMuted || isRefMuted ? null : ( + return isOpMuted ? null : (

From ddec154fcbb8da27aaf009cdf7a0b75f71911114 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 20:05:11 +0100 Subject: [PATCH 30/36] shorten sub id --- src/Feed/MuteList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Feed/MuteList.ts b/src/Feed/MuteList.ts index d18e0948..79eb14d2 100644 --- a/src/Feed/MuteList.ts +++ b/src/Feed/MuteList.ts @@ -8,7 +8,7 @@ import useSubscription, { NoteStore } from "Feed/Subscription"; export default function useMutedFeed(pubkey: HexKey) { const sub = useMemo(() => { let sub = new Subscriptions(); - sub.Id = `muted:${pubkey}`; + sub.Id = `muted:${pubkey.slice(0, 12)}`; sub.Kinds = new Set([EventKind.Lists]); sub.Authors = new Set([pubkey]); sub.DTag = Lists.Muted; From 5e0b36f16514ecbaa8678ff7af8afecb6686dd2d Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 22:09:02 +0100 Subject: [PATCH 31/36] ignore moderation when viewing profile --- src/Element/Note.tsx | 5 +++-- src/Element/Timeline.tsx | 7 ++++--- src/Pages/ProfilePage.tsx | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index e6ff52eb..f31d658e 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -19,6 +19,7 @@ export interface NoteProps { isThread?: boolean, related: TaggedRawEvent[], highlight?: boolean, + ignoreModeration?: boolean, options?: { showHeader?: boolean, showTime?: boolean, @@ -46,7 +47,7 @@ const HiddenNote = ({ children }: any) => { export default function Note(props: NoteProps) { const navigate = useNavigate(); - const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent } = props + const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); const users = useProfile(pubKeys); @@ -176,5 +177,5 @@ export default function Note(props: NoteProps) {
) - return isOpMuted ? {note} : note + return !ignoreModeration && isOpMuted ? {note} : note } diff --git a/src/Element/Timeline.tsx b/src/Element/Timeline.tsx index abf5ba56..0324309f 100644 --- a/src/Element/Timeline.tsx +++ b/src/Element/Timeline.tsx @@ -15,19 +15,20 @@ export interface TimelineProps { postsOnly: boolean, subject: TimelineSubject, method: "TIME_RANGE" | "LIMIT_UNTIL" + ignoreModeration?: boolean } /** * A list of notes by pubkeys */ -export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { +export default function Timeline({ subject, postsOnly = false, method, ignoreModeration = false }: TimelineProps) { const { muted, isMuted } = useModeration(); const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, { method }); const filterPosts = useCallback((nts: TaggedRawEvent[]) => { - return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => !isMuted(a.pubkey)); + return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true).filter(a => ignoreModeration || !isMuted(a.pubkey)); }, [postsOnly, muted]); const mainFeed = useMemo(() => { @@ -41,7 +42,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin function eventElement(e: TaggedRawEvent) { switch (e.kind) { case EventKind.TextNote: { - return + return } case EventKind.Reaction: case EventKind.Repost: { diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index 3724f55a..d86b9be3 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -108,7 +108,7 @@ export default function ProfilePage() { function tabContent() { switch (tab) { case ProfileTab.Notes: - return ; + return ; case ProfileTab.Follows: { if (isMe) { return ( From 0b67cfdc4ee4a2adac9f10c1140de349650ffd63 Mon Sep 17 00:00:00 2001 From: Alejandro Gomez Date: Sat, 28 Jan 2023 22:52:05 +0100 Subject: [PATCH 32/36] fix: note creator modal alignment --- src/Element/Modal.tsx | 4 +++- src/Element/NoteCreator.css | 9 +++++++++ src/Element/NoteCreator.tsx | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Element/Modal.tsx b/src/Element/Modal.tsx index 0fefb877..9ae75573 100644 --- a/src/Element/Modal.tsx +++ b/src/Element/Modal.tsx @@ -3,12 +3,14 @@ import { useEffect } from "react" import * as React from "react"; export interface ModalProps { + className?: string onClose?: () => void, children: React.ReactNode } export default function Modal(props: ModalProps) { const onClose = props.onClose || (() => { }); + const className = props.className || '' useEffect(() => { document.body.classList.add("scroll-lock"); @@ -16,7 +18,7 @@ export default function Modal(props: ModalProps) { }, []); return ( -
{ e.stopPropagation(); onClose(); }}> +
{ e.stopPropagation(); onClose(); }}>
{props.children}
diff --git a/src/Element/NoteCreator.css b/src/Element/NoteCreator.css index b3f675f0..e23ef17d 100644 --- a/src/Element/NoteCreator.css +++ b/src/Element/NoteCreator.css @@ -76,3 +76,12 @@ right: 25vw; } } + +@media (max-width: 720px) { + .note-creator-modal { + align-items: flex-start; + } + .note-creator-modal .modal-body { + margin-top: 20vh; + } +} diff --git a/src/Element/NoteCreator.tsx b/src/Element/NoteCreator.tsx index 48acc69f..41f51fc3 100644 --- a/src/Element/NoteCreator.tsx +++ b/src/Element/NoteCreator.tsx @@ -90,7 +90,7 @@ export function NoteCreator(props: NoteCreatorProps) { {show && ( - +