chore: Update translations

This commit is contained in:
Kieran 2023-12-20 14:15:35 +00:00
parent 06b7dcad11
commit 9ed5757875
4 changed files with 288 additions and 213 deletions

View File

@ -8,14 +8,14 @@ import classNames from "classnames";
import { getCurrentSubscription } from "@/Subscription"; import { getCurrentSubscription } from "@/Subscription";
export type SettingsMenuItems = Array<{ export type SettingsMenuItems = Array<{
title: ReactNode, title: ReactNode;
items: Array<{ items: Array<{
icon: string; icon: string;
iconBg: string; iconBg: string;
message: ReactNode, message: ReactNode;
path?: string; path?: string;
action?: () => void; action?: () => void;
}> }>;
}>; }>;
const SettingsIndex = () => { const SettingsIndex = () => {
@ -64,20 +64,20 @@ const SettingsIndex = () => {
}, },
...(sub ...(sub
? [ ? [
{ {
icon: "code-circle", icon: "code-circle",
iconBg: "bg-indigo-500", iconBg: "bg-indigo-500",
message: <FormattedMessage id="FvanT6" defaultMessage="Accounts" />, message: <FormattedMessage id="FvanT6" defaultMessage="Accounts" />,
path: "accounts", path: "accounts",
}, },
] ]
: []), : []),
{ {
icon: "tool", icon: "tool",
iconBg: "bg-slate-800", iconBg: "bg-slate-800",
message: <FormattedMessage defaultMessage="Tools" id="nUT0Lv" />, message: <FormattedMessage defaultMessage="Tools" id="nUT0Lv" />,
path: "tools" path: "tools",
} },
], ],
}, },
{ {
@ -126,23 +126,23 @@ const SettingsIndex = () => {
}, },
...(CONFIG.features.subscriptions ...(CONFIG.features.subscriptions
? [ ? [
{ {
icon: "diamond", icon: "diamond",
iconBg: "bg-violet-500", iconBg: "bg-violet-500",
message: <FormattedMessage id="R/6nsx" defaultMessage="Subscription" />, message: <FormattedMessage id="R/6nsx" defaultMessage="Subscription" />,
path: "/subscribe/manage", path: "/subscribe/manage",
}, },
] ]
: []), : []),
...(CONFIG.features.zapPool ...(CONFIG.features.zapPool
? [ ? [
{ {
icon: "piggy-bank", icon: "piggy-bank",
iconBg: "bg-rose-500", iconBg: "bg-rose-500",
message: <FormattedMessage id="i/dBAR" defaultMessage="Zap Pool" />, message: <FormattedMessage id="i/dBAR" defaultMessage="Zap Pool" />,
path: "/zap-pool", path: "/zap-pool",
}, },
] ]
: []), : []),
], ],
}, },
@ -159,7 +159,7 @@ const SettingsIndex = () => {
}, },
] as SettingsMenuItems; ] as SettingsMenuItems;
return <SettingsMenuComponent menu={settingsGroups} /> return <SettingsMenuComponent menu={settingsGroups} />;
}; };
export function SettingsMenuComponent({ menu }: { menu: SettingsMenuItems }) { export function SettingsMenuComponent({ menu }: { menu: SettingsMenuItems }) {

View File

@ -8,51 +8,87 @@ import { SnortContext } from "@snort/system-react";
import { ReactNode, useContext, useMemo } from "react"; import { ReactNode, useContext, useMemo } from "react";
import { FormattedMessage, FormattedNumber } from "react-intl"; import { FormattedMessage, FormattedNumber } from "react-intl";
export function FollowsRelayHealth({ withTitle, popularRelays, missingRelaysActions }: { withTitle?: boolean, popularRelays?: boolean, missingRelaysActions?: (k: string) => ReactNode }) { export function FollowsRelayHealth({
const system = useContext(SnortContext); withTitle,
const follows = useLogin(s => s.follows); popularRelays,
const uniqueFollows = dedupe(follows.item); missingRelaysActions,
}: {
withTitle?: boolean;
popularRelays?: boolean;
missingRelaysActions?: (k: string) => ReactNode;
}) {
const system = useContext(SnortContext);
const follows = useLogin(s => s.follows);
const uniqueFollows = dedupe(follows.item);
const hasRelays = useMemo(() => { const hasRelays = useMemo(() => {
return uniqueFollows.filter(a => (system.RelayCache.getFromCache(a)?.relays.length ?? 0) > 0); return uniqueFollows.filter(a => (system.RelayCache.getFromCache(a)?.relays.length ?? 0) > 0);
}, [uniqueFollows]); }, [uniqueFollows]);
const missingRelays = useMemo(() => { const missingRelays = useMemo(() => {
return uniqueFollows.filter(a => !hasRelays.includes(a)); return uniqueFollows.filter(a => !hasRelays.includes(a));
}, [hasRelays]); }, [hasRelays]);
const topWriteRelays = useMemo(() => { const topWriteRelays = useMemo(() => {
return pickTopRelays(system.RelayCache, uniqueFollows, 1e31, "write"); return pickTopRelays(system.RelayCache, uniqueFollows, 1e31, "write");
}, [uniqueFollows]); }, [uniqueFollows]);
return <div className="flex flex-col gap-4"> return (
{(withTitle ?? true) && <div className="text-2xl font-semibold"> <div className="flex flex-col gap-4">
<FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" /> {(withTitle ?? true) && (
</div>} <div className="text-2xl font-semibold">
<div> <FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" />
<FormattedMessage defaultMessage="{x}/{y} have relays ({percent})" id="p9Ps2l" values={{
x: hasRelays.length,
y: uniqueFollows.length,
percent: <FormattedNumber style="percent" value={hasRelays.length / uniqueFollows.length} />
}} />
</div> </div>
{missingRelays.length > 0 && <CollapsedSection className="rounded-xl border border-border-color px-3 py-4" title={<div className="text-lg"><FormattedMessage defaultMessage="Missing Relays" id="4emo2p" /></div>}> )}
<div> <div>
{missingRelays.map(a => <ProfilePreview pubkey={a} options={{ <FormattedMessage
about: false defaultMessage="{x}/{y} have relays ({percent})"
}} actions={missingRelaysActions?.(a)} />)} id="p9Ps2l"
values={{
x: hasRelays.length,
y: uniqueFollows.length,
percent: <FormattedNumber style="percent" value={hasRelays.length / uniqueFollows.length} />,
}}
/>
</div>
{missingRelays.length > 0 && (
<CollapsedSection
className="rounded-xl border border-border-color px-3 py-4"
title={
<div className="text-lg">
<FormattedMessage defaultMessage="Missing Relays" id="4emo2p" />
</div> </div>
</CollapsedSection>} }>
{(popularRelays ?? true) && <div> <div>
<div className="text-xl font-medium">Popular Relays</div> {missingRelays.map(a => (
{dedupe(topWriteRelays.flatMap(a => a.relays)) <ProfilePreview
.map(a => ({ relay: a, count: topWriteRelays.filter(b => b.relays.includes(a)).length })) pubkey={a}
.sort((a, b) => a.count > b.count ? -1 : 1) options={{
.slice(0, 10) about: false,
.map(a => <div className="flex justify-between"> }}
<div>{getRelayName(a.relay)}</div> actions={missingRelaysActions?.(a)}
<div>{a.count} (<FormattedNumber style="percent" value={a.count / uniqueFollows.length} />)</div> />
</div>)} ))}
</div>} </div>
</CollapsedSection>
)}
{(popularRelays ?? true) && (
<div>
<div className="text-xl font-medium">Popular Relays</div>
{dedupe(topWriteRelays.flatMap(a => a.relays))
.map(a => ({ relay: a, count: topWriteRelays.filter(b => b.relays.includes(a)).length }))
.sort((a, b) => (a.count > b.count ? -1 : 1))
.slice(0, 10)
.map(a => (
<div className="flex justify-between">
<div>{getRelayName(a.relay)}</div>
<div>
{a.count} (<FormattedNumber style="percent" value={a.count / uniqueFollows.length} />)
</div>
</div>
))}
</div>
)}
</div> </div>
} );
}

