Merge branch 'main' into dbfix

This commit is contained in:
Alejandro 2023-01-25 07:51:09 +01:00 committed by GitHub
commit e56031411d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 356 additions and 221 deletions

View File

@ -1,70 +1,28 @@
# Getting Started with Create React App ## Snort
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). Snort is a nostr UI built with React, Snort intends to be fast and effecient
## Available Scripts Snort supports the following NIP's
- [x] NIP-01: Basic protocol flow description
In the project directory, you can run: - [x] NIP-02: Contact List and Petnames (No petname support)
- [ ] NIP-03: OpenTimestamps Attestations for Events
### `yarn start` - [x] NIP-04: Encrypted Direct Message
- [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
Runs the app in the development mode.\ - [ ] NIP-06: Basic key derivation from mnemonic seed phrase
Open [http://localhost:3000](http://localhost:3000) to view it in your browser. - [x] NIP-07: `window.nostr` capability for web browsers
- [x] NIP-08: Handling Mentions
The page will reload when you make changes.\ - [x] NIP-09: Event Deletion
You may also see any lint errors in the console. - [x] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
- [ ] NIP-11: Relay Information Document
### `yarn test` - [x] NIP-12: Generic Tag Queries
- [ ] NIP-13: Proof of Work
Launches the test runner in the interactive watch mode.\ - [ ] NIP-14: Subject tag in text events
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - [x] NIP-15: End of Stored Events Notice
- [x] NIP-19: bech32-encoded entities
### `yarn build` - [x] NIP-20: Command Results
- [x] NIP-25: Reactions
Builds the app for production to the `build` folder.\ - [x] NIP-26: Delegated Event Signing (Display delegated signings only)
It correctly bundles React in production mode and optimizes the build for the best performance. - [ ] NIP-28: Public Chat
- [ ] NIP-36: Sensitive Content
The build is minified and the filenames include the hashes.\ - [ ] NIP-40: Expiration Timestamp
Your app is ready to be deployed! - [ ] NIP-42: Authentication of clients to relays
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `yarn build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View File

@ -20,7 +20,7 @@ export default function AsyncButton(props: any) {
} }
return ( return (
<div {...props} className={`btn ${props.className}${loading ? "disabled" : ""}`} onClick={(e) => handle(e)}> <div {...props} className={`btn ${props.className}${loading ? " disabled" : ""}`} onClick={(e) => handle(e)}>
{props.children} {props.children}
</div> </div>
) )

View File

@ -16,7 +16,7 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps)
return ( return (
<> <>
<div className="flex"> <div className="flex mt10">
<div className="f-grow">{title}</div> <div className="f-grow">{title}</div>
<div className="btn" onClick={() => followAll()}>Follow All</div> <div className="btn" onClick={() => followAll()}>Follow All</div>
</div> </div>

View File

@ -38,7 +38,8 @@ export interface LNURLTipProps {
svc?: string, svc?: string,
show?: boolean, show?: boolean,
invoice?: string, // shortcut to invoice qr tab invoice?: string, // shortcut to invoice qr tab
title?: string title?: string,
notice?: string
} }
export default function LNURLTip(props: LNURLTipProps) { export default function LNURLTip(props: LNURLTipProps) {
@ -208,6 +209,7 @@ export default function LNURLTip(props: LNURLTipProps) {
return ( return (
<> <>
<div className="invoice"> <div className="invoice">
{props.notice && <b className="error">{props.notice}</b>}
<QrCode data={pr} link={`lightning:${pr}`} /> <QrCode data={pr} link={`lightning:${pr}`} />
<div className="actions"> <div className="actions">
{pr && ( {pr && (

View File

@ -1,13 +1,22 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
export default function LoadMore({ onLoadMore }: { onLoadMore: () => void }) { export default function LoadMore({ onLoadMore, shouldLoadMore }: { onLoadMore: () => void, shouldLoadMore: boolean }) {
const { ref, inView } = useInView(); const { ref, inView } = useInView();
const [tick, setTick] = useState<number>(0);
useEffect(() => { useEffect(() => {
if (inView === true) { if (inView === true && shouldLoadMore === true) {
onLoadMore(); onLoadMore();
} }
}, [inView]); }, [inView, shouldLoadMore, tick]);
useEffect(() => {
let t = setInterval(() => {
setTick(x => x += 1);
}, 500);
return () => clearInterval(t);
}, []);
return <div ref={ref} className="mb10">Loading...</div>; return <div ref={ref} className="mb10">Loading...</div>;
} }

View File

@ -57,7 +57,7 @@ const Nip05 = (props: Nip05Params) => {
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05) const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05)
return ( return (
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}> <div className={`flex nip05${couldNotVerify ? " failed" : ""}`}>
{!isDefaultUser && ( {!isDefaultUser && (
<div className="nick"> <div className="nick">
{name} {name}

View File

@ -15,7 +15,7 @@ import LNURLTip from "Element/LNURLTip";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import { useUserProfile }from "Feed/ProfileFeed"; import { useUserProfile }from "Feed/ProfileFeed";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { hexToBech32 } from "Util"; import { debounce, hexToBech32 } from "Util";
import { UserMetadata } from "Nostr"; import { UserMetadata } from "Nostr";
type Nip05ServiceProps = { type Nip05ServiceProps = {
@ -77,7 +77,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
setAvailabilityResponse({ available: false, why: "REGEX" }); setAvailabilityResponse({ available: false, why: "REGEX" });
return; return;
} }
let t = setTimeout(() => { return debounce(500, () => {
svc.CheckAvailable(handle, domain) svc.CheckAvailable(handle, domain)
.then(a => { .then(a => {
if ('error' in a) { if ('error' in a) {
@ -87,8 +87,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
} }
}) })
.catch(console.error); .catch(console.error);
}, 500); });
return () => clearTimeout(t);
} }
}, [handle, domain]); }, [handle, domain]);
@ -177,7 +176,12 @@ export default function Nip5Service(props: Nip05ServiceProps) {
{availabilityResponse?.available === false && !registerStatus && <div className="flex"> {availabilityResponse?.available === false && !registerStatus && <div className="flex">
<b className="error">Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)}</b> <b className="error">Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)}</b>
</div>} </div>}
<LNURLTip invoice={registerResponse?.invoice} show={showInvoice} onClose={() => setShowInvoice(false)} title={`Buying ${handle}@${domain}`} /> <LNURLTip
invoice={registerResponse?.invoice}
show={showInvoice}
onClose={() => setShowInvoice(false)}
title={`Buying ${handle}@${domain}`}
notice="DO NOT CLOSE THIS POPUP OR YOUR ORDER WILL GET STUCK" />
{registerStatus?.paid && <div className="flex f-col"> {registerStatus?.paid && <div className="flex f-col">
<h4>Order Paid!</h4> <h4>Order Paid!</h4>
<p>Your new NIP-05 handle is: <code>{handle}@{domain}</code></p> <p>Your new NIP-05 handle is: <code>{handle}@{domain}</code></p>

View File

@ -1,3 +1,7 @@
.note {
min-height: 110px;
}
.note.thread { .note.thread {
border-bottom: none; border-bottom: none;
} }

View File

@ -148,14 +148,14 @@ export default function NoteFooter(props: NoteFooterProps) {
function menuItems() { function menuItems() {
return ( return (
<> <>
<MenuItem onClick={() => react("-")}> {prefs.enableReactions && (<MenuItem onClick={() => react("-")}>
<div> <div>
<FontAwesomeIcon icon={faThumbsDown} className={hasReacted('-') ? 'reacted' : ''} /> <FontAwesomeIcon icon={faThumbsDown} className={hasReacted('-') ? 'reacted' : ''} />
&nbsp; &nbsp;
{formatShort(groupReactions[Reaction.Negative])} {formatShort(groupReactions[Reaction.Negative])}
</div> </div>
Dislike Dislike
</MenuItem> </MenuItem>)}
<MenuItem onClick={() => share()}> <MenuItem onClick={() => share()}>
<FontAwesomeIcon icon={faShareNodes} /> <FontAwesomeIcon icon={faShareNodes} />
Share Share

View File

@ -63,7 +63,7 @@ export default function NoteReaction(props: NoteReactionProps) {
const root = extractRoot(); const root = extractRoot();
const opt = { const opt = {
showHeader: ev?.Kind === EventKind.Repost, showHeader: ev?.Kind === EventKind.Repost,
showFooter: ev?.Kind === EventKind.Repost, showFooter: false,
}; };
return ( return (

View File

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

View File

@ -7,7 +7,7 @@ import LoadMore from "Element/LoadMore";
import Note from "Element/Note"; import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction"; import NoteReaction from "Element/NoteReaction";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFastForward, faForward } from "@fortawesome/free-solid-svg-icons"; import { faForward } from "@fortawesome/free-solid-svg-icons";
export interface TimelineProps { export interface TimelineProps {
postsOnly: boolean, postsOnly: boolean,
@ -19,7 +19,7 @@ export interface TimelineProps {
* A list of notes by pubkeys * A list of notes by pubkeys
*/ */
export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) { export default function Timeline({ subject, postsOnly = false, method }: TimelineProps) {
const { main, related, latest, loadMore, showLatest } = useTimelineFeed(subject, { const { main, related, latest, parent, loadMore, showLatest } = useTimelineFeed(subject, {
method method
}); });
@ -42,7 +42,8 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
} }
case EventKind.Reaction: case EventKind.Reaction:
case EventKind.Repost: { case EventKind.Repost: {
return <NoteReaction data={e} key={e.id} /> 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)}/>
} }
} }
} }
@ -55,7 +56,7 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
Show latest {latestFeed.length - 1} notes Show latest {latestFeed.length - 1} notes
</div>)} </div>)}
{mainFeed.map(eventElement)} {mainFeed.map(eventElement)}
{mainFeed.length > 0 ? <LoadMore onLoadMore={loadMore} /> : null} <LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}/>
</> </>
); );
} }

View File

@ -3,4 +3,5 @@
background-color: var(--highlight); background-color: var(--highlight);
padding: 4px 8px; padding: 4px 8px;
border-radius: 16px; border-radius: 16px;
cursor: pointer;
} }

View File

@ -6,19 +6,20 @@ import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import LNURLTip from "Element/LNURLTip"; import LNURLTip from "Element/LNURLTip";
const ZapButton = ({ pubkey }: { pubkey: HexKey }) => {
const profile = useUserProfile(pubkey);
const [zap, setZap] = useState(false);
const svc = profile?.lud16 || profile?.lud06;
if (!svc) return null; const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
const profile = useProfile(pubkey)?.get(pubkey ?? "");
const [zap, setZap] = useState(false);
const service = svc ?? (profile?.lud16 || profile?.lud06);
if (!service) return null;
return ( return (
<> <>
<div className="zap-button" onClick={(e) => setZap(true)}> <div className="zap-button" onClick={(e) => setZap(true)}>
<FontAwesomeIcon icon={faBolt} /> <FontAwesomeIcon icon={faBolt} />
</div> </div>
<LNURLTip svc={svc} show={zap} onClose={() => setZap(false)} /> <LNURLTip svc={service} show={zap} onClose={() => setZap(false)} />
</> </>
) )
} }

View File

@ -54,14 +54,25 @@ export default function useEventPublisher() {
return match return match
} }
} }
const replaceNoteId = (match: string) => {
try {
const hex = bech32ToHex(match);
const idx = ev.Tags.length;
ev.Tags.push(new Tag(["e", hex, "", "mention"], idx));
return `#[${idx}]`
} catch (error) {
return match
}
}
const replaceHashtag = (match: string) => { const replaceHashtag = (match: string) => {
const tag = match.slice(1); const tag = match.slice(1);
const idx = ev.Tags.length; const idx = ev.Tags.length;
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx)); ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
return match; return match;
} }
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub); const content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
content = content.replace(HashtagRegex, replaceHashtag); .replace(/note[a-z0-9]+/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
ev.Content = content; ev.Content = content;
} }

