Merge branch 'main' into new-ui
This commit is contained in:
commit
9c47d00739
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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" />
|
||||
|
22
src/Const.ts
22
src/Const.ts
@ -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-]+)/
|
@ -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);
|
||||
}
|
||||
}
|
@ -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)
|
||||
})
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
27
src/Element/MixCloudEmbed.tsx
Normal file
27
src/Element/MixCloudEmbed.tsx
Normal 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;
|
@ -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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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" />
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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));
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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
4
src/Icons/IconProps.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export default interface IconProps {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
9
src/Icons/Search.tsx
Normal file
9
src/Icons/Search.tsx
Normal 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
|
@ -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>
|
||||
)
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
5
src/Nostr/Nips.ts
Normal file
@ -0,0 +1,5 @@
|
||||
enum Nips {
|
||||
Search = 50
|
||||
}
|
||||
|
||||
export default Nips;
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -36,6 +36,7 @@ export type RawReqFilter = {
|
||||
"#p"?: u256[],
|
||||
"#t"?: string[],
|
||||
"#d"?: string,
|
||||
search?: string,
|
||||
since?: number,
|
||||
until?: number,
|
||||
limit?: number
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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() {
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
51
src/Pages/SearchPage.tsx
Normal 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;
|
@ -30,7 +30,7 @@ export const SettingsRoutes: RouteObject[] = [
|
||||
element: <Relay />,
|
||||
},
|
||||
{
|
||||
path: "relays/:addr",
|
||||
path: "relays/:id",
|
||||
element: <RelayInfo />
|
||||
},
|
||||
{
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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
75
src/State/Users.ts
Normal 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
149
src/State/Users/Db.ts
Normal 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
59
src/State/Users/Hooks.ts
Normal 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!
|
||||
}
|
@ -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;
|
||||
|
@ -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 />
|
||||
}
|
||||
]
|
||||
}
|
||||
|
23
yarn.lock
23
yarn.lock
@ -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==
|
||||
|
Loading…
x
Reference in New Issue
Block a user