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 supports the following NIP's
Snort supports the following NIP's:
- [x] NIP-01: Basic protocol flow description
- [x] NIP-02: Contact List and Petnames (No petname support)
- [ ] NIP-03: OpenTimestamps Attestations for Events
@ -26,4 +27,5 @@ Snort supports the following NIP's
- [ ] NIP-36: Sensitive Content
- [ ] NIP-40: Expiration Timestamp
- [ ] 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-twitter-embed": "^4.0.4",
"typescript": "^4.9.4",
"unist-util-visit": "^4.1.2",
"uuid": "^9.0.0",
"workbox-background-sync": "^6.4.2",
"workbox-broadcast-update": "^6.4.2",

View File

@ -4,11 +4,11 @@
<head>
<meta charset="utf-8" />
<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="description" content="Fast nostr web ui" />
<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="manifest" href="%PUBLIC_URL%/manifest.json" />

View File

@ -5,6 +5,11 @@ import { RelaySettings } from "Nostr/Connection";
*/
export const ApiHost = "https://api.snort.social";
/**
* Void.cat file upload service url
*/
export const VoidCatHost = "https://void.cat";
/**
* Websocket re-connect timeout
*/
@ -19,9 +24,14 @@ export const ProfileCacheExpire = (1_000 * 60 * 5);
* Default bootstrap relays
*/
export const DefaultRelays = new Map<string, RelaySettings>([
["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 }],
["wss://relay.snort.social", { 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
*/
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 { MetadataCache } from "Db/User";
import { MetadataCache } from "State/Users";
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 {
users!: Table<MetadataCache>;
constructor() {
super('snortDB');
this.version(2).stores({
users: '++pubkey, name, display_name, picture, nip05, npub'
}).upgrade(tx => {
super(NAME);
this.version(VERSION).stores(STORES).upgrade(tx => {
return tx.table("users").toCollection().modify(user => {
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 { HexKey } from "Nostr";
import { RootState } from "State/Store";
import { parseId } from "Util";
export interface FollowButtonProps {
pubkey: HexKey,
className?: string
}
export default function FollowButton(props: FollowButtonProps) {
const pubkey = props.pubkey;
const pubkey = parseId(props.pubkey);
const publiser = useEventPublisher();
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);

View File

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

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

View File

@ -181,6 +181,12 @@
.light .note.active>.footer>.reaction-pill.reacted {
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 {
display: flex;

View File

@ -1,15 +1,16 @@
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 { default as NEvent } from "Nostr/Event";
import ProfileImage from "Element/ProfileImage";
import Text from "Element/Text";
import { eventLink, getReactions, hexToBech32 } from "Util";
import NoteFooter from "Element/NoteFooter";
import NoteTime from "Element/NoteTime";
import EventKind from "Nostr/EventKind";
import useProfile from "Feed/ProfileFeed";
import { useUserProfiles } from "Feed/ProfileFeed";
import { TaggedRawEvent, u256 } from "Nostr";
import { useInView } from "react-intersection-observer";
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 ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
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 { isMuted } = useModeration()
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 = {
showHeader: true,
@ -71,6 +74,15 @@ export default function Note(props: NoteProps) {
return <Text content={body} tags={ev.Tags} users={users || new Map()} />;
}, [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) {
if (!window.location.pathname.startsWith("/e/")) {
e.stopPropagation();
@ -128,14 +140,14 @@ export default function Note(props: NoteProps) {
const others = mentions.length > maxMentions ? ` & ${othersLength} other${othersLength > 1 ? 's' : ''}` : ''
return (
<div className="reply">
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions}
{others}
</>
) : replyId ? (
hexToBech32("note", replyId)?.substring(0, 12) // todo: link
) : ""}
{(mentions?.length ?? 0) > 0 ? (
<>
{pubMentions}
{others}
</>
) : replyId ? (
hexToBech32("note", replyId)?.substring(0, 12) // todo: link
) : ""}
</div>
)
}
@ -166,13 +178,16 @@ export default function Note(props: NoteProps) {
<div className="body" onClick={(e) => goToEvent(e, ev.Id)}>
{transformBody()}
</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}
</>
)
}
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()}
</div>
)

View File

@ -8,7 +8,7 @@ import Plus from "Icons/Plus";
import useEventPublisher from "Feed/EventPublisher";
import { openFile } from "Util";
import VoidUpload from "Feed/VoidUpload";
import { FileExtensionRegex } from "Const";
import { FileExtensionRegex, VoidCatHost } from "Const";
import Textarea from "Element/Textarea";
import Modal from "Element/Modal";
import { default as NEvent } from "Nostr/Event";
@ -52,7 +52,7 @@ export function NoteCreator(props: NoteCreatorProps) {
let ext = file.name.match(FileExtensionRegex);
// 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}`);
} else if (rx?.errorMessage) {

View File

@ -14,7 +14,7 @@ import useEventPublisher from "Feed/EventPublisher";
import { getReactions, hexToBech32, normalizeReaction, Reaction } from "Util";
import { NoteCreator } from "Element/NoteCreator";
import LNURLTip from "Element/LNURLTip";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import { default as NEvent } from "Nostr/Event";
import { RootState } from "State/Store";
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 { mute, block } = useModeration();
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 [reply, setReply] = useState(false);
const [tip, setTip] = useState(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,24 @@
import './Text.css'
import { useMemo } from "react";
import { useMemo, useCallback } from "react";
import { Link } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { visit, SKIP } from "unist-util-visit";
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 Invoice from "Element/Invoice";
import Hashtag from "Element/Hashtag";
import Tag from "Nostr/Tag";
import { MetadataCache } from "Db/User";
import { MetadataCache } from "State/Users";
import Mention from "Element/Mention";
import TidalEmbed from "Element/TidalEmbed";
import { useSelector } from 'react-redux';
import { RootState } from 'State/Store';
import { UserPreferences } from 'State/Login';
import SoundCloudEmbed from 'Element/SoundCloudEmded'
import MixCloudEmbed from './MixCloudEmbed';
function transformHttpLink(a: string, pref: UserPreferences) {
try {
@ -28,6 +30,7 @@ function transformHttpLink(a: string, pref: UserPreferences) {
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
const tidalId = TidalRegex.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;
if (extension) {
switch (extension) {
@ -39,6 +42,11 @@ function transformHttpLink(a: string, pref: UserPreferences) {
case "webp": {
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 "mov":
case "mkv":
@ -75,6 +83,8 @@ function transformHttpLink(a: string, pref: UserPreferences) {
return <TidalEmbed link={a} />
} else if (soundcloundId){
return <SoundCloudEmbed link={a} />
} else if (mixcloudId){
return <MixCloudEmbed link={a} />
} else {
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 }),
};
}, [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 { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
import emoji from "@jukben/emoji-search";
import TextareaAutosize from "react-textarea-autosize";
@ -10,8 +9,8 @@ import TextareaAutosize from "react-textarea-autosize";
import Avatar from "Element/Avatar";
import Nip05 from "Element/Nip05";
import { hexToBech32 } from "Util";
import { db } from "Db";
import { MetadataCache } from "Db/User";
import { MetadataCache } from "State/Users";
import { useQuery } from "State/Users/Hooks";
interface EmojiItemProps {
name: string
@ -45,16 +44,7 @@ const UserItem = (metadata: MetadataCache) => {
const Textarea = ({ users, onChange, ...rest }: any) => {
const [query, setQuery] = useState('')
const allUsers = useLiveQuery(
() => db.users
.where("npub").startsWithIgnoreCase(query)
.or("name").startsWithIgnoreCase(query)
.or("display_name").startsWithIgnoreCase(query)
.or("nip05").startsWithIgnoreCase(query)
.limit(5)
.toArray(),
[query],
);
const allUsers = useQuery(query)
const userDataProvider = (token: string) => {
setQuery(token)

View File

@ -10,6 +10,7 @@ import LoadMore from "Element/LoadMore";
import Note from "Element/Note";
import NoteReaction from "Element/NoteReaction";
import useModeration from "Hooks/useModeration";
import ProfilePreview from "./ProfilePreview";
export interface TimelineProps {
postsOnly: boolean,
@ -41,6 +42,9 @@ export default function Timeline({ subject, postsOnly = false, method, ignoreMod
function eventElement(e: TaggedRawEvent) {
switch (e.kind) {
case EventKind.SetMetadata: {
return <ProfilePreview pubkey={e.pubkey} className="card"/>
}
case EventKind.TextNote: {
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import { HexKey } from "Nostr";
import LNURLTip from "Element/LNURLTip";
const ZapButton = ({ pubkey, svc }: { pubkey?: HexKey, svc?: string }) => {
const profile = useProfile(pubkey)?.get(pubkey ?? "");
const profile = useUserProfile(pubkey!)
const [zap, setZap] = useState(false);
const service = svc ?? (profile?.lud16 || profile?.lud06);

View File

@ -14,7 +14,7 @@ declare global {
nostr: {
getPublicKey: () => Promise<HexKey>,
signEvent: (event: RawEvent) => Promise<RawEvent>,
getRelays: () => Promise<[[string, { read: boolean, write: boolean }]]>,
getRelays: () => Promise<Record<string, { read: boolean, write: boolean }>>,
nip04: {
encrypt: (pubkey: HexKey, content: string) => Promise<string>,
decrypt: (pubkey: HexKey, content: string) => Promise<string>
@ -72,12 +72,22 @@ export default function useEventPublisher() {
return match;
}
const content = msg.replace(/@npub[a-z0-9]+/g, replaceNpub)
.replace(/note[a-z0-9]+/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
.replace(/note[a-z0-9]+/g, replaceNoteId)
.replace(HashtagRegex, replaceHashtag);
ev.Content = content;
}
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) => {
if (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
*/
broadcastForBootstrap: (ev: NEvent | undefined) => {
if(ev) {
for(let [k, _] of DefaultRelays) {
if (ev) {
for (let [k, _] of DefaultRelays) {
System.WriteOnceToRelay(k, ev);
}
}
@ -205,6 +215,9 @@ export default function useEventPublisher() {
temp.add(pkAdd);
}
for (let pk of temp) {
if (pk.length !== 64) {
continue;
}
ev.Tags.push(new Tag(["p", pk], ev.Tags.length));
}
@ -217,7 +230,7 @@ export default function useEventPublisher() {
ev.Kind = EventKind.ContactList;
ev.Content = JSON.stringify(relays);
for (let pk of follows) {
if (pk === pkRemove) {
if (pk === pkRemove || pk.length !== 64) {
continue;
}
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 { Subscriptions } from "Nostr/Subscriptions";
import { addDirectMessage, setFollows, setRelays, setMuted, setBlocked, sendNotification } from "State/Login";
import type { RootState } from "State/Store";
import { db } from "Db";
import { barierNip07 } from "Feed/EventPublisher";
import { RootState } from "State/Store";
import { mapEventToProfile, MetadataCache } from "State/Users";
import { getDb } from "State/Users/Db";
import useSubscription from "Feed/Subscription";
import { getDisplayName } from "Element/ProfileImage";
import { barierNip07 } from "Feed/EventPublisher";
import { getMutedKeys, getNewest } from "Feed/MuteList";
import { mapEventToProfile, MetadataCache } from "Db/User";
import { MentionRegex } from "Const";
import useModeration from "Hooks/useModeration";
/**
@ -105,9 +107,10 @@ export default function useLoginFeed() {
return acc;
}, { created: 0, profile: null as MetadataCache | null });
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) {
await db.users.put(maxProfile.profile);
await db.put(maxProfile.profile);
}
}
})().catch(console.warn);
@ -152,6 +155,7 @@ export default function useLoginFeed() {
}, [dispatch, dmsFeed.store]);
}
async function decryptBlocked(raw: TaggedRawEvent, pubKey: HexKey, privKey?: HexKey) {
const ev = new Event(raw)
if (pubKey && privKey) {

View File

@ -1,27 +1,13 @@
import { useLiveQuery } from "dexie-react-hooks";
import { useEffect } from "react";
import { db } from "Db";
import { MetadataCache } from "Db/User";
import { useEffect, useMemo } from "react";
import { RootState } from "State/Store";
import { MetadataCache } from "State/Users";
import { useKey, useKeys } from "State/Users/Hooks";
import { HexKey } from "Nostr";
import { System } from "Nostr/System";
export default function useProfile(pubKey?: HexKey | Array<HexKey> | undefined): Map<HexKey, MetadataCache> | undefined {
const user = useLiveQuery(async () => {
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]);
export function useUserProfile(pubKey: HexKey): MetadataCache | undefined {
const users = useKey(pubKey);
useEffect(() => {
if (pubKey) {
@ -30,5 +16,19 @@ export default function useProfile(pubKey?: HexKey | Array<HexKey> | undefined):
}
}, [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 {
type: "pubkey" | "hashtag" | "global" | "ptag",
type: "pubkey" | "hashtag" | "global" | "ptag" | "keyword",
items: string[]
}
@ -47,6 +47,11 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
sub.PTags = new Set(subject.items);
break;
}
case "keyword": {
sub.Kinds.add(EventKind.SetMetadata);
sub.Search = subject.items[0];
break;
}
}
return sub;
}, [subject.type, subject.items]);
@ -72,6 +77,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
latestSub.Authors = sub.Authors;
latestSub.HashTags = sub.HashTags;
latestSub.Kinds = sub.Kinds;
latestSub.Search = sub.Search;
latestSub.Limit = 1;
latestSub.Since = Math.floor(new Date().getTime() / 1000);
sub.AddSubscription(latestSub);
@ -123,7 +129,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
if (main.store.notes.length > 0) {
setTrackingEvent(s => {
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 s;
@ -165,4 +171,4 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
latest.clear();
}
};
}
}

View File

@ -1,4 +1,5 @@
import * as secp from "@noble/secp256k1";
import { VoidCatHost } from "Const";
/**
* 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 digest = await crypto.subtle.digest("SHA-256", buf);
let req = await fetch(`https://void.cat/upload`, {
let req = await fetch(`${VoidCatHost}/upload`, {
mode: "cors",
method: "POST",
body: buf,
@ -17,7 +18,8 @@ export default async function VoidUpload(file: File | Blob, filename: string) {
"V-Content-Type": file.type,
"V-Filename": filename,
"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 (
<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"/>
</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 (
<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"/>
</svg>
)

View File

@ -7,6 +7,8 @@ import { DefaultConnectTimeout } from "Const";
import { ConnectionStats } from "Nostr/ConnectionStats";
import { RawEvent, TaggedRawEvent, u256 } from "Nostr";
import { RelayInfo } from "./RelayInfo";
import Nips from "./Nips";
import { System } from "./System";
export type CustomHook = (state: Readonly<StateSnapshot>) => void;
@ -29,10 +31,12 @@ export type StateSnapshot = {
received: number,
send: number
},
info?: RelayInfo
info?: RelayInfo,
id: string
};
export default class Connection {
Id: string;
Address: string;
Socket: WebSocket | null;
Pending: Subscriptions[];
@ -47,9 +51,12 @@ export default class Connection {
LastState: Readonly<StateSnapshot>;
IsClosed: boolean;
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) {
this.Id = uuid();
this.Address = addr;
this.Socket = null;
this.Pending = [];
@ -72,6 +79,8 @@ export default class Connection {
this.IsClosed = false;
this.ReconnectTimer = null;
this.EventsCallback = new Map();
this.AwaitingAuth = new Map();
this.Authed = false;
this.Connect();
}
@ -118,18 +127,8 @@ export default class Connection {
OnOpen(e: Event) {
this.ConnectTimeout = DefaultConnectTimeout;
this._InitSubscriptions();
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) {
@ -152,6 +151,12 @@ export default class Connection {
let msg = JSON.parse(e.data);
let tag = msg[0];
switch (tag) {
case "AUTH": {
this._OnAuthAsync(msg[1])
this.Stats.EventsReceived++;
this._UpdateState();
break;
}
case "EVENT": {
this._OnEvent(msg[1], msg[2]);
this.Stats.EventsReceived++;
@ -169,7 +174,7 @@ export default class Connection {
if (this.EventsCallback.has(id)) {
let cb = this.EventsCallback.get(id)!;
this.EventsCallback.delete(id);
cb();
cb(msg);
}
break;
}
@ -235,6 +240,11 @@ export default class Connection {
return;
}
// check relay supports search
if (sub.Search && !this.SupportsNip(Nips.Search)) {
return;
}
if (this.Subscriptions.has(sub.Id)) {
return;
}
@ -278,6 +288,13 @@ export default class Connection {
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() {
this.CurrentState.connected = this.Socket?.readyState === WebSocket.OPEN;
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.disconnects = this.Stats.Disconnects;
this.CurrentState.info = this.Info;
this.CurrentState.id = this.Id;
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
this.HasStateChange = true;
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) {
if(!this.Authed && this.AwaitingAuth.size > 0) {
this.Pending.push(sub);
return;
}
let req = ["REQ", sub.Id, sub.ToObject()];
if (sub.OrSubs.length > 0) {
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) {
let sub = this.Subscriptions.get(subId);
if (sub) {
@ -375,4 +445,4 @@ export default class Connection {
}
return ev;
}
}
}

View File

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

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;
/**
* A list of search terms
*/
Search?: string;
/**
* 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.PTags = sub?.["#p"] ? new Set(sub["#p"]) : undefined;
this.DTag = sub?.["#d"] ? sub["#d"] : undefined;
this.Search = sub?.search ?? undefined;
this.Since = sub?.since ?? undefined;
this.Until = sub?.until ?? undefined;
this.Limit = sub?.limit ?? undefined;
@ -142,6 +148,9 @@ export class Subscriptions {
if (this.DTag) {
ret["#d"] = this.DTag;
}
if (this.Search) {
ret.search = this.Search;
}
if (this.Since !== null) {
ret.since = this.Since;
}

View File

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

View File

@ -3,16 +3,17 @@ import Nostrich from "nostrich.jpg";
import { TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind";
import type { NotificationRequest } from "State/Login";
import { db } from "Db";
import { MetadataCache } from "Db/User";
import { MetadataCache } from "State/Users";
import { getDb } from "State/Users/Db";
import { getDisplayName } from "Element/ProfileImage";
import { MentionRegex } from "Const";
export async function makeNotification(ev: TaggedRawEvent): Promise<NotificationRequest | null> {
const db = getDb()
switch (ev.kind) {
case EventKind.TextNote: {
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 name = getDisplayName(fromUser, ev.pubkey);
const avatarUrl = (fromUser?.picture?.length ?? 0) === 0 ? Nostrich : fromUser?.picture;

View File

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

View File

@ -42,3 +42,15 @@ header .pfp .avatar-wrapper {
top: 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 { useEffect } from "react"
import { useEffect, useMemo } from "react"
import { useDispatch, useSelector } from "react-redux";
import { Outlet, useNavigate } from "react-router-dom";
import Envelope from "Icons/Envelope"
import Bell from "Icons/Bell"
import Envelope from "Icons/Envelope";
import Bell from "Icons/Bell";
import Search from "Icons/Search";
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 { RelaySettings } from "Nostr/Connection";
import { System } from "Nostr/System"
import ProfileImage from "Element/ProfileImage";
import useLoginFeed from "Feed/LoginFeed";
import { totalUnread } from "Pages/MessagesPage";
import { SearchRelays } from 'Const';
import useEventPublisher from "Feed/EventPublisher";
import useModeration from "Hooks/useModeration";
export default function Layout() {
const dispatch = useDispatch();
@ -23,16 +28,23 @@ export default function Layout() {
const notifications = useSelector<RootState, TaggedRawEvent[]>(s => s.login.notifications);
const readNotifications = useSelector<RootState, number>(s => s.login.readNotifications);
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 pub = useEventPublisher();
useLoginFeed();
useEffect(() => {
System.nip42Auth = pub.nip42Auth
},[pub])
useEffect(() => {
if (relays) {
for (let [k, v] of Object.entries(relays)) {
System.ConnectToRelay(k, v);
}
for (let [k, v] of System.Sockets) {
if (!relays[k]) {
if (!relays[k] && !SearchRelays.has(k)) {
System.DisconnectRelay(k);
}
}
@ -82,14 +94,17 @@ export default function Layout() {
function accountHeader() {
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 (
<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 />
{unreadDms > 0 && (<span className="has-unread"></span>)}
</div>
<div className={`btn btn-rnd${unreadNotifications === 0 ? " mr10" : ""}`} onClick={(e) => goToNotifications(e)}>
<div className="btn btn-rnd" onClick={(e) => goToNotifications(e)}>
<Bell />
{unreadNotifications > 0 && (<span className="has-unread"></span>)}
</div>
@ -101,7 +116,6 @@ export default function Layout() {
if (typeof isInit !== "boolean") {
return null;
}
return (
<div className="page">
<header>

View File

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

View File

@ -31,14 +31,21 @@ export default function NewUserPage() {
setError("");
try {
let rsp = await fetch(`${TwitterFollowsApi}?username=${twitterUsername}`);
let data = await rsp.json();
if (rsp.ok) {
setFollows(await rsp.json());
if (Array.isArray(data) && data.length === 0) {
setError(`No nostr users found for "${twitterUsername}"`);
} else {
setFollows(data);
}
} else if ("error" in data) {
setError(data.error);
} else {
setError("Failed to load follows, is your profile public?");
setError("Failed to load follows, please try again later");
}
} catch (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 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 { extractLnAddress, parseId, hexToBech32 } from "Util";
import Avatar from "Element/Avatar";
@ -38,7 +39,7 @@ export default function ProfilePage() {
const params = useParams();
const navigate = useNavigate();
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 loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const follows = useSelector<RootState, HexKey[]>(s => s.login.follows);
@ -79,16 +80,6 @@ export default function ProfilePage() {
</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)} />
</div>
)
@ -154,14 +145,27 @@ export default function ProfilePage() {
</button>
</>
) : (
!loggedOut && (
<>
<button type="button" onClick={() => navigate(`/messages/${hexToBech32("npub", id)}`)}>
Message
</button>
<FollowButton pubkey={id} />
</>
)
<>
<button
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>
<FollowButton pubkey={id} />
</>
)}
</>
)}
</div>
{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 />,
},
{
path: "relays/:addr",
path: "relays/:id",
element: <RelayInfo />
},
{

View File

@ -8,19 +8,20 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShop } from "@fortawesome/free-solid-svg-icons";
import useEventPublisher from "Feed/EventPublisher";
import useProfile from "Feed/ProfileFeed";
import { useUserProfile } from "Feed/ProfileFeed";
import VoidUpload from "Feed/VoidUpload";
import LogoutButton from "Element/LogoutButton";
import { hexToBech32, openFile } from "Util";
import Copy from "Element/Copy";
import { RootState } from "State/Store";
import { HexKey } from "Nostr";
import { VoidCatHost } from "Const";
export default function ProfileSettings() {
const navigate = useNavigate();
const id = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
const privKey = useSelector<RootState, HexKey | undefined>(s => s.login.privateKey);
const user = useProfile(id)?.get(id || "");
const user = useUserProfile(id!);
const publisher = useEventPublisher();
const [name, setName] = useState<string>();
@ -63,6 +64,7 @@ export default function ProfileSettings() {
delete userCopy["loaded"];
delete userCopy["created"];
delete userCopy["pubkey"];
delete userCopy["npub"];
console.debug(userCopy);
let ev = await publisher.metadata(userCopy);
@ -85,14 +87,14 @@ export default function ProfileSettings() {
async function setNewAvatar() {
const rsp = await uploadFile();
if (rsp) {
setPicture(rsp.meta?.url ?? `https://void.cat/d/${rsp.id}`);
setPicture(rsp.meta?.url ?? `${VoidCatHost}/d/${rsp.id}`);
}
}
async function setNewBanner() {
const rsp = await uploadFile();
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 navigate = useNavigate();
const dispatch = useDispatch();
const addr: string = `wss://${params.addr}`;
const con = System.Sockets.get(addr) ?? System.Sockets.get(`${addr}/`);
const stats = useRelayState(con?.Address ?? addr);
const conn = Array.from(System.Sockets.values()).find(a => a.Id === params.id);
console.debug(conn);
const stats = useRelayState(conn?.Address ?? "");
return (
<>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}>Relays</h3>
<div className="card">
<h3>{stats?.info?.name ?? addr}</h3>
<h3>{stats?.info?.name}</h3>
<p>{stats?.info?.description}</p>
{stats?.info?.pubkey && (<>
@ -45,7 +45,7 @@ const RelayInfo = () => {
</>)}
<div className="flex mt10 f-end">
<div className="btn error" onClick={() => {
dispatch(removeRelay(con!.Address));
dispatch(removeRelay(conn!.Address));
navigate("/settings/relays")
}}>Remove</div>
</div>

View File

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

View File

@ -1,13 +1,15 @@
import { configureStore } from "@reduxjs/toolkit";
import { reducer as LoginReducer } from "State/Login";
import { reducer as UsersReducer } from "State/Users";
const store = configureStore({
reducer: {
login: LoginReducer
login: LoginReducer,
users: UsersReducer,
}
});
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export default store;
export default store;

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

View File

@ -27,6 +27,7 @@ import MessagesPage from 'Pages/MessagesPage';
import ChatPage from 'Pages/ChatPage';
import DonatePage from 'Pages/DonatePage';
import HashTagsPage from 'Pages/HashTagsPage';
import SearchPage from 'Pages/SearchPage';
/**
* HTTP query provider
@ -35,7 +36,7 @@ const HTTP = new QueryClient()
serviceWorkerRegistration.register();
const router = createBrowserRouter([
export const router = createBrowserRouter([
{
element: <Layout />,
errorElement: <ErrorPage />,
@ -88,6 +89,10 @@ const router = createBrowserRouter([
{
path: "/t/:tag",
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"
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:
version "4.0.0"
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"
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:
version "3.39.2"
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-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:
version "0.2.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
@ -10025,4 +10022,4 @@ yargs@^16.2.0:
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==