chore: cleanup

This commit is contained in:
Kieran 2023-11-01 00:40:12 +09:00
parent 8f90daa840
commit c65bb7a992
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
56 changed files with 344 additions and 221 deletions

View File

@ -15,5 +15,10 @@
"zapPool": true
},
"eventLinkPrefix": "nevent",
"profileLinkPrefix": "nprofile"
"profileLinkPrefix": "nprofile",
"defaultRelays": {
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://nos.lol/": { "read": true, "write": true }
}
}

View File

@ -15,5 +15,10 @@
"zapPool": true
},
"eventLinkPrefix": "note",
"profileLinkPrefix": "npub"
"profileLinkPrefix": "npub",
"defaultRelays": {
"wss://relay.snort.social/": { "read": true, "write": true },
"wss://nostr.wine/": { "read": true, "write": false },
"wss://nos.lol/": { "read": true, "write": true }
}
}

View File

@ -54,4 +54,10 @@ declare const CONFIG: {
};
eventLinkPrefix: NostrPrefix;
profileLinkPrefix: NostrPrefix;
defaultRelays: Record<string, RelaySettings>;
};
/**
* Single relay (Debug)
*/
declare const SINGLE_RELAY: string | undefined;

View File

@ -376,5 +376,11 @@
<path d="M8.81413 2.99982C7.88643 2.99919 7.18706 2.99872 6.54986 3.21851C5.98936 3.41184 5.47885 3.72735 5.05527 4.14222C4.57372 4.61386 4.26137 5.23961 3.84705 6.06964C3.21363 7.33653 2.55478 8.59437 1.92983 9.86687C1.74859 10.2359 1.65797 10.4204 1.68168 10.5755C1.70218 10.7097 1.77888 10.8328 1.89025 10.9103C2.01902 11 2.22697 11 2.64287 11H21.3572C21.7731 11 21.9811 11 22.1098 10.9103C22.2212 10.8328 22.2979 10.7097 22.3184 10.5755C22.3421 10.4204 22.2515 10.2359 22.0702 9.86686C21.4453 8.59437 20.7865 7.33653 20.153 6.06966C19.7387 5.23961 19.4264 4.61386 18.9448 4.14222C18.5212 3.72735 18.0107 3.41184 17.4502 3.21851C16.813 2.99872 16.1136 2.99919 15.1859 2.99982H8.81413Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.0001 14.5996C23.0001 14.0399 23.0001 13.76 22.8912 13.5461C22.7953 13.3579 22.6423 13.2049 22.4541 13.109C22.2402 13 21.9603 13 21.4004 13H2.59962C2.03978 13 1.75985 13 1.54593 13.109C1.3578 13.2049 1.20476 13.3579 1.10891 13.5461C0.999925 13.76 0.999951 14.0399 1 14.5996C1.00002 14.8135 1.00004 15.0273 1.00004 15.2412C1.00003 16.0462 1.00002 16.7105 1.04423 17.2517C1.09016 17.8138 1.18872 18.3305 1.43601 18.8159C1.81951 19.5685 2.43143 20.1804 3.18408 20.5639C3.66941 20.8112 4.18612 20.9098 4.74821 20.9557C5.2894 20.9999 5.95376 20.9999 6.75872 20.9999H17.2414C18.0463 20.9999 18.7107 20.9999 19.2519 20.9557C19.814 20.9098 20.3307 20.8112 20.816 20.5639C21.5687 20.1804 22.1806 19.5685 22.5641 18.8159C22.8114 18.3305 22.9099 17.8138 22.9558 17.2517C23.0001 16.7105 23.0001 16.0462 23 15.2412C23 15.0274 23.0001 14.8135 23.0001 14.5996ZM6.00002 15C5.44773 15 5.00002 15.4477 5.00002 16C5.00002 16.5523 5.44773 17 6.00002 17H10C10.5523 17 11 16.5523 11 16C11 15.4477 10.5523 15 10 15H6.00002Z" fill="currentColor"/>
</symbol>
<symbol id="wifi-off" viewBox="0 0 24 20" fill="none">
<path d="M3.70711 0.292893C3.31658 -0.0976311 2.68342 -0.0976311 2.29289 0.292893C1.90237 0.683418 1.90237 1.31658 2.29289 1.70711L4.05356 3.46777C2.76797 4.14869 1.58058 4.98947 0.517762 5.96339C0.110582 6.33652 0.0829746 6.96908 0.4561 7.37626C0.829225 7.78344 1.46179 7.81105 1.86897 7.43792C2.95836 6.43965 4.19519 5.60049 5.5426 4.95681L7.83025 7.24447C6.4361 7.76113 5.16466 8.52904 4.07093 9.49251C3.65651 9.85758 3.6165 10.4895 3.98157 10.9039C4.34664 11.3183 4.97854 11.3583 5.39296 10.9933C6.53546 9.98682 7.90822 9.23792 9.42113 8.83535L12.0863 11.5005C12.0576 11.5002 12.0288 11.5 11.9999 11.5C10.3484 11.5 8.82808 12.0732 7.63085 13.0306C7.19953 13.3755 7.1295 14.0048 7.47443 14.4361C7.81937 14.8675 8.44865 14.9375 8.87997 14.5926C8.93165 14.5512 8.98416 14.5109 9.03748 14.4716C9.06175 14.4573 9.08563 14.4418 9.10903 14.4252C9.95503 13.8241 10.9671 13.5012 12.0049 13.5012C12.8194 13.5012 13.618 13.7001 14.333 14.0763C14.5973 14.2161 14.8477 14.3789 15.0814 14.5621C15.138 14.6064 15.1978 14.6437 15.2598 14.674L20.2929 19.7071C20.6834 20.0976 21.3166 20.0976 21.7071 19.7071C22.0976 19.3166 22.0976 18.6834 21.7071 18.2929L3.70711 0.292893Z" fill="currentColor"/>
<path d="M10.3098 3.59816C10.8684 3.53482 11.4326 3.50269 11.9998 3.50269C15.652 3.50269 19.1788 4.83517 21.9186 7.2502C22.3329 7.6154 22.9648 7.57559 23.33 7.16129C23.6952 6.74698 23.6554 6.11507 23.2411 5.74987C20.136 3.01283 16.139 1.50269 11.9998 1.50269C11.357 1.50269 10.7176 1.53911 10.0845 1.61089C9.53569 1.67311 9.14126 2.16841 9.20348 2.71718C9.2657 3.26595 9.761 3.66038 10.3098 3.59816Z" fill="currentColor"/>
<path d="M15.6095 7.04529C15.0822 6.88101 14.5216 7.17528 14.3573 7.70256C14.193 8.22985 14.4873 8.79048 15.0146 8.95476C16.2585 9.34233 17.4241 9.97212 18.4401 10.8184C18.8645 11.1719 19.495 11.1144 19.8485 10.69C20.2019 10.2657 20.1445 9.6351 19.7201 9.28164C18.5009 8.26611 17.1022 7.51034 15.6095 7.04529Z" fill="currentColor"/>
<path d="M12 16.5C11.4477 16.5 11 16.9477 11 17.5C11 18.0523 11.4477 18.5 12 18.5H12.01C12.5623 18.5 13.01 18.0523 13.01 17.5C13.01 16.9477 12.5623 16.5 12.01 16.5H12Z" fill="currentColor"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -1,5 +1,3 @@
import { RelaySettings } from "@snort/system";
/**
* 1 Hour in seconds
*/
@ -35,24 +33,10 @@ export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7v
*/
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
/**
* Websocket re-connect timeout
*/
export const DefaultConnectTimeout = 2000;
/**
* How long profile cache should be considered valid for
*/
export const ProfileCacheExpire = 1_000 * 60 * 60 * 6;
/**
* Default bootstrap relays
*/
export const DefaultRelays = new Map<string, RelaySettings>([
["wss://relay.snort.social/", { read: true, write: true }],
["wss://nostr.wine/", { read: true, write: false }],
["wss://nos.lol/", { read: true, write: true }],
]);
export const DefaultRelays = new Map(Object.entries(CONFIG.defaultRelays));
/**
* Default search relays

View File

@ -14,7 +14,6 @@
}
.light .spinner-button {
background: #fff;
border: 1px solid var(--border-color);
color: var(--font-secondary);
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;

View File

@ -1,5 +1,4 @@
.copy .copy-body {
font-size: var(--font-size-small);
color: var(--font-color);
margin-right: 6px;
}

View File

@ -14,7 +14,7 @@ export default function Copy({ text, maxSize = 32, className }: CopyProps) {
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
return (
<div className={classNames("copy flex pointer g8", className)} onClick={() => copy(text)}>
<div className={classNames("copy flex pointer g8 items-center", className)} onClick={() => copy(text)}>
<span className="copy-body">{trimmed}</span>
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
{copied ? <Icon name="check" size={14} /> : <Icon name="copy-solid" size={14} />}

View File

@ -0,0 +1,11 @@
import { OfflineError } from "@snort/shared";
import { Offline } from "./Offline";
import classNames from "classnames";
export function ErrorOrOffline({ error, onRetry, className }: { error: Error, onRetry?: () => void | Promise<void>, className?: string }) {
if (error instanceof OfflineError) {
return <Offline onRetry={onRetry} className={className} />;
} else {
return <b className={classNames("error", className)}>{error.message}</b>
}
}

View File

@ -86,7 +86,7 @@
}
.light .note-creator textarea {
background-color: #fff;
background-color: var(--gray-superdark);
}
.light .note-creator {

View File

@ -1,5 +1,4 @@
import "../Reveal.css";
import Icon from "Icons/Icon";
import { WarningNotice } from "Element/WarningNotice";
import { useState } from "react";
interface RevealProps {
@ -7,22 +6,14 @@ interface RevealProps {
children: React.ReactNode;
}
export default function Reveal(props: RevealProps): JSX.Element {
export default function Reveal(props: RevealProps) {
const [reveal, setReveal] = useState(false);
if (!reveal) {
return (
<div
onClick={e => {
e.stopPropagation();
setReveal(true);
}}
className="note-notice flex g8">
<Icon name="alert-circle" size={24} />
<div>{props.message}</div>
</div>
);
} else {
return <>{props.children}</>;
return <WarningNotice onClick={() => setReveal(true)}>
{props.message}
</WarningNotice>
} else if (props.children) {
return props.children;
}
}

View File

@ -0,0 +1,17 @@
import Icon from "Icons/Icon";
import AsyncButton from "./AsyncButton";
import { FormattedMessage } from "react-intl";
import classNames from "classnames";
export function Offline({ onRetry, className }: { onRetry?: () => void | Promise<void>, className?: string }) {
return <div className={classNames("flex items-center g8", className)}>
<Icon name="wifi-off" className="error" />
<div className="error">
<FormattedMessage defaultMessage="Offline" />
</div>
{onRetry &&
<AsyncButton onClick={onRetry}>
<FormattedMessage defaultMessage="Retry" />
</AsyncButton>}
</div>
}

View File

@ -2,7 +2,7 @@ import Spinner from "Icons/Spinner";
export default function PageSpinner() {
return (
<div className="flex items-center">
<div className="flex justify-center items-center">
<Spinner width={50} height={50} />
</div>
);

View File

@ -22,7 +22,7 @@ interface RelaysMetadataProps {
const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
return (
<div className="main-content">
<>
{relays?.map(({ url, settings }) => {
return (
<div key={url} className="card relay-card">
@ -35,7 +35,7 @@ const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
</div>
);
})}
</div>
</>
);
};

View File

@ -1,15 +0,0 @@
.note-notice {
color: var(--font-tertiary-color);
border: 1px solid var(--border-color);
padding: 8px 16px;
border-radius: 12px;
}
.note-notice i {
font-style: normal;
color: var(--font-color);
}
.note-notice svg {
color: var(--warning);
}

View File

@ -5,11 +5,6 @@
border-radius: 1000px;
}
.light .search {
background: #fff;
border: 1px solid var(--border-color);
}
.search input {
border: none !important;
border-radius: 0 !important;

View File

@ -8,6 +8,7 @@ import NostrBandApi from "External/NostrBand";
import SemisolDevApi from "External/SemisolDev";
import useLogin from "Hooks/useLogin";
import { hexToBech32 } from "SnortUtils";
import { ErrorOrOffline } from "./ErrorOrOffline";
enum Provider {
NostrBand = 1,
@ -18,12 +19,12 @@ export default function SuggestedProfiles() {
const login = useLogin();
const [userList, setUserList] = useState<HexKey[]>();
const [provider, setProvider] = useState(Provider.NostrBand);
const [error, setError] = useState("");
const [error, setError] = useState<Error>();
async function loadSuggestedProfiles() {
if (!login.publicKey) return;
setUserList(undefined);
setError("");
setError(undefined);
try {
switch (provider) {
@ -44,26 +45,27 @@ export default function SuggestedProfiles() {
}
} catch (e) {
if (e instanceof Error) {
setError(e.message);
setError(e);
}
}
}
useEffect(() => {
loadSuggestedProfiles().catch(console.error);
loadSuggestedProfiles();
}, [login, provider]);
return (
<>
<div className="card flex justify-between">
<div className="flex items-center justify-between bg-superdark p br">
<FormattedMessage defaultMessage="Provider" />
<select onChange={e => setProvider(Number(e.target.value))}>
<option value={Provider.NostrBand}>nostr.band</option>
{/*<option value={Provider.SemisolDev}>semisol.dev</option>*/}
</select>
</div>
{error && <b className="error">{error}</b>}
{userList ? <FollowListBase pubkeys={userList} showAbout={true} /> : <PageSpinner />}
{error && <ErrorOrOffline error={error} onRetry={loadSuggestedProfiles} />}
{userList && <FollowListBase pubkeys={userList} showAbout={true} />}
{!userList && !error && <PageSpinner />}
</>
);
}

