Merge branch 'main' into new-ui

This commit is contained in:
Alejandro Gomez 2023-01-28 22:43:56 +01:00
commit 9c47d00739
No known key found for this signature in database
GPG Key ID: 4DF39E566658C817
57 changed files with 900 additions and 261 deletions

View File

@ -2,7 +2,8 @@
Snort is a nostr UI built with React, Snort intends to be fast and effecient Snort is a nostr UI built with React, Snort intends to be fast and effecient
Snort supports the following NIP's Snort supports the following NIP's:
- [x] NIP-01: Basic protocol flow description - [x] NIP-01: Basic protocol flow description
- [x] NIP-02: Contact List and Petnames (No petname support) - [x] NIP-02: Contact List and Petnames (No petname support)
- [ ] NIP-03: OpenTimestamps Attestations for Events - [ ] NIP-03: OpenTimestamps Attestations for Events
@ -26,4 +27,5 @@ Snort supports the following NIP's
- [ ] NIP-36: Sensitive Content - [ ] NIP-36: Sensitive Content
- [ ] NIP-40: Expiration Timestamp - [ ] NIP-40: Expiration Timestamp
- [ ] NIP-42: Authentication of clients to relays - [ ] NIP-42: Authentication of clients to relays
- [ ] NIP-51: Lists - [x] NIP-50: Search
- [x] NIP-51: Lists

View File

@ -34,6 +34,7 @@
"react-textarea-autosize": "^8.4.0", "react-textarea-autosize": "^8.4.0",
"react-twitter-embed": "^4.0.4", "react-twitter-embed": "^4.0.4",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"unist-util-visit": "^4.1.2",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"workbox-background-sync": "^6.4.2", "workbox-background-sync": "^6.4.2",
"workbox-broadcast-update": "^6.4.2", "workbox-broadcast-update": "^6.4.2",

View File

@ -4,11 +4,11 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="description" content="Fast nostr web ui" /> <meta name="description" content="Fast nostr web ui" />
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" /> content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

View File

@ -5,6 +5,11 @@ import { RelaySettings } from "Nostr/Connection";
*/ */
export const ApiHost = "https://api.snort.social"; export const ApiHost = "https://api.snort.social";
/**
* Void.cat file upload service url
*/
export const VoidCatHost = "https://void.cat";
/** /**
* Websocket re-connect timeout * Websocket re-connect timeout
*/ */
@ -19,9 +24,14 @@ export const ProfileCacheExpire = (1_000 * 60 * 5);
* Default bootstrap relays * Default bootstrap relays
*/ */
export const DefaultRelays = new Map<string, RelaySettings>([ export const DefaultRelays = new Map<string, RelaySettings>([
["wss://relay.snort.social", { read: true, write: true }], ["wss://relay.snort.social", { read: true, write: true }]
["wss://relay.damus.io", { read: true, write: true }], ]);
["wss://nostr-pub.wellorder.net", { read: true, write: true }],
/**
* Default search relays
*/
export const SearchRelays = new Map<string, RelaySettings>([
["wss://relay.nostr.band", { read: true, write: false }],
]); ]);
/** /**
@ -99,3 +109,9 @@ export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
* SoundCloud regex * SoundCloud regex
*/ */
export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/ export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/
/**
* Mixcloud regex
*/
export const MixCloudRegex = /mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/

View File

@ -1,34 +0,0 @@
import { HexKey, TaggedRawEvent, UserMetadata } from "Nostr";
import { hexToBech32 } from "../Util";
export interface MetadataCache extends UserMetadata {
/**
* When the object was saved in cache
*/
loaded: number,
/**
* When the source metadata event was created
*/
created: number,
/**
* The pubkey of the owner of this metadata
*/
pubkey: HexKey
};
export function mapEventToProfile(ev: TaggedRawEvent) {
try {
let data: UserMetadata = JSON.parse(ev.content);
return {
pubkey: ev.pubkey,
npub: hexToBech32("npub", ev.pubkey),
created: ev.created_at,
loaded: new Date().getTime(),
...data
} as MetadataCache;
} catch (e) {
console.error("Failed to parse JSON", ev, e);
}
}

View File

