Merge pull request #161 from v0l/new-ui

New UI
This commit is contained in:
Kieran 2023-01-29 22:37:15 +00:00 committed by GitHub
commit 8e829bcf52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1691 additions and 675 deletions

View File

@ -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
- [x] NIP-50: Search
- [x] NIP-51: Lists

5
d.ts
View File

@ -2,3 +2,8 @@ declare module "*.jpg" {
const value: any
export default value
}
declare module "*.svg" {
const value: any
export default value
}

View File

@ -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;

View File

@ -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;
}

View File

@ -0,0 +1,17 @@
import "./BackButton.css"
import { useNavigate } from "react-router-dom";
import ArrowBack from "Icons/ArrowBack";
const BackButton = () => {
const navigate = useNavigate()
return (
<button className="back-button" type="button" onClick={() => navigate(-1)}>
<ArrowBack />Back
</button>
)
}
export default BackButton

View File

@ -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) ? (
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
Unblock
</button>
) : (
<button className="secondary" type="button" onClick={() => block(pubkey)}>
Block
</button>
)
}
export default BlockButton

39
src/Element/BlockList.tsx Normal file
View File

@ -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 (
<div className="main-content">
{variant === "muted" && (
<>
<h4>{muted.length} muted</h4>
{muted.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</>
)}
{variant === "blocked" && (
<>
<h4>{blocked.length} blocked</h4>
{blocked.map(a => {
return <ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</>
)}
</div>
)
}

View File

@ -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;
}

View File

@ -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 (
<div className="flex flex-row copy" onClick={() => copy(text)}>
@ -20,7 +20,7 @@ export default function Copy({ text, maxSize = 32 }: CopyProps) {
<FontAwesomeIcon
icon={copied ? faCheck : faCopy}
size="xs"
style={{ color: copied ? 'var(--success)' : 'currentColor', marginRight: '2px' }}
style={{ color: copied ? 'var(--success)' : 'var(--highlight)', marginRight: '2px' }}
/>
</div>
)

View File

@ -0,0 +1,3 @@
.follow-button {
width: 92px;
}

View File

@ -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<RootState, boolean>(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 (
<div className={className} onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}>
<FontAwesomeIcon icon={isFollowing ? faUserMinus : faUserPlus} size="lg" />
</div>
<button
type="button"
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}
>
{isFollowing ? 'Unfollow' : 'Follow'}
</button>
)
}
}

View File

@ -15,12 +15,12 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps)
}
return (
<>
<div className="main-content">
<div className="flex mt10">
<div className="f-grow">{title}</div>
<div className="btn" onClick={() => followAll()}>Follow All</div>
<div className="f-grow bold">{title}</div>
<button className="transparent" type="button" onClick={() => followAll()}>Follow All</button>
</div>
{pubkeys?.map(a => <ProfilePreview pubkey={a} key={a} />)}
</>
</div>
)
}
}

View File

@ -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

View File

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
interface IconButtonProps {
onClick(): void
children: ReactNode
}
const IconButton = ({ onClick, children }: IconButtonProps) => {
return (
<button
className="icon"
type="button"
onClick={onClick}
>
<div className="icon-wrapper">
{children}
</div>
</button>
)
}
export default IconButton

View File

@ -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 (
<>
<div className="note-invoice flex">
@ -63,7 +78,11 @@ export default function Invoice(props: InvoiceProps) {
{info?.expire ? <small>{info?.expired ? "Expired" : "Expires"} <NoteTime from={info.expire * 1000} /></small> : null}
</div>
{info?.expired ? <div className="btn">Expired</div> : <div className="btn" onClick={(e) => { e.stopPropagation(); setShowInvoice(true); }}>Pay</div>}
{info?.expired ? <div className="btn">Expired</div> : (
<button type="button" onClick={payInvoice}>
Pay
</button>
)}
</div>
</>

View File

@ -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;
}
}

View File

@ -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<void>,
sendPayment: (pr: string) => Promise<any>
}
}
}
import useWebln from "Hooks/useWebln";
interface LNURLService {
minSendable?: number,
@ -54,6 +45,7 @@ export default function LNURLTip(props: LNURLTipProps) {
const [comment, setComment] = useState<string>();
const [error, setError] = useState<string>();
const [success, setSuccess] = useState<LNURLSuccessAction>();
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 (
<div className="btn" onClick={() => payWebLN()}>Pay with WebLN</div>
)
}
return null;
}
function invoiceForm() {
if (invoice) return null;
return (
@ -198,7 +181,7 @@ export default function LNURLTip(props: LNURLTipProps) {
</span> : null}
</div>
{amount === -1 ? custom() : null}
{(amount ?? 0) > 0 ? <div className="btn mb10" onClick={() => loadInvoice()}>Get Invoice</div> : null}
{(amount ?? 0) > 0 && <button type="button" className="mb10" onClick={() => loadInvoice()}>Get Invoice</button>}
</>
)
}
@ -218,10 +201,9 @@ export default function LNURLTip(props: LNURLTipProps) {
<Copy text={pr} maxSize={26} />
</div>
<div className="pay-actions">
<div className="btn" onClick={() => window.open(`lightning:${pr}`)}>
<button type="button" onClick={() => window.open(`lightning:${pr}`)}>
Open Wallet
</div>
<div>{webLn()}</div>
</button>
</div>
</>
)}
@ -236,14 +218,14 @@ export default function LNURLTip(props: LNURLTipProps) {
return (
<>
<p>{success?.description ?? "Paid!"}</p>
{success.url ? <a href={success.url} target="_blank">{success.url}</a> : null}
{success.url ? <a href={success.url} rel="noreferrer" target="_blank">{success.url}</a> : null}
</>
)
}
if (!show) return null;
return (
<Modal onClose={() => onClose()}>
<Modal onClose={onClose}>
<div className="lnurl-tip" onClick={(e) => e.stopPropagation()}>
<h2>{props.title || "⚡️ Send sats"}</h2>
{invoiceForm()}

View File

@ -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 (
<button className="secondary" type="button" onClick={() => { dispatch(logout()); navigate("/"); }}>
Logout
</button>
)
}

View File

@ -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;
}
}

View File

@ -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 (
<div className="modal" onClick={(e) => { e.stopPropagation(); onClose(); }}>
<div className={`modal ${className}`}>
<div ref={ref} className="modal-body">
{props.children}
</div>
</div>
)
}
}

View File

@ -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) ? (
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
Unmute
</button>
) : (
<button className="secondary" type="button" onClick={() => mute(pubkey)}>
Mute
</button>
)
}
export default MuteButton