View File

@ -20,43 +20,57 @@ export default function useLoginFeed() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]); const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]);
const sub = useMemo(() => { const subMetadata = useMemo(() => {
if (!pubKey) { if (!pubKey) return null;
return null;
}
let sub = new Subscriptions(); let sub = new Subscriptions();
sub.Id = `login:${sub.Id}`; sub.Id = `login:meta`;
sub.Authors = new Set([pubKey]); sub.Authors = new Set([pubKey]);
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata, EventKind.DirectMessage]); sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
let notifications = new Subscriptions();
notifications.Kinds = new Set([EventKind.TextNote]);
notifications.PTags = new Set([pubKey]);
notifications.Limit = 100;
sub.AddSubscription(notifications);
let dms = new Subscriptions();
dms.Kinds = new Set([EventKind.DirectMessage]);
dms.PTags = new Set([pubKey]);
sub.AddSubscription(dms);
return sub; return sub;
}, [pubKey]); }, [pubKey]);
const main = useSubscription(sub, { leaveOpen: true }); const subNotification = useMemo(() => {
if (!pubKey) return null;
let sub = new Subscriptions();
sub.Id = "login:notifications";
sub.Kinds = new Set([EventKind.TextNote]);
sub.PTags = new Set([pubKey]);
sub.Limit = 1;
return sub;
}, [pubKey]);
const subDms = useMemo(() => {
if (!pubKey) return null;
let dms = new Subscriptions();
dms.Id = "login:dms";
dms.Kinds = new Set([EventKind.DirectMessage]);
dms.PTags = new Set([pubKey]);
let dmsFromME = new Subscriptions();
dmsFromME.Authors = new Set([pubKey]);
dmsFromME.Kinds = new Set([EventKind.DirectMessage]);
dms.AddSubscription(dmsFromME);
return dms;
}, [pubKey]);
const metadataFeed = useSubscription(subMetadata, { leaveOpen: true });
const notificationFeed = useSubscription(subNotification, { leaveOpen: true });
const dmsFeed = useSubscription(subDms, { leaveOpen: true });
useEffect(() => { useEffect(() => {
let contactList = main.store.notes.filter(a => a.kind === EventKind.ContactList); let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
let notifications = main.store.notes.filter(a => a.kind === EventKind.TextNote); let metadata = metadataFeed.store.notes.filter(a => a.kind === EventKind.SetMetadata);
let metadata = main.store.notes.filter(a => a.kind === EventKind.SetMetadata);
let profiles = metadata.map(a => mapEventToProfile(a)) let profiles = metadata.map(a => mapEventToProfile(a))
.filter(a => a !== undefined) .filter(a => a !== undefined)
.map(a => a!); .map(a => a!);
let dms = main.store.notes.filter(a => a.kind === EventKind.DirectMessage);
for (let cl of contactList) { for (let cl of contactList) {
if (cl.content !== "") { if (cl.content !== "" && cl.content !== "{}") {
let relays = JSON.parse(cl.content); let relays = JSON.parse(cl.content);
dispatch(setRelays({ relays, createdAt: cl.created_at })); dispatch(setRelays({ relays, createdAt: cl.created_at }));
} }
@ -64,14 +78,6 @@ export default function useLoginFeed() {
dispatch(setFollows(pTags)); dispatch(setFollows(pTags));
} }
if ("Notification" in window && Notification.permission === "granted") {
for (let nx of notifications.filter(a => (a.created_at * 1000) > readNotifications)) {
sendNotification(nx)
.catch(console.warn);
}
}
dispatch(addNotifications(notifications));
dispatch(addDirectMessage(dms));
(async () => { (async () => {
let maxProfile = profiles.reduce((acc, v) => { let maxProfile = profiles.reduce((acc, v) => {
if (v.created > acc.created) { if (v.created > acc.created) {
@ -87,7 +93,25 @@ export default function useLoginFeed() {
} }
} }
})().catch(console.warn); })().catch(console.warn);
}, [main.store]); }, [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);
}
}
dispatch(addNotifications(notifications));
}, [notificationFeed.store]);
useEffect(() => {
let dms = dmsFeed.store.notes.filter(a => a.kind === EventKind.DirectMessage);
dispatch(addDirectMessage(dms));
}, [dmsFeed.store]);
} }
async function makeNotification(ev: TaggedRawEvent) { async function makeNotification(ev: TaggedRawEvent) {

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useReducer, useState } from "react";
import { System } from "Nostr/System"; import { System } from "Nostr/System";
import { TaggedRawEvent } from "Nostr"; import { TaggedRawEvent } from "Nostr";
import { Subscriptions } from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
import { debounce } from "Util";
export type NoteStore = { export type NoteStore = {
notes: Array<TaggedRawEvent>, notes: Array<TaggedRawEvent>,
@ -60,6 +61,11 @@ export interface UseSubscriptionState {
append: (notes: TaggedRawEvent[]) => void append: (notes: TaggedRawEvent[]) => void
} }
/**
* Wait time before returning changed state
*/
const DebounceMs = 200;
/** /**
* *
* @param {Subscriptions} sub * @param {Subscriptions} sub
@ -68,7 +74,16 @@ export interface UseSubscriptionState {
*/ */
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState { export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState {
const [state, dispatch] = useReducer(notesReducer, initStore); const [state, dispatch] = useReducer(notesReducer, initStore);
const [debounce, setDebounce] = useState<number>(0); const [debounceOutput, setDebounceOutput] = useState<number>(0);
const [subDebounce, setSubDebounced] = useState<Subscriptions>();
useEffect(() => {
if (sub) {
return debounce(DebounceMs, () => {
setSubDebounced(sub);
});
}
}, [sub]);
useEffect(() => { useEffect(() => {
if (sub) { if (sub) {
@ -99,16 +114,14 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
System.RemoveSubscription(sub.Id); System.RemoveSubscription(sub.Id);
}; };
} }
}, [sub]); }, [subDebounce]);
useEffect(() => { useEffect(() => {
let t = setTimeout(() => { return debounce(DebounceMs, () => {
setDebounce(s => s += 1); setDebounceOutput(s => s += 1);
}, 100); });
return () => clearTimeout(t);
}, [state]); }, [state]);
const stateDebounced = useMemo(() => state, [debounce]); const stateDebounced = useMemo(() => state, [debounceOutput]);
return { return {
store: stateDebounced, store: stateDebounced,
clear: () => { clear: () => {

View File

@ -6,6 +6,7 @@ import useSubscription from "Feed/Subscription";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { UserPreferences } from "State/Login"; import { UserPreferences } from "State/Login";
import { debounce } from "Util";
export default function useThreadFeed(id: u256) { export default function useThreadFeed(id: u256) {
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]); const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
@ -14,9 +15,8 @@ export default function useThreadFeed(id: u256) {
function addId(id: u256[]) { function addId(id: u256[]) {
setTrackingEvent((s) => { setTrackingEvent((s) => {
let orig = new Set(s); let orig = new Set(s);
let idsMissing = id.filter(a => !orig.has(a)); if (id.some(a => !orig.has(a))) {
if (idsMissing.length > 0) { let tmp = new Set([...s, ...id]);
let tmp = new Set([...s, ...idsMissing]);
return Array.from(tmp); return Array.from(tmp);
} else { } else {
return s; return s;
@ -41,14 +41,18 @@ export default function useThreadFeed(id: u256) {
const main = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
useEffect(() => { useEffect(() => {
// debounce if (main.store) {
let t = setTimeout(() => { return debounce(200, () => {
let eTags = main.store.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat(); let mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
let ids = main.store.notes.map(a => a.id);
let allEvents = new Set([...eTags, ...ids]); let eTags = mainNotes
addId(Array.from(allEvents)); .filter(a => a.kind === EventKind.TextNote)
}, 200); .map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
return () => clearTimeout(t); let ids = mainNotes.map(a => a.id);
let allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
})
}
}, [main.store]); }, [main.store]);
return main.store; return main.store;

View File

@ -13,16 +13,17 @@ export interface TimelineFeedOptions {
} }
export interface TimelineSubject { export interface TimelineSubject {
type: "pubkey" | "hashtag" | "global", type: "pubkey" | "hashtag" | "global" | "ptag",
items: string[] items: string[]
} }
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) { export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow(); const now = unixNow();
const [window, setWindow] = useState<number>(60 * 10); const [window, setWindow] = useState<number>(60 * 60);
const [until, setUntil] = useState<number>(now); const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window); const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]); const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences); const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
function createSub() { function createSub() {
@ -42,6 +43,10 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
sub.HashTags = new Set(subject.items); sub.HashTags = new Set(subject.items);
break; break;
} }
case "ptag": {
sub.PTags = new Set(subject.items);
break;
}
} }
return sub; return sub;
} }
@ -68,6 +73,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
latestSub.HashTags = sub.HashTags; latestSub.HashTags = sub.HashTags;
latestSub.Kinds = sub.Kinds; latestSub.Kinds = sub.Kinds;
latestSub.Limit = 1; latestSub.Limit = 1;
latestSub.Since = Math.floor(new Date().getTime() / 1000);
sub.AddSubscription(latestSub); sub.AddSubscription(latestSub);
} }
} }
@ -81,6 +87,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
if (subLatest && !pref.autoShowLatest) { if (subLatest && !pref.autoShowLatest) {
subLatest.Id = `${subLatest.Id}:latest`; subLatest.Id = `${subLatest.Id}:latest`;
subLatest.Limit = 1; subLatest.Limit = 1;
subLatest.Since = Math.floor(new Date().getTime() / 1000);
} }
return subLatest; return subLatest;
}, [subject.type, subject.items]); }, [subject.type, subject.items]);
@ -88,18 +95,30 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const latest = useSubscription(subRealtime, { leaveOpen: true }); const latest = useSubscription(subRealtime, { leaveOpen: true });
const subNext = useMemo(() => { const subNext = useMemo(() => {
let sub: Subscriptions | undefined;
if (trackingEvents.length > 0 && pref.enableReactions) { if (trackingEvents.length > 0 && pref.enableReactions) {
let sub = new Subscriptions(); sub = new Subscriptions();
sub.Id = `timeline-related:${subject.type}`; sub.Id = `timeline-related:${subject.type}`;
sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion, EventKind.Repost]); sub.Kinds = new Set([EventKind.Reaction, EventKind.Deletion]);
sub.ETags = new Set(trackingEvents); sub.ETags = new Set(trackingEvents);
return sub;
} }
return null; return sub ?? null;
}, [trackingEvents]); }, [trackingEvents]);
const others = useSubscription(subNext, { leaveOpen: true }); const others = useSubscription(subNext, { leaveOpen: true });
const subParents = useMemo(() => {
if (trackingParentEvents.length > 0) {
let parents = new Subscriptions();
parents.Id = `timeline-parent:${subject.type}`;
parents.Ids = new Set(trackingParentEvents);
return parents;
}
return null;
}, [trackingParentEvents]);
const parent = useSubscription(subParents);
useEffect(() => { useEffect(() => {
if (main.store.notes.length > 0) { if (main.store.notes.length > 0) {
setTrackingEvent(s => { setTrackingEvent(s => {
@ -107,6 +126,20 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
let temp = new Set([...s, ...ids]); let temp = new Set([...s, ...ids]);
return Array.from(temp); return Array.from(temp);
}); });
let reposts = main.store.notes
.filter(a => a.kind === EventKind.Repost && a.content === "")
.map(a => a.tags.find(b => b[0] === "e"))
.filter(a => a)
.map(a => a![1]);
if (reposts.length > 0) {
setTrackingParentEvents(s => {
if (reposts.some(a => !s.includes(a))) {
let temp = new Set([...s, ...reposts]);
return Array.from(temp);
}
return s;
})
}
} }
}, [main.store]); }, [main.store]);
@ -114,6 +147,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
main: main.store, main: main.store,
related: others.store, related: others.store,
latest: latest.store, latest: latest.store,
parent: parent.store,
loadMore: () => { loadMore: () => {
console.debug("Timeline load more!") console.debug("Timeline load more!")
if (options.method === "LIMIT_UNTIL") { if (options.method === "LIMIT_UNTIL") {

View File

@ -1,10 +1,13 @@
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
import ZapButton from "Element/ZapButton"; import ZapButton from "Element/ZapButton";
import { HexKey } from "Nostr";
import { useEffect, useState } from "react";
import { bech32ToHex } from "Util"; import { bech32ToHex } from "Util";
const Developers = [ const Developers = [
bech32ToHex("npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49"), // kieran bech32ToHex("npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49"), // kieran
bech32ToHex("npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg") // verbiricha bech32ToHex("npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg"), // verbiricha
bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), // Karnage
]; ];
const Contributors = [ const Contributors = [
@ -12,7 +15,33 @@ const Contributors = [
bech32ToHex("npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"), // liran cohen bech32ToHex("npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"), // liran cohen
]; ];
interface Splits {
pubKey: string,
split: number
}
const DonatePage = () => { const DonatePage = () => {
const [splits, setSplits] = useState<Splits[]>([]);
async function loadSplits() {
let rsp = await fetch("https://api.snort.social/api/v1/revenue/splits");
if(rsp.ok) {
setSplits(await rsp.json());
}
}
useEffect(() => {
loadSplits().catch(console.warn);
}, []);
function actions(pk: HexKey) {
let split = splits.find(a => bech32ToHex(a.pubKey) === pk);
if(split) {
return <>{(100 * split.split).toLocaleString()}%</>
}
return <></>
}
return ( return (
<div className="m5"> <div className="m5">
<h2>Help fund the development of Snort</h2> <h2>Help fund the development of Snort</h2>
@ -25,10 +54,17 @@ const DonatePage = () => {
<p> <p>
Check out the code here: <a className="highlight" href="https://github.com/v0l/snort" rel="noreferrer" target="_blank">https://github.com/v0l/snort</a> Check out the code here: <a className="highlight" href="https://github.com/v0l/snort" rel="noreferrer" target="_blank">https://github.com/v0l/snort</a>
</p> </p>
<p>
Each contributor will get paid a percentage of all donations and NIP-05 orders, you can see the split amounts below
</p>
<div className="flex">
<div className="mr10">Lightning Donation: </div>
<ZapButton svc={"donate@snort.social"} />
</div>
<h3>Primary Developers</h3> <h3>Primary Developers</h3>
{Developers.map(a => <ProfilePreview pubkey={a} key={a} actions={<ZapButton pubkey={a} />} />)} {Developers.map(a => <ProfilePreview pubkey={a} key={a} actions={actions(a)} />)}
<h4>Contributors</h4> <h4>Contributors</h4>
{Contributors.map(a => <ProfilePreview pubkey={a} key={a} actions={<ZapButton pubkey={a} />} />)} {Contributors.map(a => <ProfilePreview pubkey={a} key={a} actions={actions(a)} />)}
</div> </div>
); );
} }

View File

@ -1,18 +1,75 @@
import { RecommendedFollows } from "Const"; import { RecommendedFollows } from "Const";
import AsyncButton from "Element/AsyncButton";
import FollowListBase from "Element/FollowListBase";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
import { HexKey } from "Nostr";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
import { bech32ToHex } from "Util";
const TwitterFollowsApi = "https://api.snort.social/api/v1/twitter/follows-for-nostr";
export default function NewUserPage() { export default function NewUserPage() {
const [twitterUsername, setTwitterUsername] = useState<string>("");
const [follows, setFollows] = useState<string[]>([]);
const currentFollows = useSelector<RootState, HexKey[]>(s => s.login.follows);
const [error, setError] = useState<string>("");
const sortedReccomends = useMemo(() => {
return RecommendedFollows
.sort(a => Math.random() >= 0.5 ? -1 : 1);
}, []);
const sortedTwitterFollows = useMemo(() => {
return follows.map(a => bech32ToHex(a))
.sort((a, b) => currentFollows.includes(a) ? 1 : -1);
}, [follows]);
async function loadFollows() {
setFollows([]);
setError("");
try {
let rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
if (rsp.ok) {
setFollows(await rsp.json());
} else {
setError("Failed to load follows, is your profile public?");
}
} catch (e) {
console.warn(e);
setError("Failed to load follows, is your profile public?");
}
}
function followSomebody() { function followSomebody() {
return ( return (
<> <>
<h2>Follow some popular accounts</h2> <h2>Follow some popular accounts</h2>
<h4>Here are some suggestions:</h4> {sortedReccomends.map(a => <ProfilePreview key={a} pubkey={a.toLowerCase()} />)}
{RecommendedFollows
.sort(a => Math.random() >= 0.5 ? -1 : 1)
.map(a => <ProfilePreview key={a} pubkey={a.toLowerCase()} />)}
</> </>
) )
} }
return followSomebody()
function importTwitterFollows() {
return (
<>
<h2>Import twitter follows</h2>
<p>Find your twitter follows on nostr (Data provided by <a href="https://nostr.directory" target="_blank" rel="noreferrer">nostr.directory</a>)</p>
<div className="flex">
<input type="text" placeholder="Twitter username.." className="f-grow mr10" value={twitterUsername} onChange={e => setTwitterUsername(e.target.value)} />
<AsyncButton onClick={loadFollows}>Check</AsyncButton>
</div>
{error.length > 0 && <b className="error">{error}</b>}
{sortedTwitterFollows.length > 0 && (<FollowListBase pubkeys={sortedTwitterFollows} />)}
</>
)
}
return (
<>
{importTwitterFollows()}
{followSomebody()}
</>
);
} }

View File

@ -1,65 +1,24 @@
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux" import { useDispatch, useSelector } from "react-redux"
import Note from "Element/Note"; import { HexKey } from "Nostr";
import NoteReaction from "Element/NoteReaction";
import useSubscription from "Feed/Subscription";
import { TaggedRawEvent } from "Nostr";
import Event from "Nostr/Event";
import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions";
import { markNotificationsRead } from "State/Login"; import { markNotificationsRead } from "State/Login";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import Timeline from "Element/Timeline";
export default function NotificationsPage() { export default function NotificationsPage() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications); const pubkey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
useEffect(() => { useEffect(() => {
dispatch(markNotificationsRead()); dispatch(markNotificationsRead());
}, []); }, []);
const etagged = useMemo(() => {
return notifications?.filter(a => a.kind === EventKind.Reaction)
.map(a => {
let ev = new Event(a);
return ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
}).filter(a => a !== undefined).map(a => a!);
}, [notifications]);
const subEvents = useMemo(() => {
let sub = new Subscriptions();
sub.Id = `reactions:${sub.Id}`;
sub.Kinds = new Set([EventKind.Reaction]);
sub.ETags = new Set(notifications?.filter(b => b.kind === EventKind.TextNote).map(b => b.id));
if (etagged.length > 0) {
let reactionsTo = new Subscriptions();
reactionsTo.Kinds = new Set([EventKind.TextNote]);
reactionsTo.Ids = new Set(etagged);
sub.OrSubs.push(reactionsTo);
}
return sub;
}, [etagged]);
const otherNotes = useSubscription(subEvents, { leaveOpen: true });
const sorted = [
...notifications
].sort((a, b) => b.created_at - a.created_at);
return ( return (
<> <>
{sorted?.map(a => { {pubkey ?
if (a.kind === EventKind.TextNote) { <Timeline subject={{ type: "ptag", items: [pubkey!] }} postsOnly={false} method={"TIME_RANGE"} />
return <Note data={a} key={a.id} related={otherNotes?.store.notes ?? []} /> : null}
} else if (a.kind === EventKind.Reaction) {
let ev = new Event(a);
let reactedTo = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
let reactedNote = otherNotes?.store.notes?.find(c => c.id === reactedTo);
return <NoteReaction data={a} key={a.id} root={reactedNote} />
}
return null;
})}
</> </>
) )
} }

View File

@ -62,7 +62,7 @@ const PreferencesPage = () => {
<div className="card flex"> <div className="card flex">
<div className="flex f-col f-grow"> <div className="flex f-col f-grow">
<div>Debug Menus</div> <div>Debug Menus</div>
<small>Shows extra options to help with debugging data</small> <small>Shows "Copy ID" and "Copy Event JSON" in the context menu on each message</small>
</div> </div>
<div> <div>
<input type="checkbox" checked={perf.showDebugMenus} onChange={e => dispatch(setPreferences({ ...perf, showDebugMenus: e.target.checked }))} /> <input type="checkbox" checked={perf.showDebugMenus} onChange={e => dispatch(setPreferences({ ...perf, showDebugMenus: e.target.checked }))} />

View File

@ -184,7 +184,7 @@ export default function ProfileSettings() {
{settings()} {settings()}
{privKey && (<div className="flex f-col bg-grey"> {privKey && (<div className="flex f-col bg-grey">
<div> <div>
<h4>Private Key:</h4> <h4>Your Private Key Is (do not share this with anyone):</h4>
</div> </div>
<div> <div>
<Copy text={hexToBech32("nsec", privKey)} /> <Copy text={hexToBech32("nsec", privKey)} />

View File

@ -111,7 +111,7 @@ const InitState = {
dms: [], dms: [],
dmInteraction: 0, dmInteraction: 0,
preferences: { preferences: {
enableReactions: true, enableReactions: false,
autoLoadMedia: true, autoLoadMedia: true,
theme: "system", theme: "system",
confirmReposts: false, confirmReposts: false,

View File

@ -145,4 +145,15 @@ export function extractLnAddress(lnurl: string) {
export function unixNow() { export function unixNow() {
return Math.floor(new Date().getTime() / 1000); return Math.floor(new Date().getTime() / 1000);
} }
/**
* Simple debounce
* @param timeout Time until falling edge
* @param fn Callack to run on falling edge
* @returns Cancel timeout function
*/
export function debounce(timeout: number, fn: () => void) {
let t = setTimeout(fn, timeout);
return () => clearTimeout(t);
}

View File

@ -296,6 +296,10 @@ body.scroll-lock {
height: 100vh; height: 100vh;
} }
.pointer {
cursor: pointer;
}
.m5 { .m5 {
margin: 5px; margin: 5px;
} }
@ -312,6 +316,10 @@ body.scroll-lock {
margin-right: 5px; margin-right: 5px;
} }
.mt10 {
margin-top: 10px;
}
.ml5 { .ml5 {
margin-left: 5px; margin-left: 5px;
} }

View File

@ -10025,4 +10025,4 @@ yargs@^16.2.0:
yocto-queue@^0.1.0: yocto-queue@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==