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

Merged
verbiricha merged 17 commits from dbfix into main 2023-01-27 21:38:42 +00:00
25 changed files with 171 additions and 100 deletions
Showing only changes of commit 78aa655e86 - Show all commits

View File

@ -1,4 +1,4 @@
## Snort ## Snort
Snort is a nostr UI built with React, Snort intends to be fast and effecient Snort is a nostr UI built with React, Snort intends to be fast and effecient
@ -13,7 +13,7 @@ Snort supports the following NIP's
- [x] NIP-08: Handling Mentions - [x] NIP-08: Handling Mentions
- [x] NIP-09: Event Deletion - [x] NIP-09: Event Deletion
- [x] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - [x] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
- [ ] NIP-11: Relay Information Document - [x] NIP-11: Relay Information Document
- [x] NIP-12: Generic Tag Queries - [x] NIP-12: Generic Tag Queries
- [ ] NIP-13: Proof of Work - [ ] NIP-13: Proof of Work
- [ ] NIP-14: Subject tag in text events - [ ] NIP-14: Subject tag in text events

View File

@ -1,5 +1,10 @@
import { RelaySettings } from "Nostr/Connection"; import { RelaySettings } from "Nostr/Connection";
/**
* Add-on api for snort features
*/
export const ApiHost = "https://api.snort.social";
/** /**
* Websocket re-connect timeout * Websocket re-connect timeout
*/ */
@ -93,5 +98,4 @@ export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
/** /**
* SoundCloud regex * SoundCloud regex
*/ */
export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/ export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/

View File