View File

@ -5,9 +5,11 @@ import PageSpinner from "Element/PageSpinner";
import Note from "Element/Event/Note";
import NostrBandApi from "External/NostrBand";
import { useReactions } from "Feed/Reactions";
import { ErrorOrOffline } from "Element/ErrorOrOffline";
export default function TrendingNotes() {
const [posts, setPosts] = useState<Array<NostrEvent>>();
const [error, setError] = useState<Error>();
const related = useReactions("trending", posts?.map(a => NostrLink.fromEvent(a)) ?? []);
async function loadTrendingNotes() {
@ -17,9 +19,14 @@ export default function TrendingNotes() {
}
useEffect(() => {
loadTrendingNotes().catch(console.error);
loadTrendingNotes().catch(e => {
if (e instanceof Error) {
setError(e);
}
});
}, []);
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingNotes} className="p" />;
if (!posts) return <PageSpinner />;
return (

View File

@ -4,9 +4,11 @@ import { HexKey } from "@snort/system";
import FollowListBase from "Element/User/FollowListBase";
import PageSpinner from "Element/PageSpinner";
import NostrBandApi from "External/NostrBand";
import { ErrorOrOffline } from "./ErrorOrOffline";
export default function TrendingUsers() {
const [userList, setUserList] = useState<HexKey[]>();
const [error, setError] = useState<Error>();
async function loadTrendingUsers() {
const api = new NostrBandApi();
@ -16,9 +18,14 @@ export default function TrendingUsers() {
}
useEffect(() => {
loadTrendingUsers().catch(console.error);
loadTrendingUsers().catch(e => {
if (e instanceof Error) {
setError(e);
}
});
}, []);
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingUsers} className="p" />;
if (!userList) return <PageSpinner />;
return <FollowListBase pubkeys={userList} showAbout={true} />;

View File

@ -6,7 +6,7 @@ import Note from "Element/Event/Note";
import useLogin from "Hooks/useLogin";
import { UserCache } from "Cache";
import messages from "./messages";
import messages from "../messages";
interface BookmarksProps {
pubkey: HexKey;
@ -27,7 +27,7 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
}
return (
<div className="main-content">
<>
<div className="flex-end p">
<select
disabled={ps.length <= 1}
@ -51,7 +51,7 @@ const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
/>
);
})}
</div>
</>
);
};

