feat: in-memory fallback for storing user profiles #110
94
README.md
94
README.md
@ -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
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
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)
|
||||
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
|
||||
- [x] NIP-04: Encrypted Direct Message
|
||||
- [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
|
||||
- [ ] NIP-06: Basic key derivation from mnemonic seed phrase
|
||||
- [x] NIP-07: `window.nostr` capability for web browsers
|
||||
- [x] NIP-08: Handling Mentions
|
||||
- [x] NIP-09: Event Deletion
|
||||
- [x] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
|
||||
- [ ] NIP-11: Relay Information Document
|
||||
- [x] NIP-12: Generic Tag Queries
|
||||
- [ ] NIP-13: Proof of Work
|
||||
- [ ] NIP-14: Subject tag in text events
|
||||
- [x] NIP-15: End of Stored Events Notice
|
||||
- [x] NIP-19: bech32-encoded entities
|
||||
- [x] NIP-20: Command Results
|
||||
- [x] NIP-25: Reactions
|
||||
- [x] NIP-26: Delegated Event Signing (Display delegated signings only)
|
||||
- [ ] NIP-28: Public Chat
|
||||
- [ ] NIP-36: Sensitive Content
|
||||
- [ ] NIP-40: Expiration Timestamp
|
||||
- [ ] NIP-42: Authentication of clients to relays
|
@ -16,7 +16,7 @@ export default function FollowListBase({ pubkeys, title }: FollowListBaseProps)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="flex mt10">
|
||||
<div className="f-grow">{title}</div>
|
||||
<div className="btn" onClick={() => followAll()}>Follow All</div>
|
||||
</div>
|
||||
|
@ -38,7 +38,8 @@ export interface LNURLTipProps {
|
||||
svc?: string,
|
||||
show?: boolean,
|
||||
invoice?: string, // shortcut to invoice qr tab
|
||||
title?: string
|
||||
title?: string,
|
||||
notice?: string
|
||||
}
|
||||
|
||||
export default function LNURLTip(props: LNURLTipProps) {
|
||||
@ -208,6 +209,7 @@ export default function LNURLTip(props: LNURLTipProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="invoice">
|
||||
{props.notice && <b className="error">{props.notice}</b>}
|
||||
<QrCode data={pr} link={`lightning:${pr}`} />
|
||||
<div className="actions">
|
||||
{pr && (
|
||||
|
@ -1,13 +1,22 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
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 [tick, setTick] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView === true) {
|
||||
if (inView === true && shouldLoadMore === true) {
|
||||
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>;
|
||||
}
|
@ -57,7 +57,7 @@ const Nip05 = (props: Nip05Params) => {
|
||||
const { isVerified, couldNotVerify } = useIsVerified(props.pubkey, props.nip05)
|
||||
|
||||
return (
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={(ev) => ev.stopPropagation()}>
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`}>
|
||||
{!isDefaultUser && (
|
||||
<div className="nick">
|
||||
{name}
|
||||
|
@ -15,7 +15,7 @@ import LNURLTip from "Element/LNURLTip";
|
||||
import Copy from "Element/Copy";
|
||||
import { useUserProfile }from "Feed/ProfileFeed";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { hexToBech32 } from "Util";
|
||||
import { debounce, hexToBech32 } from "Util";
|
||||
import { UserMetadata } from "Nostr";
|
||||
|
||||
type Nip05ServiceProps = {
|
||||
@ -77,7 +77,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
setAvailabilityResponse({ available: false, why: "REGEX" });
|
||||
return;
|
||||
}
|
||||
let t = setTimeout(() => {
|
||||
return debounce(500, () => {
|
||||
svc.CheckAvailable(handle, domain)
|
||||
.then(a => {
|
||||
if ('error' in a) {
|
||||
@ -87,8 +87,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, 500);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
}
|
||||
}, [handle, domain]);
|
||||
|
||||
@ -177,7 +176,12 @@ export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
{availabilityResponse?.available === false && !registerStatus && <div className="flex">
|
||||
<b className="error">Not available: {mapError(availabilityResponse.why!, availabilityResponse.reasonTag || null)}</b>
|
||||
</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">
|
||||
<h4>Order Paid!</h4>
|
||||
<p>Your new NIP-05 handle is: <code>{handle}@{domain}</code></p>
|
||||
|
@ -1,3 +1,7 @@
|
||||
.note {
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.note.thread {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
@ -148,14 +148,14 @@ export default function NoteFooter(props: NoteFooterProps) {
|
||||
function menuItems() {
|
||||
return (
|
||||
<>
|
||||
<MenuItem onClick={() => react("-")}>
|
||||
{prefs.enableReactions && (<MenuItem onClick={() => react("-")}>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faThumbsDown} className={hasReacted('-') ? 'reacted' : ''} />
|
||||
|
||||
{formatShort(groupReactions[Reaction.Negative])}
|
||||
</div>
|
||||
Dislike
|
||||
</MenuItem>
|
||||
</MenuItem>)}
|
||||
<MenuItem onClick={() => share()}>
|
||||
<FontAwesomeIcon icon={faShareNodes} />
|
||||
Share
|
||||
|
@ -63,7 +63,7 @@ export default function NoteReaction(props: NoteReactionProps) {
|
||||
const root = extractRoot();
|
||||
const opt = {
|
||||
showHeader: ev?.Kind === EventKind.Repost,
|
||||
showFooter: ev?.Kind === EventKind.Repost,
|
||||
showFooter: false,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -30,13 +30,11 @@ 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">
|
||||
<Link key={pubkey} to={link ?? profileLink(pubkey)}>
|
||||
{showUsername && (<div className="f-grow pointer" onClick={e => { e.stopPropagation(); navigate(link ?? profileLink(pubkey)) }}>
|
||||
<div className="profile-name">
|
||||
<div>{name}</div>
|
||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||
</div>
|
||||
</Link>
|
||||
{subHeader ? <>{subHeader}</> : null}
|
||||
</div>
|
||||
)}
|
||||
|
@ -7,7 +7,7 @@ import LoadMore from "Element/LoadMore";
|
||||
import Note from "Element/Note";
|
||||
import NoteReaction from "Element/NoteReaction";
|
||||
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 {
|
||||
postsOnly: boolean,
|
||||
@ -19,7 +19,7 @@ export interface TimelineProps {
|
||||
* A list of notes by pubkeys
|
||||
*/
|
||||
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
|
||||
});
|
||||
|
||||
@ -42,7 +42,8 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
|
||||
}
|
||||
case EventKind.Reaction:
|
||||
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
|
||||
</div>)}
|
||||
{mainFeed.map(eventElement)}
|
||||
{mainFeed.length > 0 ? <LoadMore onLoadMore={loadMore} /> : null}
|
||||
<LoadMore onLoadMore={loadMore} shouldLoadMore={main.end}/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -3,4 +3,5 @@
|
||||
background-color: var(--highlight);
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -6,19 +6,20 @@ import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { HexKey } from "Nostr";
|
||||
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 (
|
||||
<>
|
||||
<div className="zap-button" onClick={(e) => setZap(true)}>
|
||||
<FontAwesomeIcon icon={faBolt} />
|
||||
</div>
|
||||
<LNURLTip svc={svc} show={zap} onClose={() => setZap(false)} />
|
||||
<LNURLTip svc={service} show={zap} onClose={() => setZap(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -54,14 +54,25 @@ export default function useEventPublisher() {
|
||||
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 tag = match.slice(1);
|
||||
const idx = ev.Tags.length;
|
||||
ev.Tags.push(new Tag(["t", tag.toLowerCase()], idx));
|
||||
return match;
|
||||
}
|
||||
let content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub);
|
||||
content = content.replace(HashtagRegex, replaceHashtag);
|
||||
const content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
|
||||
.replace(/note[a-z0-9]+/g, replaceNoteId)
|
||||
.replace(HashtagRegex, replaceHashtag);
|
||||
ev.Content = content;
|
||||
}
|
||||
|
||||
|
@ -20,43 +20,57 @@ export default function useLoginFeed() {
|
||||
const dispatch = useDispatch();
|
||||
const [pubKey, readNotifications] = useSelector<RootState, [HexKey | undefined, number]>(s => [s.login.publicKey, s.login.readNotifications]);
|
||||
|
||||
const sub = useMemo(() => {
|
||||
if (!pubKey) {
|
||||
return null;
|
||||
}
|
||||
const subMetadata = useMemo(() => {
|
||||
if (!pubKey) return null;
|
||||
|
||||
let sub = new Subscriptions();
|
||||
sub.Id = `login:${sub.Id}`;
|
||||
sub.Id = `login:meta`;
|
||||
sub.Authors = new Set([pubKey]);
|
||||
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata, EventKind.DirectMessage]);
|
||||
|
||||
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);
|
||||
sub.Kinds = new Set([EventKind.ContactList, EventKind.SetMetadata]);
|
||||
|
||||
return sub;
|
||||
}, [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(() => {
|
||||
let contactList = main.store.notes.filter(a => a.kind === EventKind.ContactList);
|
||||
let notifications = main.store.notes.filter(a => a.kind === EventKind.TextNote);
|
||||
let metadata = main.store.notes.filter(a => a.kind === EventKind.SetMetadata);
|
||||
let contactList = metadataFeed.store.notes.filter(a => a.kind === EventKind.ContactList);
|
||||
let metadata = metadataFeed.store.notes.filter(a => a.kind === EventKind.SetMetadata);
|
||||
let profiles = metadata.map(a => mapEventToProfile(a))
|
||||
.filter(a => a !== undefined)
|
||||
.map(a => a!);
|
||||
let dms = main.store.notes.filter(a => a.kind === EventKind.DirectMessage);
|
||||
|
||||
for (let cl of contactList) {
|
||||
if (cl.content !== "") {
|
||||
if (cl.content !== "" && cl.content !== "{}") {
|
||||
let relays = JSON.parse(cl.content);
|
||||
dispatch(setRelays({ relays, createdAt: cl.created_at }));
|
||||
}
|
||||
@ -64,14 +78,6 @@ export default function useLoginFeed() {
|
||||
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 () => {
|
||||
let maxProfile = profiles.reduce((acc, v) => {
|
||||
if (v.created > acc.created) {
|
||||
@ -87,7 +93,25 @@ export default function useLoginFeed() {
|
||||
}
|
||||
}
|
||||
})().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) {
|
||||
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { System } from "Nostr/System";
|
||||
import { TaggedRawEvent } from "Nostr";
|
||||
import { Subscriptions } from "Nostr/Subscriptions";
|
||||
import { debounce } from "Util";
|
||||
|
||||
export type NoteStore = {
|
||||
notes: Array<TaggedRawEvent>,
|
||||
@ -60,6 +61,11 @@ export interface UseSubscriptionState {
|
||||
append: (notes: TaggedRawEvent[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait time before returning changed state
|
||||
*/
|
||||
const DebounceMs = 200;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Subscriptions} sub
|
||||
@ -68,7 +74,16 @@ export interface UseSubscriptionState {
|
||||
*/
|
||||
export default function useSubscription(sub: Subscriptions | null, options?: UseSubscriptionOptions): UseSubscriptionState {
|
||||
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(() => {
|
||||
if (sub) {
|
||||
@ -99,16 +114,14 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
|
||||
System.RemoveSubscription(sub.Id);
|
||||
};
|
||||
}
|
||||
}, [sub]);
|
||||
|
||||
}, [subDebounce]);
|
||||
useEffect(() => {
|
||||
let t = setTimeout(() => {
|
||||
setDebounce(s => s += 1);
|
||||
}, 100);
|
||||
return () => clearTimeout(t);
|
||||
return debounce(DebounceMs, () => {
|
||||
setDebounceOutput(s => s += 1);
|
||||
});
|
||||
}, [state]);
|
||||
|
||||
const stateDebounced = useMemo(() => state, [debounce]);
|
||||
const stateDebounced = useMemo(() => state, [debounceOutput]);
|
||||
return {
|
||||
store: stateDebounced,
|
||||
clear: () => {
|
||||
|
@ -6,6 +6,7 @@ import useSubscription from "Feed/Subscription";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "State/Store";
|
||||
import { UserPreferences } from "State/Login";
|
||||
import { debounce } from "Util";
|
||||
|
||||
export default function useThreadFeed(id: u256) {
|
||||
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
|
||||
@ -14,9 +15,8 @@ export default function useThreadFeed(id: u256) {
|
||||
function addId(id: u256[]) {
|
||||
setTrackingEvent((s) => {
|
||||
let orig = new Set(s);
|
||||
let idsMissing = id.filter(a => !orig.has(a));
|
||||
if (idsMissing.length > 0) {
|
||||
let tmp = new Set([...s, ...idsMissing]);
|
||||
if (id.some(a => !orig.has(a))) {
|
||||
let tmp = new Set([...s, ...id]);
|
||||
return Array.from(tmp);
|
||||
} else {
|
||||
return s;
|
||||
@ -41,14 +41,18 @@ export default function useThreadFeed(id: u256) {
|
||||
const main = useSubscription(sub, { leaveOpen: true });
|
||||
|
||||
useEffect(() => {
|
||||
// debounce
|
||||
let t = setTimeout(() => {
|
||||
let eTags = main.store.notes.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
|
||||
let ids = main.store.notes.map(a => a.id);
|
||||
if (main.store) {
|
||||
return debounce(200, () => {
|
||||
let mainNotes = main.store.notes.filter(a => a.kind === EventKind.TextNote);
|
||||
|
||||
let eTags = mainNotes
|
||||
.filter(a => a.kind === EventKind.TextNote)
|
||||
.map(a => a.tags.filter(b => b[0] === "e").map(b => b[1])).flat();
|
||||
let ids = mainNotes.map(a => a.id);
|
||||
let allEvents = new Set([...eTags, ...ids]);
|
||||
addId(Array.from(allEvents));
|
||||
}, 200);
|
||||
return () => clearTimeout(t);
|
||||
})
|
||||
}
|
||||
}, [main.store]);
|
||||
|
||||
return main.store;
|
||||
|
@ -13,16 +13,17 @@ export interface TimelineFeedOptions {
|
||||
}
|
||||
|
||||
export interface TimelineSubject {
|
||||
type: "pubkey" | "hashtag" | "global",
|
||||
type: "pubkey" | "hashtag" | "global" | "ptag",
|
||||
items: string[]
|
||||
}
|
||||
|
||||
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
|
||||
const now = unixNow();
|
||||
const [window, setWindow] = useState<number>(60 * 10);
|
||||
const [window, setWindow] = useState<number>(60 * 60);
|
||||
const [until, setUntil] = useState<number>(now);
|
||||
const [since, setSince] = useState<number>(now - window);
|
||||
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
|
||||
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
|
||||
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
||||
|
||||
function createSub() {
|
||||
@ -42,6 +43,10 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
sub.HashTags = new Set(subject.items);
|
||||
break;
|
||||
}
|
||||
case "ptag": {
|
||||
sub.PTags = new Set(subject.items);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
@ -68,6 +73,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
latestSub.HashTags = sub.HashTags;
|
||||
latestSub.Kinds = sub.Kinds;
|
||||
latestSub.Limit = 1;
|
||||
latestSub.Since = Math.floor(new Date().getTime() / 1000);
|
||||
sub.AddSubscription(latestSub);
|
||||
}
|
||||
}
|
||||
@ -81,6 +87,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
if (subLatest && !pref.autoShowLatest) {
|
||||
subLatest.Id = `${subLatest.Id}:latest`;
|
||||
subLatest.Limit = 1;
|
||||
subLatest.Since = Math.floor(new Date().getTime() / 1000);
|
||||
}
|
||||
return subLatest;
|
||||
}, [subject.type, subject.items]);
|
||||
@ -88,18 +95,30 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
const latest = useSubscription(subRealtime, { leaveOpen: true });
|
||||
|
||||
const subNext = useMemo(() => {
|
||||
let sub: Subscriptions | undefined;
|
||||
if (trackingEvents.length > 0 && pref.enableReactions) {
|
||||
let sub = new Subscriptions();
|
||||
sub = new Subscriptions();
|
||||
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);
|
||||
return sub;
|
||||
}
|
||||
return null;
|
||||
return sub ?? null;
|
||||
}, [trackingEvents]);
|
||||
|
||||
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(() => {
|
||||
if (main.store.notes.length > 0) {
|
||||
setTrackingEvent(s => {
|
||||
@ -107,6 +126,20 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
let temp = new Set([...s, ...ids]);
|
||||
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]);
|
||||
|
||||
@ -114,6 +147,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
|
||||
main: main.store,
|
||||
related: others.store,
|
||||
latest: latest.store,
|
||||
parent: parent.store,
|
||||
loadMore: () => {
|
||||
console.debug("Timeline load more!")
|
||||
if (options.method === "LIMIT_UNTIL") {
|
||||
|
@ -1,10 +1,13 @@
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import ZapButton from "Element/ZapButton";
|
||||
import { HexKey } from "Nostr";
|
||||
import { useEffect, useState } from "react";
|
||||
import { bech32ToHex } from "Util";
|
||||
|
||||
const Developers = [
|
||||
bech32ToHex("npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49"), // kieran
|
||||
bech32ToHex("npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg") // verbiricha
|
||||
bech32ToHex("npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg"), // verbiricha
|
||||
bech32ToHex("npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac"), // Karnage
|
||||
];
|
||||
|
||||
const Contributors = [
|
||||
@ -12,7 +15,33 @@ const Contributors = [
|
||||
bech32ToHex("npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"), // liran cohen
|
||||
];
|
||||
|
||||
interface Splits {
|
||||
pubKey: string,
|
||||
split: number
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="m5">
|
||||
<h2>Help fund the development of Snort</h2>
|
||||
@ -25,10 +54,17 @@ const DonatePage = () => {
|
||||
<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>
|
||||
</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>
|
||||
{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>
|
||||
{Contributors.map(a => <ProfilePreview pubkey={a} key={a} actions={<ZapButton pubkey={a} />} />)}
|
||||
{Contributors.map(a => <ProfilePreview pubkey={a} key={a} actions={actions(a)} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,18 +1,75 @@
|
||||
import { RecommendedFollows } from "Const";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
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() {
|
||||
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() {
|
||||
return (
|
||||
<>
|
||||
<h2>Follow some popular accounts</h2>
|
||||
<h4>Here are some suggestions:</h4>
|
||||
{RecommendedFollows
|
||||
.sort(a => Math.random() >= 0.5 ? -1 : 1)
|
||||
.map(a => <ProfilePreview key={a} pubkey={a.toLowerCase()} />)}
|
||||
{sortedReccomends.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()}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,65 +1,24 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux"
|
||||
import Note from "Element/Note";
|
||||
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 { HexKey } from "Nostr";
|
||||
import { markNotificationsRead } from "State/Login";
|
||||
import { RootState } from "State/Store";
|
||||
import Timeline from "Element/Timeline";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const dispatch = useDispatch();
|
||||
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
|
||||
const pubkey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<>
|
||||
{sorted?.map(a => {
|
||||
if (a.kind === EventKind.TextNote) {
|
||||
return <Note data={a} key={a.id} related={otherNotes?.store.notes ?? []} />
|
||||
} 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;
|
||||
})}
|
||||
{pubkey ?
|
||||
<Timeline subject={{ type: "ptag", items: [pubkey!] }} postsOnly={false} method={"TIME_RANGE"} />
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
@ -62,7 +62,7 @@ const PreferencesPage = () => {
|
||||
<div className="card flex">
|
||||
<div className="flex f-col f-grow">
|
||||
<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>
|
||||
<input type="checkbox" checked={perf.showDebugMenus} onChange={e => dispatch(setPreferences({ ...perf, showDebugMenus: e.target.checked }))} />
|
||||
|
@ -184,7 +184,7 @@ export default function ProfileSettings() {
|
||||
{settings()}
|
||||
{privKey && (<div className="flex f-col bg-grey">
|
||||
<div>
|
||||
<h4>Private Key:</h4>
|
||||
<h4>Your Private Key Is (do not share this with anyone):</h4>
|
||||
</div>
|
||||
<div>
|
||||
<Copy text={hexToBech32("nsec", privKey)} />
|
||||
|
@ -111,7 +111,7 @@ const InitState = {
|
||||
dms: [],
|
||||
dmInteraction: 0,
|
||||
preferences: {
|
||||
enableReactions: true,
|
||||
enableReactions: false,
|
||||
autoLoadMedia: true,
|
||||
theme: "system",
|
||||
confirmReposts: false,
|
||||
|
11
src/Util.ts
11
src/Util.ts
@ -146,3 +146,14 @@ export function extractLnAddress(lnurl: string) {
|
||||
export function unixNow() {
|
||||
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);
|
||||
}
|
@ -296,6 +296,10 @@ body.scroll-lock {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m5 {
|
||||
margin: 5px;
|
||||
}
|
||||
@ -312,6 +316,10 @@ body.scroll-lock {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.mt10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ml5 {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user