@ -33,7 +33,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey); const pubkey = useSelector<ReduxStore, string>(s => s.login.publicKey);
const user = useUserProfile(pubkey); const user = useUserProfile(pubkey);
const publisher = useEventPublisher(); const publisher = useEventPublisher();
const svc = new ServiceProvider(props.service); const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>(); const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
const [error, setError] = useState<ServiceError>(); const [error, setError] = useState<ServiceError>();
const [handle, setHandle] = useState<string>(""); const [handle, setHandle] = useState<string>("");
@ -43,7 +43,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
const [showInvoice, setShowInvoice] = useState<boolean>(false); const [showInvoice, setShowInvoice] = useState<boolean>(false);
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>(); const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain]); const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain, serviceConfig]);
useEffect(() => { useEffect(() => {
svc.GetConfig() svc.GetConfig()
@ -58,7 +58,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
} }
}) })
.catch(console.error) .catch(console.error)
}, [props]); }, [props, svc]);
useEffect(() => { useEffect(() => {
setError(undefined); setError(undefined);
@ -89,7 +89,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
.catch(console.error); .catch(console.error);
}); });
} }
}, [handle, domain]); }, [handle, domain, domainConfig, svc]);
useEffect(() => { useEffect(() => {
if (registerResponse && showInvoice) { if (registerResponse && showInvoice) {
@ -111,7 +111,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
}, 2_000); }, 2_000);
return () => clearInterval(t); return () => clearInterval(t);
} }
}, [registerResponse, showInvoice]) }, [registerResponse, showInvoice, svc])
function mapError(e: ServiceErrorCode, t: string | null): string | undefined { function mapError(e: ServiceErrorCode, t: string | null): string | undefined {
let whyMap = new Map([ let whyMap = new Map([
@ -159,7 +159,7 @@ export default function Nip5Service(props: Nip05ServiceProps) {
<p>Find out more info about {props.name} at <a href={props.link} target="_blank" rel="noreferrer">{props.link}</a></p> <p>Find out more info about {props.name} at <a href={props.link} target="_blank" rel="noreferrer">{props.link}</a></p>
{error && <b className="error">{error.error}</b>} {error && <b className="error">{error.error}</b>}
{!registerStatus && <div className="flex mb10"> {!registerStatus && <div className="flex mb10">
<input type="text" placeholder="Handle" value={handle} onChange={(e) => setHandle(e.target.value)} /> <input type="text" placeholder="Handle" value={handle} onChange={(e) => setHandle(e.target.value.toLowerCase())} />
&nbsp;@&nbsp; &nbsp;@&nbsp;
<select value={domain} onChange={(e) => setDomain(e.target.value)}> <select value={domain} onChange={(e) => setDomain(e.target.value)}>
{serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)} {serviceConfig?.domains.map(a => <option key={a.name}>{a.name}</option>)}

View File

@ -16,13 +16,8 @@
min-height: 40px; min-height: 40px;
background-color: var(--note-bg); background-color: var(--note-bg);
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
max-width: -webkit-fill-available; max-width: stretch;
max-width: -moz-available; min-width: stretch;
max-width: fill-available;
min-width: 100%;
min-width: -webkit-fill-available;
min-width: -moz-available;
min-width: fill-available;
} }
.note-creator .actions { .note-creator .actions {

View File

@ -31,8 +31,8 @@ export default function NoteFooter(props: NoteFooterProps) {
const [reply, setReply] = useState(false); const [reply, setReply] = useState(false);
const [tip, setTip] = useState(false); const [tip, setTip] = useState(false);
const isMine = ev.RootPubKey === login; const isMine = ev.RootPubKey === login;
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related]); const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related]); const reposts = useMemo(() => getReactions(related, ev.Id, EventKind.Repost), [related, ev]);
const groupReactions = useMemo(() => { const groupReactions = useMemo(() => {
return reactions?.reduce((acc, { content }) => { return reactions?.reduce((acc, { content }) => {
let r = normalizeReaction(content); let r = normalizeReaction(content);

View File

@ -16,7 +16,8 @@ export interface NoteReactionProps {
root?: TaggedRawEvent root?: TaggedRawEvent
} }
export default function NoteReaction(props: NoteReactionProps) { export default function NoteReaction(props: NoteReactionProps) {
const ev = useMemo(() => props["data-ev"] || new NEvent(props.data), [props.data, props["data-ev"]]) const { ["data-ev"]: dataEv, data } = props;
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv])
const refEvent = useMemo(() => { const refEvent = useMemo(() => {
if (ev) { if (ev) {
@ -32,19 +33,6 @@ export default function NoteReaction(props: NoteReactionProps) {
return null; return null;
} }
function mapReaction(c: string) {
switch (c) {
case "+": return "❤️";
case "-": return "👎";
default: {
if (c.length === 0) {
return "❤️";
}
return c;
}
}
}
/** /**
* Some clients embed the reposted note in the content * Some clients embed the reposted note in the content
*/ */

View File

@ -1,13 +1,14 @@
import "./Relay.css" import "./Relay.css"
import { faPlug, faTrash, faSquareCheck, faSquareXmark, faWifi, faUpload, faDownload, faPlugCircleXmark, faEllipsisVertical } from "@fortawesome/free-solid-svg-icons"; import { faPlug, faSquareCheck, faSquareXmark, faWifi, faPlugCircleXmark, faGear } from "@fortawesome/free-solid-svg-icons";
import useRelayState from "Feed/RelayState"; import useRelayState from "Feed/RelayState";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { removeRelay, setRelays } from "State/Login"; import { setRelays } from "State/Login";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { RelaySettings } from "Nostr/Connection"; import { RelaySettings } from "Nostr/Connection";
import { useNavigate } from "react-router-dom";
export interface RelayProps { export interface RelayProps {
addr: string addr: string
@ -15,11 +16,11 @@ export interface RelayProps {
export default function Relay(props: RelayProps) { export default function Relay(props: RelayProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate();
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays); const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
const relaySettings = allRelaySettings[props.addr]; const relaySettings = allRelaySettings[props.addr];
const state = useRelayState(props.addr); const state = useRelayState(props.addr);
const name = useMemo(() => new URL(props.addr).host, [props.addr]); const name = useMemo(() => new URL(props.addr).host, [props.addr]);
const [showExtra, setShowExtra] = useState(false);
function configure(o: RelaySettings) { function configure(o: RelaySettings) {
dispatch(setRelays({ dispatch(setRelays({
@ -62,28 +63,13 @@ export default function Relay(props: RelayProps) {
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects} <FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
</div> </div>
<div> <div>
<span className="icon-btn" onClick={() => setShowExtra(s => !s)}> <span className="icon-btn" onClick={() => navigate(name)}>
<FontAwesomeIcon icon={faEllipsisVertical} /> <FontAwesomeIcon icon={faGear} />
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{showExtra ? <div className="flex relay-extra w-max">
<div className="f-1">
<FontAwesomeIcon icon={faUpload} /> {state?.events.send}
</div>
<div className="f-1">
<FontAwesomeIcon icon={faDownload} /> {state?.events.received}
</div>
<div className="f-1">
Delete
<span className="icon-btn" onClick={() => dispatch(removeRelay(props.addr))}>
<FontAwesomeIcon icon={faTrash} />
</span>
</div>
</div> : null}
</> </>
) )
} }

View File

@ -1,5 +1,5 @@
import "./Timeline.css"; import "./Timeline.css";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed"; import useTimelineFeed, { TimelineSubject } from "Feed/TimelineFeed";
import { TaggedRawEvent } from "Nostr"; import { TaggedRawEvent } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
@ -23,17 +23,17 @@ export default function Timeline({ subject, postsOnly = false, method }: Timelin
method method
}); });
const filterPosts = (notes: TaggedRawEvent[]) => { const filterPosts = useCallback((nts: TaggedRawEvent[]) => {
return [...notes].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true); return [...nts].sort((a, b) => b.created_at - a.created_at)?.filter(a => postsOnly ? !a.tags.some(b => b[0] === "e") : true);
} }, [postsOnly]);
const mainFeed = useMemo(() => { const mainFeed = useMemo(() => {
return filterPosts(main.notes); return filterPosts(main.notes);
}, [main]); }, [main, filterPosts]);
const latestFeed = useMemo(() => { const latestFeed = useMemo(() => {
return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id)); return filterPosts(latest.notes).filter(a => !mainFeed.some(b => b.id === a.id));
}, [latest]); }, [latest, mainFeed, filterPosts]);
function eventElement(e: TaggedRawEvent) { function eventElement(e: TaggedRawEvent) {
switch (e.kind) { switch (e.kind) {

View File

@ -1,5 +1,4 @@
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { useSelector } from "react-redux";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { MetadataCache } from "State/Users"; import { MetadataCache } from "State/Users";

View File

@ -38,7 +38,8 @@ function notesReducer(state: NoteStore, arg: ReducerArg) {
if (!Array.isArray(evs)) { if (!Array.isArray(evs)) {
evs = [evs]; evs = [evs];
} }
evs = evs.filter(a => !state.notes.some(b => b.id === a.id)); let existingIds = new Set(state.notes.map(a => a.id));
evs = evs.filter(a => !existingIds.has(a.id));
if (evs.length === 0) { if (evs.length === 0) {
return state; return state;
} }
@ -83,7 +84,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
setSubDebounced(sub); setSubDebounced(sub);
}); });
} }
}, [sub]); }, [sub, options]);
useEffect(() => { useEffect(() => {
if (sub) { if (sub) {
@ -115,6 +116,7 @@ export default function useSubscription(sub: Subscriptions | null, options?: Use
}; };
} }
}, [subDebounce]); }, [subDebounce]);
useEffect(() => { useEffect(() => {
return debounce(DebounceMs, () => { return debounce(DebounceMs, () => {
setDebounceOutput(s => s += 1); setDebounceOutput(s => s += 1);

View File

@ -36,7 +36,7 @@ export default function useThreadFeed(id: u256) {
thisSub.AddSubscription(subRelated); thisSub.AddSubscription(subRelated);
return thisSub; return thisSub;
}, [trackingEvents]); }, [trackingEvents, pref, id]);
const main = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { u256 } from "Nostr"; import { u256 } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
import { Subscriptions } from "Nostr/Subscriptions"; import { Subscriptions } from "Nostr/Subscriptions";
@ -19,15 +19,15 @@ export interface TimelineSubject {
export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) { export default function useTimelineFeed(subject: TimelineSubject, options: TimelineFeedOptions) {
const now = unixNow(); const now = unixNow();
const [window, setWindow] = useState<number>(60 * 60); const [window] = useState<number>(60 * 60);
const [until, setUntil] = useState<number>(now); const [until, setUntil] = useState<number>(now);
const [since, setSince] = useState<number>(now - window); const [since, setSince] = useState<number>(now - window);
const [trackingEvents, setTrackingEvent] = useState<u256[]>([]); const [trackingEvents, setTrackingEvent] = useState<u256[]>([]);
const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]); const [trackingParentEvents, setTrackingParentEvents] = useState<u256[]>([]);
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences); const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
function createSub() { const createSub = useCallback(() => {
if (subject.type !== "global" && subject.items.length == 0) { if (subject.type !== "global" && subject.items.length === 0) {
return null; return null;
} }
@ -49,7 +49,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
} }
} }
return sub; return sub;
} }, [subject.type, subject.items]);
const sub = useMemo(() => { const sub = useMemo(() => {
let sub = createSub(); let sub = createSub();
@ -78,7 +78,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
} }
} }
return sub; return sub;
}, [subject.type, subject.items, until, since, window]); }, [until, since, options.method, pref, createSub]);
const main = useSubscription(sub, { leaveOpen: true }); const main = useSubscription(sub, { leaveOpen: true });
@ -90,7 +90,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
subLatest.Since = Math.floor(new Date().getTime() / 1000); subLatest.Since = Math.floor(new Date().getTime() / 1000);
} }
return subLatest; return subLatest;
}, [subject.type, subject.items]); }, [pref, createSub]);
const latest = useSubscription(subRealtime, { leaveOpen: true }); const latest = useSubscription(subRealtime, { leaveOpen: true });
@ -103,7 +103,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
sub.ETags = new Set(trackingEvents); sub.ETags = new Set(trackingEvents);
} }
return sub ?? null; return sub ?? null;
}, [trackingEvents]); }, [trackingEvents, pref, subject.type]);
const others = useSubscription(subNext, { leaveOpen: true }); const others = useSubscription(subNext, { leaveOpen: true });
@ -115,7 +115,7 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
return parents; return parents;
} }
return null; return null;
}, [trackingParentEvents]); }, [trackingParentEvents, subject.type]);
const parent = useSubscription(subParents); const parent = useSubscription(subParents);
@ -123,8 +123,10 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
if (main.store.notes.length > 0) { if (main.store.notes.length > 0) {
setTrackingEvent(s => { setTrackingEvent(s => {
let ids = main.store.notes.map(a => a.id); let ids = main.store.notes.map(a => a.id);
let temp = new Set([...s, ...ids]); if(ids.some(a => !s.includes(a))) {
return Array.from(temp); return Array.from(new Set([...s, ...ids]));
}
return s;
}); });
let reposts = main.store.notes let reposts = main.store.notes
.filter(a => a.kind === EventKind.Repost && a.content === "") .filter(a => a.kind === EventKind.Repost && a.content === "")

View File

@ -6,6 +6,7 @@ import { default as NEvent } from "Nostr/Event";
import { DefaultConnectTimeout } from "Const"; import { DefaultConnectTimeout } from "Const";
import { ConnectionStats } from "Nostr/ConnectionStats"; import { ConnectionStats } from "Nostr/ConnectionStats";
import { RawEvent, TaggedRawEvent, u256 } from "Nostr"; import { RawEvent, TaggedRawEvent, u256 } from "Nostr";
import { RelayInfo } from "./RelayInfo";
export type CustomHook = (state: Readonly<StateSnapshot>) => void; export type CustomHook = (state: Readonly<StateSnapshot>) => void;
@ -27,7 +28,8 @@ export type StateSnapshot = {
events: { events: {
received: number, received: number,
send: number send: number
} },
info?: RelayInfo
}; };
export default class Connection { export default class Connection {
@ -36,6 +38,7 @@ export default class Connection {
Pending: Subscriptions[]; Pending: Subscriptions[];
Subscriptions: Map<string, Subscriptions>; Subscriptions: Map<string, Subscriptions>;
Settings: RelaySettings; Settings: RelaySettings;
Info?: RelayInfo;
ConnectTimeout: number; ConnectTimeout: number;
Stats: ConnectionStats; Stats: ConnectionStats;
StateHooks: Map<string, CustomHook>; StateHooks: Map<string, CustomHook>;
@ -56,7 +59,7 @@ export default class Connection {
this.Stats = new ConnectionStats(); this.Stats = new ConnectionStats();
this.StateHooks = new Map(); this.StateHooks = new Map();
this.HasStateChange = true; this.HasStateChange = true;
this.CurrentState = <StateSnapshot>{ this.CurrentState = {
connected: false, connected: false,
disconnects: 0, disconnects: 0,
avgLatency: 0, avgLatency: 0,
@ -64,7 +67,7 @@ export default class Connection {
received: 0, received: 0,
send: 0 send: 0
} }
}; } as StateSnapshot;
this.LastState = Object.freeze({ ...this.CurrentState }); this.LastState = Object.freeze({ ...this.CurrentState });
this.IsClosed = false; this.IsClosed = false;
this.ReconnectTimer = null; this.ReconnectTimer = null;
@ -72,7 +75,29 @@ export default class Connection {
this.Connect(); this.Connect();
} }
Connect() { async Connect() {
try {
if (this.Info === undefined) {
let u = new URL(this.Address);
let rsp = await fetch(`https://${u.host}`, {
headers: {
"accept": "application/nostr+json"
}
});
if (rsp.ok) {
let data = await rsp.json();
for (let [k, v] of Object.entries(data)) {
if (v === "unset" || v === "") {
data[k] = undefined;
}
}
this.Info = data;
}
}
} catch (e) {
console.warn("Could not load relay information", e);
}
this.IsClosed = false; this.IsClosed = false;
this.Socket = new WebSocket(this.Address); this.Socket = new WebSocket(this.Address);
this.Socket.onopen = (e) => this.OnOpen(e); this.Socket.onopen = (e) => this.OnOpen(e);
@ -259,6 +284,7 @@ export default class Connection {
this.CurrentState.events.send = this.Stats.EventsSent; this.CurrentState.events.send = this.Stats.EventsSent;
this.CurrentState.avgLatency = this.Stats.Latency.length > 0 ? (this.Stats.Latency.reduce((acc, v) => acc + v, 0) / this.Stats.Latency.length) : 0; this.CurrentState.avgLatency = this.Stats.Latency.length > 0 ? (this.Stats.Latency.reduce((acc, v) => acc + v, 0) / this.Stats.Latency.length) : 0;
this.CurrentState.disconnects = this.Stats.Disconnects; this.CurrentState.disconnects = this.Stats.Disconnects;
this.CurrentState.info = this.Info;
this.Stats.Latency = this.Stats.Latency.slice(-20); // trim this.Stats.Latency = this.Stats.Latency.slice(-20); // trim
this.HasStateChange = true; this.HasStateChange = true;
this._NotifyState(); this._NotifyState();

View File

@ -1,4 +1,3 @@
/** /**
* Stats class for tracking metrics per connection * Stats class for tracking metrics per connection
*/ */

9
src/Nostr/RelayInfo.ts Normal file
View File

@ -0,0 +1,9 @@
export interface RelayInfo {
name?: string,
description?: string,
pubkey?: string,
contact?: string,
supported_nips?: number[],
software?: string,
version?: string
}

View File

@ -1,3 +1,4 @@
import { ApiHost } from "Const";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
import ZapButton from "Element/ZapButton"; import ZapButton from "Element/ZapButton";
import { HexKey } from "Nostr"; import { HexKey } from "Nostr";
@ -24,7 +25,7 @@ const DonatePage = () => {
const [splits, setSplits] = useState<Splits[]>([]); const [splits, setSplits] = useState<Splits[]>([]);
async function loadSplits() { async function loadSplits() {
let rsp = await fetch("https://api.snort.social/api/v1/revenue/splits"); let rsp = await fetch(`${ApiHost}/api/v1/revenue/splits`);
if(rsp.ok) { if(rsp.ok) {
setSplits(await rsp.json()); setSplits(await rsp.json());
} }

View File

@ -1,4 +1,4 @@
import { RecommendedFollows } from "Const"; import { ApiHost, RecommendedFollows } from "Const";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import FollowListBase from "Element/FollowListBase"; import FollowListBase from "Element/FollowListBase";
import ProfilePreview from "Element/ProfilePreview"; import ProfilePreview from "Element/ProfilePreview";
@ -8,7 +8,7 @@ import { useSelector } from "react-redux";
import { RootState } from "State/Store"; import { RootState } from "State/Store";
import { bech32ToHex } from "Util"; import { bech32ToHex } from "Util";
const TwitterFollowsApi = "https://api.snort.social/api/v1/twitter/follows-for-nostr"; const TwitterFollowsApi = `${ApiHost}/api/v1/twitter/follows-for-nostr`;
export default function NewUserPage() { export default function NewUserPage() {
const [twitterUsername, setTwitterUsername] = useState<string>(""); const [twitterUsername, setTwitterUsername] = useState<string>("");
@ -24,7 +24,7 @@ export default function NewUserPage() {
const sortedTwitterFollows = useMemo(() => { const sortedTwitterFollows = useMemo(() => {
return follows.map(a => bech32ToHex(a)) return follows.map(a => bech32ToHex(a))
.sort((a, b) => currentFollows.includes(a) ? 1 : -1); .sort((a, b) => currentFollows.includes(a) ? 1 : -1);
}, [follows]); }, [follows, currentFollows]);
async function loadFollows() { async function loadFollows() {
setFollows([]); setFollows([]);

View File

@ -3,6 +3,7 @@ import SettingsIndex from "Pages/settings/Index";
import Profile from "Pages/settings/Profile"; import Profile from "Pages/settings/Profile";
import Relay from "Pages/settings/Relays"; import Relay from "Pages/settings/Relays";
import Preferences from "Pages/settings/Preferences"; import Preferences from "Pages/settings/Preferences";
import RelayInfo from "Pages/settings/RelayInfo";
export default function SettingsPage() { export default function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -26,7 +27,11 @@ export const SettingsRoutes: RouteObject[] = [
}, },
{ {
path: "relays", path: "relays",
element: <Relay /> element: <Relay />,
},
{
path: "relays/:addr",
element: <RelayInfo />
}, },
{ {
path: "preferences", path: "preferences",

View File

@ -1,3 +1,4 @@
import { ApiHost } from "Const";
import Nip5Service from "Element/Nip5Service"; import Nip5Service from "Element/Nip5Service";
import './Verification.css' import './Verification.css'
@ -6,7 +7,7 @@ export default function VerificationPage() {
const services = [ const services = [
{ {
name: "Snort", name: "Snort",
service: "https://api.snort.social/api/v1/n5sp", service: `${ApiHost}/api/v1/n5sp`,
link: "https://snort.social/", link: "https://snort.social/",
supportLink: "https://snort.social/help", supportLink: "https://snort.social/help",
about: <>Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!</> about: <>Our very own NIP-05 verification service, help support the development of this site and get a shiny special badge on our site!</>

View File

@ -31,7 +31,6 @@ export default function ProfileSettings() {
const [about, setAbout] = useState<string>(); const [about, setAbout] = useState<string>();
const [website, setWebsite] = useState<string>(); const [website, setWebsite] = useState<string>();
const [nip05, setNip05] = useState<string>(); const [nip05, setNip05] = useState<string>();
const [lud06, setLud06] = useState<string>();
const [lud16, setLud16] = useState<string>(); const [lud16, setLud16] = useState<string>();
const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture const avatarPicture = (picture?.length ?? 0) === 0 ? Nostrich : picture
@ -45,7 +44,6 @@ export default function ProfileSettings() {
setAbout(user.about); setAbout(user.about);
setWebsite(user.website); setWebsite(user.website);
setNip05(user.nip05); setNip05(user.nip05);
setLud06(user.lud06);
setLud16(user.lud16); setLud16(user.lud16);
} }
}, [user]); }, [user]);

View File

@ -0,0 +1,57 @@
import ProfilePreview from "Element/ProfilePreview";
import useRelayState from "Feed/RelayState";
import { System } from "Nostr/System";
import { useDispatch } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import { removeRelay } from "State/Login";
import { parseId } from "Util";
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);
return (
<>
<h3 className="pointer" onClick={() => navigate("/settings/relays")}>Relays</h3>
<div className="card">
<h3>{stats?.info?.name ?? addr}</h3>
<p>{stats?.info?.description}</p>
{stats?.info?.pubkey && (<>
<h4>Owner</h4>
<ProfilePreview pubkey={parseId(stats.info.pubkey)} />
</>)}
{stats?.info?.software && (<div className="flex">
<h4 className="f-grow">Software</h4>
<div className="flex f-col">
{stats.info.software.startsWith("http") ? <a href={stats.info.software} target="_blank" rel="noreferrer">{stats.info.software}</a> : <>{stats.info.software}</>}
<small>{!stats.info.version?.startsWith("v") && "v"}{stats.info.version}</small>
</div>
</div>)}
{stats?.info?.contact && (<div className="flex">
<h4 className="f-grow">Contact</h4>
<a href={`${stats.info.contact.startsWith("mailto:") ? "" : "mailto:"}${stats.info.contact}`} target="_blank" rel="noreferrer">{stats.info.contact}</a>
</div>)}
{stats?.info?.supported_nips && (<>
<h4>Supports</h4>
<div className="f-grow">
{stats.info.supported_nips.map(a => <span className="pill" onClick={() => navigate(`https://github.com/nostr-protocol/nips/blob/master/${a.toString().padStart(2, "0")}.md`)}>NIP-{a.toString().padStart(2, "0")}</span>)}
</div>
</>)}
<div className="flex mt10 f-end">
<div className="btn error" onClick={() => {
dispatch(removeRelay(con!.Address));
navigate("/settings/relays")
}}>Remove</div>
</div>
</div>
</>
)
}
export default RelayInfo;

View File

@ -52,7 +52,7 @@ const RelaySettingsPage = () => {
<div className="flex f-col"> <div className="flex f-col">
{Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)} {Object.keys(relays || {}).map(a => <Relay addr={a} key={a} />)}
</div> </div>
<div className="flex actions"> <div className="flex mt10">
<div className="f-grow"></div> <div className="f-grow"></div>
<div className="btn" onClick={() => saveRelays()}>Save</div> <div className="btn" onClick={() => saveRelays()}>Save</div>
</div> </div>

View File

@ -1,9 +1,8 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import * as secp from '@noble/secp256k1'; import * as secp from '@noble/secp256k1';
import { DefaultRelays } from 'Const'; import { DefaultRelays } from 'Const';
import { HexKey, RawEvent, TaggedRawEvent } from 'Nostr'; import { HexKey, TaggedRawEvent } from 'Nostr';
import { RelaySettings } from 'Nostr/Connection'; import { RelaySettings } from 'Nostr/Connection';
import { useDispatch } from 'react-redux';
const PrivateKeyItem = "secret"; const PrivateKeyItem = "secret";
const PublicKeyItem = "pubkey"; const PublicKeyItem = "pubkey";
@ -182,7 +181,7 @@ const LoginSlice = createSlice({
let filtered = new Map<string, RelaySettings>(); let filtered = new Map<string, RelaySettings>();
for (let [k, v] of Object.entries(relays)) { for (let [k, v] of Object.entries(relays)) {
if (k.startsWith("wss://") || k.startsWith("ws://")) { if (k.startsWith("wss://") || k.startsWith("ws://")) {
filtered.set(k, <RelaySettings>v); filtered.set(k, v as RelaySettings);
} }
} }

View File

@ -1,6 +1,6 @@
import * as secp from "@noble/secp256k1"; import * as secp from "@noble/secp256k1";
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import { HexKey, RawEvent, TaggedRawEvent, u256 } from "Nostr"; import { HexKey, TaggedRawEvent, u256 } from "Nostr";
import EventKind from "Nostr/EventKind"; import EventKind from "Nostr/EventKind";
export async function openFile(): Promise<File | undefined> { export async function openFile(): Promise<File | undefined> {
@ -65,7 +65,7 @@ export function eventLink(hex: u256) {
* @param {string} hex * @param {string} hex
*/ */
export function hexToBech32(hrp: string, hex: string) { export function hexToBech32(hrp: string, hex: string) {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 != 0) { if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
return ""; return "";
} }

View File

@ -222,18 +222,18 @@ textarea:placeholder {
align-items: flex-start !important; align-items: flex-start !important;
} }
.f-end {
justify-content: flex-end;
}
.w-max { .w-max {
width: 100%; width: 100%;
width: -moz-available; width: stretch;
width: -webkit-fill-available;
width: fill-available;
} }
.w-max-w { .w-max-w {
max-width: 100%; max-width: 100%;
max-width: -moz-available; max-width: stretch;
max-width: -webkit-fill-available;
max-width: fill-available;
} }
a { a {
@ -263,7 +263,7 @@ div.form-group>div:nth-child(1) {
div.form-group>div:nth-child(2) { div.form-group>div:nth-child(2) {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
justify-content: end; justify-content: flex-end;
} }
div.form-group>div:nth-child(2) input { div.form-group>div:nth-child(2) input {