View File

@ -1,50 +1,51 @@
import { FormattedMessage } from "react-intl" import { FormattedMessage } from "react-intl";
import { Outlet, RouteObject } from "react-router-dom" import { Outlet, RouteObject } from "react-router-dom";
import { SettingsMenuComponent, SettingsMenuItems } from "../Menu" import { SettingsMenuComponent, SettingsMenuItems } from "../Menu";
import { PruneFollowList } from "./prune-follows"; import { PruneFollowList } from "./prune-follows";
import { FollowsRelayHealth } from "./follows-relay-health"; import { FollowsRelayHealth } from "./follows-relay-health";
const ToolMenuItems = [ const ToolMenuItems = [
{ {
title: <FormattedMessage defaultMessage="Follow List" id="CM+Cfj" />, title: <FormattedMessage defaultMessage="Follow List" id="CM+Cfj" />,
items: [ items: [
{ {
icon: "trash", icon: "trash",
iconBg: "bg-red-500", iconBg: "bg-red-500",
message: <FormattedMessage defaultMessage="Prune Follow List" id="hF6IN2" />, message: <FormattedMessage defaultMessage="Prune Follow List" id="hF6IN2" />,
path: "prune-follows" path: "prune-follows",
}, },
{ {
icon: "medical-cross", icon: "medical-cross",
iconBg: "bg-green-800", iconBg: "bg-green-800",
message: <FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" />, message: <FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" />,
path: "follows-relay-health" path: "follows-relay-health",
} },
] ],
} },
] as SettingsMenuItems; ] as SettingsMenuItems;
export const ToolsPages = [ export const ToolsPages = [
{ {
path: "", path: "",
element: <> element: (
<h2> <>
<FormattedMessage defaultMessage="Tools" id="nUT0Lv" /> <h2>
</h2> <FormattedMessage defaultMessage="Tools" id="nUT0Lv" />
<SettingsMenuComponent menu={ToolMenuItems} /> </h2>
</> <SettingsMenuComponent menu={ToolMenuItems} />
}, </>
{ ),
path: "prune-follows", },
element: <PruneFollowList /> {
}, path: "prune-follows",
{ element: <PruneFollowList />,
path: "follows-relay-health", },
element: <FollowsRelayHealth /> {
} path: "follows-relay-health",
] as Array<RouteObject> element: <FollowsRelayHealth />,
},
] as Array<RouteObject>;
export function ToolsPage() { export function ToolsPage() {
return <Outlet /> return <Outlet />;
} }

