This commit is contained in:
parent
06b7dcad11
commit
9ed5757875
@ -8,14 +8,14 @@ import classNames from "classnames";
|
||||
import { getCurrentSubscription } from "@/Subscription";
|
||||
|
||||
export type SettingsMenuItems = Array<{
|
||||
title: ReactNode,
|
||||
title: ReactNode;
|
||||
items: Array<{
|
||||
icon: string;
|
||||
iconBg: string;
|
||||
message: ReactNode,
|
||||
message: ReactNode;
|
||||
path?: string;
|
||||
action?: () => void;
|
||||
}>
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const SettingsIndex = () => {
|
||||
@ -64,20 +64,20 @@ const SettingsIndex = () => {
|
||||
},
|
||||
...(sub
|
||||
? [
|
||||
{
|
||||
icon: "code-circle",
|
||||
iconBg: "bg-indigo-500",
|
||||
message: <FormattedMessage id="FvanT6" defaultMessage="Accounts" />,
|
||||
path: "accounts",
|
||||
},
|
||||
]
|
||||
{
|
||||
icon: "code-circle",
|
||||
iconBg: "bg-indigo-500",
|
||||
message: <FormattedMessage id="FvanT6" defaultMessage="Accounts" />,
|
||||
path: "accounts",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: "tool",
|
||||
iconBg: "bg-slate-800",
|
||||
message: <FormattedMessage defaultMessage="Tools" id="nUT0Lv" />,
|
||||
path: "tools"
|
||||
}
|
||||
path: "tools",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -126,23 +126,23 @@ const SettingsIndex = () => {
|
||||
},
|
||||
...(CONFIG.features.subscriptions
|
||||
? [
|
||||
{
|
||||
icon: "diamond",
|
||||
iconBg: "bg-violet-500",
|
||||
message: <FormattedMessage id="R/6nsx" defaultMessage="Subscription" />,
|
||||
path: "/subscribe/manage",
|
||||
},
|
||||
]
|
||||
{
|
||||
icon: "diamond",
|
||||
iconBg: "bg-violet-500",
|
||||
message: <FormattedMessage id="R/6nsx" defaultMessage="Subscription" />,
|
||||
path: "/subscribe/manage",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(CONFIG.features.zapPool
|
||||
? [
|
||||
{
|
||||
icon: "piggy-bank",
|
||||
iconBg: "bg-rose-500",
|
||||
message: <FormattedMessage id="i/dBAR" defaultMessage="Zap Pool" />,
|
||||
path: "/zap-pool",
|
||||
},
|
||||
]
|
||||
{
|
||||
icon: "piggy-bank",
|
||||
iconBg: "bg-rose-500",
|
||||
message: <FormattedMessage id="i/dBAR" defaultMessage="Zap Pool" />,
|
||||
path: "/zap-pool",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
@ -159,7 +159,7 @@ const SettingsIndex = () => {
|
||||
},
|
||||
] as SettingsMenuItems;
|
||||
|
||||
return <SettingsMenuComponent menu={settingsGroups} />
|
||||
return <SettingsMenuComponent menu={settingsGroups} />;
|
||||
};
|
||||
|
||||
export function SettingsMenuComponent({ menu }: { menu: SettingsMenuItems }) {
|
||||
|
@ -8,51 +8,87 @@ import { SnortContext } from "@snort/system-react";
|
||||
import { ReactNode, useContext, useMemo } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
|
||||
export function FollowsRelayHealth({ withTitle, popularRelays, missingRelaysActions }: { withTitle?: boolean, popularRelays?: boolean, missingRelaysActions?: (k: string) => ReactNode }) {
|
||||
const system = useContext(SnortContext);
|
||||
const follows = useLogin(s => s.follows);
|
||||
const uniqueFollows = dedupe(follows.item);
|
||||
export function FollowsRelayHealth({
|
||||
withTitle,
|
||||
popularRelays,
|
||||
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(() => {
|
||||
return uniqueFollows.filter(a => (system.RelayCache.getFromCache(a)?.relays.length ?? 0) > 0);
|
||||
}, [uniqueFollows]);
|
||||
const hasRelays = useMemo(() => {
|
||||
return uniqueFollows.filter(a => (system.RelayCache.getFromCache(a)?.relays.length ?? 0) > 0);
|
||||
}, [uniqueFollows]);
|
||||
|
||||
const missingRelays = useMemo(() => {
|
||||
return uniqueFollows.filter(a => !hasRelays.includes(a));
|
||||
}, [hasRelays]);
|
||||
const missingRelays = useMemo(() => {
|
||||
return uniqueFollows.filter(a => !hasRelays.includes(a));
|
||||
}, [hasRelays]);
|
||||
|
||||
const topWriteRelays = useMemo(() => {
|
||||
return pickTopRelays(system.RelayCache, uniqueFollows, 1e31, "write");
|
||||
}, [uniqueFollows]);
|
||||
const topWriteRelays = useMemo(() => {
|
||||
return pickTopRelays(system.RelayCache, uniqueFollows, 1e31, "write");
|
||||
}, [uniqueFollows]);
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
{(withTitle ?? true) && <div className="text-2xl font-semibold">
|
||||
<FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" />
|
||||
</div>}
|
||||
<div>
|
||||
<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} />
|
||||
}} />
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{(withTitle ?? true) && (
|
||||
<div className="text-2xl font-semibold">
|
||||
<FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" />
|
||||
</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>
|
||||
{missingRelays.map(a => <ProfilePreview pubkey={a} options={{
|
||||
about: false
|
||||
}} actions={missingRelaysActions?.(a)} />)}
|
||||
)}
|
||||
<div>
|
||||
<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>
|
||||
{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>
|
||||
</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>
|
||||
{missingRelays.map(a => (
|
||||
<ProfilePreview
|
||||
pubkey={a}
|
||||
options={{
|
||||
about: false,
|
||||
}}
|
||||
actions={missingRelaysActions?.(a)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -1,50 +1,51 @@
|
||||
import { FormattedMessage } from "react-intl"
|
||||
import { Outlet, RouteObject } from "react-router-dom"
|
||||
import { SettingsMenuComponent, SettingsMenuItems } from "../Menu"
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Outlet, RouteObject } from "react-router-dom";
|
||||
import { SettingsMenuComponent, SettingsMenuItems } from "../Menu";
|
||||
import { PruneFollowList } from "./prune-follows";
|
||||
import { FollowsRelayHealth } from "./follows-relay-health";
|
||||
|
||||
|
||||
const ToolMenuItems = [
|
||||
{
|
||||
title: <FormattedMessage defaultMessage="Follow List" id="CM+Cfj" />,
|
||||
items: [
|
||||
{
|
||||
icon: "trash",
|
||||
iconBg: "bg-red-500",
|
||||
message: <FormattedMessage defaultMessage="Prune Follow List" id="hF6IN2" />,
|
||||
path: "prune-follows"
|
||||
},
|
||||
{
|
||||
icon: "medical-cross",
|
||||
iconBg: "bg-green-800",
|
||||
message: <FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" />,
|
||||
path: "follows-relay-health"
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
title: <FormattedMessage defaultMessage="Follow List" id="CM+Cfj" />,
|
||||
items: [
|
||||
{
|
||||
icon: "trash",
|
||||
iconBg: "bg-red-500",
|
||||
message: <FormattedMessage defaultMessage="Prune Follow List" id="hF6IN2" />,
|
||||
path: "prune-follows",
|
||||
},
|
||||
{
|
||||
icon: "medical-cross",
|
||||
iconBg: "bg-green-800",
|
||||
message: <FormattedMessage defaultMessage="Follows Relay Health" id="XQiFEl" />,
|
||||
path: "follows-relay-health",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as SettingsMenuItems;
|
||||
|
||||
export const ToolsPages = [
|
||||
{
|
||||
path: "",
|
||||
element: <>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Tools" id="nUT0Lv" />
|
||||
</h2>
|
||||
<SettingsMenuComponent menu={ToolMenuItems} />
|
||||
</>
|
||||
},
|
||||
{
|
||||
path: "prune-follows",
|
||||
element: <PruneFollowList />
|
||||
},
|
||||
{
|
||||
path: "follows-relay-health",
|
||||
element: <FollowsRelayHealth />
|
||||
}
|
||||
] as Array<RouteObject>
|
||||
{
|
||||
path: "",
|
||||
element: (
|
||||
<>
|
||||
<h2>
|
||||
<FormattedMessage defaultMessage="Tools" id="nUT0Lv" />
|
||||
</h2>
|
||||
<SettingsMenuComponent menu={ToolMenuItems} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "prune-follows",
|
||||
element: <PruneFollowList />,
|
||||
},
|
||||
{
|
||||
path: "follows-relay-health",
|
||||
element: <FollowsRelayHealth />,
|
||||
},
|
||||
] as Array<RouteObject>;
|
||||
|
||||
export function ToolsPage() {
|
||||
return <Outlet />
|
||||
return <Outlet />;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Day } from "@/Const";
|
||||
import AsyncButton from "@/Element/Button/AsyncButton";
|
||||
import useLogin from "@/Hooks/useLogin"
|
||||
import useLogin from "@/Hooks/useLogin";
|
||||
import { dedupe, unixNow } from "@snort/shared";
|
||||
import { RequestBuilder } from "@snort/system";
|
||||
import { useMemo, useState } from "react";
|
||||
@ -11,126 +11,164 @@ import useEventPublisher from "@/Hooks/useEventPublisher";
|
||||
import { setFollows } from "@/Login";
|
||||
|
||||
const enum PruneStage {
|
||||
FetchLastPostTimestamp,
|
||||
Done
|
||||
FetchLastPostTimestamp,
|
||||
Done,
|
||||
}
|
||||
|
||||
export function PruneFollowList() {
|
||||
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const uniqueFollows = dedupe(follows.item);
|
||||
const [status, setStatus] = useState<PruneStage>();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [lastPost, setLastPosts] = useState<Record<string, number>>();
|
||||
const [unfollow, setUnfollow] = useState<Array<string>>([]);
|
||||
const { id, follows } = useLogin(s => ({ id: s.id, follows: s.follows }));
|
||||
const { publisher, system } = useEventPublisher();
|
||||
const uniqueFollows = dedupe(follows.item);
|
||||
const [status, setStatus] = useState<PruneStage>();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [lastPost, setLastPosts] = useState<Record<string, number>>();
|
||||
const [unfollow, setUnfollow] = useState<Array<string>>([]);
|
||||
|
||||
async function fetchLastPosts() {
|
||||
setStatus(PruneStage.FetchLastPostTimestamp);
|
||||
setProgress(0);
|
||||
setLastPosts(undefined);
|
||||
async function fetchLastPosts() {
|
||||
setStatus(PruneStage.FetchLastPostTimestamp);
|
||||
setProgress(0);
|
||||
setLastPosts(undefined);
|
||||
|
||||
const BatchSize = 10;
|
||||
const chunks = uniqueFollows.reduce((acc, v, i) => {
|
||||
const batch = Math.floor(i / BatchSize).toString();
|
||||
acc[batch] ??= [];
|
||||
acc[batch].push(v);
|
||||
return acc;
|
||||
}, {} as Record<string, Array<string>>);
|
||||
const BatchSize = 10;
|
||||
const chunks = uniqueFollows.reduce(
|
||||
(acc, v, i) => {
|
||||
const batch = Math.floor(i / BatchSize).toString();
|
||||
acc[batch] ??= [];
|
||||
acc[batch].push(v);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Array<string>>,
|
||||
);
|
||||
|
||||
const result = {} as Record<string, number>;
|
||||
const batches = Math.ceil(uniqueFollows.length / BatchSize);
|
||||
for (const [batch, pubkeys] of Object.entries(chunks)) {
|
||||
console.debug(batch, pubkeys);
|
||||
const req = new RequestBuilder(`prune-${batch}`);
|
||||
req.withOptions({
|
||||
outboxPickN: 10,
|
||||
timeout: 10_000
|
||||
});
|
||||
pubkeys.forEach(p => req.withFilter().limit(1).kinds([0, 1, 3, 5, 6, 7, 10002]).authors([p]));
|
||||
const results = await system.Fetch(req);
|
||||
console.debug(results);
|
||||
for (const rx of results) {
|
||||
if ((result[rx.pubkey] ?? 0) < rx.created_at) {
|
||||
result[rx.pubkey] = rx.created_at;
|
||||
}
|
||||
}
|
||||
setProgress(Number(batch) / batches);
|
||||
const result = {} as Record<string, number>;
|
||||
const batches = Math.ceil(uniqueFollows.length / BatchSize);
|
||||
for (const [batch, pubkeys] of Object.entries(chunks)) {
|
||||
console.debug(batch, pubkeys);
|
||||
const req = new RequestBuilder(`prune-${batch}`);
|
||||
req.withOptions({
|
||||
outboxPickN: 10,
|
||||
timeout: 10_000,
|
||||
});
|
||||
pubkeys.forEach(p => req.withFilter().limit(1).kinds([0, 1, 3, 5, 6, 7, 10002]).authors([p]));
|
||||
const results = await system.Fetch(req);
|
||||
console.debug(results);
|
||||
for (const rx of results) {
|
||||
if ((result[rx.pubkey] ?? 0) < rx.created_at) {
|
||||
result[rx.pubkey] = rx.created_at;
|
||||
}
|
||||
|
||||
for (const pk of uniqueFollows) {
|
||||
result[pk] ??= 0;
|
||||
}
|
||||
setLastPosts(result);
|
||||
setStatus(PruneStage.Done);
|
||||
}
|
||||
setProgress(Number(batch) / batches);
|
||||
}
|
||||
|
||||
const newFollowList = useMemo(() => {
|
||||
return uniqueFollows.filter(a => !unfollow.includes(a) && a.length === 64);
|
||||
}, [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);
|
||||
}
|
||||
for (const pk of uniqueFollows) {
|
||||
result[pk] ??= 0;
|
||||
}
|
||||
setLastPosts(result);
|
||||
setStatus(PruneStage.Done);
|
||||
}
|
||||
|
||||
const newFollowList = useMemo(() => {
|
||||
return uniqueFollows.filter(a => !unfollow.includes(a) && a.length === 64);
|
||||
}, [uniqueFollows, unfollow]);
|
||||
|
||||
function getStatus() {
|
||||
switch (status) {
|
||||
case PruneStage.FetchLastPostTimestamp: return <FormattedMessage defaultMessage="Searching for account activity ({progress})" id="nIchMQ" values={{
|
||||
progress: <FormattedNumber style="percent" value={progress} />
|
||||
}} />
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function personToggle(k: string,) {
|
||||
return <div className="flex gap-1">
|
||||
<input type="checkbox" onChange={e => setUnfollow(v => e.target.checked ? dedupe([...v, k]) : v.filter(a => a !== k))} checked={unfollow.includes(k)} />
|
||||
<FormattedMessage defaultMessage="Unfollow" id="izWS4J" />
|
||||
</div>
|
||||
function getStatus() {
|
||||
switch (status) {
|
||||
case PruneStage.FetchLastPostTimestamp:
|
||||
return (
|
||||
<FormattedMessage
|
||||
defaultMessage="Searching for account activity ({progress})"
|
||||
id="nIchMQ"
|
||||
values={{
|
||||
progress: <FormattedNumber style="percent" value={progress} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<div className="text-2xl font-semibold">
|
||||
<FormattedMessage defaultMessage="Prune follow list" id="CM0k0d" />
|
||||
</div>
|
||||
<p>
|
||||
<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" />
|
||||
</p>
|
||||
<div>
|
||||
<FormattedMessage defaultMessage="{x} follows ({y} duplicates)" 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>
|
||||
function personToggle(k: string) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={e => setUnfollow(v => (e.target.checked ? dedupe([...v, k]) : v.filter(a => a !== k)))}
|
||||
checked={unfollow.includes(k)}
|
||||
/>
|
||||
<FormattedMessage defaultMessage="Unfollow" id="izWS4J" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-2xl font-semibold">
|
||||
<FormattedMessage defaultMessage="Prune follow list" id="CM0k0d" />
|
||||
</div>
|
||||
<p>
|
||||
<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"
|
||||
/>
|
||||
</p>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
defaultMessage="{x} follows ({y} duplicates)"
|
||||
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 className="px-4 pb-5 pt-2 rounded-2xl bg-bg-secondary">
|
||||
<p>
|
||||
<FormattedMessage defaultMessage="New follow list length {length}" id="6559gb" values={{ length: newFollowList.length }} />
|
||||
</p>
|
||||
<AsyncButton onClick={publishFollowList}>
|
||||
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-5 pt-2 rounded-2xl bg-bg-secondary">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="New follow list length {length}"
|
||||
id="6559gb"
|
||||
values={{ length: newFollowList.length }}
|
||||
/>
|
||||
</p>
|
||||
<AsyncButton onClick={publishFollowList}>
|
||||
<FormattedMessage defaultMessage="Save" id="jvo0vs" />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user