Merge branch 'main' into dbfix
This commit is contained in:
commit
e56031411d
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
|
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)
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
|
@ -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 && (
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
.note {
|
||||||
|
min-height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
.note.thread {
|
.note.thread {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
@ -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' : ''} />
|
||||||
|
|
||||||
{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
|
||||||
|
@ -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 (
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
@ -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}/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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)} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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: () => {
|
||||||
|
@ -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;
|
||||||
|
@ -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") {
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
@ -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;
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -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 }))} />
|
||||||
|
@ -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)} />
|
||||||
|
@ -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,
|
||||||
|
13
src/Util.ts
13
src/Util.ts
@ -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);
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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==
|
Loading…
Reference in New Issue
Block a user