38
src/Element/MutedList.tsx Normal file
View File

@ -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 (
<div className="main-content">
<div className="flex mt10">
<div className="f-grow bold">{`${pubkeys?.length} muted`}</div>
<button
disabled={hasAllMuted || pubkeys.length === 0}
className="transparent" type="button" onClick={() => muteAll(pubkeys)}
>
Mute all
</button>
</div>
{pubkeys?.map(a => {
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
})}
</div>
)
}

View File

@ -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;
}

View File

@ -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 (
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`}>
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}>
{!isDefaultUser && (
<div className="nick">
{name}
{`${name}@`}
</div>
)}
<div className={`domain text-gradient`} data-domain={domain?.toLowerCase()}>
<span className="domain" data-domain={domain?.toLowerCase()}>
{domain}
</div>
</span>
<span className="badge">
{isVerified && (
<FontAwesomeIcon
color={"var(--highlight)"}
icon={faCircleCheck}
size="xs"
/>
)}
{!isVerified && !couldNotVerify && (
<FontAwesomeIcon
color={"var(--fg-color)"}

View File

@ -11,6 +11,14 @@
color: var(--font-secondary-color);
}
.note>.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));
}
}
.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);
}

View File

@ -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 : (
<div className="card note hidden-note">
<div className="header">
<p>
This note was hidden because of your moderation settings
</p>
<button onClick={() => setShow(true)}>
Show
</button>
</div>
</div>
)
}
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<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(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: (
<Link to={`/p/${npub}`}>
{u.name ? `@${u.name}` : shortNpub}
</Link>
)
});
} else {
mentions.push(hexToBech32("npub", pk).substring(0, 12));
mentions.push({
pk,
name: shortNpub,
link: (
<Link to={`/p/${npub}`}>
{shortNpub}
</Link>
)
});
}
}
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 (
<div className="reply">
{(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
) : ""}
</div>
)
}
@ -121,17 +179,18 @@ export default function Note(props: NoteProps) {
{transformBody()}
</div>
{extendable && !showMore && (<div className="flex f-center">
<button className="btn mt10" onClick={() => setShowMore(true)}>Show more</button>
<button className="show-more" onClick={() => setShowMore(true)}>Show more</button>
</div>)}
{options.showFooter ? <NoteFooter ev={ev} related={related} /> : null}
</>
)
}
return (
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}${extendable && !showMore ? " note-expand" : ""}`}
ref={ref} >
{content()}
</div>
const note = (
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}${extendable && !showMore ? " note-expand" : ""}`} ref={ref}>
{content()}
</div>
)
return !ignoreModeration && isOpMuted ? <HiddenNote>{note}</HiddenNote> : note
}

View File

@ -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);
}
}
.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;
}
}

View File

@ -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<string>();
const [error, setError] = useState<string>();
@ -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<HTMLButtonElement>) {
ev.stopPropagation();
sendNote().catch(console.warn);
}
if (!props.show) return null;
return (
<>
<button className="note-create-button" type="button" onClick={() => setShow(!show)}>
<Plus />
</button>
{show && (
<Modal
className="note-creator-modal"
onClose={() => setShow(false)}
>
<div className={`flex note-creator ${props.replyTo ? 'note-reply' : ''}`}>
<div className="flex f-col mr10 f-grow">
<Textarea
@ -80,19 +97,22 @@ export function NoteCreator(props: NoteCreatorProps) {
value={note}
onFocus={() => setActive(true)}
/>
{active && note && (
<div className="actions flex f-row">
<div className="attachment flex f-row">
{(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null}
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div>
<button type="button" className="btn" onClick={onSubmit}>
{props.replyTo ? 'Reply' : 'Send'}
</button>
</div>
)}
<div className="attachment">
{(error?.length ?? 0) > 0 ? <b className="error">{error}</b> : null}
<FontAwesomeIcon icon={faPaperclip} size="xl" onClick={(e) => attachFile()} />
</div>
</div>
</div>
<div className="note-creator-actions">
<button className="secondary" type="button" onClick={cancel}>
Cancel
</button>
<button type="button" onClick={onSubmit}>
{props.replyTo ? 'Reply' : 'Send'}
</button>
</div>
</Modal>
)}
</>
);
}

View File

@ -1,9 +1,14 @@
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { faHeart, faReply, faThumbsDown, faTrash, faBolt, faRepeat, faEllipsisVertical, faShareNodes, faCopy } 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';
import Dislike from "Icons/Dislike";
import Heart from "Icons/Heart";
import Dots from "Icons/Dots";
import Zap from "Icons/Zap";
import Reply from "Icons/Reply";
import { formatShort } from "Number";
import useEventPublisher from "Feed/EventPublisher";
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
@ -15,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[],
@ -25,6 +31,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const { related, ev } = props;
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useUserProfile(ev.RootPubKey);
const publisher = useEventPublisher();
@ -82,7 +89,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<>
<div className="reaction-pill" onClick={() => setTip(true)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faBolt} />
<Zap />
</div>
</div>
</>
@ -114,7 +121,7 @@ export default function NoteFooter(props: NoteFooterProps) {
<>
<div className={`reaction-pill ${hasReacted('+') ? 'reacted' : ''} `} onClick={() => react("+")}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faHeart} />
<Heart />
</div>
<div className="reaction-pill-number">
{formatShort(groupReactions[Reaction.Positive])}
@ -148,14 +155,14 @@ export default function NoteFooter(props: NoteFooterProps) {
function menuItems() {
return (
<>
{prefs.enableReactions && (<MenuItem onClick={() => react("-")}>
<div>
<FontAwesomeIcon icon={faThumbsDown} className={hasReacted('-') ? 'reacted' : ''} />
&nbsp;
{prefs.enableReactions && (
<MenuItem onClick={() => react("-")}>
<Dislike />
{formatShort(groupReactions[Reaction.Negative])}
</div>
Dislike
</MenuItem>)}
&nbsp;
Dislike
</MenuItem>
)}
<MenuItem onClick={() => share()}>
<FontAwesomeIcon icon={faShareNodes} />
Share
@ -164,6 +171,14 @@ export default function NoteFooter(props: NoteFooterProps) {
<FontAwesomeIcon icon={faCopy} />
Copy ID
</MenuItem>
<MenuItem onClick={() => mute(ev.PubKey)}>
<FontAwesomeIcon icon={faCommentSlash} />
Mute
</MenuItem>
<MenuItem onClick={() => block(ev.PubKey)}>
<FontAwesomeIcon icon={faBan} />
Block
</MenuItem>
{prefs.showDebugMenus && (
<MenuItem onClick={() => copyEvent()}>
<FontAwesomeIcon icon={faCopy} />
@ -181,33 +196,33 @@ export default function NoteFooter(props: NoteFooterProps) {
}
return (
<>
<div className="footer">
{ login && (
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faReply} />
</div>
</div>
)}
<Menu menuButton={<div className="reaction-pill">
<div className="footer">
<div className="footer-reactions">
{tipButton()}
{reactionIcons()}
<div className={`reaction-pill ${reply ? 'reacted' : ''}`} onClick={(e) => setReply(s => !s)}>
<div className="reaction-pill-icon">
<FontAwesomeIcon icon={faEllipsisVertical} />
<Reply />
</div>
</div>} menuClassName="ctx-menu">
</div>
<Menu menuButton={<div className="reaction-pill">
<div className="reaction-pill-icon">
<Dots />
</div>
</div>}
menuClassName="ctx-menu"
>
{menuItems()}
</Menu>
{reactionIcons()}
{tipButton()}
</div>
<NoteCreator
autoFocus={true}
replyTo={ev}
onSend={() => setReply(false)}
show={reply}
setShow={setReply}
/>
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
</>
</div>
)
}

View File

@ -1,9 +1,8 @@
.reaction {
margin-bottom: 24px;
}
.reaction > .note {
margin: 10px 20px;
margin: 10px 0;
}
.reaction > .header {

View File

@ -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) {
@ -49,12 +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,
};
return (
return isOpMuted ? null : (
<div className="reaction">
<div className="header flex">
<ProfileImage pubkey={ev.RootPubKey} />

View File

@ -1,6 +1,6 @@
.pfp {
display: flex;
align-items: center;
display: flex;
align-items: center;
}
.pfp .avatar-wrapper {
@ -8,7 +8,6 @@
}
.pfp .avatar {
border-width: 1px;
width: 48px;
height: 48px;
cursor: pointer;
@ -23,13 +22,14 @@
text-decoration-color: var(--gray-superlight);
}
.pfp .username {
display: flex;
flex-direction: column;
align-items: flex-start;
font-weight: 600;
}
.pfp .profile-name {
display: flex;
flex-direction: column;
font-weight: bold;
}
.pfp .nip05 {
margin: 0;
margin-top: -.2em;
}

View File

@ -30,13 +30,18 @@ export default function ProfileImage({ pubkey, subHeader, showUsername = true, c
<div className="avatar-wrapper">
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
</div>
{showUsername && (<div className="f-grow pointer" onClick={e => { e.stopPropagation(); navigate(link ?? profileLink(pubkey)) }}>
<div className="profile-name">
<div>{name}</div>
{showUsername && (
<div className="profile-name f-grow">
<div className="username">
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
{name}
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</Link>
</div>
{subHeader ? <>{subHeader}</> : null}
</div>
<div className="subheader">
{subHeader}
</div>
</div>
)}
</div>
)

View File

@ -1,3 +1,8 @@
.text {
font-size: var(--font-size);
line-height: 24px;
}
.text a {
color: var(--highlight);
text-decoration: none;
@ -64,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;
}

View File

@ -48,12 +48,12 @@
align-items: flex-start;
}
.nip05 {
font-size: 12px;
.user-item .nip05 {
font-size: var(--font-size-tiny);
}
.emoji-item {
font-size: 12px;
font-size: var(--font-size-tiny);
}
.emoji-item .emoji {

3
src/Element/Thread.css Normal file
View File

@ -0,0 +1,3 @@
.thread-container {
margin: 12px 0 150px 0;
}

View File

@ -1,9 +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";
@ -82,7 +85,9 @@ export default function Thread(props: ThreadProps) {
}
return (
<>
<>
<BackButton />
<div className="thread-container">
{renderRoot()}
{root ? renderChain(root.Id) : null}
{root ? null : <>
@ -98,6 +103,7 @@ export default function Thread(props: ThreadProps) {
)
})}
</>}
</>
</div>
</>
);
}
}

View File

@ -2,4 +2,4 @@
cursor: pointer;
font-weight: bold;
user-select: none;
}
}

View File

@ -1,39 +1,43 @@
import "./Timeline.css";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faForward } from "@fortawesome/free-solid-svg-icons";
import { useCallback, useMemo } from "react";
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 useModeration from "Hooks/useModeration";
import ProfilePreview from "./ProfilePreview";
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);
}, [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 => ignoreModeration || !isMuted(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));
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id))
}, [latest, mainFeed, filterPosts]);
function eventElement(e: TaggedRawEvent) {
@ -42,7 +46,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
return <ProfilePreview pubkey={e.pubkey} className="card"/>
}
case EventKind.TextNote: {
return <Note key={e.id} data={e} related={related.notes} />
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
}
case EventKind.Reaction:
case EventKind.Repost: {
@ -53,7 +57,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
}
return (
<>
<div className="main-content">
{latestFeed.length > 1 && (<div className="card latest-notes pointer" onClick={() => showLatest()}>
<FontAwesomeIcon icon={faForward} size="xl"/>
&nbsp;
@ -61,6 +65,6 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
</div>)}
{mainFeed.map(eventElement)}
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}/>
</>
</div>
);
}
}

View File

@ -1,10 +1,11 @@
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 { HexKey, RawEvent, u256, UserMetadata, Lists } from "Nostr";
import { bech32ToHex } from "Util"
import { DefaultRelays, HashtagRegex } from "Const";
@ -105,6 +106,28 @@ export default function useEventPublisher() {
}
}
},
muted: async (keys: HexKey[], priv: HexKey[]) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Lists;
ev.Tags.push(new Tag(["d", Lists.Muted], ev.Tags.length))
keys.forEach(p => {
ev.Tags.push(new Tag(["p", p], ev.Tags.length))
})
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);
}
},
metadata: async (obj: UserMetadata) => {
if (pubKey) {
let ev = NEvent.ForPubKey(pubKey);
@ -292,7 +315,7 @@ const delay = (t: number) => {
});
}
const barierNip07 = async (then: () => Promise<any>) => {
export const barierNip07 = async (then: () => Promise<any>) => {
while (isNip07Busy) {
await delay(10);
}
@ -302,4 +325,4 @@ const barierNip07 = async (then: () => Promise<any>) => {
} finally {
isNip07Busy = false;
}
};
};

View File

@ -1,24 +1,29 @@
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 } from "State/Login";
import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login";
import { RootState } from "State/Store";
import { mapEventToProfile, MetadataCache } from "State/Users";
import { getDb } from "State/Users/Db";
import useSubscription from "Feed/Subscription";
import { getDisplayName } from "Element/ProfileImage";
import { barierNip07 } from "Feed/EventPublisher";
import { getMutedKeys, getNewest } from "Feed/MuteList";
import { MentionRegex } from "Const";
import useModeration from "Hooks/useModeration";
/**
* Managed loading data for the current logged in user
*/
export default function useLoginFeed() {
const dispatch = useDispatch();
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(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;
@ -27,6 +32,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]);
@ -42,6 +48,19 @@ 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]);
sub.DTag = Lists.Muted;
sub.Limit = 1;
return sub;
}, [pubKey]);
const subDms = useMemo(() => {
if (!pubKey) return null;
@ -61,6 +80,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);
@ -75,7 +95,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 () => {
@ -85,7 +105,7 @@ export default function useLoginFeed() {
acc.created = v.created;
}
return acc;
}, { created: 0, profile: <MetadataCache | null>null });
}, { created: 0, profile: null as MetadataCache | null });
if (maxProfile.profile) {
const db = getDb()
let existing = await db.find(maxProfile.profile.pubkey);
@ -94,72 +114,53 @@ export default function useLoginFeed() {
}
}
})().catch(console.warn);
}, [metadataFeed.store]);
}, [dispatch, metadataFeed.store]);
useEffect(() => {
let notifications = notificationFeed.store.notes.filter(a => a.kind === EventKind.TextNote);
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, notificationFeed.store]);
dispatch(addNotifications(notifications));
}, [notificationFeed.store]);
useEffect(() => {
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) {
const db = getDb()
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.bulkGet(Array.from(pubkeys))
// @ts-ignore
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));
}
}

46
src/Feed/MuteList.ts Normal file
View File

@ -0,0 +1,46 @@
import { useMemo } from "react";
import { HexKey, TaggedRawEvent, Lists } from "Nostr";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import useSubscription, { NoteStore } from "Feed/Subscription";
export default function useMutedFeed(pubkey: HexKey) {
const sub = useMemo(() => {
let sub = new Subscriptions();
sub.Id = `muted:${pubkey.slice(0, 12)}`;
sub.Kinds = new Set([EventKind.Lists]);
sub.Authors = new Set([pubkey]);
sub.DTag = Lists.Muted;
sub.Limit = 1;
return sub;
}, [pubkey]);
return useSubscription(sub);
}
export function getNewest(rawNotes: TaggedRawEvent[]){
const notes = [...rawNotes]
notes.sort((a, b) => a.created_at - b.created_at)
if (notes.length > 0) {
return notes[0]
}
}
export function getMutedKeys(rawNotes: TaggedRawEvent[]): { createdAt: number, keys: HexKey[] } {
const newest = getNewest(rawNotes)
if (newest) {
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: [] }
}
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;
}

View File

@ -0,0 +1,78 @@
import { useSelector, useDispatch } from "react-redux";
import type { RootState } from "State/Store";
import { HexKey } from "Nostr";
import useEventPublisher from "Feed/EventPublisher";
import { setMuted, setBlocked } from "State/Login";
export default function useModeration() {
const dispatch = useDispatch()
const { blocked, muted } = useSelector((s: RootState) => s.login)
const publisher = useEventPublisher()
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) || blocked.includes(id)
}
function isBlocked(id: HexKey) {
return blocked.includes(id)
}
function unmute(id: HexKey) {
const newMuted = muted.filter(p => p !== id)
dispatch(setMuted({
createdAt: new Date().getTime(),
keys: 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.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, blocked)
dispatch(setMuted({
createdAt: new Date().getTime(),
keys: newMuted
}))
}
return { muted, mute, muteAll, unmute, isMuted, blocked, block, unblock, isBlocked }
}

25
src/Hooks/useWebln.ts Normal file
View File

@ -0,0 +1,25 @@
import { useEffect } from "react";
declare global {
interface Window {
webln?: {
enabled: boolean,
enable: () => Promise<void>,
sendPayment: (pr: string) => Promise<any>
}
}
}
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
}

9
src/Icons/ArrowBack.tsx Normal file
View File

@ -0,0 +1,9 @@
const ArrowBack = () => {
return (
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6667 6.5H1.33334M1.33334 6.5L6.33334 11.5M1.33334 6.5L6.33334 1.5" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default ArrowBack

9
src/Icons/Bell.tsx Normal file
View File

@ -0,0 +1,9 @@
const Bell = () => {
return (
<svg width="20" height="23" viewBox="0 0 20 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.35419 20.5C8.05933 21.1224 8.98557 21.5 10 21.5C11.0145 21.5 11.9407 21.1224 12.6458 20.5M16 7.5C16 5.9087 15.3679 4.38258 14.2427 3.25736C13.1174 2.13214 11.5913 1.5 10 1.5C8.40872 1.5 6.8826 2.13214 5.75738 3.25736C4.63216 4.38258 4.00002 5.9087 4.00002 7.5C4.00002 10.5902 3.22049 12.706 2.34968 14.1054C1.61515 15.2859 1.24788 15.8761 1.26134 16.0408C1.27626 16.2231 1.31488 16.2926 1.46179 16.4016C1.59448 16.5 2.19261 16.5 3.38887 16.5H16.6112C17.8074 16.5 18.4056 16.5 18.5382 16.4016C18.6852 16.2926 18.7238 16.2231 18.7387 16.0408C18.7522 15.8761 18.3849 15.2859 17.6504 14.1054C16.7795 12.706 16 10.5902 16 7.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Bell

9
src/Icons/Dislike.tsx Normal file
View File

@ -0,0 +1,9 @@
const Dislike = () => {
return (
<svg width="19" height="20" viewBox="0 0 19 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.1667 1.66667V10.8333M17.3333 8.16667V4.33334C17.3333 3.39992 17.3333 2.93321 17.1517 2.57669C16.9919 2.26308 16.7369 2.00812 16.4233 1.84833C16.0668 1.66667 15.6001 1.66667 14.6667 1.66667H5.76501C4.54711 1.66667 3.93816 1.66667 3.44632 1.88953C3.01284 2.08595 2.64442 2.40202 2.38437 2.8006C2.08931 3.25283 1.99672 3.8547 1.81153 5.05844L1.37563 7.89178C1.13137 9.47943 1.00925 10.2733 1.24484 10.8909C1.45162 11.4331 1.84054 11.8864 2.34494 12.1732C2.91961 12.5 3.72278 12.5 5.32912 12.5H6C6.46671 12.5 6.70007 12.5 6.87833 12.5908C7.03513 12.6707 7.16261 12.7982 7.24251 12.955C7.33334 13.1333 7.33334 13.3666 7.33334 13.8333V16.2785C7.33334 17.4133 8.25333 18.3333 9.3882 18.3333C9.65889 18.3333 9.90419 18.1739 10.0141 17.9266L12.8148 11.6252C12.9421 11.3385 13.0058 11.1952 13.1065 11.0902C13.1955 10.9973 13.3048 10.9263 13.4258 10.8827C13.5627 10.8333 13.7195 10.8333 14.0332 10.8333H14.6667C15.6001 10.8333 16.0668 10.8333 16.4233 10.6517C16.7369 10.4919 16.9919 10.2369 17.1517 9.92332C17.3333 9.5668 17.3333 9.10009 17.3333 8.16667Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Dislike

11
src/Icons/Dots.tsx Normal file
View File

@ -0,0 +1,11 @@
const Dots = () => {
return (
<svg width="4" height="16" viewBox="0 0 4 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.99996 8.86865C2.4602 8.86865 2.83329 8.49556 2.83329 8.03532C2.83329 7.57508 2.4602 7.20199 1.99996 7.20199C1.53972 7.20199 1.16663 7.57508 1.16663 8.03532C1.16663 8.49556 1.53972 8.86865 1.99996 8.86865Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.99996 3.03532C2.4602 3.03532 2.83329 2.66222 2.83329 2.20199C2.83329 1.74175 2.4602 1.36865 1.99996 1.36865C1.53972 1.36865 1.16663 1.74175 1.16663 2.20199C1.16663 2.66222 1.53972 3.03532 1.99996 3.03532Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.99996 14.702C2.4602 14.702 2.83329 14.3289 2.83329 13.8687C2.83329 13.4084 2.4602 13.0353 1.99996 13.0353C1.53972 13.0353 1.16663 13.4084 1.16663 13.8687C1.16663 14.3289 1.53972 14.702 1.99996 14.702Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Dots

11
src/Icons/Envelope.tsx Normal file
View File

@ -0,0 +1,11 @@
import type IconProps from './IconProps'
const Envelope = (props: IconProps) => {
return (
<svg width="22" height="19" viewBox="0 0 22 19" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M1 4.5L9.16492 10.2154C9.82609 10.6783 10.1567 10.9097 10.5163 10.9993C10.8339 11.0785 11.1661 11.0785 11.4837 10.9993C11.8433 10.9097 12.1739 10.6783 12.8351 10.2154L21 4.5M5.8 17.5H16.2C17.8802 17.5 18.7202 17.5 19.362 17.173C19.9265 16.8854 20.3854 16.4265 20.673 15.862C21 15.2202 21 14.3802 21 12.7V6.3C21 4.61984 21 3.77976 20.673 3.13803C20.3854 2.57354 19.9265 2.1146 19.362 1.82698C18.7202 1.5 17.8802 1.5 16.2 1.5H5.8C4.11984 1.5 3.27976 1.5 2.63803 1.82698C2.07354 2.1146 1.6146 2.57354 1.32698 3.13803C1 3.77976 1 4.61984 1 6.3V12.7C1 14.3802 1 15.2202 1.32698 15.862C1.6146 16.4265 2.07354 16.8854 2.63803 17.173C3.27976 17.5 4.11984 17.5 5.8 17.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Envelope

9
src/Icons/Heart.tsx Normal file
View File

@ -0,0 +1,9 @@
const Heart = () => {
return (
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99425 3.315C8.32813 1.36716 5.54975 0.843192 3.4622 2.62683C1.37466 4.41048 1.08077 7.39264 2.72012 9.50216C4.08314 11.2561 8.2081 14.9552 9.56004 16.1525C9.7113 16.2865 9.78692 16.3534 9.87514 16.3798C9.95213 16.4027 10.0364 16.4027 10.1134 16.3798C10.2016 16.3534 10.2772 16.2865 10.4285 16.1525C11.7804 14.9552 15.9054 11.2561 17.2684 9.50216C18.9077 7.39264 18.6497 4.39171 16.5263 2.62683C14.4029 0.861954 11.6604 1.36716 9.99425 3.315Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Heart

4
src/Icons/IconProps.ts Normal file
View File

@ -0,0 +1,4 @@
export default interface IconProps {
width?: number
height?: number
}

9
src/Icons/Link.tsx Normal file
View File

@ -0,0 +1,9 @@
const Link = () => {
return (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.99996 12C9.42941 12.5742 9.97731 13.0492 10.6065 13.393C11.2357 13.7367 11.9315 13.9411 12.6466 13.9924C13.3617 14.0436 14.0795 13.9404 14.7513 13.6898C15.4231 13.4392 16.0331 13.0471 16.54 12.54L19.54 9.54003C20.4507 8.59702 20.9547 7.334 20.9433 6.02302C20.9319 4.71204 20.4061 3.45797 19.479 2.53093C18.552 1.60389 17.2979 1.07805 15.987 1.06666C14.676 1.05526 13.413 1.55924 12.47 2.47003L10.75 4.18003M13 10C12.5705 9.4259 12.0226 8.95084 11.3934 8.60709C10.7642 8.26333 10.0684 8.05891 9.3533 8.00769C8.63816 7.95648 7.92037 8.05966 7.24861 8.31025C6.57685 8.56083 5.96684 8.95296 5.45996 9.46003L2.45996 12.46C1.54917 13.403 1.04519 14.666 1.05659 15.977C1.06798 17.288 1.59382 18.5421 2.52086 19.4691C3.4479 20.3962 4.70197 20.922 6.01295 20.9334C7.32393 20.9448 8.58694 20.4408 9.52995 19.53L11.24 17.82" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Link

9
src/Icons/Plus.tsx Normal file
View File

@ -0,0 +1,9 @@
const Plus = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1V15M1 8H15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Plus

11
src/Icons/Qr.tsx Normal file
View File

@ -0,0 +1,11 @@
import IconProps from './IconProps';
const Qr = (props: IconProps) => {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M4.5 4.5H4.51M15.5 4.5H15.51M4.5 15.5H4.51M11 11H11.01M15.5 15.5H15.51M15 19H19V15M12 14.5V19M19 12H14.5M13.6 8H17.4C17.9601 8 18.2401 8 18.454 7.89101C18.6422 7.79513 18.7951 7.64215 18.891 7.45399C19 7.24008 19 6.96005 19 6.4V2.6C19 2.03995 19 1.75992 18.891 1.54601C18.7951 1.35785 18.6422 1.20487 18.454 1.10899C18.2401 1 17.9601 1 17.4 1H13.6C13.0399 1 12.7599 1 12.546 1.10899C12.3578 1.20487 12.2049 1.35785 12.109 1.54601C12 1.75992 12 2.03995 12 2.6V6.4C12 6.96005 12 7.24008 12.109 7.45399C12.2049 7.64215 12.3578 7.79513 12.546 7.89101C12.7599 8 13.0399 8 13.6 8ZM2.6 8H6.4C6.96005 8 7.24008 8 7.45399 7.89101C7.64215 7.79513 7.79513 7.64215 7.89101 7.45399C8 7.24008 8 6.96005 8 6.4V2.6C8 2.03995 8 1.75992 7.89101 1.54601C7.79513 1.35785 7.64215 1.20487 7.45399 1.10899C7.24008 1 6.96005 1 6.4 1H2.6C2.03995 1 1.75992 1 1.54601 1.10899C1.35785 1.20487 1.20487 1.35785 1.10899 1.54601C1 1.75992 1 2.03995 1 2.6V6.4C1 6.96005 1 7.24008 1.10899 7.45399C1.20487 7.64215 1.35785 7.79513 1.54601 7.89101C1.75992 8 2.03995 8 2.6 8ZM2.6 19H6.4C6.96005 19 7.24008 19 7.45399 18.891C7.64215 18.7951 7.79513 18.6422 7.89101 18.454C8 18.2401 8 17.9601 8 17.4V13.6C8 13.0399 8 12.7599 7.89101 12.546C7.79513 12.3578 7.64215 12.2049 7.45399 12.109C7.24008 12 6.96005 12 6.4 12H2.6C2.03995 12 1.75992 12 1.54601 12.109C1.35785 12.2049 1.20487 12.3578 1.10899 12.546C1 12.7599 1 13.0399 1 13.6V17.4C1 17.9601 1 18.2401 1.10899 18.454C1.20487 18.6422 1.35785 18.7951 1.54601 18.891C1.75992 19 2.03995 19 2.6 19Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
export default Qr

9
src/Icons/Reply.tsx Normal file
View File

@ -0,0 +1,9 @@
const Reply = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.50004 9.70199L1.33337 5.53532M1.33337 5.53532L5.50004 1.36865M1.33337 5.53532H6.66671C9.46697 5.53532 10.8671 5.53532 11.9367 6.08029C12.8775 6.55965 13.6424 7.32456 14.1217 8.26537C14.6667 9.33493 14.6667 10.7351 14.6667 13.5353V14.702" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Reply

9
src/Icons/Search.tsx Normal file
View File

@ -0,0 +1,9 @@
const Search = () => {
return (
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 19.5L14.65 15.15M17 9.5C17 13.9183 13.4183 17.5 9 17.5C4.58172 17.5 1 13.9183 1 9.5C1 5.08172 4.58172 1.5 9 1.5C13.4183 1.5 17 5.08172 17 9.5Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
export default Search

11
src/Icons/Zap.tsx Normal file
View File

@ -0,0 +1,11 @@
import type IconProps from './IconProps'
const Zap = (props: IconProps) => {
return (
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Zap

View File

@ -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"])
}
}
}

View File

@ -8,7 +8,8 @@ const enum EventKind {
Deletion = 5, // NIP-09
Repost = 6, // NIP-18
Reaction = 7, // NIP-25
Auth = 22242 // NIP-42
Auth = 22242, // NIP-42
Lists = 30000, // NIP-51
};
export default EventKind;
export default EventKind;

View File

@ -42,6 +42,11 @@ export class Subscriptions {
*/
HashTags?: Set<string>;
/**
* A "d" tag to search
*/
DTag?: string;
/**
* A list of search terms
*/
@ -94,6 +99,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.DTag = sub?.["#d"] ? sub["#d"] : undefined;
this.Search = sub?.search ?? undefined;
this.Since = sub?.since ?? undefined;
this.Until = sub?.until ?? undefined;
@ -136,9 +142,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.DTag) {
ret["#d"] = this.DTag;
}
if (this.Search) {
ret.search = this.Search;
}

View File

@ -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,9 +66,12 @@ export default class Tag {
case "t": {
return ["t", this.Hashtag!];
}
case "d": {
return ["d", this.DTag!];
}
default: {
return this.Original;
}
}
}
}
}

View File

@ -35,6 +35,7 @@ export type RawReqFilter = {
"#e"?: u256[],
"#p"?: u256[],
"#t"?: string[],
"#d"?: string,
search?: string,
since?: number,
until?: number,
@ -55,3 +56,10 @@ export type UserMetadata = {
lud06?: string,
lud16?: string
}
/**
* NIP-51 list types
*/
export enum Lists {
Muted = "mute"
}

44
src/Notifications.ts Normal file
View File

@ -0,0 +1,44 @@
import Nostrich from "nostrich.jpg";
import { TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind";
import type { NotificationRequest } from "State/Login";
import { MetadataCache } from "State/Users";
import { getDb } from "State/Users/Db";
import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const";
export async function makeNotification(ev: TaggedRawEvent): Promise<NotificationRequest | null> {
const db = getDb()
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.bulkGet(Array.from(pubkeys))
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();
}

View File

@ -73,9 +73,9 @@ export default function ChatPage() {
<div className="write-dm">
<div className="inner">
<textarea className="f-grow mr10" value={content} onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => onEnter(e)}></textarea>
<div className="btn" onClick={() => sendDm()}>Send</div>
<button type="button" onClick={() => sendDm()}>Send</button>
</div>
</div>
</>
)
}
}

View File

@ -55,7 +55,7 @@ const DonatePage = () => {
}
return (
<div className="m5">
<div className="main-content m5">
<h2>Help fund the development of Snort</h2>
<p>
Snort is an open source project built by passionate people in their free time

View File

@ -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;
}

View File

@ -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<RootState, TaggedRawEvent[]>(s => s.login.notifications);
const readNotifications = useSelector<RootState, number>(s => s.login.readNotifications);
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
const { isMuted } = useModeration();
const filteredDms = dms.filter(a => !isMuted(a.pubkey))
const prefs = useSelector<RootState, UserPreferences>(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 (
<>
<div className={`btn btn-rnd${unreadDms === 0 ? " mr10" : ""}`} onClick={(e) => navigate("/messages")}>
<FontAwesomeIcon icon={faMessage} size="xl" />
<div className="header-actions">
<div className="btn btn-rnd" onClick={(e) => navigate("/search")}>
<Search />
</div>
{unreadDms > 0 && (<span className="unread-count">
{unreadDms > 100 ? ">99" : unreadDms}
</span>)}
<div className={`btn btn-rnd${unreadNotifications === 0 ? " mr10" : ""}`} onClick={(e) => goToNotifications(e)}>
<FontAwesomeIcon icon={faBell} size="xl" />
<div className="btn btn-rnd" onClick={(e) => navigate("/messages")}>
<Envelope />
{unreadDms > 0 && (<span className="has-unread"></span>)}
</div>
<div className="btn btn-rnd" onClick={(e) => goToNotifications(e)}>
<Bell />
{unreadNotifications > 0 && (<span className="has-unread"></span>)}
</div>
{unreadNotifications > 0 && (<span className="unread-count">
{unreadNotifications > 100 ? ">99" : unreadNotifications}
</span>)}
<ProfileImage pubkey={key || ""} showUsername={false} />
</>
</div>
)
}
@ -114,17 +118,14 @@ export default function Layout() {
}
return (
<div className="page">
<div className="header">
<div className="logo" onClick={() => navigate("/")}>snort</div>
<header>
<div className="logo" onClick={() => navigate("/")}>Snort</div>
<div>
<div className={`btn btn-rnd mr10`} onClick={(e) => navigate("/search")}>
<FontAwesomeIcon icon={faSearch} size="xl" />
</div>
{key ? accountHeader() :
<div className="btn" onClick={() => navigate("/login")}>Login</div>
<button type="button" onClick={() => navigate("/login")}>Login</button>
}
</div>
</div>
</header>
<Outlet />
</div>

View File

@ -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() {
<>
<h2>Other Login Methods</h2>
<div className="flex">
<div className="btn" onClick={(e) => doNip07Login()}>Login with Extension (NIP-07)</div>
<button type="button" onClick={(e) => doNip07Login()}>Login with Extension (NIP-07)</button>
</div>
</>
)
}
return (
<>
<div className="main-content">
<h1>Login</h1>
<div className="flex">
<input type="text" placeholder="nsec / npub / nip-05 / hex private key..." className="f-grow" onChange={e => setKey(e.target.value)} />
</div>
{error.length > 0 ? <b className="error">{error}</b> : null}
<div className="tabs">
<div className="btn" onClick={(e) => doLogin()}>Login</div>
<div className="btn" onClick={() => makeRandomKey()}>Generate Key</div>
<button type="button" onClick={(e) => doLogin()}>Login</button>
<button type="button" onClick={() => makeRandomKey()}>Generate Key</button>
</div>
{altLogins()}
</>
</div>
);
}
}

View File

@ -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<RootState, HexKey | undefined>(s => s.login.publicKey);
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
const dmInteraction = useSelector<RootState, number>(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 (
<>
<div className="main-content">
<div className="flex">
<h3 className="f-grow">Messages</h3>
<div className="btn" onClick={() => markAllRead()}>Mark All Read</div>
<button type="button" onClick={() => markAllRead()}>Mark All Read</button>
</div>
{chats.sort((a, b) => {
return a.pubkey === myPubKey ? -1 :
b.pubkey === myPubKey ? 1 :
b.newestMessage - a.newestMessage
}).map(person)}
</>
</div>
)
}
@ -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;
})
}
}

View File

@ -74,9 +74,9 @@ export default function NewUserPage() {
}
return (
<>
<div className="main-content">
{importTwitterFollows()}
{followSomebody()}
</>
</div>
);
}

View File

@ -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;
}
.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;
}

View File

@ -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<RootState, boolean | undefined>(s => s.login.loggedOut);
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const isMe = loginPubKey === id;
const [showLnQr, setShowLnQr] = useState<boolean>(false);
const [tab, setTab] = useState(ProfileTab.Notes);
const [showProfileQr, setShowProfileQr] = useState<boolean>(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'}
<FollowsYou pubkey={id} />
</h2>
<Copy text={params.id || ""} />
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
<Copy text={params.id || ""} />
{links()}
</div>
)
}
function links() {
return (
<div className="links">
{user?.website && (
<div className="website f-ellipsis">
<span className="link-icon">
<Link />
</span>
<a href={user.website} target="_blank" rel="noreferrer">{user.website}</a>
</div>
)}
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
</div>
)
}
function bio() {
const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
return (
<div className="details">
<div>{about}</div>
<div className="links">
{user?.website && (
<div className="website f-ellipsis">
<a href={user.website} target="_blank" rel="noreferrer">{user.website}</a>
</div>
)}
{lnurl && (
<div className="f-ellipsis" onClick={(e) => setShowLnQr(true)}>
<span className="zap"></span>
<span className="lnurl" >
{lnurl}
</span>
</div>
)}
</div>
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
</div>
return aboutText.length > 0 && (
<>
<h3>Bio</h3>
<div className="details">
{about}
</div>
</>
)
}
function tabContent() {
switch (tab) {
case ProfileTab.Notes:
return <Timeline key={id} subject={{ type: "pubkey", items: [id] }} postsOnly={false} method={"LIMIT_UNTIL"} />;
return <Timeline key={id} subject={{ type: "pubkey", items: [id] }} postsOnly={false} method={"LIMIT_UNTIL"} ignoreModeration={true} />;
case ProfileTab.Follows: {
if (isMe) {
return (
<>
<div className="main-content">
<h4>Following {follows.length}</h4>
{follows.map(a => <ProfilePreview key={a} pubkey={a.toLowerCase()} options={{ about: false }} />)}
</>
</div>
);
} else {
return <FollowsList pubkey={id} />;
@ -107,6 +120,12 @@ export default function ProfilePage() {
case ProfileTab.Followers: {
return <FollowersList pubkey={id} />
}
case ProfileTab.Muted: {
return isMe ? <BlockList variant="muted" /> : <MutedList pubkey={id} />
}
case ProfileTab.Blocked: {
return isMe ? <BlockList variant="blocked" /> : null
}
}
}
@ -118,58 +137,71 @@ export default function ProfilePage() {
)
}
function renderIcons() {
return (
<div className="icon-actions">
<IconButton onClick={() => setShowProfileQr(true)}>
<Qr width={14} height={16} />
</IconButton>
{showProfileQr && (
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
<QrCode data={`nostr:${hexToBech32("npub", id)}`} link={undefined} className="m10"/>
</Modal>
)}
{isMe ? (
<>
<LogoutButton />
<button type="button" onClick={() => navigate("/settings")}>
Settings
</button>
</>
) : (
<>
<IconButton onClick={() => setShowLnQr(true)}>
<Zap width={14} height={16} />
</IconButton>
{!loggedOut && (
<>
<IconButton onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<Envelope width={16} height={13} />
</IconButton>
</>
)}
</>
)}
</div>
)
}
function userDetails() {
return (
<div className="details-wrapper">
{username()}
<div className="p-buttons">
<div className="btn" onClick={() => setShowProfileQr(true)}>
<FontAwesomeIcon icon={faQrcode} size="lg" />
</div>
{showProfileQr && (<Modal onClose={() => setShowProfileQr(false)}>
<div className="card">
<QrCode data={`nostr:${hexToBech32("npub", id)}`} link={undefined} className="m10"/>
</div>
</Modal>)}
{isMe ? (
<div className="btn" onClick={() => navigate("/settings")}>
<FontAwesomeIcon icon={faGear} size="lg" />
</div>
) : <>
<div className="btn" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
<FontAwesomeIcon icon={faEnvelope} size="lg" />
</div>
<FollowButton pubkey={id} />
</>
}
<div className="profile-actions">
{renderIcons()}
{!isMe && <FollowButton pubkey={id} />}
</div>
{bio()}
</div>
)
}
function renderTab(v: ProfileTab) {
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
}
return (
<>
<div className="profile flex">
{user?.banner && <img alt="banner" className="banner" src={user.banner} />}
{user?.banner ? (
<>
{avatar()}
{userDetails()}
</>
) : (
<div className="no-banner">
{avatar()}
{userDetails()}
</div>
)}
{user?.banner && <img alt="banner" className="banner" src={user.banner} />}
<div className="profile-wrapper flex">
{avatar()}
{userDetails()}
</div>
</div>
<div className="tabs">
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows].map(v => {
return <div className={`tab f-1${tab === v ? " active" : ""}`} key={v} onClick={() => setTab(v)}>{v}</div>
})}
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)}
{isMe && renderTab(ProfileTab.Blocked)}
</div>
{tabContent()}
</>

View File

@ -16,6 +16,7 @@ const RootTab = {
};
export default function RootPage() {
const [show, setShow] = useState(false)
const [loggedOut, pubKey, follows] = useSelector<RootState, [boolean | undefined, HexKey | undefined, HexKey[]]>(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 ? <>
<NoteCreator show={true} autoFocus={false} />
<div className="tabs root-tabs">
<div className={`root-tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
<div className="tabs">
<div className={`tab f-1 ${tab === RootTab.Posts ? "active" : ""}`} onClick={() => setTab(RootTab.Posts)}>
Posts
</div>
<div className={`root-tab f-1 ${tab === RootTab.PostsAndReplies ? "active" : ""}`} onClick={() => setTab(RootTab.PostsAndReplies)}>
Posts &amp; Replies
<div className={`tab f-1 ${tab === RootTab.PostsAndReplies ? "active" : ""}`} onClick={() => setTab(RootTab.PostsAndReplies)}>
Conversations
</div>
<div className={`root-tab f-1 ${tab === RootTab.Global ? "active" : ""}`} onClick={() => setTab(RootTab.Global)}>
<div className={`tab f-1 ${tab === RootTab.Global ? "active" : ""}`} onClick={() => setTab(RootTab.Global)}>
Global
</div>
</div></> : null}
{followHints()}
<Timeline key={tab} subject={timelineSubect} postsOnly={tab === RootTab.Posts} method={"TIME_RANGE"} />
<NoteCreator autoFocus={true} show={show} setShow={setShow} />
</>
);
}
}

