diff --git a/README.md b/README.md index e630064f..54f68e5b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Snort is a nostr UI built with React, Snort intends to be fast and effecient -Snort supports the following NIP's +Snort supports the following NIP's: + - [x] NIP-01: Basic protocol flow description - [x] NIP-02: Contact List and Petnames (No petname support) - [ ] NIP-03: OpenTimestamps Attestations for Events @@ -26,4 +27,5 @@ Snort supports the following NIP's - [ ] NIP-36: Sensitive Content - [ ] NIP-40: Expiration Timestamp - [ ] NIP-42: Authentication of clients to relays -- [x] NIP-50: Search \ No newline at end of file +- [x] NIP-50: Search +- [x] NIP-51: Lists 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/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/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..bcf91d5e --- /dev/null +++ b/src/Element/BlockList.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 BlockButton from "Element/BlockButton"; +import ProfilePreview from "Element/ProfilePreview"; +import useMutedFeed, { getMuted } from "Feed/MuteList"; +import useModeration from "Hooks/useModeration"; + +interface BlockListProps { + variant: "muted" | "blocked" +} + +export default function BlockList({ variant }: BlockListProps) { + const { publicKey } = useSelector((s: RootState) => s.login) + const { blocked, muted } = useModeration(); + + return ( +
+ {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/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.css b/src/Element/FollowButton.css new file mode 100644 index 00000000..8c27ce23 --- /dev/null +++ b/src/Element/FollowButton.css @@ -0,0 +1,3 @@ +.follow-button { + width: 92px; +} diff --git a/src/Element/FollowButton.tsx b/src/Element/FollowButton.tsx index 2c929c9c..da194005 100644 --- a/src/Element/FollowButton.tsx +++ b/src/Element/FollowButton.tsx @@ -1,3 +1,4 @@ +import "./FollowButton.css"; import { useSelector } from "react-redux"; import useEventPublisher from "Feed/EventPublisher"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -8,14 +9,13 @@ import { parseId } from "Util"; export interface FollowButtonProps { pubkey: HexKey, - className?: string, + className?: string } export default function FollowButton(props: FollowButtonProps) { const pubkey = parseId(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}`; + const baseClassname = `${props.className} follow-button` async function follow(pubkey: HexKey) { let ev = await publiser.addFollow(pubkey); @@ -28,8 +28,12 @@ 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..99e063e9 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
+
{title}
+
{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/IconButton.tsx b/src/Element/IconButton.tsx new file mode 100644 index 00000000..69dee2ff --- /dev/null +++ b/src/Element/IconButton.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; + +interface IconButtonProps { + onClick(): void + children: ReactNode +} + +const IconButton = ({ onClick, children }: IconButtonProps) => { + return ( + + ) +} + +export default IconButton diff --git a/src/Element/Invoice.tsx b/src/Element/Invoice.tsx index 7f90d1cd..80ad2cb5 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
: ( + + )} 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/LNURLTip.tsx b/src/Element/LNURLTip.tsx index e02cf7d5..b25bf4a5 100644 --- a/src/Element/LNURLTip.tsx +++ b/src/Element/LNURLTip.tsx @@ -4,16 +4,7 @@ import { bech32ToText } from "Util"; import Modal from "Element/Modal"; import QrCode from "Element/QrCode"; import Copy from "Element/Copy"; - -declare global { - interface Window { - webln?: { - enabled: boolean, - enable: () => Promise, - sendPayment: (pr: string) => Promise - } - } -} +import useWebln from "Hooks/useWebln"; interface LNURLService { minSendable?: number, @@ -54,6 +45,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 +128,7 @@ export default function LNURLTip(props: LNURLTipProps) { } else { setInvoice(data); setError(""); + payWebLNIfEnabled(data); } } else { setError("Failed to load invoice"); @@ -156,29 +149,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 +181,7 @@ export default function LNURLTip(props: LNURLTipProps) { : null} {amount === -1 ? custom() : null} - {(amount ?? 0) > 0 ?
loadInvoice()}>Get Invoice
: null} + {(amount ?? 0) > 0 && } ) } @@ -218,10 +201,9 @@ export default function LNURLTip(props: LNURLTipProps) {
-
window.open(`lightning:${pr}`)}> +
-
{webLn()}
+
)} @@ -236,14 +218,14 @@ export default function LNURLTip(props: LNURLTipProps) { return ( <>

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

- {success.url ? {success.url} : null} + {success.url ? {success.url} : null} ) } if (!show) return null; return ( - onClose()}> +
e.stopPropagation()}>

{props.title || "⚡️ Send sats"}

{invoiceForm()} 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/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..3d56118f 100644 --- a/src/Element/Modal.tsx +++ b/src/Element/Modal.tsx @@ -1,14 +1,32 @@ import "./Modal.css"; -import { useEffect } from "react" +import { useEffect, useRef } from "react" import * as React from "react"; export interface ModalProps { + className?: string onClose?: () => void, children: React.ReactNode } +function useOnClickOutside(ref: any, onClickOutside: () => void) { + useEffect(() => { + function handleClickOutside(ev: any) { + if (ref && ref.current && !ref.current.contains(ev.target)) { + onClickOutside() + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [ref]); +} + export default function Modal(props: ModalProps) { + const ref = useRef(null); const onClose = props.onClose || (() => { }); + const className = props.className || '' + useOnClickOutside(ref, onClose) useEffect(() => { document.body.classList.add("scroll-lock"); @@ -16,8 +34,10 @@ export default function Modal(props: ModalProps) { }, []); return ( -
{ e.stopPropagation(); onClose(); }}> +
+
{props.children} +
) -} \ No newline at end of file +} 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..23657ab6 --- /dev/null +++ b/src/Element/MutedList.tsx @@ -0,0 +1,38 @@ +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 { muted, isMuted, mute, unmute, muteAll } = useModeration(); + const feed = useMutedFeed(pubkey) + const pubkeys = useMemo(() => { + return 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/Nip05.css b/src/Element/Nip05.css index 2cc3ef46..bdde8a85 100644 --- a/src/Element/Nip05.css +++ b/src/Element/Nip05.css @@ -1,61 +1,46 @@ .nip05 { - justify-content: flex-start; - align-items: center; - font-size: 14px; - margin: .2em; + justify-content: flex-start; + align-items: center; + font-weight: normal; } .nip05.failed { - text-decoration: line-through; + text-decoration: line-through; } .nip05 .nick { - color: var(--gray-light); - font-weight: bold; - margin-right: .2em; + color: var(--font-secondary-color); } .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); + color: var(--gray-light); + -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(--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); + background-image: var(--nostrplebs-gradient); } .nip05 .domain[data-domain="strike.army"] { - background-image: var(--strike-army-gradient); + background-image: var(--strike-army-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 d68332e2..3b25f4fa 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,23 @@ const Nip05 = (props: Nip05Params) => { const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05) return ( -
+
ev.stopPropagation()}> {!isDefaultUser && (
- {name} + {`${name}@`}
)} -
+ {domain} -
+ + {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,8 @@ } .note>.body { - margin-top: 12px; + margin-top: 4px; + margin-bottom: 24px; padding-left: 56px; text-overflow: ellipsis; white-space: pre-wrap; @@ -31,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); @@ -57,17 +78,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; @@ -140,6 +150,7 @@ .note.active>.header>.info { color: var(--font-tertiary-color); + font-weight: 500; } .note.active>.footer>.reaction-pill { @@ -170,10 +181,35 @@ .light .note.active>.footer>.reaction-pill.reacted { color: var(--highlight); } - .note-expand .body { max-height: 300px; overflow-y: hidden; mask-image: linear-gradient(to bottom, var(--note-bg) 60%, rgba(0,0,0,0)); -webkit-mask-image: linear-gradient(to bottom, var(--note-bg) 60%, rgba(0,0,0,0)); -} \ No newline at end of file +} + +.hidden-note .header { + display: flex; + align-items: center; +} + +.card.note.hidden-note { + min-height: unset; +} + +.hidden-note button { + max-height: 30px; +} + +.show-more { + background: none; + margin: 0; + padding: 0; + font-weight: 400; + color: var(--highlight); +} + +.show-more:hover { + background: none; + color: var(--highlight); +} diff --git a/src/Element/Note.tsx b/src/Element/Note.tsx index 6cf87cd2..146946b1 100644 --- a/src/Element/Note.tsx +++ b/src/Element/Note.tsx @@ -1,10 +1,11 @@ import "./Note.css"; -import { useCallback, useLayoutEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react"; +import { useNavigate, Link } from "react-router-dom"; import { default as NEvent } from "Nostr/Event"; import ProfileImage from "Element/ProfileImage"; import Text from "Element/Text"; + import { eventLink, getReactions, hexToBech32 } from "Util"; import NoteFooter from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; @@ -12,12 +13,14 @@ import EventKind from "Nostr/EventKind"; import { useUserProfiles } from "Feed/ProfileFeed"; import { TaggedRawEvent, u256 } from "Nostr"; import { useInView } from "react-intersection-observer"; +import useModeration from "Hooks/useModeration"; export interface NoteProps { data?: TaggedRawEvent, isThread?: boolean, related: TaggedRawEvent[], highlight?: boolean, + ignoreModeration?: boolean, options?: { showHeader?: boolean, showTime?: boolean, @@ -26,13 +29,32 @@ 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 + 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 = useUserProfiles(pubKeys); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); + const { isMuted } = useModeration() + const isOpMuted = isMuted(ev.PubKey) const { ref, inView, entry } = useInView({ triggerOnce: true }); const [extendable, setExtendable] = useState(false); const [showMore, setShowMore] = useState(false); @@ -75,21 +97,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 + ) : ""}
) } @@ -121,17 +179,18 @@ export default function Note(props: NoteProps) { {transformBody()}
{extendable && !showMore && (
- +
)} {options.showFooter ? : null} ) } - return ( -
- {content()} -
+ const note = ( +
+ {content()} +
) + + return !ignoreModeration && isOpMuted ? {note} : note } diff --git a/src/Element/NoteCreator.css b/src/Element/NoteCreator.css index 117e113f..e23ef17d 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 { @@ -44,4 +49,39 @@ background-color: var(--bg-color); color: var(--font-color); font-size: var(--font-size); -} \ No newline at end of file +} + +.note-create-button { + width: 48px; + height: 48px; + background-color: var(--highlight); + border: none; + border-radius: 100%; + position: fixed; + bottom: 50px; + right: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +@media (min-width: 520px) { + .note-create-button { + right: 10vw; + } +} + +@media (min-width: 1020px) { + .note-create-button { + 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 719645be..43e8a8b0 100644 --- a/src/Element/NoteCreator.tsx +++ b/src/Element/NoteCreator.tsx @@ -4,20 +4,24 @@ 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 Textarea from "Element/Textarea"; +import Modal from "Element/Modal"; import { default as NEvent } from "Nostr/Event"; import useFileUpload from "Feed/FileUpload"; export interface NoteCreatorProps { + show: boolean + setShow: (s: boolean) => void replyTo?: NEvent, onSend?: Function, - show: boolean, autoFocus: boolean } export function NoteCreator(props: NoteCreatorProps) { + const { show, setShow } = props const publisher = useEventPublisher(); const [note, setNote] = useState(); const [error, setError] = useState(); @@ -30,6 +34,7 @@ export function NoteCreator(props: NoteCreatorProps) { console.debug("Sending note: ", ev); publisher.broadcast(ev); setNote(""); + setShow(false); if (typeof props.onSend === "function") { props.onSend(); } @@ -63,14 +68,26 @@ 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 && ( + setShow(false)} + >
-
sendDm()}>Send
+
) -} \ No newline at end of file +} diff --git a/src/Pages/DonatePage.tsx b/src/Pages/DonatePage.tsx index fb5bac3d..22e213ac 100644 --- a/src/Pages/DonatePage.tsx +++ b/src/Pages/DonatePage.tsx @@ -55,7 +55,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 0648eebb..25922ec7 100644 --- a/src/Pages/Layout.css +++ b/src/Pages/Layout.css @@ -1,5 +1,46 @@ .logo { cursor: pointer; + font-weight: 700; + font-size: 29px; + line-height: 23px; +} + +header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 72px; + padding: 0 12px; +} + +@media (min-width: 720px) { + header { + padding: 0; + } +} + +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; } .search { @@ -13,20 +54,3 @@ .search .btn { display: none; } - -.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; -} diff --git a/src/Pages/Layout.tsx b/src/Pages/Layout.tsx index a7e2cfaa..5cb2ac9f 100644 --- a/src/Pages/Layout.tsx +++ b/src/Pages/Layout.tsx @@ -2,8 +2,9 @@ import "./Layout.css"; import { useEffect, useMemo } from "react" import { useDispatch, useSelector } from "react-redux"; import { Outlet, useNavigate } from "react-router-dom"; -import { faBell, faMessage, faSearch } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Envelope from "Icons/Envelope"; +import Bell from "Icons/Bell"; +import Search from "Icons/Search"; import { RootState } from "State/Store"; import { init, UserPreferences } from "State/Login"; @@ -15,6 +16,8 @@ import useLoginFeed from "Feed/LoginFeed"; import { totalUnread } from "Pages/MessagesPage"; import { SearchRelays } from 'Const'; import useEventPublisher from "Feed/EventPublisher"; +import useModeration from "Hooks/useModeration"; + export default function Layout() { const dispatch = useDispatch(); @@ -25,6 +28,8 @@ export default function Layout() { const notifications = useSelector(s => s.login.notifications); const readNotifications = useSelector(s => s.login.readNotifications); const dms = useSelector(s => s.login.dms); + const { isMuted } = useModeration(); + const filteredDms = dms.filter(a => !isMuted(a.pubkey)) const prefs = useSelector(s => s.login.preferences); const pub = useEventPublisher(); useLoginFeed(); @@ -89,23 +94,22 @@ export default function Layout() { function accountHeader() { const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length; - const unreadDms = key ? totalUnread(dms, key) : 0; + const unreadDms = key ? totalUnread(filteredDms, key) : 0; return ( - <> -

navigate("/messages")}> - +
+
navigate("/search")}> +
- {unreadDms > 0 && ( - {unreadDms > 100 ? ">99" : unreadDms} - )} -
goToNotifications(e)}> - +
navigate("/messages")}> + + {unreadDms > 0 && ()} +
+
goToNotifications(e)}> + + {unreadNotifications > 0 && ()}
- {unreadNotifications > 0 && ( - {unreadNotifications > 100 ? ">99" : unreadNotifications} - )} - +
) } @@ -114,17 +118,14 @@ export default function Layout() { } return (
-
-
navigate("/")}>snort
+
+
navigate("/")}>Snort
-
navigate("/search")}> - -
{key ? accountHeader() : -
navigate("/login")}>Login
+ }
-
+
diff --git a/src/Pages/Login.tsx b/src/Pages/Login.tsx index 8169591f..b1b02eac 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) { @@ -93,24 +93,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..01c6b392 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,9 +21,10 @@ 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 { isMuted } = useModeration(); const chats = useMemo(() => { - return extractChats(dms, myPubKey!); + return extractChats(dms.filter(a => !isMuted(a.pubkey)), myPubKey!) }, [dms, myPubKey, dmInteraction]); function noteToSelf(chat: DmChat) { @@ -51,17 +53,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)} - +
) } @@ -91,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) { @@ -122,4 +124,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 32c0bf66..f7b7d13c 100644 --- a/src/Pages/NewUserPage.tsx +++ b/src/Pages/NewUserPage.tsx @@ -74,9 +74,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 6ba6bd67..53c54e1e 100644 --- a/src/Pages/ProfilePage.css +++ b/src/Pages/ProfilePage.css @@ -1,63 +1,73 @@ .profile { - flex-direction: column; + 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; } .profile .name h2 { - margin: 0; + margin: 12px 0 0 0; + font-weight: 600; + font-size: 19px; + line-height: 23px; } -@media (min-width: 720px) { - .profile .banner { - width: 100%; - max-width: 720px; - height: 300px; - margin-bottom: -120px; - } +.profile .nip05 { + display: flex; + font-size: 16px; + margin: 0 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; + border-radius: 16px; + margin: 0 auto; + margin-bottom: 12px; } .profile .details p { @@ -76,146 +86,64 @@ .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; + width: calc(100% - 32px); } -.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 .p-buttons { - position: absolute; - top: -30px; - right: 20px; -} - -.profile .p-buttons>div { - margin-right: 10px; -} - -.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; -} - -.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 .details .text { + font-size: 14px; } .profile .links { - margin: 8px 12px; + margin-top: 4px; + margin-left: 2px; + margin-bottom: 12px; +} + +.profile h3 { + color: var(--font-secondary-color); + font-size: 10px; + letter-spacing: .11em; + font-weight: 600; + line-height: 12px; + text-transform: uppercase; + margin-left: 12px; } .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; } @@ -225,6 +153,63 @@ text-overflow: ellipsis; } -.profile .zap { - margin-right: .3em; -} \ No newline at end of file +.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; + display: flex; + flex-direction: row; + align-items: center; +} + +.profile .icon-actions { + display: flex; + flex-direction: row; + align-items: center; +} + +@media (min-width: 520px) { + .profile .profile-actions { + top: 120px; + } +} + +.profile .profile-actions button:not(:last-child) { + margin-right: 8px; +} + +.profile .profile-actions button.icon:not(:last-child) { + margin-right: 0; +} + +@media (min-width: 520px) { + .profile .banner { + width: 100%; + max-width: 720px; + height: 300px; + margin-bottom: -100px; + } + .profile .profile-actions button.icon:not(:last-child) { + margin-right: 2px; + } +} + +.profile .npub { + display: flex; + flex-direction: row; + align-items: center; +} + +.qr-modal .modal-body { + width: unset; +} diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index e4091c6a..81f997b3 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -2,14 +2,17 @@ import "./ProfilePage.css"; import { useEffect, useMemo, useState } from "react"; import { useSelector } from "react-redux"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faGear, faEnvelope, faQrcode } from "@fortawesome/free-solid-svg-icons"; import { useNavigate, useParams } from "react-router-dom"; +import Link from "Icons/Link"; +import Qr from "Icons/Qr"; +import Zap from "Icons/Zap"; +import Envelope from "Icons/Envelope"; import { useUserProfile } 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,7 +20,10 @@ 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 IconButton from "Element/IconButton"; import { RootState } from "State/Store"; import { HexKey } from "Nostr"; import FollowsYou from "Element/FollowsYou" @@ -28,7 +34,9 @@ enum ProfileTab { Notes = "Notes", Reactions = "Reactions", Followers = "Followers", - Follows = "Follows" + Follows = "Follows", + Muted = "Muted", + Blocked = "Blocked" }; export default function ProfilePage() { @@ -36,13 +44,16 @@ export default function ProfilePage() { const navigate = useNavigate(); const id = useMemo(() => parseId(params.id!), [params]); const user = useUserProfile(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; const [showLnQr, setShowLnQr] = useState(false); const [tab, setTab] = useState(ProfileTab.Notes); const [showProfileQr, setShowProfileQr] = useState(false); - const about = Text({ content: user?.about || '', tags: [], users: new Map() }) + const aboutText = user?.about || '' + const about = Text({ content: aboutText, tags: [], users: new Map() }) + const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || ""); useEffect(() => { setTab(ProfileTab.Notes); @@ -55,50 +66,52 @@ export default function ProfilePage() { {user?.display_name || user?.name || 'Nostrich'} - {user?.nip05 && } + + {links()}
) } + function links() { + return ( +
+ {user?.website && ( +
+ + + + {user.website} +
+ )} + + 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} +
+ ) } function tabContent() { switch (tab) { case ProfileTab.Notes: - return ; + return ; case ProfileTab.Follows: { if (isMe) { return ( - <> +

Following {follows.length}

{follows.map(a => )} - +
); } else { return ; @@ -107,6 +120,12 @@ export default function ProfilePage() { case ProfileTab.Followers: { return } + case ProfileTab.Muted: { + return isMe ? : + } + case ProfileTab.Blocked: { + return isMe ? : null + } } } @@ -118,58 +137,71 @@ export default function ProfilePage() { ) } + function renderIcons() { + return ( +
+ setShowProfileQr(true)}> + + + {showProfileQr && ( + setShowProfileQr(false)}> + + + )} + {isMe ? ( + <> + + + + ) : ( + <> + setShowLnQr(true)}> + + + {!loggedOut && ( + <> + navigate(`/messages/${hexToBech32("npub", id)}`)}> + + + + )} + + )} +
+ ) + } + function userDetails() { return (
{username()} - -
-
setShowProfileQr(true)}> - -
- {showProfileQr && ( setShowProfileQr(false)}> -
- -
-
)} - {isMe ? ( -
navigate("/settings")}> - -
- ) : <> -
navigate(`/messages/${hexToBech32("npub", id)}`)}> - -
- - - } +
+ {renderIcons()} + {!isMe && }
- {bio()}
) } + function renderTab(v: ProfileTab) { + return
setTab(v)}>{v}
+ } + return ( <>
- {user?.banner && banner} - {user?.banner ? ( - <> - {avatar()} - {userDetails()} - - ) : ( -
- {avatar()} - {userDetails()} -
- )} + {user?.banner && banner} +
+ {avatar()} + {userDetails()} +
- {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => { - return
setTab(v)}>{v}
- })} + {[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)} + {isMe && renderTab(ProfileTab.Blocked)}
{tabContent()} 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 4ceaddd7..daf0b76c 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/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/Pages/settings/Profile.tsx b/src/Pages/settings/Profile.tsx index 09ffd469..a88f399f 100644 --- a/src/Pages/settings/Profile.tsx +++ b/src/Pages/settings/Profile.tsx @@ -2,14 +2,15 @@ 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"; import useEventPublisher from "Feed/EventPublisher"; import { useUserProfile } from "Feed/ProfileFeed"; -import { logout } from "State/Login"; +import VoidUpload from "Feed/VoidUpload"; +import LogoutButton from "Element/LogoutButton"; import { hexToBech32, openFile } from "Util"; import Copy from "Element/Copy"; import { RootState } from "State/Store"; @@ -20,7 +21,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 = useUserProfile(id!); const publisher = useEventPublisher(); const uploader = useFileUpload(); @@ -130,11 +130,11 @@ export default function ProfileSettings() {
NIP-05:
setNip05(e.target.value)} /> -
navigate("/verification")}> +
+
@@ -145,10 +145,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/State/Login.ts b/src/State/Login.ts index 66950a3b..d7c756db 100644 --- a/src/State/Login.ts +++ b/src/State/Login.ts @@ -3,6 +3,7 @@ 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"; @@ -11,6 +12,13 @@ const UserPreferencesKey = "preferences"; const RelayListKey = "last-relays"; const FollowList = "last-follows"; +export interface NotificationRequest { + title: string + body: string + icon: string + timestamp: number +} + export interface UserPreferences { /** * Enable reactions / reposts / zaps @@ -79,6 +87,26 @@ export interface LoginStore { */ follows: HexKey[], + /** + * Newest relay list timestamp + */ + latestFollows: number, + + /** + * A list of pubkeys this user has muted + */ + muted: HexKey[], + + /** + * Last seen mute list event timestamp + */ + latestMuted: number, + + /** + * A list of pubkeys this user has muted privately + */ + blocked: HexKey[], + /** * Notifications for this login session */ @@ -112,6 +140,10 @@ const InitState = { relays: {}, latestRelays: 0, follows: [], + latestFollows: 0, + muted: [], + blocked: [], + latestMuted: 0, notifications: [], readNotifications: new Date().getTime(), dms: [], @@ -132,6 +164,11 @@ export interface SetRelaysPayload { createdAt: number }; +export interface SetFollowsPayload { + keys: HexKey[] + createdAt: number +}; + const LoginSlice = createSlice({ name: "Login", initialState: InitState, @@ -212,9 +249,14 @@ const LoginSlice = createSlice({ state.relays = { ...state.relays }; window.localStorage.setItem(RelayListKey, JSON.stringify(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.filter(a => a.length === 64)) { @@ -232,28 +274,26 @@ const LoginSlice = createSlice({ if (changes) { state.follows = Array.from(existing); + state.latestFollows = createdAt; } window.localStorage.setItem(FollowList, JSON.stringify(state.follows)); }, - 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 - ]; - } + 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.latestMuted = createdAt + } + }, + 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; @@ -268,6 +308,7 @@ const LoginSlice = createSlice({ didChange = true; } } + if (didChange) { state.dms = [ ...state.dms @@ -301,11 +342,36 @@ export const { setRelays, removeRelay, setFollows, - addNotifications, + setMuted, + setBlocked, addDirectMessage, incDmInteraction, logout, markNotificationsRead, - setPreferences + 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 9e9b5d55..419060ef 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;500;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 { @@ -93,7 +87,7 @@ code { @media (min-width: 720px) { .card { - margin-bottom: 24px; + margin-bottom: 16px; padding: 12px 24px; } } @@ -111,14 +105,90 @@ html.light .card { .card>.footer { display: flex; - flex-direction: row-reverse; - margin-top: 12px; + flex-direction: row; +} + +button { + cursor: pointer; + padding: 6px 12px; + font-weight: 700; + color: white; + font-size: var(--font-size); + background-color: var(--highlight); + border: none; + border-radius: 16px; + outline: none; +} + +button:disabled { + cursor: not-allowed; + color: var(--gray); +} + +.light button.transparent { + color: var(--font-color); +} + +.light button:disabled { + color: var(--font-color); +} + +button:hover { + background-color: var(--font-color); + color: var(--bg-color); +} + +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); +} + +button.icon { + border: none; + background: none; + color: var(--font-color); + min-height: 28px; +} + +button.icon .icon-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +button.icon:hover { + color: var(--highlight); } .btn { padding: 10px; border-radius: 5px; cursor: pointer; + color: var(--font-color); user-select: none; background-color: var(--bg-color); color: var(--font-color); @@ -138,7 +208,7 @@ html.light .card { border: 2px solid; background-color: var(--gray-secondary); color: var(--font-color); - font-weight: bold; + font-weight: 700; } .btn.disabled { @@ -155,6 +225,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 { @@ -345,16 +426,12 @@ body.scroll-lock { } .tabs>div { - margin-right: 10px; cursor: pointer; -} - -.tabs>div:last-child { margin: 0; } .tabs .active { - font-weight: bold; + font-weight: 700; } .error { @@ -369,18 +446,24 @@ 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: 700; + line-height: 19px; + 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 { @@ -402,10 +485,6 @@ body.scroll-lock { } @media(max-width: 720px) { - .page { - width: calc(100vw - 8px); - } - div.form-group { flex-direction: column; align-items: flex-start; @@ -414,4 +493,18 @@ 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; + } +} + +.bold { + font-weight: 700; +}