View File

@ -15,7 +15,7 @@ export default function MutedList({ pubkeys }: MutedListProps) {
const hasAllMuted = pubkeys.every(isMuted);
return (
<div className="main-content p">
<div className="p">
<div className="flex justify-between">
<div className="bold">
<FormattedMessage {...messages.MuteCount} values={{ n: pubkeys?.length }} />

View File

@ -0,0 +1,18 @@
.warning-notice {
color: var(--font-tertiary-color);
border: 1px solid var(--border-color);
padding: 8px 16px;
border-radius: 12px;
display: flex;
gap: 8px;
}
.warning-notice i {
font-style: normal;
color: var(--font-color);
}
.warning-notice > svg {
color: var(--warning);
}

View File

@ -0,0 +1,12 @@
import "./WarningNotice.css";
import Icon from "Icons/Icon";
export function WarningNotice({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) {
return <div className="warning-notice" onClick={e => {
e.stopPropagation();
onClick?.();
}}>
<Icon name="alert-circle" size={24} />
<div>{children}</div>
</div>
}

View File

@ -1,3 +1,4 @@
import { throwIfOffline } from "@snort/shared";
import { NostrEvent } from "@snort/system";
export interface TrendingUser {
@ -52,6 +53,7 @@ export default class NostrBandApi {
}
async #json<T>(method: string, path: string) {
throwIfOffline();
const res = await fetch(`${this.#url}${path}`, {
method: method ?? "GET",
});

View File

@ -1,3 +1,5 @@
import { throwIfOffline } from "@snort/shared";
export interface RecommendedProfilesResponse {
quality: number;
recommendations: Array<[pubkey: string, score: number]>;
@ -26,6 +28,7 @@ export default class SemisolDevApi {
}
async #json<T>(method: string, path: string, body?: unknown) {
throwIfOffline();
const url = `${this.#url}${path}`;
const res = await fetch(url, {
method: method ?? "GET",

View File

@ -1,3 +1,4 @@
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher } from "@snort/system";
import { ApiHost } from "Const";
import { SubscriptionType } from "Subscription";
@ -131,6 +132,7 @@ export default class SnortApi {
body?: object,
headers?: { [key: string]: string },
): Promise<T> {
throwIfOffline();
const rsp = await fetch(`${this.#url}${path}`, {
method: method,
body: body ? JSON.stringify(body) : undefined,

View File

@ -51,7 +51,7 @@ export default function useLoginFeed() {
b.withOptions({
leaveOpen: true,
});
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]);
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList, EventKind.Relays]);
if (CONFIG.features.subscriptions && !login.readonly) {
b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]);
b.withFilter()
@ -84,16 +84,28 @@ export default function useLoginFeed() {
if (loginFeed.data) {
const contactList = getNewest(loginFeed.data.filter(a => a.kind === EventKind.ContactList));
if (contactList) {
if (contactList.content !== "" && contactList.content !== "{}") {
const relays = JSON.parse(contactList.content);
setRelays(login, relays, contactList.created_at * 1000);
}
const pTags = contactList.tags.filter(a => a[0] === "p").map(a => a[1]);
setFollows(login, pTags, contactList.created_at * 1000);
FollowsFeed.backFillIfMissing(system, pTags);
}
const relays = getNewest(loginFeed.data.filter(a => a.kind === EventKind.Relays));
if (relays) {
const parsedRelays = relays.tags
.filter(a => a[0] === "r")
.map(a => {
return [
a[1],
{
read: a[2] === "read" || a[2] === undefined,
write: a[2] === "write" || a[2] === undefined,
},
];
});
setRelays(login, Object.fromEntries(parsedRelays), relays.created_at * 1000);
}
Nip4Chats.onEvent(loginFeed.data);
Nip28Chats.onEvent(loginFeed.data);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useMemo } from "react";
import { EventKind, NostrLink, NoteCollection, RequestBuilder } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react";
import { unixNow } from "@snort/shared";
@ -134,12 +134,6 @@ export default function useTimelineFeed(subject: TimelineSubject, options: Timel
const latest = useRequestBuilder(NoteCollection, subRealtime);
const reactions = useReactions(`${sub?.id}-reactions`, main.data?.map(a => NostrLink.fromEvent(a)) ?? []);
useEffect(() => {
// clear store if changing relays
main.clear();
latest.clear();
}, [subject.relay]);
return {
main: main.data,
related: reactions.data,

View File

@ -1,12 +1,11 @@
import { useIntl } from "react-intl";
import { Nip46Signer, KeyStorage } from "@snort/system";
import { fetchNip05Pubkey, unwrap } from "@snort/shared";
import { EmailRegex, MnemonicRegex } from "Const";
import { LoginSessionType, LoginStore } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { getNip05PubKey } from "Pages/LoginPage";
import { bech32ToHex } from "SnortUtils";
import { unwrap } from "@snort/shared";
export default function useLoginHandler() {
const { formatMessage } = useIntl();
@ -50,7 +49,11 @@ export default function useLoginHandler() {
const hexKey = bech32ToHex(key);
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.match(EmailRegex)) {
const hexKey = await getNip05PubKey(key);
const [name, domain] = key.split("@");
const hexKey = await fetchNip05Pubkey(name, domain);
if (!hexKey) {
throw new Error("Invalid nostr address");
}
LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.startsWith("bunker://")) {
const nip46 = new Nip46Signer(key);

View File

@ -1,6 +1,7 @@
import { useEffect } from "react";
import useLogin from "./useLogin";
import useEventPublisher from "./useEventPublisher";
import { RelaySettings, SystemInterface } from "@snort/system";
export function useLoginRelays() {
const { relays } = useLogin();
@ -8,16 +9,23 @@ export function useLoginRelays() {
useEffect(() => {
if (relays) {
(async () => {
for (const [k, v] of Object.entries(relays.item)) {
await system.ConnectToRelay(k, v);
}
for (const v of system.Sockets) {
if (!relays.item[v.address] && !v.ephemeral) {
system.DisconnectRelay(v.address);
}
}
})();
updateRelayConnections(system, relays.item)
.catch(console.error);
}
}, [relays]);
}
export async function updateRelayConnections(system: SystemInterface, relays: Record<string, RelaySettings>) {
if (SINGLE_RELAY) {
system.ConnectToRelay(SINGLE_RELAY, { read: true, write: true });
} else {
for (const [k, v] of Object.entries(relays)) {
await system.ConnectToRelay(k, v);
}
for (const v of system.Sockets) {
if (!relays[v.address] && !v.ephemeral) {
system.DisconnectRelay(v.address);
}
}
}
}

View File

@ -20,6 +20,15 @@ import { Chats, FollowsFeed, GiftsCache, Notifications } from "Cache";
import { Nip7OsSigner } from "./Nip7OsSigner";
export function setRelays(state: LoginSession, relays: Record<string, RelaySettings>, createdAt: number) {
if (SINGLE_RELAY) {
state.relays.item = {
[SINGLE_RELAY]: { read: true, write: true },
};
state.relays.timestamp = 100;
LoginStore.updateSession(state);
return;
}
if (state.relays.timestamp >= createdAt) {
return;
}

View File

@ -161,6 +161,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
}
decideInitRelays(relays: Record<string, RelaySettings> | undefined): Record<string, RelaySettings> {
if (SINGLE_RELAY) return { [SINGLE_RELAY]: { read: true, write: true } };
if (relays && Object.keys(relays).length > 0) {
return relays;
}
@ -172,7 +173,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (this.#accounts.has(pubKey)) {
throw new Error("Already logged in with this pubkey");
}
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
const initRelays = this.decideInitRelays(relays);
const newSession = {
...LoggedOut,
id: uuid(),

View File

@ -1,3 +1,5 @@
import { throwIfOffline } from "@snort/shared";
export type ServiceErrorCode =
| "UNKNOWN_ERROR"
| "INVALID_BODY"
@ -99,6 +101,7 @@ export class ServiceProvider {
body?: unknown,
headers?: { [key: string]: string },
): Promise<T | ServiceError> {
throwIfOffline();
try {
const rsp = await fetch(`${this.url}${path}`, {
method: method,

View File

@ -1,3 +1,5 @@
import { throwIfOffline } from "@snort/shared";
interface NostrJson {
names: Record<string, string>;
}
@ -7,6 +9,7 @@ export async function fetchNip05Pubkey(name: string, domain: string, timeout = 2
return undefined;
}
try {
throwIfOffline();
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`, {
signal: AbortSignal.timeout(timeout),
});

View File

@ -1,6 +1,6 @@
import SuggestedProfiles from "Element/SuggestedProfiles";
import { Tab, TabElement } from "Element/Tabs";
import TrendingNotes from "Element/Feed/TrendingPosts";
import TrendingNotes from "Element/TrendingPosts";
import TrendingUsers from "Element/TrendingUsers";
import { useState } from "react";
import { useIntl } from "react-intl";

View File

@ -159,7 +159,7 @@ const AccountHeader = () => {
}
return (
<div className="header-actions">
{!location.pathname.startsWith("/search") && <SearchBox />}
{!location.pathname.startsWith("/search") ? <SearchBox /> : <div className="grow"></div>}
{!readonly && (
<Link className="btn" to="/messages">
<Icon name="mail" size={24} />

View File

@ -59,21 +59,6 @@ const Artwork: Array<ArtworkEntry> = [
},
];
export async function getNip05PubKey(addr: string): Promise<string> {
const [username, domain] = addr.split("@");
const rsp = await fetch(
`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(username.toLocaleLowerCase())}`,
);
if (rsp.ok) {
const data = await rsp.json();
const pKey = data.names[username.toLowerCase()];
if (pKey) {
return pKey;
}
}
throw new Error("User key not found");
}
export default function LoginPage() {
const navigate = useNavigate();
const [key, setKey] = useState("");

View File

@ -2,9 +2,9 @@ import { NostrPrefix, tryParseNostrLink } from "@snort/system";
import { useEffect, useState } from "react";
import { FormattedMessage } from "react-intl";
import { useLocation, useParams } from "react-router-dom";
import { fetchNip05Pubkey } from "@snort/shared";
import Spinner from "Icons/Spinner";
import { getNip05PubKey } from "Pages/LoginPage";
import ProfilePage from "Pages/Profile/ProfilePage";
import { ThreadRoute } from "Element/Event/Thread";
@ -30,7 +30,7 @@ export default function NostrLinkHandler() {
setRenderComponent(<ProfilePage state={state} />); // Directly render ProfilePage from route state
} else {
try {
const pubkey = await getNip05PubKey(`${link}@${CONFIG.nip05Domain}`);
const pubkey = await fetchNip05Pubkey(link, CONFIG.nip05Domain);
if (pubkey) {
setRenderComponent(<ProfilePage id={pubkey} state={state} />); // Directly render ProfilePage
}

View File

@ -100,12 +100,20 @@ export default function NotificationsPage({ onClick }: { onClick?: (link: NostrL
}, [myNotifications]);
return (
<div className="main-content">
<NotificationSummary evs={myNotifications as TaggedNostrEvent[]} />
<>
<div className="main-content p flex g12 items-center">
<Icon name="bell" />
<h3 className="my-0">
<FormattedMessage defaultMessage="Notifications" />
</h3>
</div>
<div className="main-content">
<NotificationSummary evs={myNotifications as TaggedNostrEvent[]} />
{login.publicKey &&
[...timeGrouped.entries()].map(([k, g]) => <NotificationGroup key={k} evs={g} onClick={onClick} />)}
</div>
{login.publicKey &&
[...timeGrouped.entries()].map(([k, g]) => <NotificationGroup key={k} evs={g} onClick={onClick} />)}
</div>
</>
);
}
@ -194,6 +202,8 @@ function NotificationSummary({ evs }: { evs: Array<TaggedNostrEvent> }) {
);
}, [evs, period]);
if (evs.length === 0) return;
const filterIcon = (f: NotificationSummaryFilter, icon: string, iconActiveClass: string) => {
const active = hasFlag(filter, f);
return (

View File

@ -12,7 +12,7 @@ import {
TLVEntryType,
tryParseNostrLink,
} from "@snort/system";
import { LNURL } from "@snort/shared";
import { LNURL, fetchNip05Pubkey } from "@snort/shared";
import { useUserProfile } from "@snort/system-react";
import { findTag, getLinkReactions, unwrap } from "SnortUtils";
@ -44,7 +44,6 @@ import BadgeList from "Element/User/BadgeList";
import { ProxyImg } from "Element/ProxyImg";
import useHorizontalScroll from "Hooks/useHorizontalScroll";
import { EmailRegex } from "Const";
import { getNip05PubKey } from "Pages/LoginPage";
import useLogin from "Hooks/useLogin";
import { ZapTarget } from "Zapper";
import { useStatusFeed } from "Feed/StatusFeed";
@ -113,7 +112,8 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
if (!id) {
const resolvedId = propId || params.id;
if (resolvedId?.match(EmailRegex)) {
getNip05PubKey(resolvedId).then(a => {
const [name, domain] = resolvedId.split("@");
fetchNip05Pubkey(name, domain).then(a => {
setId(a);
});
} else {
@ -187,14 +187,14 @@ export default function ProfilePage({ id: propId, state }: ProfilePageProps) {
targets={
lnurl?.lnurl && id
? [
{
type: "lnurl",
value: lnurl?.lnurl,
weight: 1,
name: user?.display_name || user?.name,
zap: { pubkey: id },
} as ZapTarget,
]
{
type: "lnurl",
value: lnurl?.lnurl,
weight: 1,
name: user?.display_name || user?.name,
zap: { pubkey: id },
} as ZapTarget,
]
: undefined
}
show={showLnQr}

View File

@ -9,7 +9,7 @@ import useFollowsFeed from "Feed/FollowsFeed";
import useRelaysFeed from "Feed/RelaysFeed";
import RelaysMetadata from "Element/Relay/RelaysMetadata";
import useBookmarkFeed from "Feed/BookmarkFeed";
import Bookmarks from "Element/Bookmarks";
import Bookmarks from "Element/User/Bookmarks";
import Icon from "Icons/Icon";
import { Tab } from "Element/Tabs";
import { default as ZapElement } from "Element/Event/Zap";
@ -32,14 +32,14 @@ export function ZapsProfileTab({ id }: { id: HexKey }) {
const zaps = useZapsFeed(new NostrLink(NostrPrefix.PublicKey, id));
const zapsTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
return (
<div className="main-content">
<>
<h2 className="p">
<FormattedMessage {...messages.Sats} values={{ n: formatShort(zapsTotal) }} />
</h2>
{zaps.map(z => (
<ZapElement showZapped={false} zap={z} />
))}
</div>
</>
);
}

View File

@ -11,7 +11,7 @@ import { debounce, getRelayName, sha256 } from "SnortUtils";
import useLogin from "Hooks/useLogin";
import Discover from "Pages/Discover";
import TrendingUsers from "Element/TrendingUsers";
import TrendingNotes from "Element/Feed/TrendingPosts";
import TrendingNotes from "Element/TrendingPosts";
import HashTagsPage from "Pages/HashTagsPage";
import SuggestedProfiles from "Element/SuggestedProfiles";
import { TaskList } from "Tasks/TaskList";
@ -65,25 +65,19 @@ export const GlobalTab = () => {
const [now] = useState(unixNow());
const system = useContext(SnortContext);
const subject: TimelineSubject = {
type: "global",
items: [],
relay: relay?.url ? [relay.url] : undefined,
discriminator: `all-${sha256(relay?.url ?? "").slice(0, 12)}`,
};
function globalRelaySelector() {
if (!allRelays || allRelays.length === 0) return null;
const paidRelays = allRelays.filter(a => a.paid);
const publicRelays = allRelays.filter(a => !a.paid);
return (
<div className="flex items-center mb10 justify-end nowrap">
<FormattedMessage
defaultMessage="Read global from"
description="Label for reading global feed from specific relays"
/>
&nbsp;
<div className="flex items-center g8 justify-end nowrap">
<h3>
<FormattedMessage
defaultMessage="Relay"
description="Label for reading global feed from specific relays"
/>
</h3>
<select
className="f-ellipsis"
onChange={e => setRelay(allRelays.find(a => a.url === e.target.value))}
@ -113,10 +107,12 @@ export const GlobalTab = () => {
return debounce(500, () => {
const ret: RelayOption[] = [];
system.Sockets.forEach(v => {
ret.push({
url: v.address,
paid: v.info?.limitation?.payment_required ?? false,
});
if (v.connected) {
ret.push({
url: v.address,
paid: v.info?.limitation?.payment_required ?? false,
});
}
});
ret.sort(a => (a.paid ? -1 : 1));
@ -130,7 +126,14 @@ export const GlobalTab = () => {
return (
<>
{globalRelaySelector()}
{relay && <Timeline subject={subject} postsOnly={false} method={"TIME_RANGE"} window={600} now={now} />}
{relay && <Timeline subject={
{
type: "global",
items: [],
relay: [relay.url],
discriminator: `all-${sha256(relay.url)}`,
}
} postsOnly={false} method={"TIME_RANGE"} window={600} now={now} />}
</>
);
};
@ -148,8 +151,8 @@ export const NotesTab = () => {
noteOnClick={
deckContext
? ev => {
deckContext.setThread(NostrLink.fromEvent(ev));
}
deckContext.setThread(NostrLink.fromEvent(ev));
}
: undefined
}
/>

View File

@ -1,13 +1,13 @@
import { useIntl, FormattedMessage } from "react-intl";
import { useParams } from "react-router-dom";
import Timeline from "Element/Feed/Timeline";
import { Tab, TabElement } from "Element/Tabs";
import Tabs, { Tab } from "Element/Tabs";
import { useEffect, useState } from "react";
import { debounce } from "SnortUtils";
import { router } from "index";
import TrendingUsers from "Element/TrendingUsers";
import TrendingNotes from "Element/Feed/TrendingPosts";
import TrendingNotes from "Element/TrendingPosts";
const NOTES = 0;
const PROFILES = 1;
@ -19,11 +19,11 @@ const SearchPage = () => {
const [keyword, setKeyword] = useState<string | undefined>(params.keyword);
const [sortPopular, setSortPopular] = useState<boolean>(true);
// tabs
const SearchTab = {
Posts: { text: formatMessage({ defaultMessage: "Notes" }), value: NOTES },
Profiles: { text: formatMessage({ defaultMessage: "People" }), value: PROFILES },
};
const [tab, setTab] = useState<Tab>(SearchTab.Posts);
const SearchTab = [
{ text: formatMessage({ defaultMessage: "Notes" }), value: NOTES },
{ text: formatMessage({ defaultMessage: "People" }), value: PROFILES },
];
const [tab, setTab] = useState<Tab>(SearchTab[0]);
useEffect(() => {
if (keyword) {
@ -72,9 +72,8 @@ const SearchPage = () => {
function sortOptions() {
if (tab.value != PROFILES) return null;
return (
<div className="flex mb10 justify-end">
<div className="flex items-center justify-end g8">
<FormattedMessage defaultMessage="Sort" description="Label for sorting options for people search" />
&nbsp;
<select onChange={e => setSortPopular(e.target.value == "true")} value={sortPopular ? "true" : "false"}>
<option value={"true"}>
<FormattedMessage defaultMessage="Popular" description="Sort order name" />
@ -87,26 +86,22 @@ const SearchPage = () => {
);
}
function renderTab(v: Tab) {
return <TabElement key={v.value} t={v} tab={tab} setTab={setTab} />;
}
return (
<div className="main-content p">
<h2>
<FormattedMessage defaultMessage="Search" />
</h2>
<div className="flex mb10">
<div className="main-content">
<div className="p flex flex-col g8">
<h2>
<FormattedMessage defaultMessage="Search" />
</h2>
<input
type="text"
className="grow mr10"
className="w-max"
placeholder={formatMessage({ defaultMessage: "Search..." })}
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus={true}
/>
<Tabs tabs={SearchTab} tab={tab} setTab={setTab} />
</div>
<div className="tabs p">{[SearchTab.Posts, SearchTab.Profiles].map(renderTab)}</div>
{tabContent()}
</div>
);

View File

@ -13,6 +13,7 @@ import useLogin from "Hooks/useLogin";
import Icon from "Icons/Icon";
import Avatar from "Element/User/Avatar";
import { FormattedMessage } from "react-intl";
import { ErrorOrOffline } from "Element/ErrorOrOffline";
export interface ProfileSettingsProps {
avatar?: boolean;
@ -25,6 +26,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
const user = useUserProfile(id ?? "");
const { publisher, system } = useEventPublisher();
const uploader = useFileUpload();
const [error, setError] = useState<Error>();
const [name, setName] = useState<string>();
const [picture, setPicture] = useState<string>();
@ -79,15 +81,20 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
}
async function uploadFile() {
const file = await openFile();
if (file) {
console.log(file);
const rsp = await uploader.upload(file, file.name);
console.log(rsp);
if (typeof rsp?.error === "string") {
throw new Error(`Upload failed ${rsp.error}`);
try {
setError(undefined);
const file = await openFile();
if (file) {
const rsp = await uploader.upload(file, file.name);
if (typeof rsp?.error === "string") {
throw new Error(`Upload failed ${rsp.error}`);
}
return rsp.url;
}
} catch (e) {
if (e instanceof Error) {
setError(e);
}
return rsp.url;
}
}
@ -210,7 +217,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
<Avatar pubkey={id} user={user} image={picture} />
<AsyncButton
type="button"
className="circle flex align-centerjustify-between"
className="circle flex align-center justify-between"
onClick={() => setNewAvatar()}
disabled={readonly}>
<Icon name="upload-01" />
@ -218,6 +225,7 @@ export default function ProfileSettings(props: ProfileSettingsProps) {
</div>
)}
</div>
{error && <ErrorOrOffline error={error} />}
{editor()}
</>
);

View File

@ -5,14 +5,20 @@ import { Link, useNavigate } from "react-router-dom";
import { ApiHost } from "Const";
import useEventPublisher from "Hooks/useEventPublisher";
import SnortServiceProvider, { ManageHandle } from "Nip05/SnortServiceProvider";
import { ErrorOrOffline } from "Element/ErrorOrOffline";
export default function ListHandles() {
const navigate = useNavigate();
const { publisher } = useEventPublisher();
const [handles, setHandles] = useState<Array<ManageHandle>>([]);
const [error, setError] = useState<Error>();
useEffect(() => {
loadHandles().catch(console.error);
loadHandles().catch(e => {
if (e instanceof Error) {
setError(e);
}
});
}, [publisher]);
async function loadHandles() {
@ -60,6 +66,7 @@ export default function ListHandles() {
<FormattedMessage defaultMessage="Buy Handle" />
</button>
)}
{error && <ErrorOrOffline error={error} onRetry={loadHandles} />}
</>
);
}

View File

@ -7,6 +7,7 @@ import useEventPublisher from "Hooks/useEventPublisher";
import SnortApi, { Subscription, SubscriptionError } from "External/SnortApi";
import { mapSubscriptionErrorCode } from ".";
import SubscriptionCard from "./SubscriptionCard";
import { ErrorOrOffline } from "Element/ErrorOrOffline";
export default function ManageSubscriptionPage() {
const { publisher } = useEventPublisher();
@ -14,21 +15,25 @@ export default function ManageSubscriptionPage() {
const navigate = useNavigate();
const [subs, setSubs] = useState<Array<Subscription>>();
const [error, setError] = useState<SubscriptionError>();
const [error, setError] = useState<Error>();
useEffect(() => {
(async () => {
try {
const s = await api.listSubscriptions();
setSubs(s);
} catch (e) {
if (e instanceof SubscriptionError) {
setError(e);
}
async function loadSubs() {
setError(undefined);
try {
const s = await api.listSubscriptions();
setSubs(s);
} catch (e) {
if (e instanceof Error) {
setError(e);
}
})();
}
}
useEffect(() => {
loadSubs();
}, []);
if (!(error instanceof SubscriptionError) && error instanceof Error) return <ErrorOrOffline error={error} onRetry={loadSubs} className="main-content p" />;
if (subs === undefined) {
return <PageSpinner />;
}
@ -59,7 +64,7 @@ export default function ManageSubscriptionPage() {
/>
</p>
)}
{error && <b className="error">{mapSubscriptionErrorCode(error)}</b>}
{error instanceof SubscriptionError && <b className="error">{mapSubscriptionErrorCode(error)}</b>}
</div>
);
}

View File

@ -1,3 +1,4 @@
import Nostrich from "../nostrich.webp";
import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils";
import { sha256 as hash } from "@noble/hashes/sha256";
@ -18,6 +19,7 @@ import {
} from "@snort/system";
import { Day } from "Const";
import AnimalName from "Element/User/AnimalName";
import { isOffline } from "@snort/shared";
export const sha256 = (str: string | Uint8Array): u256 => {
return utils.bytesToHex(hash(str));
@ -464,6 +466,7 @@ export function kvToObject<T>(o: string, sep?: string) {
}
export function defaultAvatar(input?: string) {
if (isOffline()) return Nostrich;
return `https://robohash.v0l.io/${input ?? "missing"}.png${isHalloween() ? "?set=set2" : ""}`;
}

View File

@ -1,4 +1,5 @@
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher } from "@snort/system";
import { UploadResult } from "Upload";
@ -30,6 +31,7 @@ export default async function NostrBuild(file: File | Blob, publisher?: EventPub
headers,
});
if (rsp.ok) {
throwIfOffline();
const data = (await rsp.json()) as NostrBuildUploadResponse;
const res = data.data[0];
return {

View File

@ -1,6 +1,8 @@
import { throwIfOffline } from "@snort/shared";
import { UploadResult } from "Upload";
export default async function NostrImg(file: File | Blob): Promise<UploadResult> {
throwIfOffline();
const fd = new FormData();
fd.append("image", file);

View File

@ -4,6 +4,7 @@ import { UploadState, VoidApi } from "@void-cat/api";
import { FileExtensionRegex, VoidCatHost } from "Const";
import { UploadResult } from "Upload";
import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared";
/**
* Upload file to void.cat
@ -16,6 +17,7 @@ export default async function VoidCatUpload(
progress?: (n: number) => void,
stage?: (n: "starting" | "hashing" | "uploading" | "done" | undefined) => void,
): Promise<UploadResult> {
throwIfOffline();
const auth = publisher
? async (url: string, method: string) => {
const auth = await publisher.generic(eb => {

View File

@ -1,3 +1,4 @@
import { throwIfOffline } from "@snort/shared";
import {
InvoiceRequest,
LNWallet,
@ -120,6 +121,7 @@ export default class LNDHubWallet implements LNWallet {
}
private async getJson<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
throwIfOffline();
const auth = `Bearer ${this.auth?.access_token}`;
const url = `${this.url.pathname === "/" ? this.url.toString().slice(0, -1) : this.url.toString()}${path}`;
const rsp = await fetch(url, {

View File

@ -92,8 +92,8 @@ html.light {
--gray-superlight: #333;
--gray-light: #555;
--gray-dark: #ccc;
--gray-superdark: #fff;
--gray-ultradark: #fff;
--gray-superdark: #f0f0f0;
--gray-ultradark: #fefefe;
--dm-gradient: var(--gray);
--invoice-gradient: linear-gradient(45deg, var(--gray-superdark) 50%, #f7b73333, #fc4a1a33);
--paid-invoice-gradient: linear-gradient(45deg, var(--gray-superdark) 50%, #f7b73399, #fc4a1a99);
@ -908,22 +908,15 @@ svg.zap-solid {
border: 1px solid var(--border-color);
}
.light .spinner-button {
background: #fff;
border: 1px solid var(--border-color);
color: var(--font-secondary);
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;
}
.light .spinner-button:hover {
box-shadow: rgba(0, 0, 0, 0.2) 0 1px 3px;
}
.main-content.p {
.main-content.p:not(:last-of-type) {
border-bottom: 0;
border-top: 0;
}
.main-content.p:last-of-type {
border-top: 0;
}
.light button.icon {
border: 1px solid var(--border-color);
box-shadow: rgba(0, 0, 0, 0.08) 0 1px 1px;

View File

@ -20,7 +20,7 @@ import {
PowWorker,
} from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { removeUndefined } from "@snort/shared";
import { removeUndefined, throwIfOffline } from "@snort/shared";
import * as serviceWorkerRegistration from "serviceWorkerRegistration";
import { IntlProvider } from "IntlProvider";
@ -49,6 +49,7 @@ import { LoginStore } from "Login";
import { SnortDeckLayout } from "Pages/DeckLayout";
import FreeNostrAddressPage from "./Pages/FreeNostrAddressPage";
import { ListFeedPage } from "Pages/ListFeedPage";
import { updateRelayConnections } from "Hooks/useLoginRelays";
const WasmQueryOptimizer = {
expandFilter: (f: ReqFilter) => {
@ -96,6 +97,7 @@ const System = new NostrSystem({
async function fetchProfile(key: string) {
try {
throwIfOffline();
const rsp = await fetch(`${CONFIG.httpCache}/profile/${key}`);
if (rsp.ok) {
const data = (await rsp.json()) as NostrEvent;
@ -134,9 +136,9 @@ async function initSite() {
await preload(login.follows.item);
}
for (const [k, v] of Object.entries(login.relays.item)) {
System.ConnectToRelay(k, v);
}
updateRelayConnections(System, login.relays.item)
.catch(console.error);
try {
if ("registerProtocolHandler" in window.navigator) {
window.navigator.registerProtocolHandler("web+nostr", `${window.location.protocol}//${window.location.host}/%s`);

View File

@ -90,6 +90,7 @@ const config = {
: false,
new DefinePlugin({
CONFIG: JSON.stringify(appConfig),
SINGLE_RELAY: JSON.stringify(process.env.SINGLE_RELAY),
}),
],
module: {

View File

@ -1,5 +1,5 @@
import { EmailRegex } from "./const";
import { bech32ToText, unwrap } from "./utils";
import { bech32ToText, throwIfOffline, unwrap } from "./utils";
const PayServiceTag = "payRequest";
@ -93,6 +93,7 @@ export class LNURL {
}
async load() {
throwIfOffline();
const rsp = await fetch(this.#url);
if (rsp.ok) {
this.#service = await rsp.json();
@ -108,6 +109,7 @@ export class LNURL {
* @returns
*/
async getInvoice(amount: number, comment?: string, zap?: object) {
throwIfOffline();
const callback = new URL(unwrap(this.#service?.callback));
const query = new Map<string, string>();

View File

@ -215,3 +215,15 @@ export function normalizeReaction(content: string) {
return Reaction.Positive;
}
}
export class OfflineError extends Error {}
export function throwIfOffline() {
if (isOffline()) {
throw new OfflineError("Offline");
}
}
export function isOffline() {
return !("navigator" in globalThis && globalThis.navigator.onLine);
}