@ -1,16 +1,20 @@
import Dexie, { Table } from "dexie"; import Dexie, { Table } from "dexie";
import { MetadataCache } from "Db/User"; import { MetadataCache } from "State/Users";
import { hexToBech32 } from "Util"; import { hexToBech32 } from "Util";
export const NAME = 'snortDB'
export const VERSION = 2
const STORES = {
users: '++pubkey, name, display_name, picture, nip05, npub'
}
export class SnortDB extends Dexie { export class SnortDB extends Dexie {
users!: Table<MetadataCache>; users!: Table<MetadataCache>;
constructor() { constructor() {
super('snortDB'); super(NAME);
this.version(2).stores({ this.version(VERSION).stores(STORES).upgrade(tx => {
users: '++pubkey, name, display_name, picture, nip05, npub'
}).upgrade(tx => {
return tx.table("users").toCollection().modify(user => { return tx.table("users").toCollection().modify(user => {
user.npub = hexToBech32("npub", user.pubkey) user.npub = hexToBech32("npub", user.pubkey)
}) })

View File

@ -4,13 +4,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons"; import { faUserMinus, faUserPlus } from "@fortawesome/free-solid-svg-icons";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { parseId } from "Util";
export interface FollowButtonProps { export interface FollowButtonProps {
pubkey: HexKey, pubkey: HexKey,
className?: string className?: string
} }
export default function FollowButton(props: FollowButtonProps) { export default function FollowButton(props: FollowButtonProps) {
const pubkey = props.pubkey; const pubkey = parseId(props.pubkey);
const publiser = useEventPublisher(); const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false); const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);

View File

@ -1,11 +1,11 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import useProfile from "Feed/ProfileFeed"; import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import { hexToBech32, profileLink } from "Util"; import { hexToBech32, profileLink } from "Util";
export default function Mention({ pubkey }: { pubkey: HexKey }) { export default function Mention({ pubkey }: { pubkey: HexKey }) {
const user = useProfile(pubkey)?.get(pubkey); const user = useUserProfile(pubkey)
const name = useMemo(() => { const name = useMemo(() => {
let name = hexToBech32("npub", pubkey).substring(0, 12); let name = hexToBech32("npub", pubkey).substring(0, 12);

View File

@ -0,0 +1,27 @@
import { MixCloudRegex } from "Const";
import { useSelector } from "react-redux";
import { RootState } from "State/Store";
const MixCloudEmbed = ({link}: {link: string}) => {
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + ( MixCloudRegex.test(link) && RegExp.$2)
const lightTheme = useSelector<RootState, boolean>(s => s.login.preferences.theme === "light");
const lightParams = lightTheme ? "light=1" : "light=0";
return(
<>
<br/>
<iframe
title="SoundCloud player"
width="100%"
height="120"
frameBorder="0"
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
/>
</>
)
}
export default MixCloudEmbed;

View File

@ -13,7 +13,7 @@ import {
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import LNURLTip from "Element/LNURLTip"; import LNURLTip from "Element/LNURLTip";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import useProfile from "Feed/ProfileFeed"; import { useUserProfile }from "Feed/ProfileFeed";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { debounce, hexToBech32 } from "Util"; import { debounce, hexToBech32 } from "Util";
import { UserMetadata } from "Nostr"; import { UserMetadata } from "Nostr";
@ -31,7 +31,7 @@ type ReduxStore = any;
export default function Nip5Service(props: Nip05ServiceProps) { export default function Nip5Service(props: Nip05ServiceProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey); const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
const user = useProfile(pubkey); const user = useUserProfile(pubkey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]); const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>(); const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();

View File

@ -181,6 +181,12 @@
.light .note.active>.footer>.reaction-pill.reacted { .light .note.active>.footer>.reaction-pill.reacted {
color: var(--highlight); color: var(--highlight);
} }
.note-expand .body {
max-height: 300px;
overflow-y: hidden;
mask-image: linear-gradient(to bottom, var(--note-bg) 60%, rgba(0,0,0,0));
-webkit-mask-image: linear-gradient(to bottom, var(--note-bg) 60%, rgba(0,0,0,0));
}
.hidden-note .header { .hidden-note .header {
display: flex; display: flex;

View File

@ -1,15 +1,16 @@
import "./Note.css"; import "./Note.css";
import { useCallback, useMemo, useState, ReactNode } from "react"; import { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { default as NEvent } from "Nostr/Event"; import { default as NEvent } from "Nostr/Event";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import Text from "Element/Text"; import Text from "Element/Text";
import { eventLink, getReactions, hexToBech32 } from "Util"; import { eventLink, getReactions, hexToBech32 } from "Util";
import NoteFooter from "Element/NoteFooter"; import NoteFooter from "Element/NoteFooter";
import NoteTime from "Element/NoteTime"; import NoteTime from "Element/NoteTime";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import useProfile from "Feed/ProfileFeed"; import { useUserProfiles } from "Feed/ProfileFeed";
import { TaggedRawEvent, u256 } from "Nostr"; import { TaggedRawEvent, u256 } from "Nostr";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
@ -50,11 +51,13 @@ export default function Note(props: NoteProps) {
const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props const { data, isThread, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false} = props
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]); const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]); const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
const users = useProfile(pubKeys); const users = useUserProfiles(pubKeys);
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]); const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
const { isMuted } = useModeration() const { isMuted } = useModeration()
const isOpMuted = isMuted(ev.PubKey) const isOpMuted = isMuted(ev.PubKey)
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView, entry } = useInView({ triggerOnce: true });
const [extendable, setExtendable] = useState<boolean>(false);
const [showMore, setShowMore] = useState<boolean>(false);
const options = { const options = {
showHeader: true, showHeader: true,
@ -71,6 +74,15 @@ export default function Note(props: NoteProps) {
return <Text content={body} tags={ev.Tags} users={users || new Map()} />; return <Text content={body} tags={ev.Tags} users={users || new Map()} />;
}, [ev]); }, [ev]);
useLayoutEffect(() => {
if (entry && inView && extendable === false) {
let h = entry?.target.clientHeight ?? 0;
if (h > 650) {
setExtendable(true);
}
}
}, [inView, entry, extendable]);
function goToEvent(e: any, id: u256) { function goToEvent(e: any, id: u256) {
if (!window.location.pathname.startsWith("/e/")) { if (!window.location.pathname.startsWith("/e/")) {
e.stopPropagation(); e.stopPropagation();
@ -166,13 +178,16 @@ export default function Note(props: NoteProps) {
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}> <div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()} {transformBody()}
</div> </div>
{extendable && !showMore && (<div className="flex f-center">
<button className="btn mt10" onClick={() => setShowMore(true)}>Show more</button>
</div>)}
{options.showFooter ? <NoteFooter ev={ev} related={related} /> : null} {options.showFooter ? <NoteFooter ev={ev} related={related} /> : null}
</> </>
) )
} }
const note = ( const note = (
<div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}`} ref={ref}> <div className={`note card${highlight ? " active" : ""}${isThread ? " thread" : ""}${extendable && !showMore ? " note-expand" : ""}`} ref={ref}>
{content()} {content()}
</div> </div>
) )

View File

@ -8,7 +8,7 @@ import Plus from "Icons/Plus";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import { openFile } from "Util"; import { openFile } from "Util";
import VoidUpload from "Feed/VoidUpload"; import VoidUpload from "Feed/VoidUpload";
import { FileExtensionRegex } from "Const"; import { FileExtensionRegex, VoidCatHost } from "Const";
import Textarea from "Element/Textarea"; import Textarea from "Element/Textarea";
import Modal from "Element/Modal"; import Modal from "Element/Modal";
import { default as NEvent } from "Nostr/Event"; import { default as NEvent } from "Nostr/Event";
@ -52,7 +52,7 @@ export function NoteCreator(props: NoteCreatorProps) {
let ext = file.name.match(FileExtensionRegex); let ext = file.name.match(FileExtensionRegex);
// extension tricks note parser to embed the content // extension tricks note parser to embed the content
let url = rx.file.meta?.url ?? `https://void.cat/d/${rx.file.id}${ext ? `.${ext[1]}` : ""}`; let url = rx.file.meta?.url ?? `${VoidCatHost}/d/${rx.file.id}${ext ? `.${ext[1]}` : ""}`;
setNote(n => `${n}\n${url}`); setNote(n => `${n}\n${url}`);
} else if (rx?.errorMessage) { } else if (rx?.errorMessage) {

View File

@ -14,7 +14,7 @@ import useEventPublisher from "Feed/EventPublisher";
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util"; import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
import { NoteCreator } from "Element/NoteCreator"; import { NoteCreator } from "Element/NoteCreator";
import LNURLTip from "Element/LNURLTip"; import LNURLTip from "Element/LNURLTip";
import useProfile from "Feed/ProfileFeed"; import { useUserProfile } from "Feed/ProfileFeed";
import { default as NEvent } from "Nostr/Event"; import { default as NEvent } from "Nostr/Event";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { HexKey, TaggedRawEvent } from "Nostr"; import { HexKey, TaggedRawEvent } from "Nostr";
@ -33,7 +33,7 @@ export default function NoteFooter(props: NoteFooterProps) {
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const { mute, block } = useModeration(); const { mute, block } = useModeration();
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences); const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const author = useProfile(ev.RootPubKey)?.get(ev.RootPubKey); const author = useUserProfile(ev.RootPubKey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [reply, setReply] = useState(false); const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);

View File

@ -3,7 +3,7 @@ import "./NoteToSelf.css";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons" import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons"
import useProfile from "Feed/ProfileFeed"; import { useUserProfile } from "Feed/ProfileFeed";
import Nip05 from "Element/Nip05"; import Nip05 from "Element/Nip05";
import { profileLink } from "Util"; import { profileLink } from "Util";
@ -15,7 +15,7 @@ export interface NoteToSelfProps {
}; };
function NoteLabel({pubkey, link}:NoteToSelfProps) { function NoteLabel({pubkey, link}:NoteToSelfProps) {
const user = useProfile(pubkey)?.get(pubkey); const user = useUserProfile(pubkey);
return ( return (
<div> <div>
Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" /> Note to Self <FontAwesomeIcon icon={faCertificate} size="xs" />

View File

@ -1,13 +1,13 @@
import "./ProfileImage.css"; import "./ProfileImage.css";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate, Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import useProfile from "Feed/ProfileFeed"; import { useUserProfile } from "Feed/ProfileFeed";
import { hexToBech32, profileLink } from "Util"; import { hexToBech32, profileLink } from "Util";
import Avatar from "Element/Avatar" import Avatar from "Element/Avatar"
import Nip05 from "Element/Nip05"; import Nip05 from "Element/Nip05";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import { MetadataCache } from "Db/User"; import { MetadataCache } from "State/Users";
export interface ProfileImageProps { export interface ProfileImageProps {
pubkey: HexKey, pubkey: HexKey,
@ -19,7 +19,7 @@ export interface ProfileImageProps {
export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) { export default function ProfileImage({ pubkey, subHeader, showUsername = true, className, link }: ProfileImageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useProfile(pubkey)?.get(pubkey); const user = useUserProfile(pubkey);
const name = useMemo(() => { const name = useMemo(() => {
return getDisplayName(user, pubkey); return getDisplayName(user, pubkey);

View File

@ -3,7 +3,7 @@ import { ReactNode } from "react";
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import FollowButton from "Element/FollowButton"; import FollowButton from "Element/FollowButton";
import useProfile from "Feed/ProfileFeed"; import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
@ -12,11 +12,12 @@ export interface ProfilePreviewProps {
options?: { options?: {
about?: boolean about?: boolean
}, },
actions?: ReactNode actions?: ReactNode,
className?: string
} }
export default function ProfilePreview(props: ProfilePreviewProps) { export default function ProfilePreview(props: ProfilePreviewProps) {
const pubkey = props.pubkey; const pubkey = props.pubkey;
const user = useProfile(pubkey)?.get(pubkey); const user = useUserProfile(pubkey);
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView } = useInView({ triggerOnce: true });
const options = { const options = {
about: true, about: true,
@ -24,7 +25,7 @@ export default function ProfilePreview(props: ProfilePreviewProps) {
}; };
return ( return (
<div className="profile-preview" ref={ref}> <div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
{inView && <> {inView && <>
<ProfileImage pubkey={pubkey} subHeader= <ProfileImage pubkey={pubkey} subHeader=
{options.about ? <div className="f-ellipsis about"> {options.about ? <div className="f-ellipsis about">

View File

@ -63,7 +63,7 @@ export default function Relay(props: RelayProps) {
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects} <FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div> </div>
<div> <div>
<span className="icon-btn" onClick={() => navigate(name)}> <span className="icon-btn" onClick={() => navigate(state!.id)}>
<FontAwesomeIcon icon={faGear} /> <FontAwesomeIcon icon={faGear} />
</span> </span>
</div> </div>

View File

@ -57,7 +57,7 @@
margin: 20px; margin: 20px;
} }
.text img, .text video, .text iframe { .text img, .text video, .text iframe, .text audio {
max-width: 100%; max-width: 100%;
max-height: 500px; max-height: 500px;
margin: 10px auto; margin: 10px auto;

View File

@ -1,22 +1,24 @@
import './Text.css' import './Text.css'
import { useMemo } from "react"; import { useMemo, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { visit, SKIP } from "unist-util-visit";
import { TwitterTweetEmbed } from "react-twitter-embed"; import { TwitterTweetEmbed } from "react-twitter-embed";
import { UrlRegex, FileExtensionRegex, MentionRegex, InvoiceRegex, YoutubeUrlRegex, TweetUrlRegex, HashtagRegex, TidalRegex, SoundCloudRegex } from "Const"; import { UrlRegex, FileExtensionRegex, MentionRegex, InvoiceRegex, YoutubeUrlRegex, TweetUrlRegex, HashtagRegex, TidalRegex, SoundCloudRegex, MixCloudRegex } from "Const";
import { eventLink, hexToBech32 } from "Util"; import { eventLink, hexToBech32 } from "Util";
import Invoice from "Element/Invoice"; import Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag"; import Hashtag from "Element/Hashtag";
import Tag from "Nostr/Tag"; import Tag from "Nostr/Tag";
import { MetadataCache } from "Db/User"; import { MetadataCache } from "State/Users";
import Mention from "Element/Mention"; import Mention from "Element/Mention";
import TidalEmbed from "Element/TidalEmbed"; import TidalEmbed from "Element/TidalEmbed";
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 SoundCloudEmbed from 'Element/SoundCloudEmded' import SoundCloudEmbed from 'Element/SoundCloudEmded'
import MixCloudEmbed from './MixCloudEmbed';
function transformHttpLink(a: string, pref: UserPreferences) { function transformHttpLink(a: string, pref: UserPreferences) {
try { try {
@ -28,6 +30,7 @@ function transformHttpLink(a: string, pref: UserPreferences) {
const tweetId = TweetUrlRegex.test(a) && RegExp.$2; const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const tidalId = TidalRegex.test(a) && RegExp.$1; const tidalId = TidalRegex.test(a) && RegExp.$1;
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1; const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1; const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
if (extension) { if (extension) {
switch (extension) { switch (extension) {
@ -39,6 +42,11 @@ function transformHttpLink(a: string, pref: UserPreferences) {
case "webp": { case "webp": {
return <img key={url.toString()} src={url.toString()} />; return <img key={url.toString()} src={url.toString()} />;
} }
case "wav":
case "mp3":
case "ogg": {
return <audio key={url.toString()} src={url.toString()} controls />
}
case "mp4": case "mp4":
case "mov": case "mov":
case "mkv": case "mkv":
@ -75,6 +83,8 @@ function transformHttpLink(a: string, pref: UserPreferences) {
return <TidalEmbed link={a} /> return <TidalEmbed link={a} />
} else if (soundcloundId){ } else if (soundcloundId){
return <SoundCloudEmbed link={a} /> return <SoundCloudEmbed link={a} />
} else if (mixcloudId){
return <MixCloudEmbed link={a} />
} else { } else {
return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a> return <a href={a} onClick={(e) => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">{a}</a>
} }
@ -207,5 +217,26 @@ export default function Text({ content, tags, users }: TextProps) {
li: (x: any) => transformLi({ body: x.children ?? [], tags, users, pref }), li: (x: any) => transformLi({ body: x.children ?? [], tags, users, pref }),
}; };
}, [content]); }, [content]);
return <ReactMarkdown className="text" components={components}>{content}</ReactMarkdown> const disableMarkdownLinks = useCallback(() => (tree: any) => {
visit(tree, (node, index, parent) => {
if (
parent &&
typeof index === 'number' &&
(node.type === 'link' ||
node.type === 'linkReference' ||
node.type === 'image' ||
node.type === 'imageReference' ||
node.type === 'definition')
) {
node.type = 'text';
node.value = content.slice(node.position.start.offset, node.position.end.offset).replace(/\)$/, ' )');
return SKIP;
}
})
}, [content]);
return <ReactMarkdown
className="text"
components={components}
remarkPlugins={[disableMarkdownLinks]}
>{content}</ReactMarkdown>
} }

View File

@ -2,7 +2,6 @@ import "@webscopeio/react-textarea-autocomplete/style.css";
import "./Textarea.css"; import "./Textarea.css";
import { useState } from "react"; import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import emoji from "@jukben/emoji-search"; import emoji from "@jukben/emoji-search";
import TextareaAutosize from "react-textarea-autosize"; import TextareaAutosize from "react-textarea-autosize";
@ -10,8 +9,8 @@ import TextareaAutosize from "react-textarea-autosize";
import Avatar from "Element/Avatar"; import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05"; import Nip05 from "Element/Nip05";
import { hexToBech32 } from "Util"; import { hexToBech32 } from "Util";
import { db } from "Db"; import { MetadataCache } from "State/Users";
import { MetadataCache } from "Db/User"; import { useQuery } from "State/Users/Hooks";
interface EmojiItemProps { interface EmojiItemProps {
name: string name: string
@ -45,16 +44,7 @@ const UserItem = (metadata: MetadataCache) => {
const Textarea = ({ users, onChange, ...rest }: any) => { const Textarea = ({ users, onChange, ...rest }: any) => {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const allUsers = useLiveQuery( const allUsers = useQuery(query)
() => db.users
.where("npub").startsWithIgnoreCase(query)
.or("name").startsWithIgnoreCase(query)
.or("display_name").startsWithIgnoreCase(query)
.or("nip05").startsWithIgnoreCase(query)
.limit(5)
.toArray(),
[query],
);
const userDataProvider = (token: string) => { const userDataProvider = (token: string) => {
setQuery(token) setQuery(token)

View File

@ -10,6 +10,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 useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
import ProfilePreview from "./ProfilePreview";
export interface TimelineProps { export interface TimelineProps {
postsOnly: boolean, postsOnly: boolean,
@ -41,6 +42,9 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod
function eventElement(e: TaggedRawEvent) { function eventElement(e: TaggedRawEvent) {
switch (e.kind) { switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview pubkey={e.pubkey} className="card"/>
}
case EventKind.TextNote: { case EventKind.TextNote: {
return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} /> return <Note key={e.id} data={e} related={related.notes} ignoreModeration={ignoreModeration} />
} }

View File

@ -2,12 +2,13 @@ import "./ZapButton.css";
import { faBolt } from "@fortawesome/free-solid-svg-icons"; import { faBolt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react"; import { useState } from "react";
import useProfile from "Feed/ProfileFeed"; 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, svc }: { pubkey?: HexKey, svc?: string }) => { const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
const profile = useProfile(pubkey)?.get(pubkey ?? ""); const profile = useUserProfile(pubkey!)
const [zap, setZap] = useState(false); const [zap, setZap] = useState(false);
const service = svc ?? (profile?.lud16 || profile?.lud06); const service = svc ?? (profile?.lud16 || profile?.lud06);

View File

@ -14,7 +14,7 @@ declare global {
nostr: { nostr: {
getPublicKey: () => Promise<HexKey>, getPublicKey: () => Promise<HexKey>,
signEvent: (event: RawEvent) => Promise<RawEvent>, signEvent: (event: RawEvent) => Promise<RawEvent>,
getRelays: () => Promise<[[string, { read: boolean, write: boolean }]]>, getRelays: () => Promise<Record<string, { read: boolean, write: boolean }>>,
nip04: { nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>, encrypt: (pubkey: HexKey, content: string) => Promise<string>,
decrypt: (pubkey: HexKey, content: string) => Promise<string> decrypt: (pubkey: HexKey, content: string) => Promise<string>
@ -78,6 +78,16 @@ export default function useEventPublisher() {
} }
return { return {
nip42Auth: async (challenge: string, relay:string) => {
if(pubKey) {
const ev = NEvent.ForPubKey(pubKey);
ev.Kind = EventKind.Auth;
ev.Content = "";
ev.Tags.push(new Tag(["relay", relay], 0));
ev.Tags.push(new Tag(["challenge", challenge], 1));
return await signEvent(ev);
}
},
broadcast: (ev: NEvent | undefined) => { broadcast: (ev: NEvent | undefined) => {
if (ev) { if (ev) {
console.debug("Sending event: ", ev); console.debug("Sending event: ", ev);
@ -90,8 +100,8 @@ export default function useEventPublisher() {
* When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state * When they open the site again we wont see that updated relay list and so it will appear to reset back to the previous state
*/ */
broadcastForBootstrap: (ev: NEvent | undefined) => { broadcastForBootstrap: (ev: NEvent | undefined) => {
if(ev) { if (ev) {
for(let [k, _] of DefaultRelays) { for (let [k, _] of DefaultRelays) {
System.WriteOnceToRelay(k, ev); System.WriteOnceToRelay(k, ev);
} }
} }
@ -205,6 +215,9 @@ export default function useEventPublisher() {
temp.add(pkAdd); temp.add(pkAdd);
} }
for (let pk of temp) { for (let pk of temp) {
if (pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
} }
@ -217,7 +230,7 @@ export default function useEventPublisher() {
ev.Kind = EventKind.ContactList; ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays); ev.Content = JSON.stringify(relays);
for (let pk of follows) { for (let pk of follows) {
if (pk === pkRemove) { if (pk === pkRemove || pk.length !== 64) {
continue; continue;
} }
ev.Tags.push(new Tag(["p", pk], ev.Tags.length)); ev.Tags.push(new Tag(["p", pk], ev.Tags.length));

View File

@ -7,12 +7,14 @@ import EventKind from "Nostr/EventKind";
import Event from "Nostr/Event"; import Event from "Nostr/Event";
import { Subscriptions } from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login"; import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login";
import type { RootState } from "State/Store"; import { RootState } from "State/Store";
import { db } from "Db"; import { mapEventToProfile, MetadataCache } from "State/Users";
import { barierNip07 } from "Feed/EventPublisher"; import { getDb } from "State/Users/Db";
import useSubscription from "Feed/Subscription"; import useSubscription from "Feed/Subscription";
import { getDisplayName } from "Element/ProfileImage";
import { barierNip07 } from "Feed/EventPublisher";
import { getMutedKeys, getNewest } from "Feed/MuteList"; import { getMutedKeys, getNewest } from "Feed/MuteList";
import { mapEventToProfile, MetadataCache } from "Db/User"; import { MentionRegex } from "Const";
import useModeration from "Hooks/useModeration"; import useModeration from "Hooks/useModeration";
/** /**
@ -105,9 +107,10 @@ export default function useLoginFeed() {
return acc; return acc;
}, { created: 0, profile: null as MetadataCache | null }); }, { created: 0, profile: null as MetadataCache | null });
if (maxProfile.profile) { if (maxProfile.profile) {
let existing = await db.users.get(maxProfile.profile.pubkey); const db = getDb()
let existing = await db.find(maxProfile.profile.pubkey);
if ((existing?.created ?? 0) < maxProfile.created) { if ((existing?.created ?? 0) < maxProfile.created) {
await db.users.put(maxProfile.profile); await db.put(maxProfile.profile);
} }
} }
})().catch(console.warn); })().catch(console.warn);
@ -152,6 +155,7 @@ export default function useLoginFeed() {
}, [dispatch, dmsFeed.store]); }, [dispatch, dmsFeed.store]);
} }
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) { async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
const ev = new Event(raw) const ev = new Event(raw)
if (pubKey && privKey) { if (pubKey && privKey) {

View File

@ -1,27 +1,13 @@
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { db } from "Db"; import { RootState } from "State/Store";
import { MetadataCache } from "Db/User"; import { MetadataCache } from "State/Users";
import { useKey, useKeys } from "State/Users/Hooks";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import { System } from "Nostr/System"; import { System } from "Nostr/System";
export default function useProfile(pubKey?: HexKey | Array<HexKey> | undefined): Map<HexKey, MetadataCache> | undefined { export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
const user = useLiveQuery(async () => { const users = useKey(pubKey);
let userList = new Map<HexKey, MetadataCache>();
if (pubKey) {
if (Array.isArray(pubKey)) {
let ret = await db.users.bulkGet(pubKey);
let filtered = ret.filter(a => a !== undefined).map(a => a!);
return new Map(filtered.map(a => [a.pubkey, a]))
} else {
let ret = await db.users.get(pubKey);
if (ret) {
userList.set(ret.pubkey, ret);
}
}
}
return userList;
}, [pubKey]);
useEffect(() => { useEffect(() => {
if (pubKey) { if (pubKey) {
@ -30,5 +16,19 @@ export default function useProfile(pubKey?: HexKey | Array<HexKey> | undefined):
} }
}, [pubKey]); }, [pubKey]);
return user; return users;
}
export function useUserProfiles(pubKeys: Array<HexKey>): Map<HexKey, MetadataCache> | undefined {
const users = useKeys(pubKeys);
useEffect(() => {
if (pubKeys) {
System.TrackMetadata(pubKeys);
return () => System.UntrackMetadata(pubKeys);
}
}, [pubKeys]);
return users;
} }

View File

@ -13,7 +13,7 @@ export interface TimelineFeedOptions {
} }
export interface TimelineSubject { export interface TimelineSubject {
type: "pubkey" | "hashtag" | "global" | "ptag", type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword",
items: string[] items: string[]
} }
@ -47,6 +47,11 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
sub.PTags = new Set(subject.items); sub.PTags = new Set(subject.items);
break; break;
} }
case "keyword": {
sub.Kinds.add(EventKind.SetMetadata);
sub.Search = subject.items[0];
break;
}
} }
return sub; return sub;
}, [subject.type, subject.items]); }, [subject.type, subject.items]);
@ -72,6 +77,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
latestSub.Authors = sub.Authors; latestSub.Authors = sub.Authors;
latestSub.HashTags = sub.HashTags; latestSub.HashTags = sub.HashTags;
latestSub.Kinds = sub.Kinds; latestSub.Kinds = sub.Kinds;
latestSub.Search = sub.Search;
latestSub.Limit = 1; latestSub.Limit = 1;
latestSub.Since = Math.floor(new Date().getTime() / 1000); latestSub.Since = Math.floor(new Date().getTime() / 1000);
sub.AddSubscription(latestSub); sub.AddSubscription(latestSub);
@ -123,7 +129,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
if (main.store.notes.length > 0) { if (main.store.notes.length > 0) {
setTrackingEvent(s => { setTrackingEvent(s => {
let ids = main.store.notes.map(a => a.id); let ids = main.store.notes.map(a => a.id);
if(ids.some(a => !s.includes(a))) { if (ids.some(a => !s.includes(a))) {
return Array.from(new Set([...s, ...ids])); return Array.from(new Set([...s, ...ids]));
} }
return s; return s;

View File

@ -1,4 +1,5 @@
import * as secp from "@noble/secp256k1"; import * as secp from "@noble/secp256k1";
import { VoidCatHost } from "Const";
/** /**
* Upload file to void.cat * Upload file to void.cat
@ -8,7 +9,7 @@ export default async function VoidUpload(file: File | Blob, filename: string) {
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buf); const digest = await crypto.subtle.digest("SHA-256", buf);
let req = await fetch(`https://void.cat/upload`, { let req = await fetch(`${VoidCatHost}/upload`, {
mode: "cors", mode: "cors",
method: "POST", method: "POST",
body: buf, body: buf,
@ -17,7 +18,8 @@ export default async function VoidUpload(file: File | Blob, filename: string) {
"V-Content-Type": file.type, "V-Content-Type": file.type,
"V-Filename": filename, "V-Filename": filename,
"V-Full-Digest": secp.utils.bytesToHex(new Uint8Array(digest)), "V-Full-Digest": secp.utils.bytesToHex(new Uint8Array(digest)),
"V-Description": "Upload from https://snort.social" "V-Description": "Upload from https://snort.social",
"V-Strip-Metadata": "true"
} }
}); });

View File

@ -1,6 +1,8 @@
const Envelope = () => { import type IconProps from './IconProps'
const Envelope = (props: IconProps) => {
return ( return (
<svg width="22" height="19" viewBox="0 0 22 19" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="22" height="19" viewBox="0 0 22 19" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M1 4.5L9.16492 10.2154C9.82609 10.6783 10.1567 10.9097 10.5163 10.9993C10.8339 11.0785 11.1661 11.0785 11.4837 10.9993C11.8433 10.9097 12.1739 10.6783 12.8351 10.2154L21 4.5M5.8 17.5H16.2C17.8802 17.5 18.7202 17.5 19.362 17.173C19.9265 16.8854 20.3854 16.4265 20.673 15.862C21 15.2202 21 14.3802 21 12.7V6.3C21 4.61984 21 3.77976 20.673 3.13803C20.3854 2.57354 19.9265 2.1146 19.362 1.82698C18.7202 1.5 17.8802 1.5 16.2 1.5H5.8C4.11984 1.5 3.27976 1.5 2.63803 1.82698C2.07354 2.1146 1.6146 2.57354 1.32698 3.13803C1 3.77976 1 4.61984 1 6.3V12.7C1 14.3802 1 15.2202 1.32698 15.862C1.6146 16.4265 2.07354 16.8854 2.63803 17.173C3.27976 17.5 4.11984 17.5 5.8 17.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M1 4.5L9.16492 10.2154C9.82609 10.6783 10.1567 10.9097 10.5163 10.9993C10.8339 11.0785 11.1661 11.0785 11.4837 10.9993C11.8433 10.9097 12.1739 10.6783 12.8351 10.2154L21 4.5M5.8 17.5H16.2C17.8802 17.5 18.7202 17.5 19.362 17.173C19.9265 16.8854 20.3854 16.4265 20.673 15.862C21 15.2202 21 14.3802 21 12.7V6.3C21 4.61984 21 3.77976 20.673 3.13803C20.3854 2.57354 19.9265 2.1146 19.362 1.82698C18.7202 1.5 17.8802 1.5 16.2 1.5H5.8C4.11984 1.5 3.27976 1.5 2.63803 1.82698C2.07354 2.1146 1.6146 2.57354 1.32698 3.13803C1 3.77976 1 4.61984 1 6.3V12.7C1 14.3802 1 15.2202 1.32698 15.862C1.6146 16.4265 2.07354 16.8854 2.63803 17.173C3.27976 17.5 4.11984 17.5 5.8 17.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
) )

4
src/Icons/IconProps.ts Normal file
View File

@ -0,0 +1,4 @@
export default interface IconProps {
width?: number
height?: number
}

9
src/Icons/Search.tsx Normal file
View File

@ -0,0 +1,9 @@
const Search = () => {
return (
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 19.5L14.65 15.15M17 9.5C17 13.9183 13.4183 17.5 9 17.5C4.58172 17.5 1 13.9183 1 9.5C1 5.08172 4.58172 1.5 9 1.5C13.4183 1.5 17 5.08172 17 9.5Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)
}
export default Search

View File

@ -1,6 +1,8 @@
const Zap = () => { import type IconProps from './IconProps'
const Zap = (props: IconProps) => {
return ( return (
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8.8333 1.70166L1.41118 10.6082C1.12051 10.957 0.975169 11.1314 0.972948 11.2787C0.971017 11.4068 1.02808 11.5286 1.12768 11.6091C1.24226 11.7017 1.46928 11.7017 1.92333 11.7017H7.99997L7.16663 18.3683L14.5888 9.46178C14.8794 9.11297 15.0248 8.93857 15.027 8.79128C15.0289 8.66323 14.9719 8.54141 14.8723 8.46092C14.7577 8.36833 14.5307 8.36833 14.0766 8.36833H7.99997L8.8333 1.70166Z" stroke="currentColor" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
) )

View File

@ -7,6 +7,8 @@ import { DefaultConnectTimeout } from "Const";
import { ConnectionStats } from "Nostr/ConnectionStats"; import { ConnectionStats } from "Nostr/ConnectionStats";
import { RawEvent, TaggedRawEvent, u256 } from "Nostr"; import { RawEvent, TaggedRawEvent, u256 } from "Nostr";
import { RelayInfo } from "./RelayInfo"; import { RelayInfo } from "./RelayInfo";
import Nips from "./Nips";
import { System } from "./System";
export type CustomHook = (state: Readonly<StateSnapshot>) => void; export type CustomHook = (state: Readonly<StateSnapshot>) => void;
@ -29,10 +31,12 @@ export type StateSnapshot = {
received: number, received: number,
send: number send: number
}, },
info?: RelayInfo info?: RelayInfo,
id: string
}; };
export default class Connection { export default class Connection {
Id: string;
Address: string; Address: string;
Socket: WebSocket | null; Socket: WebSocket | null;
Pending: Subscriptions[]; Pending: Subscriptions[];
@ -47,9 +51,12 @@ export default class Connection {
LastState: Readonly<StateSnapshot>; LastState: Readonly<StateSnapshot>;
IsClosed: boolean; IsClosed: boolean;
ReconnectTimer: ReturnType<typeof setTimeout> | null; ReconnectTimer: ReturnType<typeof setTimeout> | null;
EventsCallback: Map<u256, () => void>; EventsCallback: Map<u256, (msg?:any) => void>;
AwaitingAuth: Map<string, boolean>;
Authed: boolean;
constructor(addr: string, options: RelaySettings) { constructor(addr: string, options: RelaySettings) {
this.Id = uuid();
this.Address = addr; this.Address = addr;
this.Socket = null; this.Socket = null;
this.Pending = []; this.Pending = [];
@ -72,6 +79,8 @@ export default class Connection {
this.IsClosed = false; this.IsClosed = false;
this.ReconnectTimer = null; this.ReconnectTimer = null;
this.EventsCallback = new Map(); this.EventsCallback = new Map();
this.AwaitingAuth = new Map();
this.Authed = false;
this.Connect(); this.Connect();
} }
@ -118,18 +127,8 @@ export default class Connection {
OnOpen(e: Event) { OnOpen(e: Event) {
this.ConnectTimeout = DefaultConnectTimeout; this.ConnectTimeout = DefaultConnectTimeout;
this._InitSubscriptions();
console.log(`[${this.Address}] Open!`); console.log(`[${this.Address}] Open!`);
// send pending
for (let p of this.Pending) {
this._SendJson(p);
}
this.Pending = [];
for (let [_, s] of this.Subscriptions) {
this._SendSubscription(s);
}
this._UpdateState();
} }
OnClose(e: CloseEvent) { OnClose(e: CloseEvent) {
@ -152,6 +151,12 @@ export default class Connection {
let msg = JSON.parse(e.data); let msg = JSON.parse(e.data);
let tag = msg[0]; let tag = msg[0];
switch (tag) { switch (tag) {
case "AUTH": {
this._OnAuthAsync(msg[1])
this.Stats.EventsReceived++;
this._UpdateState();
break;
}
case "EVENT": { case "EVENT": {
this._OnEvent(msg[1], msg[2]); this._OnEvent(msg[1], msg[2]);
this.Stats.EventsReceived++; this.Stats.EventsReceived++;
@ -169,7 +174,7 @@ export default class Connection {
if (this.EventsCallback.has(id)) { if (this.EventsCallback.has(id)) {
let cb = this.EventsCallback.get(id)!; let cb = this.EventsCallback.get(id)!;
this.EventsCallback.delete(id); this.EventsCallback.delete(id);
cb(); cb(msg);
} }
break; break;
} }
@ -235,6 +240,11 @@ export default class Connection {
return; return;
} }
// check relay supports search
if (sub.Search && !this.SupportsNip(Nips.Search)) {
return;
}
if (this.Subscriptions.has(sub.Id)) { if (this.Subscriptions.has(sub.Id)) {
return; return;
} }
@ -278,6 +288,13 @@ export default class Connection {
return this.LastState; return this.LastState;
} }
/**
* Using relay document to determine if this relay supports a feature
*/
SupportsNip(n: number) {
return this.Info?.supported_nips?.some(a => a === n) ?? false;
}
_UpdateState() { _UpdateState() {
this.CurrentState.connected = this.Socket?.readyState === WebSocket.OPEN; this.CurrentState.connected = this.Socket?.readyState === WebSocket.OPEN;
this.CurrentState.events.received = this.Stats.EventsReceived; this.CurrentState.events.received = this.Stats.EventsReceived;
@ -285,6 +302,7 @@ export default class Connection {
this.CurrentState.avgLatency = this.Stats.Latency.length > 0 ? (this.Stats.Latency.reduce((acc, v) => acc + v, 0) / this.Stats.Latency.length) : 0; this.CurrentState.avgLatency = this.Stats.Latency.length > 0 ? (this.Stats.Latency.reduce((acc, v) => acc + v, 0) / this.Stats.Latency.length) : 0;
this.CurrentState.disconnects = this.Stats.Disconnects; this.CurrentState.disconnects = this.Stats.Disconnects;
this.CurrentState.info = this.Info; this.CurrentState.info = this.Info;
this.CurrentState.id = this.Id;
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
this.HasStateChange = true; this.HasStateChange = true;
this._NotifyState(); this._NotifyState();
@ -297,7 +315,25 @@ export default class Connection {
} }
} }
_InitSubscriptions() {
// send pending
for (let p of this.Pending) {
this._SendJson(p);
}
this.Pending = [];
for (let [_, s] of this.Subscriptions) {
this._SendSubscription(s);
}
this._UpdateState();
}
_SendSubscription(sub: Subscriptions) { _SendSubscription(sub: Subscriptions) {
if(!this.Authed && this.AwaitingAuth.size > 0) {
this.Pending.push(sub);
return;
}
let req = ["REQ", sub.Id, sub.ToObject()]; let req = ["REQ", sub.Id, sub.ToObject()];
if (sub.OrSubs.length > 0) { if (sub.OrSubs.length > 0) {
req = [ req = [
@ -332,6 +368,40 @@ export default class Connection {
} }
} }
async _OnAuthAsync(challenge: string): Promise<void> {
const authCleanup = () => {
this.AwaitingAuth.delete(challenge)
}
this.AwaitingAuth.set(challenge, true)
const authEvent = await System.nip42Auth(challenge, this.Address)
return new Promise((resolve,_) => {
if(!authEvent) {
authCleanup();
return Promise.reject('no event');
}
let t = setTimeout(() => {
authCleanup();
resolve();
}, 10_000);
this.EventsCallback.set(authEvent.Id, (msg:any[]) => {
clearTimeout(t);
authCleanup();
if(msg.length > 3 && msg[2] === true) {
this.Authed = true;
this._InitSubscriptions();
}
resolve();
});
let req = ["AUTH", authEvent.ToObject()];
this._SendJson(req);
this.Stats.EventsSent++;
this._UpdateState();
})
}
_OnEnd(subId: string) { _OnEnd(subId: string) {
let sub = this.Subscriptions.get(subId); let sub = this.Subscriptions.get(subId);
if (sub) { if (sub) {

View File

@ -8,6 +8,7 @@ const enum EventKind {
Deletion = 5, // NIP-09 Deletion = 5, // NIP-09
Repost = 6, // NIP-18 Repost = 6, // NIP-18
Reaction = 7, // NIP-25 Reaction = 7, // NIP-25
Auth = 22242, // NIP-42
Lists = 30000, // NIP-51 Lists = 30000, // NIP-51
}; };

5
src/Nostr/Nips.ts Normal file
View File

@ -0,0 +1,5 @@
enum Nips {
Search = 50
}
export default Nips;

View File

@ -47,6 +47,11 @@ export class Subscriptions {
*/ */
DTag?: string; DTag?: string;
/**
* A list of search terms
*/
Search?: string;
/** /**
* a timestamp, events must be newer than this to pass * a timestamp, events must be newer than this to pass
*/ */
@ -95,6 +100,7 @@ export class Subscriptions {
this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined; this.ETags = sub?.["#e"] ? new Set(sub["#e"]) : undefined;
this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined; this.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
this.DTag = sub?.["#d"] ? sub["#d"] : undefined; this.DTag = sub?.["#d"] ? sub["#d"] : undefined;
this.Search = sub?.search ?? undefined;
this.Since = sub?.since ?? undefined; this.Since = sub?.since ?? undefined;
this.Until = sub?.until ?? undefined; this.Until = sub?.until ?? undefined;
this.Limit = sub?.limit ?? undefined; this.Limit = sub?.limit ?? undefined;
@ -142,6 +148,9 @@ export class Subscriptions {
if (this.DTag) { if (this.DTag) {
ret["#d"] = this.DTag; ret["#d"] = this.DTag;
} }
if (this.Search) {
ret.search = this.Search;
}
if (this.Since !== null) { if (this.Since !== null) {
ret.since = this.Since; ret.since = this.Since;
} }

View File

@ -1,7 +1,7 @@
import { HexKey, TaggedRawEvent } from "Nostr"; import { HexKey, TaggedRawEvent } from "Nostr";
import { getDb } from "State/Users/Db";
import { ProfileCacheExpire } from "Const"; import { ProfileCacheExpire } from "Const";
import { db } from "Db"; import { mapEventToProfile, MetadataCache } from "State/Users";
import { mapEventToProfile, MetadataCache } from "Db/User";
import Connection, { RelaySettings } from "Nostr/Connection"; import Connection, { RelaySettings } from "Nostr/Connection";
import Event from "Nostr/Event"; import Event from "Nostr/Event";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
@ -71,14 +71,14 @@ export class NostrSystem {
} }
AddSubscription(sub: Subscriptions) { AddSubscription(sub: Subscriptions) {
for (let [_, s] of this.Sockets) { for (let [a, s] of this.Sockets) {
s.AddSubscription(sub); s.AddSubscription(sub);
} }
this.Subscriptions.set(sub.Id, sub); this.Subscriptions.set(sub.Id, sub);
} }
RemoveSubscription(subId: string) { RemoveSubscription(subId: string) {
for (let [_, s] of this.Sockets) { for (let [a, s] of this.Sockets) {
s.RemoveSubscription(subId); s.RemoveSubscription(subId);
} }
this.Subscriptions.delete(subId); this.Subscriptions.delete(subId);
@ -167,7 +167,8 @@ export class NostrSystem {
async _FetchMetadata() { async _FetchMetadata() {
let missing = new Set<HexKey>(); let missing = new Set<HexKey>();
let meta = await db.users.bulkGet(Array.from(this.WantsMetadata)); const db = getDb()
let meta = await db.bulkGet(Array.from(this.WantsMetadata));
let expire = new Date().getTime() - ProfileCacheExpire; let expire = new Date().getTime() - ProfileCacheExpire;
for (let pk of this.WantsMetadata) { for (let pk of this.WantsMetadata) {
let m = meta.find(a => a?.pubkey === pk); let m = meta.find(a => a?.pubkey === pk);
@ -190,18 +191,18 @@ export class NostrSystem {
sub.OnEvent = async (e) => { sub.OnEvent = async (e) => {
let profile = mapEventToProfile(e); let profile = mapEventToProfile(e);
if (profile) { if (profile) {
let existing = await db.users.get(profile.pubkey); let existing = await db.find(profile.pubkey);
if ((existing?.created ?? 0) < profile.created) { if ((existing?.created ?? 0) < profile.created) {
await db.users.put(profile); await db.put(profile);
} else if (existing) { } else if (existing) {
await db.users.update(profile.pubkey, { loaded: new Date().getTime() }); await db.update(profile.pubkey, { loaded: new Date().getTime() });
} }
} }
} }
let results = await this.RequestSubscription(sub); let results = await this.RequestSubscription(sub);
let couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a)); let couldNotFetch = Array.from(missing).filter(a => !results.some(b => b.pubkey === a));
console.debug("No profiles: ", couldNotFetch); console.debug("No profiles: ", couldNotFetch);
await db.users.bulkPut(couldNotFetch.map(a => { await db.bulkPut(couldNotFetch.map(a => {
return { return {
pubkey: a, pubkey: a,
loaded: new Date().getTime() loaded: new Date().getTime()
@ -211,6 +212,10 @@ export class NostrSystem {
setTimeout(() => this._FetchMetadata(), 500); setTimeout(() => this._FetchMetadata(), 500);
} }
async nip42Auth(challenge: string, relay:string): Promise<Event|undefined> {
return
}
} }
export const System = new NostrSystem(); export const System = new NostrSystem();

View File

@ -36,6 +36,7 @@ export type RawReqFilter = {
"#p"?: u256[], "#p"?: u256[],
"#t"?: string[], "#t"?: string[],
"#d"?: string, "#d"?: string,
search?: string,
since?: number, since?: number,
until?: number, until?: number,
limit?: number limit?: number

View File

@ -3,16 +3,17 @@ import Nostrich from "nostrich.jpg";
import { TaggedRawEvent } from "Nostr"; import { TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import type { NotificationRequest } from "State/Login"; import type { NotificationRequest } from "State/Login";
import { db } from "Db"; import { MetadataCache } from "State/Users";
import { MetadataCache } from "Db/User"; import { getDb } from "State/Users/Db";
import { getDisplayName } from "Element/ProfileImage"; import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const"; import { MentionRegex } from "Const";
export async function makeNotification(ev: TaggedRawEvent): Promise<NotificationRequest | null> { export async function makeNotification(ev: TaggedRawEvent): Promise<NotificationRequest | null> {
const db = getDb()
switch (ev.kind) { switch (ev.kind) {
case EventKind.TextNote: { case EventKind.TextNote: {
const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]); const pubkeys = new Set([ev.pubkey, ...ev.tags.filter(a => a[0] === "p").map(a => a[1]!)]);
const users = (await db.users.bulkGet(Array.from(pubkeys))).filter(a => a !== undefined).map(a => a!); const users = await db.bulkGet(Array.from(pubkeys))
const fromUser = users.find(a => a?.pubkey === ev.pubkey); const fromUser = users.find(a => a?.pubkey === ev.pubkey);
const name = getDisplayName(fromUser, ev.pubkey); const name = getDisplayName(fromUser, ev.pubkey);
const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture; const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;

View File

@ -14,6 +14,7 @@ const Developers = [
const Contributors = [ const Contributors = [
bech32ToHex("npub10djxr5pvdu97rjkde7tgcsjxzpdzmdguwacfjwlchvj7t88dl7nsdl54nf"), // ivan bech32ToHex("npub10djxr5pvdu97rjkde7tgcsjxzpdzmdguwacfjwlchvj7t88dl7nsdl54nf"), // ivan
bech32ToHex("npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"), // liran cohen bech32ToHex("npub148jmlutaa49y5wl5mcll003ftj59v79vf7wuv3apcwpf75hx22vs7kk9ay"), // liran cohen
bech32ToHex("npub1xdtducdnjerex88gkg2qk2atsdlqsyxqaag4h05jmcpyspqt30wscmntxy"), // artur
]; ];
interface Splits { interface Splits {
@ -21,18 +22,28 @@ interface Splits {
split: number split: number
} }
interface TotalToday {
donations: number,
nip5: number
}
const DonatePage = () => { const DonatePage = () => {
const [splits, setSplits] = useState<Splits[]>([]); const [splits, setSplits] = useState<Splits[]>([]);
const [today, setSumToday] = useState<TotalToday>();
async function loadSplits() { async function loadData() {
let rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`); let rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
if(rsp.ok) { if(rsp.ok) {
setSplits(await rsp.json()); setSplits(await rsp.json());
} }
let rsp2 = await fetch(`${ApiHost}/api/v1/revenue/today`);
if(rsp2.ok) {
setSumToday(await rsp2.json());
}
} }
useEffect(() => { useEffect(() => {
loadSplits().catch(console.warn); loadData().catch(console.warn);
}, []); }, []);
function actions(pk: HexKey) { function actions(pk: HexKey) {
@ -62,6 +73,7 @@ const DonatePage = () => {
<div className="mr10">Lightning Donation: </div> <div className="mr10">Lightning Donation: </div>
<ZapButton svc={"donate@snort.social"} /> <ZapButton svc={"donate@snort.social"} />
</div> </div>
{today && (<small>Total today (UTC): {today.donations.toLocaleString()} sats</small>)}
<h3>Primary Developers</h3> <h3>Primary Developers</h3>
{Developers.map(a => <ProfilePreview pubkey={a} key={a} actions={actions(a)} />)} {Developers.map(a => <ProfilePreview pubkey={a} key={a} actions={actions(a)} />)}
<h4>Contributors</h4> <h4>Contributors</h4>

View File

@ -42,3 +42,15 @@ header .pfp .avatar-wrapper {
top: 0; top: 0;
right: 0; right: 0;
} }
.search {
margin: 0 10px 0 10px;
}
.search input {
margin: 0 5px 0 5px;
}
.search .btn {
display: none;
}

View File

@ -1,18 +1,23 @@
import "./Layout.css"; import "./Layout.css";
import { useEffect } from "react" import { useEffect, useMemo } from "react"
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { Outlet, useNavigate } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import Envelope from "Icons/Envelope" import Envelope from "Icons/Envelope";
import Bell from "Icons/Bell" import Bell from "Icons/Bell";
import Search from "Icons/Search";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { init, setPreferences, UserPreferences } from "State/Login"; import { init, UserPreferences } from "State/Login";
import { HexKey, RawEvent, TaggedRawEvent } from "Nostr"; import { HexKey, RawEvent, TaggedRawEvent } from "Nostr";
import { RelaySettings } from "Nostr/Connection"; import { RelaySettings } from "Nostr/Connection";
import { System } from "Nostr/System" import { System } from "Nostr/System"
import ProfileImage from "Element/ProfileImage"; import ProfileImage from "Element/ProfileImage";
import useLoginFeed from "Feed/LoginFeed"; import useLoginFeed from "Feed/LoginFeed";
import { totalUnread } from "Pages/MessagesPage"; import { totalUnread } from "Pages/MessagesPage";
import { SearchRelays } from 'Const';
import useEventPublisher from "Feed/EventPublisher";
import useModeration from "Hooks/useModeration";
export default function Layout() { export default function Layout() {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -23,16 +28,23 @@ export default function Layout() {
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications); const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
const readNotifications = useSelector<RootState, number>(s => s.login.readNotifications); const readNotifications = useSelector<RootState, number>(s => s.login.readNotifications);
const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms); const dms = useSelector<RootState, RawEvent[]>(s => s.login.dms);
const { isMuted } = useModeration();
const filteredDms = dms.filter(a => !isMuted(a.pubkey))
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences); const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
const pub = useEventPublisher();
useLoginFeed(); useLoginFeed();
useEffect(() => {
System.nip42Auth = pub.nip42Auth
},[pub])
useEffect(() => { useEffect(() => {
if (relays) { if (relays) {
for (let [k, v] of Object.entries(relays)) { for (let [k, v] of Object.entries(relays)) {
System.ConnectToRelay(k, v); System.ConnectToRelay(k, v);
} }
for (let [k, v] of System.Sockets) { for (let [k, v] of System.Sockets) {
if (!relays[k]) { if (!relays[k] && !SearchRelays.has(k)) {
System.DisconnectRelay(k); System.DisconnectRelay(k);
} }
} }
@ -82,14 +94,17 @@ export default function Layout() {
function accountHeader() { function accountHeader() {
const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length; const unreadNotifications = notifications?.filter(a => (a.created_at * 1000) > readNotifications).length;
const unreadDms = key ? totalUnread(dms, key) : 0; const unreadDms = key ? totalUnread(filteredDms, key) : 0;
return ( return (
<div className="header-actions"> <div className="header-actions">
<div className={`btn btn-rnd${unreadDms === 0 ? " mr10" : ""}`} onClick={(e) => navigate("/messages")}> <div className="btn btn-rnd" onClick={(e) => navigate("/search")}>
<Search />
</div>
<div className="btn btn-rnd" onClick={(e) => navigate("/messages")}>
<Envelope /> <Envelope />
{unreadDms > 0 && (<span className="has-unread"></span>)} {unreadDms > 0 && (<span className="has-unread"></span>)}
</div> </div>
<div className={`btn btn-rnd${unreadNotifications === 0 ? " mr10" : ""}`} onClick={(e) => goToNotifications(e)}> <div className="btn btn-rnd" onClick={(e) => goToNotifications(e)}>
<Bell /> <Bell />
{unreadNotifications > 0 && (<span className="has-unread"></span>)} {unreadNotifications > 0 && (<span className="has-unread"></span>)}
</div> </div>
@ -101,7 +116,6 @@ export default function Layout() {
if (typeof isInit !== "boolean") { if (typeof isInit !== "boolean") {
return null; return null;
} }
return ( return (
<div className="page"> <div className="page">
<header> <header>

View File

@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
import * as secp from '@noble/secp256k1'; import * as secp from '@noble/secp256k1';
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { setPrivateKey, setPublicKey } from "State/Login"; import { setPrivateKey, setPublicKey, setRelays } from "State/Login";
import { EmailRegex } from "Const"; import { EmailRegex } from "Const";
import { bech32ToHex } from "Util"; import { bech32ToHex } from "Util";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
@ -73,6 +73,14 @@ export default function LoginPage() {
async function doNip07Login() { async function doNip07Login() {
let pubKey = await window.nostr.getPublicKey(); let pubKey = await window.nostr.getPublicKey();
dispatch(setPublicKey(pubKey)); dispatch(setPublicKey(pubKey));
if ("getRelays" in window.nostr) {
let relays = await window.nostr.getRelays();
dispatch(setRelays({
relays: relays,
createdAt: 1
}));
}
} }
function altLogins() { function altLogins() {

View File

@ -31,14 +31,21 @@ export default function NewUserPage() {
setError(""); setError("");
try { try {
let rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`); let rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
let data = await rsp.json();
if (rsp.ok) { if (rsp.ok) {
setFollows(await rsp.json()); if (Array.isArray(data) && data.length === 0) {
setError(`No nostr users found for "${twitterUsername}"`);
} else { } else {
setError("Failed to load follows, is your profile public?"); setFollows(data);
}
} else if ("error" in data) {
setError(data.error);
} else {
setError("Failed to load follows, please try again later");
} }
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
setError("Failed to load follows, is your profile public?"); setError("Failed to load follows, please try again later");
} }
} }

View File

@ -6,7 +6,8 @@ import { useNavigate, useParams } from "react-router-dom";
import Link from "Icons/Link"; import Link from "Icons/Link";
import Zap from "Icons/Zap"; import Zap from "Icons/Zap";
import useProfile from "Feed/ProfileFeed"; import Envelope from "Icons/Envelope";
import { useUserProfile } from "Feed/ProfileFeed";
import FollowButton from "Element/FollowButton"; import FollowButton from "Element/FollowButton";
import { extractLnAddress, parseId, hexToBech32 } from "Util"; import { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar"; import Avatar from "Element/Avatar";
@ -38,7 +39,7 @@ export default function ProfilePage() {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const id = useMemo(() => parseId(params.id!), [params]); const id = useMemo(() => parseId(params.id!), [params]);
const user = useProfile(id)?.get(id); const user = useUserProfile(id);
const loggedOut = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut); const loggedOut = useSelector<RootState, boolean | undefined>(s => s.login.loggedOut);
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows); const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
@ -79,16 +80,6 @@ export default function ProfilePage() {
</div> </div>
)} )}
{lnurl && (
<div className="ln-address" onClick={(e) => setShowLnQr(true)}>
<span className="link-icon">
<Zap />
</span>
<span className="lnurl f-ellipsis" >
{lnurl}
</span>
</div>
)}
<LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} /> <LNURLTip svc={lnurl} show={showLnQr} onClose={() => setShowLnQr(false)} />
</div> </div>
) )
@ -154,14 +145,27 @@ export default function ProfilePage() {
</button> </button>
</> </>
) : ( ) : (
!loggedOut && (
<> <>
<button type="button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}> <button
Message className="icon"
type="button"
onClick={() => setShowLnQr(true)}
>
<Zap width={14} height={16} />
</button>
{!loggedOut && (
<>
<button
className="icon"
type="button"
onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}
>
<Envelope width={16} height={13} />
</button> </button>
<FollowButton pubkey={id} /> <FollowButton pubkey={id} />
</> </>
) )}
</>
)} )}
</div> </div>
{bio()} {bio()}

51
src/Pages/SearchPage.tsx Normal file
View File

@ -0,0 +1,51 @@
import { useParams } from "react-router-dom";
import Timeline from "Element/Timeline";
import { useEffect, useState } from "react";
import { debounce } from "Util";
import { router } from "index";
import { SearchRelays } from "Const";
import { System } from "Nostr/System";
const SearchPage = () => {
const params: any = useParams();
const [search, setSearch] = useState<string>();
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
useEffect(() => {
if (keyword) {
// "navigate" changing only url
router.navigate(`/search/${encodeURIComponent(keyword)}`)
}
}, [keyword]);
useEffect(() => {
return debounce(500, () => setKeyword(search));
}, [search]);
useEffect(() => {
let addedRelays: string[] = [];
for (let [k, v] of SearchRelays) {
if (!System.Sockets.has(k)) {
System.ConnectToRelay(k, v);
addedRelays.push(k);
}
}
return () => {
for (let r of addedRelays) {
System.DisconnectRelay(r);
}
}
}, []);
return (
<>
<h2>Search</h2>
<div className="flex mb10">
<input type="text" className="f-grow mr10" placeholder="Search.." value={search} onChange={e => setSearch(e.target.value)} />
</div>
{keyword && <Timeline key={keyword} subject={{ type: "keyword", items: [keyword] }} postsOnly={false} method={"LIMIT_UNTIL"} />}
</>
)
}
export default SearchPage;

View File

@ -30,7 +30,7 @@ export const SettingsRoutes: RouteObject[] = [
element: <Relay />, element: <Relay />,
}, },
{ {
path: "relays/:addr", path: "relays/:id",
element: <RelayInfo /> element: <RelayInfo />
}, },
{ {

View File

@ -8,19 +8,20 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShop } from "@fortawesome/free-solid-svg-icons"; import { faShop } from "@fortawesome/free-solid-svg-icons";
import useEventPublisher from "Feed/EventPublisher"; import useEventPublisher from "Feed/EventPublisher";
import useProfile from "Feed/ProfileFeed"; import { useUserProfile } from "Feed/ProfileFeed";
import VoidUpload from "Feed/VoidUpload"; import VoidUpload from "Feed/VoidUpload";
import LogoutButton from "Element/LogoutButton"; import LogoutButton from "Element/LogoutButton";
import { hexToBech32, openFile } from "Util"; import { hexToBech32, openFile } from "Util";
import Copy from "Element/Copy"; import Copy from "Element/Copy";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
import { VoidCatHost } from "Const";
export default function ProfileSettings() { export default function ProfileSettings() {
const navigate = useNavigate(); const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey); const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey); const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const user = useProfile(id)?.get(id || ""); const user = useUserProfile(id!);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const [name, setName] = useState<string>(); const [name, setName] = useState<string>();
@ -63,6 +64,7 @@ export default function ProfileSettings() {
delete userCopy["loaded"]; delete userCopy["loaded"];
delete userCopy["created"]; delete userCopy["created"];
delete userCopy["pubkey"]; delete userCopy["pubkey"];
delete userCopy["npub"];
console.debug(userCopy); console.debug(userCopy);
let ev = await publisher.metadata(userCopy); let ev = await publisher.metadata(userCopy);
@ -85,14 +87,14 @@ export default function ProfileSettings() {
async function setNewAvatar() { async function setNewAvatar() {
const rsp = await uploadFile(); const rsp = await uploadFile();
if (rsp) { if (rsp) {
setPicture(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`); setPicture(rsp.meta?.url ?? `${VoidCatHost}/d/${rsp.id}`);
} }
} }
async function setNewBanner() { async function setNewBanner() {
const rsp = await uploadFile(); const rsp = await uploadFile();
if (rsp) { if (rsp) {
setBanner(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`); setBanner(rsp.meta?.url ?? `${VoidCatHost}/d/${rsp.id}`);
} }
} }

View File

@ -10,16 +10,16 @@ const RelayInfo = () => {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const addr: string = `wss://${params.addr}`;
const con = System.Sockets.get(addr) ?? System.Sockets.get(`${addr}/`); const conn = Array.from(System.Sockets.values()).find(a => a.Id === params.id);
const stats = useRelayState(con?.Address ?? addr); console.debug(conn);
const stats = useRelayState(conn?.Address ?? "");
return ( return (
<> <>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}>Relays</h3> <h3 className="pointer" onClick={() => navigate("/settings/relays")}>Relays</h3>
<div className="card"> <div className="card">
<h3>{stats?.info?.name ?? addr}</h3> <h3>{stats?.info?.name}</h3>
<p>{stats?.info?.description}</p> <p>{stats?.info?.description}</p>
{stats?.info?.pubkey && (<> {stats?.info?.pubkey && (<>
@ -45,7 +45,7 @@ const RelayInfo = () => {
</>)} </>)}
<div className="flex mt10 f-end"> <div className="flex mt10 f-end">
<div className="btn error" onClick={() => { <div className="btn error" onClick={() => {
dispatch(removeRelay(con!.Address)); dispatch(removeRelay(conn!.Address));
navigate("/settings/relays") navigate("/settings/relays")
}}>Remove</div> }}>Remove</div>
</div> </div>

View File

@ -9,6 +9,8 @@ const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey"; const PublicKeyItem = "pubkey";
const NotificationsReadItem = "notifications-read"; const NotificationsReadItem = "notifications-read";
const UserPreferencesKey = "preferences"; const UserPreferencesKey = "preferences";
const RelayListKey = "last-relays";
const FollowList = "last-follows";
export interface NotificationRequest { export interface NotificationRequest {
title: string title: string
@ -142,7 +144,7 @@ const InitState = {
dms: [], dms: [],
dmInteraction: 0, dmInteraction: 0,
preferences: { preferences: {
enableReactions: false, enableReactions: true,
autoLoadMedia: true, autoLoadMedia: true,
theme: "system", theme: "system",
confirmReposts: false, confirmReposts: false,
@ -175,8 +177,6 @@ const LoginSlice = createSlice({
state.loggedOut = true; state.loggedOut = true;
} }
state.relays = Object.fromEntries(DefaultRelays.entries());
// check pub key only // check pub key only
let pubKey = window.localStorage.getItem(PublicKeyItem); let pubKey = window.localStorage.getItem(PublicKeyItem);
if (pubKey && !state.privateKey) { if (pubKey && !state.privateKey) {
@ -184,6 +184,18 @@ const LoginSlice = createSlice({
state.loggedOut = false; state.loggedOut = false;
} }
let lastRelayList = window.localStorage.getItem(RelayListKey);
if (lastRelayList) {
state.relays = JSON.parse(lastRelayList);
} else {
state.relays = Object.fromEntries(DefaultRelays.entries());
}
let lastFollows = window.localStorage.getItem(FollowList);
if (lastFollows) {
state.follows = JSON.parse(lastFollows);
}
// notifications // notifications
let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem) ?? "0"); let readNotif = parseInt(window.localStorage.getItem(NotificationsReadItem) ?? "0");
if (!isNaN(readNotif)) { if (!isNaN(readNotif)) {
@ -224,10 +236,12 @@ const LoginSlice = createSlice({
state.relays = Object.fromEntries(filtered.entries()); state.relays = Object.fromEntries(filtered.entries());
state.latestRelays = createdAt; state.latestRelays = createdAt;
window.localStorage.setItem(RelayListKey, JSON.stringify(state.relays));
}, },
removeRelay: (state, action: PayloadAction<string>) => { removeRelay: (state, action: PayloadAction<string>) => {
delete state.relays[action.payload]; delete state.relays[action.payload];
state.relays = { ...state.relays }; state.relays = { ...state.relays };
window.localStorage.setItem(RelayListKey, JSON.stringify(state.relays));
}, },
setFollows: (state, action: PayloadAction<SetFollowsPayload>) => { setFollows: (state, action: PayloadAction<SetFollowsPayload>) => {
const { keys, createdAt } = action.payload const { keys, createdAt } = action.payload
@ -239,16 +253,25 @@ const LoginSlice = createSlice({
let update = Array.isArray(keys) ? keys : [keys]; let update = Array.isArray(keys) ? keys : [keys];
let changes = false; let changes = false;
for (let pk of update) { for (let pk of update.filter(a => a.length === 64)) {
if (!existing.has(pk)) { if (!existing.has(pk)) {
existing.add(pk); existing.add(pk);
changes = true; changes = true;
} }
} }
for (let pk of existing) {
if (!update.includes(pk)) {
existing.delete(pk);
changes = true;
}
}
if (changes) { if (changes) {
state.follows = Array.from(existing); state.follows = Array.from(existing);
state.latestFollows = createdAt; state.latestFollows = createdAt;
} }
window.localStorage.setItem(FollowList, JSON.stringify(state.follows));
}, },
setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) { setMuted(state, action: PayloadAction<{createdAt: number, keys: HexKey[]}>) {
const { createdAt, keys } = action.payload const { createdAt, keys } = action.payload
@ -290,10 +313,10 @@ const LoginSlice = createSlice({
state.dmInteraction += 1; state.dmInteraction += 1;
}, },
logout: (state) => { logout: (state) => {
window.localStorage.clear();
Object.assign(state, InitState); Object.assign(state, InitState);
state.loggedOut = true; state.loggedOut = true;
state.relays = Object.fromEntries(DefaultRelays.entries()); state.relays = Object.fromEntries(DefaultRelays.entries());
window.localStorage.clear();
}, },
markNotificationsRead: (state) => { markNotificationsRead: (state) => {
state.readNotifications = new Date().getTime(); state.readNotifications = new Date().getTime();

View File

@ -1,9 +1,11 @@
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from "@reduxjs/toolkit";
import { reducer as LoginReducer } from "State/Login"; import { reducer as LoginReducer } from "State/Login";
import { reducer as UsersReducer } from "State/Users";
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
login: LoginReducer login: LoginReducer,
users: UsersReducer,
} }
}); });

75
src/State/Users.ts Normal file
View File

@ -0,0 +1,75 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { HexKey, TaggedRawEvent, UserMetadata } from "Nostr";
import { hexToBech32 } from "../Util";
export interface MetadataCache extends UserMetadata {
/**
* When the object was saved in cache
*/
loaded: number,
/**
* When the source metadata event was created
*/
created: number,
/**
* The pubkey of the owner of this metadata
*/
pubkey: HexKey
/**
* The bech32 encoded pubkey
*/
npub: string
};
export function mapEventToProfile(ev: TaggedRawEvent) {
try {
let data: UserMetadata = JSON.parse(ev.content);
return {
pubkey: ev.pubkey,
npub: hexToBech32("npub", ev.pubkey),
created: ev.created_at,
loaded: new Date().getTime(),
...data
} as MetadataCache;
} catch (e) {
console.error("Failed to parse JSON", ev, e);
}
}
export interface UsersDb {
isAvailable(): Promise<boolean>
query(str: string): Promise<MetadataCache[]>
find(key: HexKey): Promise<MetadataCache | undefined>
add(user: MetadataCache): Promise<any>
put(user: MetadataCache): Promise<any>
bulkAdd(users: MetadataCache[]): Promise<any>
bulkGet(keys: HexKey[]): Promise<MetadataCache[]>
bulkPut(users: MetadataCache[]): Promise<any>
update(key: HexKey, fields: Record<string, any>): Promise<any>
}
export interface UsersStore {
/**
* A list of seen users
*/
users: Record<HexKey, MetadataCache>,
};
const InitState = { users: {} } as UsersStore;
const UsersSlice = createSlice({
name: "Users",
initialState: InitState,
reducers: {
setUsers(state, action: PayloadAction<Record<HexKey, MetadataCache>>) {
state.users = action.payload
}
}
});
export const { setUsers } = UsersSlice.actions
export const reducer = UsersSlice.reducer;

149
src/State/Users/Db.ts Normal file
View File

@ -0,0 +1,149 @@
import { HexKey } from "Nostr";
import { db as idb } from "Db";
import { UsersDb, MetadataCache, setUsers } from "State/Users";
import store from "State/Store";
class IndexedDb implements UsersDb {
isAvailable() {
if ("indexedDB" in window) {
return new Promise<boolean>((resolve) => {
const req = window.indexedDB.open("dummy", 1)
req.onsuccess = (ev) => {
resolve(true)
}
req.onerror = (ev) => {
resolve(false)
}
})
}
return Promise.resolve(false)
}
find(key: HexKey) {
return idb.users.get(key);
}
query(q: string) {
return idb.users
.where("npub").startsWithIgnoreCase(q)
.or("name").startsWithIgnoreCase(q)
.or("display_name").startsWithIgnoreCase(q)
.or("nip05").startsWithIgnoreCase(q)
.limit(5)
.toArray()
}
bulkGet(keys: HexKey[]) {
return idb.users.bulkGet(keys).then(ret => ret.filter(a => a !== undefined).map(a => a!));
}
add(user: MetadataCache) {
return idb.users.add(user)
}
put(user: MetadataCache) {
return idb.users.put(user)
}
bulkAdd(users: MetadataCache[]) {
return idb.users.bulkAdd(users)
}
bulkPut(users: MetadataCache[]) {
return idb.users.bulkPut(users)
}
update(key: HexKey, fields: Record<string, any>) {
return idb.users.update(key, fields)
}
}
function groupByPubkey(acc: Record<HexKey, MetadataCache>, user: MetadataCache) {
return { ...acc, [user.pubkey]: user }
}
class ReduxUsersDb implements UsersDb {
async isAvailable() { return true }
async query(q: string) {
const state = store.getState()
const { users } = state.users
return Object.values(users).filter(user => {
const profile = user as MetadataCache
return profile.name?.includes(q)
|| profile.npub?.includes(q)
|| profile.display_name?.includes(q)
|| profile.nip05?.includes(q)
})
}
async find(key: HexKey) {
const state = store.getState()
const { users } = state.users
return users[key]
}
async add(user: MetadataCache) {
const state = store.getState()
const { users } = state.users
store.dispatch(setUsers({...users, [user.pubkey]: user }))
}
async put(user: MetadataCache) {
const state = store.getState()
const { users } = state.users
store.dispatch(setUsers({...users, [user.pubkey]: user }))
}
async bulkAdd(newUserProfiles: MetadataCache[]) {
const state = store.getState()
const { users } = state.users
const newUsers = newUserProfiles.reduce(groupByPubkey, {})
store.dispatch(setUsers({...users, ...newUsers }))
}
async bulkGet(keys: HexKey[]) {
const state = store.getState()
const { users } = state.users
const ids = new Set([...keys])
return Object.values(users).filter(user => {
return ids.has(user.pubkey)
})
}
async update(key: HexKey, fields: Record<string, any>) {
const state = store.getState()
const { users } = state.users
const current = users[key]
const updated = {...current, ...fields }
store.dispatch(setUsers({...users, [key]: updated }))
}
async bulkPut(newUsers: MetadataCache[]) {
const state = store.getState()
const { users } = state.users
const newProfiles = newUsers.reduce(groupByPubkey, {})
store.dispatch(setUsers({ ...users, ...newProfiles }))
}
}
const indexedDb = new IndexedDb()
export const inMemoryDb = new ReduxUsersDb()
let db: UsersDb = inMemoryDb
indexedDb.isAvailable().then((available) => {
if (available) {
console.debug('Using Indexed DB')
db = indexedDb;
} else {
console.debug('Using in-memory DB')
}
})
export function getDb() {
return db
}

59
src/State/Users/Hooks.ts Normal file
View File

@ -0,0 +1,59 @@
import { useSelector } from "react-redux"
import { useLiveQuery } from "dexie-react-hooks";
import { MetadataCache } from "State/Users";
import { getDb, inMemoryDb } from "State/Users/Db";
import type { RootState } from "State/Store"
import { HexKey } from "Nostr";
export function useQuery(query: string, limit: number = 5) {
const db = getDb()
const allUsers = useLiveQuery(
() => db.query(query)
.catch((err) => {
console.error(err)
return inMemoryDb.query(query)
}),
[query],
)
return allUsers
}
export function useKey(pubKey: HexKey) {
const db = getDb()
const { users } = useSelector((state: RootState) => state.users)
const defaultUser = users[pubKey]
const user = useLiveQuery(async () => {
if (pubKey) {
try {
return await db.find(pubKey);
} catch (error) {
console.error(error)
return defaultUser
}
}
}, [pubKey, defaultUser]);
return user
}
export function useKeys(pubKeys: HexKey[]): Map<HexKey, MetadataCache> {
const db = getDb()
const dbUsers = useLiveQuery(async () => {
if (pubKeys) {
try {
const ret = await db.bulkGet(pubKeys);
return new Map(ret.map(a => [a.pubkey, a]))
} catch (error) {
console.error(error)
const ret = await inMemoryDb.bulkGet(pubKeys);
return new Map(ret.map(a => [a.pubkey, a]))
}
}
return new Map()
}, [pubKeys]);
return dbUsers!
}

View File

@ -130,8 +130,7 @@ button:disabled {
} }
.light button:disabled { .light button:disabled {
color: var(--font-secondary-color); color: var(--font-color);
border-color: var(--font-secondary-color);
} }
button:hover { button:hover {
@ -168,6 +167,17 @@ button.transparent:hover {
background-color: var(--gray-secondary); background-color: var(--gray-secondary);
} }
button.icon {
border: none;
background: none;
color: var(--font-color);
}
button.icon:hover {
color: white;
background: var(--highlight);
}
.btn { .btn {
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
@ -393,10 +403,35 @@ body.scroll-lock {
margin-bottom: 10px; margin-bottom: 10px;
} }
.mb20 {
margin-bottom: 20px;
}
.mr-auto { .mr-auto {
margin-right: auto; margin-right: auto;
} }
.tabs {
display: flex;
align-content: center;
text-align: center;
margin: 10px 0;
overflow-x: auto;
}
.tabs>div {
margin-right: 10px;
cursor: pointer;
}
.tabs>div:last-child {
margin: 0;
}
.tabs .active {
font-weight: 700;
}
.error { .error {
color: var(--error); color: var(--error);
} }
@ -410,27 +445,12 @@ body.scroll-lock {
} }
.tabs { .tabs {
display: flex; padding: 0;
align-content: center; align-items: center;
text-align: center; justify-content: flex-start;
margin-top: 10px;
overflow-x: auto;
margin-bottom: 16px; margin-bottom: 16px;
} }
.tabs > * {
margin-right: 10px;
cursor: pointer;
}
.tabs > *:last-child {
margin: 0;
}
.tabs .active {
font-weight: 700;
}
.tab { .tab {
border-bottom: 1px solid var(--gray-secondary); border-bottom: 1px solid var(--gray-secondary);
font-weight: 700; font-weight: 700;

View File

@ -27,6 +27,7 @@ import MessagesPage from 'Pages/MessagesPage';
import ChatPage from 'Pages/ChatPage'; import ChatPage from 'Pages/ChatPage';
import DonatePage from 'Pages/DonatePage'; import DonatePage from 'Pages/DonatePage';
import HashTagsPage from 'Pages/HashTagsPage'; import HashTagsPage from 'Pages/HashTagsPage';
import SearchPage from 'Pages/SearchPage';
/** /**
* HTTP query provider * HTTP query provider
@ -35,7 +36,7 @@ const HTTP = new QueryClient()
serviceWorkerRegistration.register(); serviceWorkerRegistration.register();
const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
element: <Layout />, element: <Layout />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
@ -88,6 +89,10 @@ const router = createBrowserRouter([
{ {
path: "/t/:tag", path: "/t/:tag",
element: <HashTagsPage /> element: <HashTagsPage />
},
{
path: "/search/:keyword?",
element: <SearchPage />
} }
] ]
} }

View File

@ -6043,11 +6043,6 @@ js-sha3@0.8.0:
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==
js-stylesheet@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/js-stylesheet/-/js-stylesheet-0.0.1.tgz#12cc1451220e454184b46de3b098c0d154762c38"
integrity sha512-jSPbDIaHlK8IFXEbE6MZkeAQshRHHxxOcQ+LCZejm3KpW+POpi2TE3GXWoFsOOG2+AhCTWHjD7AXRFt3FDm8Zw==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -7999,13 +7994,6 @@ react-markdown@^8.0.4:
unist-util-visit "^4.0.0" unist-util-visit "^4.0.0"
vfile "^5.0.0" vfile "^5.0.0"
react-menu@^0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/react-menu/-/react-menu-0.0.10.tgz#c2b338cdd88e4c436325969239089e0752a142bc"
integrity sha512-SGl/OZljPUB1ITWBG2wt1p7hyE3Y449O9FUezxkKzu+JM5HWkPjrU/JRmG4ZguyegLAnZx3qyjhdFYqB44sqJw==
dependencies:
js-stylesheet "0.0.1"
react-query@^3.39.2: react-query@^3.39.2:
version "3.39.2" version "3.39.2"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.2.tgz#9224140f0296f01e9664b78ed6e4f69a0cc9216f" resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.2.tgz#9224140f0296f01e9664b78ed6e4f69a0cc9216f"
@ -9354,6 +9342,15 @@ unist-util-visit@^4.0.0:
unist-util-is "^5.0.0" unist-util-is "^5.0.0"
unist-util-visit-parents "^5.1.1" unist-util-visit-parents "^5.1.1"
unist-util-visit@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.2.tgz#125a42d1eb876283715a3cb5cceaa531828c72e2"
integrity sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==
dependencies:
"@types/unist" "^2.0.0"
unist-util-is "^5.0.0"
unist-util-visit-parents "^5.1.1"
universalify@^0.2.0: universalify@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"