View File

@ -1,6 +1,6 @@
import { Day } from "@/Const"; import { Day } from "@/Const";
import AsyncButton from "@/Element/Button/AsyncButton"; import AsyncButton from "@/Element/Button/AsyncButton";
import useLogin from "@/Hooks/useLogin" import useLogin from "@/Hooks/useLogin";
import { dedupe, unixNow } from "@snort/shared"; import { dedupe, unixNow } from "@snort/shared";
import { RequestBuilder } from "@snort/system"; import { RequestBuilder } from "@snort/system";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@ -11,126 +11,164 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
import { setFollows } from "@/Login"; import { setFollows } from "@/Login";
const enum PruneStage { const enum PruneStage {
FetchLastPostTimestamp, FetchLastPostTimestamp,
Done Done,
} }
export function PruneFollowList() { export function PruneFollowList() {
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows })); const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
const { publisher, system } = useEventPublisher(); const { publisher, system } = useEventPublisher();
const uniqueFollows = dedupe(follows.item); const uniqueFollows = dedupe(follows.item);
const [status, setStatus] = useState<PruneStage>(); const [status, setStatus] = useState<PruneStage>();
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [lastPost, setLastPosts] = useState<Record<string, number>>(); const [lastPost, setLastPosts] = useState<Record<string, number>>();
const [unfollow, setUnfollow] = useState<Array<string>>([]); const [unfollow, setUnfollow] = useState<Array<string>>([]);
async function fetchLastPosts() { async function fetchLastPosts() {
setStatus(PruneStage.FetchLastPostTimestamp); setStatus(PruneStage.FetchLastPostTimestamp);
setProgress(0); setProgress(0);
setLastPosts(undefined); setLastPosts(undefined);
const BatchSize = 10; const BatchSize = 10;
const chunks = uniqueFollows.reduce((acc, v, i) => { const chunks = uniqueFollows.reduce(
const batch = Math.floor(i / BatchSize).toString(); (acc, v, i) => {
acc[batch] ??= []; const batch = Math.floor(i / BatchSize).toString();
acc[batch].push(v); acc[batch] ??= [];
return acc; acc[batch].push(v);
}, {} as Record<string, Array<string>>); return acc;
},
{} as Record<string, Array<string>>,
);
const result = {} as Record<string, number>; const result = {} as Record<string, number>;
const batches = Math.ceil(uniqueFollows.length / BatchSize); const batches = Math.ceil(uniqueFollows.length / BatchSize);
for (const [batch, pubkeys] of Object.entries(chunks)) { for (const [batch, pubkeys] of Object.entries(chunks)) {
console.debug(batch, pubkeys); console.debug(batch, pubkeys);
const req = new RequestBuilder(`prune-${batch}`); const req = new RequestBuilder(`prune-${batch}`);
req.withOptions({ req.withOptions({
outboxPickN: 10, outboxPickN: 10,
timeout: 10_000 timeout: 10_000,
}); });
pubkeys.forEach(p => req.withFilter().limit(1).kinds([0, 1, 3, 5, 6, 7, 10002]).authors([p])); pubkeys.forEach(p => req.withFilter().limit(1).kinds([0, 1, 3, 5, 6, 7, 10002]).authors([p]));
const results = await system.Fetch(req); const results = await system.Fetch(req);
console.debug(results); console.debug(results);
for (const rx of results) { for (const rx of results) {
if ((result[rx.pubkey] ?? 0) < rx.created_at) { if ((result[rx.pubkey] ?? 0) < rx.created_at) {
result[rx.pubkey] = rx.created_at; result[rx.pubkey] = rx.created_at;
}
}
setProgress(Number(batch) / batches);
} }
}
for (const pk of uniqueFollows) { setProgress(Number(batch) / batches);
result[pk] ??= 0;
}
setLastPosts(result);
setStatus(PruneStage.Done);
} }
const newFollowList = useMemo(() => { for (const pk of uniqueFollows) {
return uniqueFollows.filter(a => !unfollow.includes(a) && a.length === 64); result[pk] ??= 0;
}, [uniqueFollows, unfollow]);
async function publishFollowList() {
const newFollows = newFollowList.map(a => ["p", a]) as Array<[string, string]>;
if (publisher) {
const ev = await publisher.contactList(newFollows);
await system.BroadcastEvent(ev);
setFollows(id, newFollowList, ev.created_at * 1000);
}
} }
setLastPosts(result);
setStatus(PruneStage.Done);
}
const newFollowList = useMemo(() => {
return uniqueFollows.filter(a => !unfollow.includes(a) && a.length === 64);
}, [uniqueFollows, unfollow]);
function getStatus() { async function publishFollowList() {
switch (status) { const newFollows = newFollowList.map(a => ["p", a]) as Array<[string, string]>;
case PruneStage.FetchLastPostTimestamp: return <FormattedMessage defaultMessage="Searching for account activity ({progress})" id="nIchMQ" values={{ if (publisher) {
progress: <FormattedNumber style="percent" value={progress} /> const ev = await publisher.contactList(newFollows);
}} /> await system.BroadcastEvent(ev);
} setFollows(id, newFollowList, ev.created_at * 1000);
} }
}
function personToggle(k: string,) { function getStatus() {
return <div className="flex gap-1"> switch (status) {
<input type="checkbox" onChange={e => setUnfollow(v => e.target.checked ? dedupe([...v, k]) : v.filter(a => a !== k))} checked={unfollow.includes(k)} /> case PruneStage.FetchLastPostTimestamp:
<FormattedMessage defaultMessage="Unfollow" id="izWS4J" /> return (
</div> <FormattedMessage
defaultMessage="Searching for account activity ({progress})"
id="nIchMQ"
values={{
progress: <FormattedNumber style="percent" value={progress} />,
}}
/>
);
} }
}
return <div className="flex flex-col gap-4"> function personToggle(k: string) {
<div className="text-2xl font-semibold"> return (
<FormattedMessage defaultMessage="Prune follow list" id="CM0k0d" /> <div className="flex gap-1">
</div> <input
<p> type="checkbox"
<FormattedMessage defaultMessage="This tool will search for the last event published by all of your follows and remove those who have not posted in 6 months" id="vU/Q5i" /> onChange={e => setUnfollow(v => (e.target.checked ? dedupe([...v, k]) : v.filter(a => a !== k)))}
</p> checked={unfollow.includes(k)}
<div> />
<FormattedMessage defaultMessage="{x} follows ({y} duplicates)" id="iICVoL" values={{ <FormattedMessage defaultMessage="Unfollow" id="izWS4J" />
x: follows.item.length, </div>
y: follows.item.length - uniqueFollows.length );
}} /> }
</div>
<FollowsRelayHealth withTitle={false} popularRelays={false} missingRelaysActions={(k) => personToggle(k)} /> return (
<AsyncButton onClick={fetchLastPosts}> <div className="flex flex-col gap-4">
<FormattedMessage defaultMessage="Compute prune list" id="bJ+wrA" /> <div className="text-2xl font-semibold">
</AsyncButton> <FormattedMessage defaultMessage="Prune follow list" id="CM0k0d" />
{getStatus()} </div>
<div className="flex flex-col gap-1"> <p>
{lastPost && Object.entries(lastPost).filter(([, v]) => v <= unixNow() - (90 * Day)).sort(([, a], [, b]) => a > b ? -1 : 1).map(([k, v]) => { <FormattedMessage
return <div className="flex justify-between"> defaultMessage="This tool will search for the last event published by all of your follows and remove those who have not posted in 6 months"
<ProfileImage pubkey={k} /> id="vU/Q5i"
<div className="flex flex-col gap-1"> />
<FormattedMessage defaultMessage="Last post {time}" id="I1AoOu" values={{ </p>
time: new Date(v * 1000).toLocaleDateString() <div>
}} /> <FormattedMessage
{personToggle(k)} defaultMessage="{x} follows ({y} duplicates)"
</div> id="iICVoL"
values={{
x: follows.item.length,
y: follows.item.length - uniqueFollows.length,
}}
/>
</div>
<FollowsRelayHealth withTitle={false} popularRelays={false} missingRelaysActions={k => personToggle(k)} />
<AsyncButton onClick={fetchLastPosts}>
<FormattedMessage defaultMessage="Compute prune list" id="bJ+wrA" />
</AsyncButton>
{getStatus()}
<div className="flex flex-col gap-1">
{lastPost &&
Object.entries(lastPost)
.filter(([, v]) => v <= unixNow() - 90 * Day)
.sort(([, a], [, b]) => (a > b ? -1 : 1))
.map(([k, v]) => {
return (
<div className="flex justify-between">
<ProfileImage pubkey={k} />
<div className="flex flex-col gap-1">
<FormattedMessage
defaultMessage="Last post {time}"
id="I1AoOu"
values={{
time: new Date(v * 1000).toLocaleDateString(),
}}
/>
{personToggle(k)}
</div>
</div> </div>
);
})} })}
</div> </div>
<div className="px-4 pb-5 pt-2 rounded-2xl bg-bg-secondary"> <div className="px-4 pb-5 pt-2 rounded-2xl bg-bg-secondary">
<p> <p>
<FormattedMessage defaultMessage="New follow list length {length}" id="6559gb" values={{ length: newFollowList.length }} /> <FormattedMessage
</p> defaultMessage="New follow list length {length}"
<AsyncButton onClick={publishFollowList}> id="6559gb"
<FormattedMessage defaultMessage="Save" id="jvo0vs" /> values={{ length: newFollowList.length }}
</AsyncButton> />
</div> </p>
<AsyncButton onClick={publishFollowList}>
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
</AsyncButton>
</div>
</div> </div>
} );
}