feature flags config / typed app config
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Kieran 2023-10-10 10:37:53 +01:00
parent c023a89271
commit 9d33abbf1e
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
20 changed files with 118 additions and 99 deletions

View File

@ -6,5 +6,8 @@
"favicon": "public/favicon.ico", "favicon": "public/favicon.ico",
"appleTouchIconUrl": "/nostrich_512.png", "appleTouchIconUrl": "/nostrich_512.png",
"httpCache": "", "httpCache": "",
"animalNamePlaceholders": false "animalNamePlaceholders": false,
"features": {
"subscriptions": true
}
} }

View File

@ -6,5 +6,8 @@
"favicon": "public/iris/favicon.ico", "favicon": "public/iris/favicon.ico",
"appleTouchIconUrl": "/img/apple-touch-icon.png", "appleTouchIconUrl": "/img/apple-touch-icon.png",
"httpCache": "https://api.iris.to", "httpCache": "https://api.iris.to",
"animalNamePlaceholders": true "animalNamePlaceholders": true,
"features": {
"subscriptions": false
}
} }

View File

@ -34,3 +34,17 @@ declare module "emojilib" {
const value: Record<string, string>; const value: Record<string, string>;
export default value; export default value;
} }
declare const CONFIG: {
appName: string;
appNameCapitalized: string;
appTitle: string;
nip05Domain: string;
favicon: string;
appleTouchIconUrl: string;
httpCache: string;
animalNamePlaceholders: boolean;
features: {
subscriptions: boolean;
};
};

View File

@ -12,7 +12,7 @@ const ExtendedFormattedMessage: FC<ExtendedProps> = props => {
useEffect(() => { useEffect(() => {
const translatedMessage = formatMessage({ id, defaultMessage }, values); const translatedMessage = formatMessage({ id, defaultMessage }, values);
if (typeof translatedMessage === "string") { if (typeof translatedMessage === "string") {
setProcessedMessage(translatedMessage.replace("Snort", process.env.APP_NAME_CAPITALIZED || "Snort")); setProcessedMessage(translatedMessage.replace("Snort", CONFIG.appNameCapitalized || "Snort"));
} }
}, [id, defaultMessage, values, formatMessage]); }, [id, defaultMessage, values, formatMessage]);

View File

@ -4,7 +4,7 @@ const Logo = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<h1 className="logo" onClick={() => navigate("/")}> <h1 className="logo" onClick={() => navigate("/")}>
{process.env.APP_NAME} {CONFIG.appNameCapitalized}
</h1> </h1>
); );
}; };

View File

@ -145,7 +145,7 @@ export function LoginUnlock() {
<FormattedMessage <FormattedMessage
defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open {site}." defaultMessage="Enter a pin to encrypt your private key, you must enter this pin every time you open {site}."
values={{ values={{
site: process.env.APP_NAME_CAPITALIZED, site: CONFIG.appNameCapitalized,
}} }}
/> />
</p> </p>

View File

@ -1,6 +1,6 @@
import "./DisplayName.css"; import "./DisplayName.css";
import React, { useMemo } from "react"; import { useMemo } from "react";
import { HexKey, UserMetadata, NostrPrefix } from "@snort/system"; import { HexKey, UserMetadata, NostrPrefix } from "@snort/system";
import AnimalName from "Element/User/AnimalName"; import AnimalName from "Element/User/AnimalName";
import { hexToBech32 } from "SnortUtils"; import { hexToBech32 } from "SnortUtils";
@ -22,7 +22,7 @@ export function getDisplayNameOrPlaceHolder(user: UserMetadata | undefined, pubk
name = user.display_name; name = user.display_name;
} else if (typeof user?.name === "string" && user.name.length > 0) { } else if (typeof user?.name === "string" && user.name.length > 0) {
name = user.name; name = user.name;
} else if (pubkey && process.env.ANIMAL_NAME_PLACEHOLDERS) { } else if (pubkey && CONFIG.animalNamePlaceholders) {
name = AnimalName(pubkey); name = AnimalName(pubkey);
isPlaceHolder = true; isPlaceHolder = true;
} }

View File

