Add simple stream moderation tool

This commit is contained in:
hzrd149 2023-09-27 10:32:20 -05:00
parent 88dba19bc9
commit cff1e8b49a
7 changed files with 198 additions and 5 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple stream moderation tool

View File

@ -58,6 +58,7 @@ import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
import CommunitiesHomeView from "./views/communities";
import CommunityFindByNameView from "./views/community/find-by-name";
import CommunityView from "./views/community/index";
import StreamModerationView from "./views/tools/stream-moderation";
const NetworkView = React.lazy(() => import("./views/tools/network"));
const NetworkGraphView = React.lazy(() => import("./views/tools/network-mute-graph"));
@ -173,6 +174,7 @@ const router = createHashRouter([
{ path: "", element: <ToolsHomeView /> },
{ path: "network", element: <NetworkView /> },
{ path: "network-graph", element: <NetworkGraphView /> },
{ path: "stream-moderation", element: <StreamModerationView /> },
],
},
{

View File

@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
import { MUTE_LIST_KIND, getPubkeysFromList, listAddPerson, listRemovePerson } from "./lists";
import { MUTE_LIST_KIND, getPubkeysFromList, isPubkeyInList, listAddPerson, listRemovePerson } from "./lists";
export function getPubkeysFromMuteList(muteList: NostrEvent | DraftNostrEvent) {
const expirations = getPubkeysExpiration(muteList);
@ -20,6 +20,17 @@ export function getPubkeysExpiration(muteList: NostrEvent | DraftNostrEvent) {
return dir;
}, {});
}
export function getPubkeyExpiration(muteList: NostrEvent, pubkey: string) {
const tag = muteList.tags.find((tag) => {
return tag[0] === "mute_expiration" && tag[1] === pubkey && tag[2];
}, {});
if (tag && tag[1] && tag[2]) {
const date = parseInt(tag[2]);
if (dayjs.unix(date).isValid()) return date;
}
return isPubkeyInList(muteList, pubkey) ? Infinity : 0;
}
export function createEmptyMuteList(): DraftNostrEvent {
return {

View File

@ -2,6 +2,7 @@ import NostrPublishAction from "../classes/nostr-publish-action";
import { isPubkeyInList } from "../helpers/nostr/lists";
import {
createEmptyMuteList,
getPubkeyExpiration,
muteListAddPubkey,
muteListRemovePubkey,
pruneExpiredPubkeys,
@ -19,6 +20,7 @@ export default function useUserMuteFunctions(pubkey: string) {
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
const isMuted = isPubkeyInList(muteList, pubkey);
const expiration = muteList ? getPubkeyExpiration(muteList, pubkey) : 0;
const mute = useAsyncErrorHandler(async () => {
let draft = muteListAddPubkey(muteList || createEmptyMuteList(), pubkey);
@ -37,5 +39,5 @@ export default function useUserMuteFunctions(pubkey: string) {
replaceableEventLoaderService.handleEvent(signed);
}, [requestSignature, muteList]);
return { isMuted, mute, unmute };
return { isMuted, expiration, mute, unmute };
}

View File

@ -11,7 +11,7 @@ import { NostrQuery } from "../../../../types/nostr-query";
import useUserMuteFilter from "../../../../hooks/use-user-mute-filter";
import useClientSideMuteFilter from "../../../../hooks/use-client-side-mute-filter";
export default function useStreamChatTimeline(stream: ParsedStream) {
export default function useStreamChatTimeline(stream: ParsedStream, filterMuted = true) {
const streamRelays = useRelaySelectionRelays();
const hostMuteFilter = useUserMuteFilter(stream.host);
@ -21,7 +21,8 @@ export default function useStreamChatTimeline(stream: ParsedStream) {
(event: NostrEvent) => {
if (stream.starts && event.created_at < stream.starts) return false;
if (stream.ends && event.created_at > stream.ends) return false;
return !(hostMuteFilter(event) || muteFilter(event));
if (filterMuted) return !(hostMuteFilter(event) || muteFilter(event));
return true;
},
[hostMuteFilter, muteFilter],
);

View File

@ -1,6 +1,6 @@
import { Button, Divider, Flex, Heading, Image, Link } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { ExternalLinkIcon, MapIcon, ToolsIcon } from "../../components/icons";
import { ExternalLinkIcon, LiveStreamIcon, MapIcon, ToolsIcon } from "../../components/icons";
import VerticalPageLayout from "../../components/vertical-page-layout";
import { ConnectedRelays } from "../../components/connected-relays";
@ -21,6 +21,9 @@ export default function ToolsHomeView() {
<Button as={RouterLink} to="/map" leftIcon={<MapIcon />}>
Map
</Button>
<Button as={RouterLink} to="/tools/stream-moderation" leftIcon={<LiveStreamIcon />}>
Stream Moderation
</Button>
<ConnectedRelays />
</Flex>

View File

@ -0,0 +1,169 @@
import {
Button,
ButtonGroup,
Card,
CardBody,
CardHeader,
Divider,
Flex,
Heading,
Select,
Spacer,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import useParsedStreams from "../../hooks/use-parsed-streams";
import useSubject from "../../hooks/use-subject";
import { ParsedStream, STREAM_KIND, getATag, parseStreamEvent } from "../../helpers/nostr/stream";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import RequireCurrentAccount from "../../providers/require-current-account";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { ReactNode, useCallback, useMemo, useState } from "react";
import { NostrEvent } from "../../types/nostr-event";
import { getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useStreamChatTimeline from "../streams/stream/stream-chat/use-stream-chat-timeline";
import { UserAvatar } from "../../components/user-avatar";
import { UserLink } from "../../components/user-link";
import StreamChat from "../streams/stream/stream-chat";
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
import { useMuteModalContext } from "../../providers/mute-modal-provider";
import RelaySelectionProvider from "../../providers/relay-selection-provider";
import useUserMuteList from "../../hooks/use-user-mute-list";
import { isPubkeyInList } from "../../helpers/nostr/lists";
function UserCard({ pubkey }: { pubkey: string }) {
const { isMuted, mute, unmute, expiration } = useUserMuteFunctions(pubkey);
const { openModal } = useMuteModalContext();
let buttons: ReactNode | null = null;
if (isMuted) {
if (expiration === Infinity) {
buttons = <Button onClick={unmute}>Unban</Button>;
} else {
buttons = <Button onClick={unmute}>Unmute ({dayjs.unix(expiration).fromNow()})</Button>;
}
} else {
buttons = (
<>
<Button onClick={() => openModal(pubkey)}>Mute</Button>
<Button onClick={mute}>Ban</Button>
</>
);
}
return (
<Flex gap="2" direction="row" alignItems="center">
{!isMuted && <UserAvatar pubkey={pubkey} noProxy size="sm" />}
<UserLink pubkey={pubkey} />
<ButtonGroup size="sm" ml="auto">
{buttons}
</ButtonGroup>
</Flex>
);
}
function StreamModerationDashboard({ stream }: { stream: ParsedStream }) {
const account = useCurrentAccount()!;
const streamChatTimeline = useStreamChatTimeline(stream, false);
const chatEvents = useSubject(streamChatTimeline.timeline);
const muteList = useUserMuteList(account.pubkey);
const pubkeysInChat = useMemo(() => {
const pubkeys: string[] = [];
for (const event of chatEvents) {
if (!pubkeys.includes(event.pubkey)) pubkeys.push(event.pubkey);
}
return pubkeys;
}, [chatEvents]);
const peopleInChat = pubkeysInChat.filter((pubkey) => !isPubkeyInList(muteList, pubkey));
const mutedPubkeys = pubkeysInChat.filter((pubkey) => isPubkeyInList(muteList, pubkey));
return (
<Flex gap="2" overflow="hidden">
<Card w="md">
<CardHeader pt="2" px="2" pb="0">
<Heading size="md">Users in chat</Heading>
</CardHeader>
<CardBody p="2" gap="2" display="flex" overflowY="auto" overflowX="hidden" flexDirection="column">
{peopleInChat.map((pubkey) => (
<UserCard key={pubkey} pubkey={pubkey} />
))}
{mutedPubkeys.length > 0 && (
<>
<Heading size="sm">Muted</Heading>
<Divider />
{mutedPubkeys.map((pubkey) => (
<UserCard key={pubkey} pubkey={pubkey} />
))}
</>
)}
</CardBody>
</Card>
<Spacer />
<StreamChat stream={stream} w="lg" />
</Flex>
);
}
function StreamModerationPage() {
const account = useCurrentAccount()!;
const readRelays = useReadRelayUrls();
const eventFilter = useCallback((event: NostrEvent) => {
try {
const parsed = parseStreamEvent(event);
return parsed.status === "live";
} catch (e) {}
return false;
}, []);
const timeline = useTimelineLoader(
account.pubkey + "-streams",
readRelays,
[
{
authors: [account.pubkey],
kinds: [STREAM_KIND],
},
{ "#p": [account.pubkey], kinds: [STREAM_KIND] },
],
{ eventFilter },
);
const streamEvents = useSubject(timeline.timeline);
const streams = useParsedStreams(streamEvents);
const [selected, setSelected] = useState<ParsedStream>();
return (
<Flex direction="column" p="2" overflow="hidden" gap="2" h="100vh">
<Flex gap="2" flexShrink={0}>
<Select
placeholder="Select stream"
value={selected && getATag(selected)}
onChange={(e) => setSelected(streams.find((s) => getATag(s) === e.target.value))}
>
{streams.map((stream) => (
<option key={getEventUID(stream.event)} value={getATag(stream)}>
{stream.title}
</option>
))}
</Select>
</Flex>
{selected && (
<RelaySelectionProvider additionalDefaults={selected.relays ?? []}>
<StreamModerationDashboard stream={selected} />
</RelaySelectionProvider>
)}
</Flex>
);
}
export default function StreamModerationView() {
return (
<RequireCurrentAccount>
<StreamModerationPage />
</RequireCurrentAccount>
);
}