feat: in-memory fallback for storing user profiles #110

Merged
verbiricha merged 17 commits from dbfix into main 2023-01-27 21:38:42 +00:00
28 changed files with 356 additions and 221 deletions
Showing only changes of commit e56031411d - Show all commits

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

View File

@ -20,7 +20,7 @@ export default function AsyncButton(props: any) {
}
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}
</div>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' : ''} />
&nbsp;
{formatShort(groupReactions[Reaction.Negative])}
</div>
Dislike
</MenuItem>
</MenuItem>)}
<MenuItem onClick={() => share()}>
<FontAwesomeIcon icon={faShareNodes} />
Share

View File

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

View File

@ -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)}>
<div className="profile-name">
<div>{name}</div>
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
</div>
</Link>
{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>
{subHeader ? <>{subHeader}</> : null}
</div>
)}

View File

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

View File

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

View File

@ -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)} />
</>
)
}

View File

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

View File

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

View File

@ -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: () => {

View File

@ -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);
let allEvents = new Set([...eTags, ...ids]);
addId(Array.from(allEvents));
}, 200);
return () => clearTimeout(t);
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));
})
}
}, [main.store]);
return main.store;

View File

@ -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") {

View File

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

View File

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

View File

@ -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}
</>
)
}

View File

@ -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 }))} />

View File

@ -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)} />

View File

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

View File

@ -145,4 +145,15 @@ 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);
}

View File

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

View File

@ -10025,4 +10025,4 @@ yargs@^16.2.0:
yocto-queue@^0.1.0:
version "0.1.0"
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==