commit
529074e0c6
@ -7,6 +7,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@jukben/emoji-search": "^2.0.1",
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
|
@ -103,4 +103,4 @@ export default function HyperText({ link, creator }: { link: string, creator: He
|
||||
}, [link]);
|
||||
|
||||
return render();
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,16 @@
|
||||
import "./LNURLTip.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { bech32ToText } from "Util";
|
||||
import { HexKey } from "Nostr";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
import Copy from "Element/Copy";
|
||||
import useWebln from "Hooks/useWebln";
|
||||
|
||||
interface LNURLService {
|
||||
allowsNostr?: boolean
|
||||
nostrPubkey?: HexKey
|
||||
minSendable?: number,
|
||||
maxSendable?: number,
|
||||
metadata: string,
|
||||
@ -31,12 +35,15 @@ export interface LNURLTipProps {
|
||||
invoice?: string, // shortcut to invoice qr tab
|
||||
title?: string,
|
||||
notice?: string
|
||||
note?: HexKey
|
||||
author?: HexKey
|
||||
}
|
||||
|
||||
export default function LNURLTip(props: LNURLTipProps) {
|
||||
const onClose = props.onClose || (() => { });
|
||||
const service = props.svc;
|
||||
const show = props.show || false;
|
||||
const { note, author } = props
|
||||
const amounts = [50, 100, 500, 1_000, 5_000, 10_000, 50_000];
|
||||
const [payService, setPayService] = useState<LNURLService>();
|
||||
const [amount, setAmount] = useState<number>();
|
||||
@ -46,6 +53,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
const [error, setError] = useState<string>();
|
||||
const [success, setSuccess] = useState<LNURLSuccessAction>();
|
||||
const webln = useWebln(show);
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
useEffect(() => {
|
||||
if (show && !props.invoice) {
|
||||
@ -117,7 +125,16 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
|
||||
async function loadInvoice() {
|
||||
if (!amount || !payService) return null;
|
||||
const url = `${payService.callback}?amount=${Math.floor(amount * 1000)}${comment ? `&comment=${encodeURIComponent(comment)}` : ""}`;
|
||||
let url = ''
|
||||
const amountParam = `amount=${Math.floor(amount * 1000)}`
|
||||
const commentParam = comment ? `&comment=${encodeURIComponent(comment)}` : ""
|
||||
if (payService.allowsNostr && payService.nostrPubkey && author) {
|
||||
const ev = await publisher.zap(author, note, comment)
|
||||
const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`
|
||||
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
|
||||
} else {
|
||||
url = `${payService.callback}?${amountParam}${commentParam}`;
|
||||
}
|
||||
try {
|
||||
let rsp = await fetch(url);
|
||||
if (rsp.ok) {
|
||||
@ -235,4 +252,4 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import useEventPublisher from "Feed/EventPublisher";
|
||||
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
|
||||
import { NoteCreator } from "Element/NoteCreator";
|
||||
import LNURLTip from "Element/LNURLTip";
|
||||
import { parseZap, ZapsSummary } from "Element/Zap";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { default as NEvent } from "Nostr/Event";
|
||||
import { RootState } from "State/Store";
|
||||
@ -50,6 +51,9 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], { type: "language" });
|
||||
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
|
||||
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]);
|
||||
const zaps = useMemo(() => getReactions(related, ev.Id, EventKind.ZapReceipt).map(parseZap).filter(z => z.valid), [related]);
|
||||
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0)
|
||||
const didZap = zaps.some(a => a.zapper === login);
|
||||
const groupReactions = useMemo(() => {
|
||||
return reactions?.reduce((acc, { content }) => {
|
||||
let r = normalizeReaction(content);
|
||||
@ -97,10 +101,11 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
if (service) {
|
||||
return (
|
||||
<>
|
||||
<div className="reaction-pill" onClick={() => setTip(true)}>
|
||||
<div className={`reaction-pill ${didZap ? 'reacted' : ''}`} onClick={() => setTip(true)}>
|
||||
<div className="reaction-pill-icon">
|
||||
<Zap />
|
||||
</div>
|
||||
{zapTotal > 0 && (<div className="reaction-pill-number">{formatShort(zapTotal)}</div>)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@ -233,6 +238,7 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="footer">
|
||||
<div className="footer-reactions">
|
||||
{tipButton()}
|
||||
@ -259,7 +265,11 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
show={reply}
|
||||
setShow={setReply}
|
||||
/>
|
||||
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} />
|
||||
<LNURLTip svc={author?.lud16 || author?.lud06} onClose={() => setTip(false)} show={tip} author={author?.pubkey} note={ev.Id} />
|
||||
</div>
|
||||
<div className="zaps-container">
|
||||
<ZapsSummary zaps={zaps} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
|
||||
import { TaggedRawEvent } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import LoadMore from "Element/LoadMore";
|
||||
import Zap, { parseZap } from "Element/Zap";
|
||||
import Note from "Element/Note";
|
||||
import NoteReaction from "Element/NoteReaction";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
@ -45,15 +46,19 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod
|
||||
function eventElement(e: TaggedRawEvent) {
|
||||
switch (e.kind) {
|
||||
case EventKind.SetMetadata: {
|
||||
return <ProfilePreview pubkey={e.pubkey} className="card"/>
|
||||
return <ProfilePreview pubkey={e.pubkey} className="card" />
|
||||
}
|
||||
case EventKind.TextNote: {
|
||||
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
|
||||
}
|
||||
case EventKind.ZapReceipt: {
|
||||
const zap = parseZap(e)
|
||||
return zap.e ? null : <Zap zap={zap} key={e.id} />
|
||||
}
|
||||
case EventKind.Reaction:
|
||||
case EventKind.Repost: {
|
||||
let eRef = e.tags.find(a => a[0] === "e")?.at(1);
|
||||
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)}/>
|
||||
return <NoteReaction data={e} key={e.id} root={parent.notes.find(a => a.id === eRef)} />
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,12 +66,12 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod
|
||||
return (
|
||||
<div className="main-content">
|
||||
{latestFeed.length > 1 && (<div className="card latest-notes pointer" onClick={() => showLatest()}>
|
||||
<FontAwesomeIcon icon={faForward} size="xl"/>
|
||||
<FontAwesomeIcon icon={faForward} size="xl" />
|
||||
|
||||
Show latest {latestFeed.length - 1} notes
|
||||
</div>)}
|
||||
{mainFeed.map(eventElement)}
|
||||
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}/>
|
||||
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
84
src/Element/Zap.css
Normal file
84
src/Element/Zap.css
Normal file
@ -0,0 +1,84 @@
|
||||
.zap {
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.zap .header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.zap .header .pfp {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
.zap .header .amount {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.zap .summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.zap .amount {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.zap .amount:before {
|
||||
content: '⚡️ ';
|
||||
}
|
||||
|
||||
.top-zap .amount:before {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.zaps-summary {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.top-zap {
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.top-zap .pfp {
|
||||
margin-right: .3em;
|
||||
}
|
||||
|
||||
.top-zap .summary {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.top-zap .avatar {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.top-zap .nip05 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.amount-number {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rest-zaps:before {
|
||||
content: ", ";
|
||||
}
|
||||
|
||||
.note.zap > .header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note.zap > .body {
|
||||
margin-bottom: 0;
|
||||
}
|
155
src/Element/Zap.tsx
Normal file
155
src/Element/Zap.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import "./Zap.css";
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
// @ts-expect-error
|
||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
import { sha256 } from "Util";
|
||||
|
||||
//import { sha256 } from "Util";
|
||||
import { formatShort } from "Number";
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import Event from "Nostr/Event";
|
||||
import Text from "Element/Text";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
function findTag(e: TaggedRawEvent, tag: string) {
|
||||
const maybeTag = e.tags.find((evTag) => {
|
||||
return evTag[0] === tag
|
||||
})
|
||||
return maybeTag && maybeTag[1]
|
||||
}
|
||||
|
||||
function getInvoice(zap: TaggedRawEvent) {
|
||||
const bolt11 = findTag(zap, 'bolt11')
|
||||
const decoded = invoiceDecode(bolt11)
|
||||
|
||||
const amount = decoded.sections.find((section: any) => section.name === 'amount')?.value
|
||||
const hash = decoded.sections.find((section: any) => section.name === 'description_hash')?.value;
|
||||
|
||||
return { amount, hash: hash ? bytesToHex(hash) : undefined };
|
||||
}
|
||||
|
||||
interface Zapper {
|
||||
pubkey?: HexKey,
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
function getZapper(zap: TaggedRawEvent, dhash: string): Zapper {
|
||||
const zapRequest = findTag(zap, 'description')
|
||||
if (zapRequest) {
|
||||
const rawEvent: TaggedRawEvent = JSON.parse(zapRequest);
|
||||
if (Array.isArray(rawEvent)) {
|
||||
// old format, ignored
|
||||
return { isValid: false };
|
||||
}
|
||||
const metaHash = sha256(zapRequest);
|
||||
const ev = new Event(rawEvent)
|
||||
return { pubkey: ev.PubKey, isValid: dhash === metaHash };
|
||||
}
|
||||
return { isValid: false }
|
||||
}
|
||||
|
||||
interface ParsedZap {
|
||||
id: HexKey
|
||||
e?: HexKey
|
||||
p: HexKey
|
||||
amount: number
|
||||
content: string
|
||||
zapper?: HexKey
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
export function parseZap(zap: TaggedRawEvent): ParsedZap {
|
||||
const { amount, hash } = getInvoice(zap)
|
||||
const zapper = hash ? getZapper(zap, hash) : { isValid: false };
|
||||
const e = findTag(zap, 'e')
|
||||
const p = findTag(zap, 'p')!
|
||||
return {
|
||||
id: zap.id,
|
||||
e,
|
||||
p,
|
||||
amount: Number(amount) / 1000,
|
||||
zapper: zapper.pubkey,
|
||||
content: zap.content,
|
||||
valid: zapper.isValid,
|
||||
}
|
||||
}
|
||||
|
||||
const Zap = ({ zap, showZapped = true }: { zap: ParsedZap, showZapped?: boolean }) => {
|
||||
const { amount, content, zapper, valid, p } = zap
|
||||
const pubKey = useSelector((s: RootState) => s.login.publicKey)
|
||||
|
||||
return valid ? (
|
||||
<div className="zap note card">
|
||||
<div className="header">
|
||||
{zapper ? <ProfileImage pubkey={zapper} /> : <div>Anon </div>}
|
||||
{p !== pubKey && showZapped && <ProfileImage pubkey={p} />}
|
||||
<div className="amount">
|
||||
<span className="amount-number">{formatShort(amount)}</span> sats
|
||||
</div>
|
||||
</div>
|
||||
<div className="body">
|
||||
<Text
|
||||
creator={zapper || ""}
|
||||
content={content}
|
||||
tags={[]}
|
||||
users={new Map()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
interface ZapsSummaryProps { zaps: ParsedZap[] }
|
||||
|
||||
export const ZapsSummary = ({ zaps }: ZapsSummaryProps) => {
|
||||
const sortedZaps = useMemo(() => {
|
||||
const pub = [...zaps.filter(z => z.zapper)]
|
||||
const priv = [...zaps.filter(z => !z.zapper)]
|
||||
pub.sort((a, b) => b.amount - a.amount)
|
||||
return pub.concat(priv)
|
||||
}, [zaps])
|
||||
|
||||
if (zaps.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [topZap, ...restZaps] = sortedZaps
|
||||
const restZapsTotal = restZaps.reduce((acc, z) => acc + z.amount, 0)
|
||||
const { zapper, amount, content, valid } = topZap
|
||||
|
||||
return (
|
||||
<div className="zaps-summary">
|
||||
{amount && (
|
||||
<div className={`top-zap`}>
|
||||
<div className="summary">
|
||||
{zapper ? <ProfileImage pubkey={zapper} /> : <div>Anon </div>}
|
||||
<div className="amount">
|
||||
zapped <span className="amount-number">{formatShort(amount)}</span> sats
|
||||
</div>
|
||||
</div>
|
||||
<div className="body">
|
||||
{content && (
|
||||
<Text
|
||||
creator={zapper || ""}
|
||||
content={content}
|
||||
tags={[]}
|
||||
users={new Map()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{restZapsTotal > 0 && (
|
||||
<div className="rest-zaps">
|
||||
{restZaps.length} other{restZaps.length > 1 ? 's' : ''} zapped
|
||||
<span className="amount-number">{formatShort(restZapsTotal)}</span> sats
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Zap
|
@ -19,7 +19,7 @@ const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
|
||||
<div className="zap-button" onClick={(e) => setZap(true)}>
|
||||
<FontAwesomeIcon icon={faBolt} />
|
||||
</div>
|
||||
<LNURLTip svc={service} show={zap} onClose={() => setZap(false)} />
|
||||
<LNURLTip svc={service} show={zap} onClose={() => setZap(false)} author={pubkey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -144,6 +144,24 @@ export default function useEventPublisher() {
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
zap: async (author: HexKey, note?: HexKey, msg?: string) => {
|
||||
if (pubKey) {
|
||||
let ev = NEvent.ForPubKey(pubKey);
|
||||
ev.Kind = EventKind.ZapRequest;
|
||||
if (note) {
|
||||
// @ts-ignore
|
||||
ev.Tags.push(new Tag(["e", note]))
|
||||
}
|
||||
// @ts-ignore
|
||||
ev.Tags.push(new Tag(["p", author]))
|
||||
// @ts-ignore
|
||||
const relayTag = ['relays', ...Object.keys(relays).slice(0, 10)]
|
||||
// @ts-ignore
|
||||
ev.Tags.push(new Tag(relayTag))
|
||||
processContent(ev, msg || '');
|
||||
return await signEvent(ev);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Reply to a note
|
||||
*/
|
||||
|
@ -41,6 +41,7 @@ export default function useLoginFeed() {
|
||||
|
||||
let sub = new Subscriptions();
|
||||
sub.Id = "login:notifications";
|
||||
// todo: add zaps
|
||||
sub.Kinds = new Set([EventKind.TextNote]);
|
||||
sub.PTags = new Set([pubKey]);
|
||||
sub.Limit = 1;
|
||||
|
@ -31,7 +31,7 @@ export default function useThreadFeed(id: u256) {
|
||||
|
||||
// get replies to this event
|
||||
const subRelated = new Subscriptions();
|
||||
subRelated.Kinds = new Set(pref.enableReactions ? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost] : [EventKind.TextNote]);
|
||||
subRelated.Kinds = new Set(pref.enableReactions ? [EventKind.Reaction, EventKind.TextNote, EventKind.Deletion, EventKind.Repost, EventKind.ZapReceipt] : [EventKind.TextNote]);
|
||||
subRelated.ETags = thisSub.Ids;
|
||||
thisSub.AddSubscription(subRelated);
|
||||
|
||||
@ -56,4 +56,4 @@ export default function useThreadFeed(id: u256) {
|
||||
}, [main.store]);
|
||||
|
||||
return main.store;
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
|
||||
let sub = new Subscriptions();
|
||||
sub.Id = `timeline:${subject.type}:${subject.discriminator}`;
|
||||
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost]);
|
||||
sub.Kinds = new Set([EventKind.TextNote, EventKind.Repost, EventKind.ZapReceipt]);
|
||||
switch (subject.type) {
|
||||
case "pubkey": {
|
||||
sub.Authors = new Set(subject.items);
|
||||
@ -107,7 +107,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
if (trackingEvents.length > 0 && pref.enableReactions) {
|
||||
sub = new Subscriptions();
|
||||
sub.Id = `timeline-related:${subject.type}`;
|
||||
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion]);
|
||||
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.ZapReceipt]);
|
||||
sub.ETags = new Set(trackingEvents);
|
||||
}
|
||||
return sub ?? null;
|
||||
|
17
src/Feed/ZapsFeed.ts
Normal file
17
src/Feed/ZapsFeed.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { HexKey } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
import { Subscriptions } from "Nostr/Subscriptions";
|
||||
import useSubscription from "./Subscription";
|
||||
|
||||
export default function useZapsFeed(pubkey: HexKey) {
|
||||
const sub = useMemo(() => {
|
||||
let x = new Subscriptions();
|
||||
x.Id = `zaps:${pubkey}`;
|
||||
x.Kinds = new Set([EventKind.ZapReceipt]);
|
||||
x.PTags = new Set([pubkey]);
|
||||
return x;
|
||||
}, [pubkey]);
|
||||
|
||||
return useSubscription(sub, { leaveOpen: true, cache: true });
|
||||
}
|
@ -10,6 +10,8 @@ const enum EventKind {
|
||||
Reaction = 7, // NIP-25
|
||||
Auth = 22242, // NIP-42
|
||||
Lists = 30000, // NIP-51
|
||||
ZapRequest = 9734, // NIP tba
|
||||
ZapReceipt = 9735 // NIP tba
|
||||
};
|
||||
|
||||
export default EventKind;
|
||||
|
@ -4,11 +4,11 @@ const intl = new Intl.NumberFormat("en", {
|
||||
});
|
||||
|
||||
export function formatShort(n: number) {
|
||||
if (n < 999) {
|
||||
if (n < 2e3) {
|
||||
return n
|
||||
} else if (n < 1e8) {
|
||||
return `${intl.format(Math.floor(n / 1e3))}K`
|
||||
return `${intl.format(n / 1e3)}K`
|
||||
} else {
|
||||
return `${intl.format(Math.floor(n / 1e6))}M`
|
||||
return `${intl.format(n / 1e6)}M`
|
||||
}
|
||||
}
|
||||
|
@ -71,7 +71,10 @@ const DonatePage = () => {
|
||||
</p>
|
||||
<div className="flex">
|
||||
<div className="mr10">Lightning Donation: </div>
|
||||
<ZapButton svc={"donate@snort.social"} />
|
||||
<ZapButton
|
||||
pubkey={"84de35e2584d2b144aae823c9ed0b0f3deda09648530b93d1a2a146d1dea9864"}
|
||||
svc={"donate@snort.social"}
|
||||
/>
|
||||
</div>
|
||||
{today && (<small>Total today (UTC): {today.donations.toLocaleString()} sats</small>)}
|
||||
<h3>Primary Developers</h3>
|
||||
|
@ -213,3 +213,8 @@
|
||||
.qr-modal .modal-body {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.profile .zap-amount {
|
||||
font-weight: normal;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
@ -4,11 +4,14 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
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 useZapsFeed from "Feed/ZapsFeed";
|
||||
import { default as ZapElement, parseZap } from "Element/Zap";
|
||||
import FollowButton from "Element/FollowButton";
|
||||
import { extractLnAddress, parseId, hexToBech32 } from "Util";
|
||||
import Avatar from "Element/Avatar";
|
||||
@ -36,6 +39,7 @@ enum ProfileTab {
|
||||
Reactions = "Reactions",
|
||||
Followers = "Followers",
|
||||
Follows = "Follows",
|
||||
Zaps = "Zaps",
|
||||
Muted = "Muted",
|
||||
Blocked = "Blocked"
|
||||
};
|
||||
@ -58,6 +62,13 @@ export default function ProfilePage() {
|
||||
const website_url = (user?.website && !user.website.startsWith("http"))
|
||||
? "https://" + user.website
|
||||
: user?.website || "";
|
||||
const zapFeed = useZapsFeed(id)
|
||||
const zaps = useMemo(() => {
|
||||
const profileZaps = zapFeed.store.notes.map(parseZap).filter(z => z.valid && z.p === id && !z.e && z.zapper !== id)
|
||||
profileZaps.sort((a, b) => b.amount - a.amount)
|
||||
return profileZaps
|
||||
}, [zapFeed.store.notes, id])
|
||||
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0)
|
||||
|
||||
useEffect(() => {
|
||||
setTab(ProfileTab.Notes);
|
||||
@ -89,7 +100,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
|
||||
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} author={id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -109,6 +120,14 @@ export default function ProfilePage() {
|
||||
switch (tab) {
|
||||
case ProfileTab.Notes:
|
||||
return <Timeline key={id} subject={{ type: "pubkey", items: [id], discriminator: id.slice(0, 12) }} postsOnly={false} method={"LIMIT_UNTIL"} ignoreModeration={true} />;
|
||||
case ProfileTab.Zaps: {
|
||||
return (
|
||||
<div className="main-content">
|
||||
{zaps.map(z => <ZapElement showZapped={false} zap={z} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case ProfileTab.Follows: {
|
||||
if (isMe) {
|
||||
return (
|
||||
@ -163,6 +182,9 @@ export default function ProfilePage() {
|
||||
<>
|
||||
<IconButton onClick={() => setShowLnQr(true)}>
|
||||
<Zap width={14} height={16} />
|
||||
<span className="zap-amount">
|
||||
{zapsTotal > 0 && formatShort(zapsTotal)}
|
||||
</span>
|
||||
</IconButton>
|
||||
{!loggedOut && (
|
||||
<>
|
||||
@ -205,7 +227,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabs">
|
||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Muted].map(renderTab)}
|
||||
{[ProfileTab.Notes, ProfileTab.Followers, ProfileTab.Follows, ProfileTab.Zaps, ProfileTab.Muted].map(renderTab)}
|
||||
{isMe && renderTab(ProfileTab.Blocked)}
|
||||
</div>
|
||||
{tabContent()}
|
||||
|
@ -1,8 +1,13 @@
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import { sha256 as hash } from '@noble/hashes/sha256';
|
||||
import { bech32 } from "bech32";
|
||||
import { HexKey, TaggedRawEvent, u256 } from "Nostr";
|
||||
import { HexKey, RawEvent, TaggedRawEvent, u256 } from "Nostr";
|
||||
import EventKind from "Nostr/EventKind";
|
||||
|
||||
export const sha256 = (str: string) => {
|
||||
return secp.utils.bytesToHex(hash(str))
|
||||
}
|
||||
|
||||
export async function openFile(): Promise<File | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let elm = document.createElement("input");
|
||||
@ -156,4 +161,4 @@ export function unixNow() {
|
||||
export function debounce(timeout: number, fn: () => void) {
|
||||
let t = setTimeout(fn, timeout);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
@ -1569,6 +1569,11 @@
|
||||
dependencies:
|
||||
eslint-scope "5.1.1"
|
||||
|
||||
"@noble/hashes@^1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12"
|
||||
integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==
|
||||
|
||||
"@noble/secp256k1@^1.7.0":
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c"
|
||||
|
Loading…
x
Reference in New Issue
Block a user