add noStrudel users view

This commit is contained in:
hzrd149 2024-11-27 10:09:39 -06:00
parent 5403d37a49
commit fb5d7f2d2b
13 changed files with 217 additions and 84 deletions

View File

@ -119,6 +119,7 @@ const DMTimelineView = lazy(() => import("./views/tools/dm-timeline"));
const TransformNoteView = lazy(() => import("./views/tools/transform-note"));
const SatelliteCDNView = lazy(() => import("./views/tools/satellite-cdn"));
const CorrectionsFeedView = lazy(() => import("./views/tools/corrections"));
const NoStrudelUsersView = lazy(() => import("./views/tools/nostrudel-users/index"));
const UserStreamsTab = lazy(() => import("./views/user/streams"));
const StreamsView = lazy(() => import("./views/streams"));
@ -413,6 +414,7 @@ const router = createHashRouter([
{ path: "unknown", element: <UnknownTimelineView /> },
{ path: "console", element: <EventConsoleView /> },
{ path: "corrections", element: <CorrectionsFeedView /> },
{ path: "nostrudel-users", element: <NoStrudelUsersView /> },
],
},
{

View File

@ -1,32 +1,6 @@
import { useMemo } from "react";
import { useColorModeValue, useTheme } from "@chakra-ui/react";
import {
Chart as ChartJS,
ArcElement,
CategoryScale,
ChartData,
Colors,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
} from "chart.js";
import { Pie } from "react-chartjs-2";
ChartJS.register(
ArcElement,
Tooltip,
Legend,
Colors,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
);
import { ChartData } from "chart.js";
import SimplePieChart from "./simple-pie-chart";
function createChartData(kinds: Record<string, number>) {
const sortedKinds = Object.entries(kinds)
@ -42,19 +16,7 @@ function createChartData(kinds: Record<string, number>) {
}
export default function EventKindsPieChart({ kinds }: { kinds: Record<string, number> }) {
const theme = useTheme();
const token = theme.semanticTokens.colors["chakra-body-text"];
const color = useColorModeValue(token._light, token._dark) as string;
const chartData = useMemo(() => createChartData(kinds), [kinds]);
return (
<Pie
data={chartData}
options={{
color,
plugins: { colors: { forceOverride: true } },
}}
/>
);
return <SimplePieChart data={chartData} />;
}

View File

@ -0,0 +1,44 @@
import { useColorModeValue, useTheme } from "@chakra-ui/react";
import {
Chart as ChartJS,
ArcElement,
CategoryScale,
ChartData,
Colors,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
} from "chart.js";
import { Pie } from "react-chartjs-2";
ChartJS.register(
ArcElement,
Tooltip,
Legend,
Colors,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
);
export default function SimplePieChart({ data }: { data: ChartData<"pie", number[], string> }) {
const theme = useTheme();
const token = theme.semanticTokens.colors["chakra-body-text"];
const color = useColorModeValue(token._light, token._dark) as string;
return (
<Pie
data={data}
options={{
color,
plugins: { colors: { forceOverride: true } },
}}
/>
);
}

View File

@ -1,6 +1,7 @@
import { ColorModeWithSystem } from "@chakra-ui/react";
import { NostrEvent } from "../types/nostr-event";
import { safeJson } from "./parse";
export const APP_SETTINGS_KIND = 30078;
export const APP_SETTING_IDENTIFIER = "nostrudel-settings";
export type AppSettingsV0 = {
version: 0;
@ -88,19 +89,3 @@ export const defaultSettings: AppSettings = {
redditRedirect: undefined,
youtubeRedirect: undefined,
};
export function upgradeSettings(settings: { version: number }): AppSettings | null {
return { ...defaultSettings, ...settings, version: 10 };
}
export function parseAppSettings(event: NostrEvent): AppSettings {
const json = safeJson(event.content, {});
const upgraded = upgradeSettings(json);
return upgraded
? {
...defaultSettings,
...upgraded,
}
: defaultSettings;
}

View File

@ -4,7 +4,9 @@ export function safeUrl(url: string) {
} catch (e) {}
}
export function safeJson<T>(json: string, fallback: T) {
export function safeJson<T>(json: string): T | undefined;
export function safeJson<T>(json: string, fallback: T): T;
export function safeJson<T>(json: string, fallback?: T): T | undefined {
try {
return JSON.parse(json);
} catch (e) {

View File

@ -3,10 +3,9 @@ import { useStoreQuery } from "applesauce-react/hooks";
import { EventTemplate } from "nostr-tools";
import dayjs from "dayjs";
import { AppSettings, defaultSettings } from "../helpers/app-settings";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND, AppSettings, defaultSettings } from "../helpers/app-settings";
import useCurrentAccount from "./use-current-account";
import accountService from "../services/account";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND } from "../services/user-app-settings";
import { usePublishEvent } from "../providers/global/publish-provider";
import AppSettingsQuery from "../queries/app-settings";
import useReplaceableEvent from "./use-replaceable-event";

View File

@ -1,8 +1,7 @@
import { Query } from "applesauce-core";
import { map } from "rxjs";
import { AppSettings, defaultSettings } from "../helpers/app-settings";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND } from "../services/user-app-settings";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND, AppSettings, defaultSettings } from "../helpers/app-settings";
import { safeJson } from "../helpers/parse";
export default function AppSettingsQuery(pubkey: string): Query<AppSettings> {
@ -12,7 +11,7 @@ export default function AppSettingsQuery(pubkey: string): Query<AppSettings> {
events.replaceable(APP_SETTINGS_KIND, pubkey, APP_SETTING_IDENTIFIER).pipe(
map((event) => {
if (!event) return defaultSettings;
const parsed = safeJson(event.content, defaultSettings) as Partial<AppSettings>;
const parsed = safeJson<Partial<AppSettings>>(event.content, defaultSettings);
return { ...defaultSettings, ...parsed };
}),
),

View File

@ -1,2 +0,0 @@
export const APP_SETTINGS_KIND = 30078;
export const APP_SETTING_IDENTIFIER = "nostrudel-settings";

View File

@ -11,12 +11,12 @@ import accountService from "./account";
import clientRelaysService from "./client-relays";
import { offlineMode } from "./offline-mode";
import replaceableEventsService from "./replaceable-events";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND } from "./user-app-settings";
import { eventStore, queryStore } from "./event-store";
import { Account } from "../classes/accounts/account";
import { MultiSubscription } from "applesauce-net/subscription";
import relayPoolService from "./relay-pool";
import { localRelay } from "./local-relay";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND } from "../helpers/app-settings";
const log = logger.extend("UserEventSync");
function downloadEvents(account: Account) {

View File

@ -5,7 +5,7 @@ import { AddressPointer } from "nostr-tools/nip19";
import { NostrEvent } from "../../../../types/nostr-event";
import HoverLinkOverlay from "../../../../components/hover-link-overlay";
import { DVMAvatarLink } from "./dvm-avatar";
import { DVMAvatar } from "./dvm-avatar";
import { getEventAddressPointer } from "../../../../helpers/nostr/event";
import { DVMName } from "./dvm-name";
import DebugEventButton from "../../../../components/debug-modal/debug-event-button";
@ -26,7 +26,7 @@ export default function DVMCard({
<>
<Card as={LinkBox} display="block" p="4" ref={ref} {...props}>
<DebugEventButton size="sm" float="right" zIndex={1} event={appData} />
<DVMAvatarLink pointer={pointer} w="24" float="left" mr="4" mb="2" />
<DVMAvatar pointer={pointer} w="24" float="left" mr="4" mb="2" />
<Heading size="md">
<HoverLinkOverlay as={RouterLink} to={to} onClick={onClick}>
<DVMName pointer={pointer} />

View File

@ -35,32 +35,30 @@ import Feed from "./components/feed";
import { AddressPointer } from "nostr-tools/nip19";
import useParamsAddressPointer from "../../../hooks/use-params-address-pointer";
import DVMParams from "./components/dvm-params";
import useUserMailboxes from "../../../hooks/use-user-mailboxes";
import { useUserOutbox } from "../../../hooks/use-user-mailboxes";
import { usePublishEvent } from "../../../providers/global/publish-provider";
import { getHumanReadableCoordinate } from "../../../services/replaceable-events";
function DVMFeedPage({ pointer }: { pointer: AddressPointer }) {
const [since] = useState(() => dayjs().subtract(1, "hour").unix());
const [since] = useState(() => dayjs().subtract(1, "day").unix());
const publish = usePublishEvent();
const navigate = useNavigate();
const account = useCurrentAccount()!;
const debugModal = useDisclosure();
const dvmRelays = useUserMailboxes(pointer.pubkey)?.outboxes;
const dvmRelays = useUserOutbox(pointer.pubkey);
const readRelays = useReadRelays(dvmRelays);
const { loader, timeline: events } = useTimelineLoader(
const { loader, timeline } = useTimelineLoader(
`${getHumanReadableCoordinate(pointer.kind, pointer.pubkey, pointer.identifier)}-jobs`,
readRelays,
[
{
authors: [account.pubkey, pointer.pubkey],
"#p": [account.pubkey, pointer.pubkey],
kinds: [DVM_CONTENT_DISCOVERY_JOB_KIND, DVM_CONTENT_DISCOVERY_RESULT_KIND, DVM_STATUS_KIND],
since,
},
],
{
authors: [account.pubkey, pointer.pubkey],
"#p": [account.pubkey, pointer.pubkey],
kinds: [DVM_CONTENT_DISCOVERY_JOB_KIND, DVM_CONTENT_DISCOVERY_RESULT_KIND, DVM_STATUS_KIND],
since,
},
);
const jobs = groupEventsIntoJobs(events);
const jobs = groupEventsIntoJobs(timeline);
const pages = chainJobs(Array.from(Object.values(jobs)));
const jobChains = flattenJobChain(pages);
@ -87,7 +85,7 @@ function DVMFeedPage({ pointer }: { pointer: AddressPointer }) {
useEffect(() => {
setRequesting(false);
}, [events.length]);
}, [timeline.length]);
return (
<VerticalPageLayout>

View File

@ -22,6 +22,7 @@ import ShieldOff from "../../components/icons/shield-off";
import MessageQuestionSquare from "../../components/icons/message-question-square";
import UploadCloud01 from "../../components/icons/upload-cloud-01";
import Edit04 from "../../components/icons/edit-04";
import Users03 from "../../components/icons/users-03";
export const internalApps: App[] = [
{
@ -122,6 +123,13 @@ export const internalTools: App[] = [
id: "corrections",
to: "/tools/corrections ",
},
{
title: "noStrudel Users",
description: "Discover other users using noStrudel",
icon: Users03,
id: "nostrudel-users",
to: "/tools/nostrudel-users",
},
];
export const externalTools: App[] = [

View File

@ -0,0 +1,136 @@
import {
Box,
Flex,
IconButton,
Stat,
StatLabel,
StatNumber,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import { APP_SETTING_IDENTIFIER, APP_SETTINGS_KIND, AppSettings } from "../../../helpers/app-settings";
import { useReadRelays } from "../../../hooks/use-client-relays";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import useTimelineLoader from "../../../hooks/use-timeline-loader";
import IntersectionObserverProvider from "../../../providers/local/intersection-observer";
import { safeJson } from "../../../helpers/parse";
import UserAvatarLink from "../../../components/user/user-avatar-link";
import UserLink from "../../../components/user/user-link";
import Timestamp from "../../../components/timestamp";
import useCurrentAccount from "../../../hooks/use-current-account";
import { GhostIcon } from "../../../components/icons";
import accountService from "../../../services/account";
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
function UserRow({ event, settings }: { event: NostrEvent; settings: Partial<AppSettings> }) {
const account = useCurrentAccount();
const isSelf = event.pubkey === account?.pubkey;
const ref = useEventIntersectionRef<HTMLTableRowElement>(event);
return (
<Tr ref={ref}>
<Td>
<Flex alignItems="center" gap="2">
<UserAvatarLink pubkey={event.pubkey} size="sm" />
<UserLink pubkey={event.pubkey} />
</Flex>
</Td>
<Td>{settings.primaryColor && <Box w="6" h="6" rounded="md" bg={settings.primaryColor} />}</Td>
<Td isNumeric>
<Timestamp timestamp={event.created_at} />
</Td>
<Td isNumeric>
{!isSelf && (
<IconButton
icon={<GhostIcon />}
size="sm"
aria-label="ghost user"
title="ghost user"
onClick={() => accountService.startGhost(event.pubkey)}
/>
)}
</Td>
</Tr>
);
}
export default function NoStrudelUsersView() {
const readRelays = useReadRelays();
const { loader, timeline } = useTimelineLoader("nostrudel-users", readRelays, {
kinds: [APP_SETTINGS_KIND],
"#d": [APP_SETTING_IDENTIFIER],
});
const callback = useTimelineCurserIntersectionCallback(loader);
const users = timeline
.map((event) => ({ event, settings: safeJson<Partial<AppSettings>>(event.content) }))
.filter((s) => !!s.settings) as { event: NostrEvent; settings: Partial<AppSettings> }[];
const colors = new Set(users.map((u) => u.settings.primaryColor).filter((c) => !!c) as string[]);
const colorModes = users.reduce<Record<string, number>>((dir, u) => {
if (u.settings.colorMode) dir[u.settings.colorMode] = (dir[u.settings.colorMode] ?? 0) + 1;
return dir;
}, {});
return (
<VerticalPageLayout>
<IntersectionObserverProvider callback={callback}>
<Flex gap="4" wrap="wrap">
<Stat maxW={{ base: "50%", md: "xs" }}>
<StatLabel>Users</StatLabel>
<StatNumber>{timeline.length}</StatNumber>
</Stat>
<Stat maxW={{ base: "50%", md: "xs" }}>
<StatLabel>Unique Colors</StatLabel>
<StatNumber>{colors.size}</StatNumber>
</Stat>
<TableContainer w={{ base: "full", sm: "xs" }}>
<Table size="sm">
<Thead>
<Tr>
<Th>Color Mode</Th>
<Th isNumeric>Count</Th>
</Tr>
</Thead>
<Tbody>
{Object.entries(colorModes).map(([mode, count]) => (
<Tr key={mode}>
<Td>{mode}</Td>
<Td isNumeric>{count}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Flex>
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>User</Th>
<Th>Color</Th>
<Th isNumeric>Last updated</Th>
<Th isNumeric></Th>
</Tr>
</Thead>
<Tbody>
{users.map((user) => (
<UserRow key={user.event.pubkey} event={user.event} settings={user.settings} />
))}
</Tbody>
</Table>
</TableContainer>
</IntersectionObserverProvider>
</VerticalPageLayout>
);
}