View File

@ -9,10 +9,10 @@ export default function SettingsPage() {
const navigate = useNavigate();
return (
<>
<div className="main-content">
<h2 onClick={() => navigate("/settings")} className="pointer">Settings</h2>
<Outlet />
</>
</div>
);
}

View File

@ -24,7 +24,7 @@ export default function VerificationPage() {
];
return (
<div className="verification">
<div className="main-content verification">
<h2>Get Verified</h2>
<p>
NIP-05 is a DNS based verification spec which helps to validate you as a real user.

View File

@ -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 (
<>
<div className="settings-nav">
<div className="card" onClick={() => navigate("profile")}>
<FontAwesomeIcon icon={faUser} size="xl" className="mr10" />
@ -24,7 +35,12 @@ const SettingsIndex = () => {
<FontAwesomeIcon icon={faCircleDollarToSlot} size="xl" className="mr10" />
Donate
</div>
<div className="card" onClick={handleLogout}>
<FontAwesomeIcon icon={faRightFromBracket} size="xl" className="mr10" />
Log Out
</div>
</div>
</>
)
}

View File

@ -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<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(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() {
<div>NIP-05:</div>
<div>
<input type="text" className="mr10" value={nip05} onChange={(e) => setNip05(e.target.value)} />
<div className="btn" onClick={() => navigate("/verification")}>
<button type="button" onClick={() => navigate("/verification")}>
<FontAwesomeIcon icon={faShop} />
&nbsp;
Buy
</div>
</button>
</div>
</div>
<div className="form-group">
@ -145,10 +145,10 @@ export default function ProfileSettings() {
</div>
<div className="form-group">
<div>
<div className="btn" onClick={() => { dispatch(logout()); navigate("/"); }}>Logout</div>
<LogoutButton />
</div>
<div>
<div className="btn" onClick={() => saveProfile()}>Save</div>
<button type="button" onClick={() => saveProfile()}>Save</button>
</div>
</div>
</div>

View File

@ -27,7 +27,7 @@ const RelaySettingsPage = () => {
<div className="flex mb10">
<input type="text" className="f-grow" placeholder="wss://my-relay.com" value={newRelay} onChange={(e) => setNewRelay(e.target.value)} />
</div>
<div className="btn mb10" onClick={() => addNewRelay()}>Add</div>
<button className="secondary mb10" onClick={() => addNewRelay()}>Add</button>
</>
)
}
@ -49,16 +49,16 @@ const RelaySettingsPage = () => {
return (
<>
<h3>Relays</h3>
<div className="flex f-col">
<div className="flex f-col mb10">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div>
<div className="flex mt10">
<div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div>
<button type="button" onClick={() => saveRelays()}>Save</button>
</div>
{addRelay()}
</>
)
}
export default RelaySettingsPage;
export default RelaySettingsPage;

View File

@ -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<HexKey | HexKey[]>) => {
setFollows: (state, action: PayloadAction<SetFollowsPayload>) => {
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<TaggedRawEvent | TaggedRawEvent[]>) => {
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<TaggedRawEvent | Array<TaggedRawEvent>>) => {
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;

View File

@ -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);
}
}
.main-content {
padding: 0 12px;
}
@media (min-width: 720px) {
.main-content {
padding: 0;
}
}
.bold {
font-weight: 700;
}