@ -49,7 +49,7 @@ export default function useLoginFeed() {
leaveOpen: true, leaveOpen: true,
}); });
b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]); b.withFilter().authors([pubKey]).kinds([EventKind.ContactList]);
if (!login.readonly) { if (CONFIG.features.subscriptions && !login.readonly) {
b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]); b.withFilter().authors([pubKey]).kinds([EventKind.AppData]).tag("d", ["snort"]);
b.withFilter() b.withFilter()
.relay("wss://relay.snort.social") .relay("wss://relay.snort.social")

View File

@ -94,13 +94,13 @@ const DonatePage = () => {
<h2> <h2>
<FormattedMessage <FormattedMessage
defaultMessage="Help fund the development of {site}" defaultMessage="Help fund the development of {site}"
values={{ site: process.env.APP_NAME_CAPITALIZED }} values={{ site: CONFIG.appNameCapitalized }}
/> />
</h2> </h2>
<p> <p>
<FormattedMessage <FormattedMessage
defaultMessage="{site} is an open source project built by passionate people in their free time" defaultMessage="{site} is an open source project built by passionate people in their free time"
values={{ site: process.env.APP_NAME_CAPITALIZED }} values={{ site: CONFIG.appNameCapitalized }}
/> />
</p> </p>
<p> <p>

View File

@ -209,7 +209,7 @@ function LogoHeader() {
return ( return (
<Link to="/" className="logo"> <Link to="/" className="logo">
<h1>{process.env.APP_NAME}</h1> <h1>{CONFIG.appName}</h1>
{currentSubscription && ( {currentSubscription && (
<small className="flex"> <small className="flex">
<Icon name="diamond" size={10} className="mr5" /> <Icon name="diamond" size={10} className="mr5" />

View File

@ -143,7 +143,7 @@ export default function LoginPage() {
function generateNip46() { function generateNip46() {
const meta = { const meta = {
name: process.env.APP_NAME_CAPITALIZED, name: CONFIG.appNameCapitalized,
url: window.location.href, url: window.location.href,
}; };
@ -287,7 +287,7 @@ export default function LoginPage() {
<div> <div>
<div className="login-container"> <div className="login-container">
<h1 className="logo" onClick={() => navigate("/")}> <h1 className="logo" onClick={() => navigate("/")}>
{process.env.APP_NAME} {CONFIG.appName}
</h1> </h1>
<h1 dir="auto"> <h1 dir="auto">
<FormattedMessage defaultMessage="Login" description="Login header" /> <FormattedMessage defaultMessage="Login" description="Login header" />
@ -342,7 +342,7 @@ export default function LoginPage() {
<FormattedMessage <FormattedMessage
defaultMessage="Secure your private key with a PIN, ensuring enhanced protection on {site}. You'll be prompted to enter this PIN each time you access the site." defaultMessage="Secure your private key with a PIN, ensuring enhanced protection on {site}. You'll be prompted to enter this PIN each time you access the site."
values={{ values={{
site: process.env.APP_NAME_CAPITALIZED, site: CONFIG.appNameCapitalized,
}} }}
/> />
</p> </p>

View File

@ -25,7 +25,7 @@ export default function NostrLinkHandler() {
} }
} else { } else {
try { try {
const pubkey = await getNip05PubKey(`${link}@${process.env.NIP05_DOMAIN}`); const pubkey = await getNip05PubKey(`${link}@${CONFIG.nip05Domain}`);
if (pubkey) { if (pubkey) {
setRenderComponent(<ProfilePage id={pubkey} />); // Directly render ProfilePage setRenderComponent(<ProfilePage id={pubkey} />); // Directly render ProfilePage
} }

View File

@ -139,8 +139,8 @@ export default function ProfilePage({ id: propId }: ProfilePageProps) {
useEffect(() => { useEffect(() => {
if (user?.nip05 && user?.isNostrAddressValid) { if (user?.nip05 && user?.isNostrAddressValid) {
if (user.nip05.endsWith(`@${process.env.NIP05_DOMAIN}`)) { if (user.nip05.endsWith(`@${CONFIG.nip05Domain}`)) {
const username = user.nip05?.replace(`@${process.env.NIP05_DOMAIN}`, ""); const username = user.nip05?.replace(`@${CONFIG.nip05Domain}`, "");
navigate(`/${username}`, { replace: true }); navigate(`/${username}`, { replace: true });
} }
} }

View File

@ -14,11 +14,11 @@ export default defineMessages({
KeysSaved: { defaultMessage: "I have saved my keys, continue" }, KeysSaved: { defaultMessage: "I have saved my keys, continue" },
WhatIsSnort: { WhatIsSnort: {
defaultMessage: "What is {site} and how does it work?", defaultMessage: "What is {site} and how does it work?",
values: { site: process.env.APP_NAME_CAPITALIZED }, values: { site: CONFIG.appNameCapitalized },
}, },
WhatIsSnortIntro: { WhatIsSnortIntro: {
defaultMessage: `{site} is a Nostr UI, nostr is a decentralised protocol for saving and distributing "notes".`, defaultMessage: `{site} is a Nostr UI, nostr is a decentralised protocol for saving and distributing "notes".`,
values: { site: process.env.APP_NAME_CAPITALIZED }, values: { site: CONFIG.appNameCapitalized },
}, },
WhatIsSnortNotes: { WhatIsSnortNotes: {
defaultMessage: `Notes hold text content, the most popular usage of these notes is to store "tweet like" messages.`, defaultMessage: `Notes hold text content, the most popular usage of these notes is to store "tweet like" messages.`,
@ -26,7 +26,7 @@ export default defineMessages({
WhatIsSnortExperience: { WhatIsSnortExperience: {
defaultMessage: "{site} is designed to have a similar experience to Twitter.", defaultMessage: "{site} is designed to have a similar experience to Twitter.",
values: { site: process.env.APP_NAME_CAPITALIZED }, values: { site: CONFIG.appNameCapitalized },
}, },
HowKeysWork: { defaultMessage: "How do keys work?" }, HowKeysWork: { defaultMessage: "How do keys work?" },
DigitalSignatures: { DigitalSignatures: {
@ -70,9 +70,9 @@ export default defineMessages({
NameSquatting: { NameSquatting: {
defaultMessage: defaultMessage:
"Name-squatting and impersonation is not allowed. {site} and our partners reserve the right to terminate your handle (not your account - nobody can take that away) for violating this rule.", "Name-squatting and impersonation is not allowed. {site} and our partners reserve the right to terminate your handle (not your account - nobody can take that away) for violating this rule.",
values: { site: process.env.APP_NAME_CAPITALIZED }, values: { site: CONFIG.appNameCapitalized },
}, },
PreviewOnSnort: { defaultMessage: "Preview on {site}", values: { site: process.env.APP_NAME_CAPITALIZED } }, PreviewOnSnort: { defaultMessage: "Preview on {site}", values: { site: CONFIG.appNameCapitalized } },
GetSnortId: { defaultMessage: "Get a Snort identifier" }, GetSnortId: { defaultMessage: "Get a Snort identifier" },
GetSnortIdHelp: { GetSnortIdHelp: {
defaultMessage: defaultMessage:

View File

@ -6,9 +6,9 @@ import Icon from "Icons/Icon";
import { LoginStore, logout } from "Login"; import { LoginStore, logout } from "Login";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { getCurrentSubscription } from "Subscription"; import { getCurrentSubscription } from "Subscription";
import usePageWidth from "Hooks/usePageWidth";
import messages from "./messages"; import messages from "./messages";
import usePageWidth from "Hooks/usePageWidth";
const SettingsIndex = () => { const SettingsIndex = () => {
const login = useLogin(); const login = useLogin();
@ -61,11 +61,13 @@ const SettingsIndex = () => {
<FormattedMessage defaultMessage="Nostr Adddress" /> <FormattedMessage defaultMessage="Nostr Adddress" />
<Icon name="arrowFront" size={16} /> <Icon name="arrowFront" size={16} />
</div> </div>
<div className="settings-row" onClick={() => navigate("/subscribe/manage")}> {CONFIG.features.subscriptions && (
<Icon name="diamond" size={24} /> <div className="settings-row" onClick={() => navigate("/subscribe/manage")}>
<FormattedMessage defaultMessage="Subscription" /> <Icon name="diamond" size={24} />
<Icon name="arrowFront" size={16} /> <FormattedMessage defaultMessage="Subscription" />
</div> <Icon name="arrowFront" size={16} />
</div>
)}
{sub && ( {sub && (
<div className="settings-row" onClick={() => navigate("accounts")}> <div className="settings-row" onClick={() => navigate("accounts")}>
<Icon name="code-circle" size={24} /> <Icon name="code-circle" size={24} />

View File

@ -15,7 +15,7 @@ export class DonateTask extends BaseUITask {
<p> <p>
<FormattedMessage <FormattedMessage
defaultMessage="Thanks for using {site}, please consider donating if you can." defaultMessage="Thanks for using {site}, please consider donating if you can."
values={{ site: process.env.APP_NAME_CAPITALIZED }} values={{ site: CONFIG.appNameCapitalized }}
/> />
</p> </p>
<Link to="/donate"> <Link to="/donate">

View File

@ -16,6 +16,7 @@ import {
ReqFilter, ReqFilter,
PowMiner, PowMiner,
NostrEvent, NostrEvent,
mapEventToProfile,
} from "@snort/system"; } from "@snort/system";
import { SnortContext } from "@snort/system-react"; import { SnortContext } from "@snort/system-react";
@ -87,6 +88,29 @@ export const System = new NostrSystem({
}, },
}); });
async function fetchProfile(key: string) {
const rsp = await fetch(`${CONFIG.httpCache}/profile/${key}`);
if (rsp.ok) {
try {
const data = (await rsp.json()) as NostrEvent;
if (data) {
return mapEventToProfile(data);
}
} catch (e) {
console.error(e);
}
}
}
/**
* Add profile loader fn
*/
if (CONFIG.httpCache) {
System.ProfileLoader.loaderFn = async (keys: Array<string>) => {
return (await Promise.all(keys.map(a => fetchProfile(a)))).filter(a => a !== undefined).map(a => unwrap(a));
};
}
/** /**
* Singleton user profile loader * Singleton user profile loader
*/ */
@ -191,7 +215,7 @@ export const router = createBrowserRouter([
}, },
...NewUserRoutes, ...NewUserRoutes,
...WalletRoutes, ...WalletRoutes,
...SubscribeRoutes, ...(CONFIG.features.subscriptions ? SubscribeRoutes : []),
{ {
path: "/debug", path: "/debug",
element: <DebugPage />, element: <DebugPage />,

View File

@ -88,11 +88,7 @@ const config = {
}) })
: false, : false,
new DefinePlugin({ new DefinePlugin({
"process.env.APP_NAME": JSON.stringify(appConfig.get("appName")), CONFIG: JSON.stringify(appConfig),
"process.env.APP_NAME_CAPITALIZED": JSON.stringify(appConfig.get("appNameCapitalized")),
"process.env.NIP05_DOMAIN": JSON.stringify(appConfig.get("nip05Domain")),
"process.env.HTTP_CACHE": JSON.stringify(appConfig.get("httpCache")),
"process.env.ANIMAL_NAME_PLACEHOLDERS": JSON.stringify(appConfig.get("animalNamePlaceholders")),
}), }),
], ],
module: { module: {

View File

@ -11,24 +11,6 @@ export function useUserProfile(pubKey?: HexKey): MetadataCache | undefined {
h => { h => {
if (pubKey) { if (pubKey) {
system.ProfileLoader.TrackMetadata(pubKey); system.ProfileLoader.TrackMetadata(pubKey);
if (process.env.HTTP_CACHE && !system.ProfileLoader.Cache.getFromCache(pubKey)) {
fetch(`${process.env.HTTP_CACHE}/profile/${pubKey}`)
.then(async r => {
if (r.ok) {
try {
const data = await r.json();
if (data) {
system.ProfileLoader.onProfileEvent(data);
}
} catch (e) {
console.error(e);
}
}
})
.catch(e => {
console.error(e);
});
}
} }
const release = system.ProfileLoader.Cache.hook(h, pubKey); const release = system.ProfileLoader.Cache.hook(h, pubKey);
return () => { return () => {

View File

@ -1,8 +1,9 @@
import debug from "debug"; import debug from "debug";
import { unixNowMs, FeedCache } from "@snort/shared"; import { unixNowMs, FeedCache } from "@snort/shared";
import { EventKind, HexKey, SystemInterface, TaggedNostrEvent, NoteCollection, RequestBuilder } from "."; import { EventKind, HexKey, SystemInterface, TaggedNostrEvent, RequestBuilder } from ".";
import { ProfileCacheExpire } from "./const"; import { ProfileCacheExpire } from "./const";
import { mapEventToProfile, MetadataCache } from "./cache"; import { mapEventToProfile, MetadataCache } from "./cache";
import { v4 as uuid } from "uuid";
const MetadataRelays = ["wss://purplepag.es"]; const MetadataRelays = ["wss://purplepag.es"];
@ -23,6 +24,11 @@ export class ProfileLoaderService {
readonly #log = debug("ProfileCache"); readonly #log = debug("ProfileCache");
/**
* Custom loader function for fetching profiles from alternative sources
*/
loaderFn?: (pubkeys: Array<string>) => Promise<Array<MetadataCache>>;
constructor(system: SystemInterface, cache: FeedCache<MetadataCache>) { constructor(system: SystemInterface, cache: FeedCache<MetadataCache>) {
this.#system = system; this.#system = system;
this.#cache = cache; this.#cache = cache;
@ -92,50 +98,8 @@ export class ProfileLoaderService {
if (missing.size > 0) { if (missing.size > 0) {
this.#log("Wants profiles: %d missing, %d expired", missingFromCache.length, expired.length); this.#log("Wants profiles: %d missing, %d expired", missingFromCache.length, expired.length);
const sub = new RequestBuilder("profiles"); const results = await this.#loadProfiles([...missing]);
sub
.withOptions({
skipDiff: true,
})
.withFilter()
.kinds([EventKind.SetMetadata])
.authors([...missing]);
if (this.#missingLastRun.size > 0) {
const fMissing = sub
.withFilter()
.kinds([EventKind.SetMetadata])
.authors([...this.#missingLastRun]);
MetadataRelays.forEach(r => fMissing.relay(r));
}
const newProfiles = new Set<string>();
const q = this.#system.Query<NoteCollection>(NoteCollection, sub);
const feed = (q?.feed as NoteCollection) ?? new NoteCollection();
// never release this callback, it will stop firing anyway after eose
const releaseOnEvent = feed.onEvent(async e => {
for (const pe of e) {
newProfiles.add(pe.id);
await this.onProfileEvent(pe);
}
});
const results = await new Promise<Readonly<Array<TaggedNostrEvent>>>(resolve => {
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
const release = feed.hook(() => {
if (!feed.loading) {
clearTimeout(timeout);
resolve(feed.getSnapshotData() ?? []);
this.#log("Profiles finished: %s", sub.id);
release();
}
});
timeout = setTimeout(() => {
release();
resolve(feed.getSnapshotData() ?? []);
this.#log("Profiles timeout: %s", sub.id);
}, 5_000);
});
releaseOnEvent();
const couldNotFetch = [...missing].filter(a => !results.some(b => b.pubkey === a)); const couldNotFetch = [...missing].filter(a => !results.some(b => b.pubkey === a));
this.#missingLastRun = new Set(couldNotFetch); this.#missingLastRun = new Set(couldNotFetch);
if (couldNotFetch.length > 0) { if (couldNotFetch.length > 0) {
@ -150,12 +114,43 @@ export class ProfileLoaderService {
await Promise.all(empty); await Promise.all(empty);
} }
// When we fetch an expired profile and its the same as what we already have /* When we fetch an expired profile and its the same as what we already have
// onEvent is not fired and the loaded timestamp never gets updated // onEvent is not fired and the loaded timestamp never gets updated
const expiredSame = results.filter(a => !newProfiles.has(a.id) && expired.includes(a.pubkey)); const expiredSame = results.filter(a => !newProfiles.has(a.id) && expired.includes(a.pubkey));
await Promise.all(expiredSame.map(v => this.onProfileEvent(v))); await Promise.all(expiredSame.map(v => this.onProfileEvent(v)));*/
} }
setTimeout(() => this.#FetchMetadata(), 500); setTimeout(() => this.#FetchMetadata(), 500);
} }
async #loadProfiles(missing: Array<string>) {
if (this.loaderFn) {
const results = await this.loaderFn(missing);
await Promise.all(results.map(a => this.#cache.update(a)));
return results;
} else {
const sub = new RequestBuilder(`profiles-${uuid()}`);
sub
.withOptions({
skipDiff: true,
})
.withFilter()
.kinds([EventKind.SetMetadata])
.authors(missing);
if (this.#missingLastRun.size > 0) {
const fMissing = sub
.withFilter()
.kinds([EventKind.SetMetadata])
.authors([...this.#missingLastRun]);
MetadataRelays.forEach(r => fMissing.relay(r));
}
const results = (await this.#system.Fetch(sub, async e => {
for (const pe of e) {
await this.onProfileEvent(pe);
}
})) as ReadonlyArray<TaggedNostrEvent>;
return results;
}
}
} }