From cff1e8b49a15cd2ab4fd10f6ecd5bb9ce2f5a393 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 27 Sep 2023 10:32:20 -0500 Subject: [PATCH] Add simple stream moderation tool --- .changeset/chilly-carrots-knock.md | 5 + src/app.tsx | 2 + src/helpers/nostr/mute-list.ts | 13 +- src/hooks/use-user-mute-functions.ts | 4 +- .../stream-chat/use-stream-chat-timeline.ts | 5 +- src/views/tools/index.tsx | 5 +- src/views/tools/stream-moderation.tsx | 169 ++++++++++++++++++ 7 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 .changeset/chilly-carrots-knock.md create mode 100644 src/views/tools/stream-moderation.tsx diff --git a/.changeset/chilly-carrots-knock.md b/.changeset/chilly-carrots-knock.md new file mode 100644 index 000000000..f21447f75 --- /dev/null +++ b/.changeset/chilly-carrots-knock.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add simple stream moderation tool diff --git a/src/app.tsx b/src/app.tsx index aec417962..79f4c2b8d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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: }, { path: "network", element: }, { path: "network-graph", element: }, + { path: "stream-moderation", element: }, ], }, { diff --git a/src/helpers/nostr/mute-list.ts b/src/helpers/nostr/mute-list.ts index 2f74048db..582144b7d 100644 --- a/src/helpers/nostr/mute-list.ts +++ b/src/helpers/nostr/mute-list.ts @@ -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 { diff --git a/src/hooks/use-user-mute-functions.ts b/src/hooks/use-user-mute-functions.ts index 546f9496d..9cb3d2328 100644 --- a/src/hooks/use-user-mute-functions.ts +++ b/src/hooks/use-user-mute-functions.ts @@ -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 }; } diff --git a/src/views/streams/stream/stream-chat/use-stream-chat-timeline.ts b/src/views/streams/stream/stream-chat/use-stream-chat-timeline.ts index 1cc36933b..d1ae3fe59 100644 --- a/src/views/streams/stream/stream-chat/use-stream-chat-timeline.ts +++ b/src/views/streams/stream/stream-chat/use-stream-chat-timeline.ts @@ -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], ); diff --git a/src/views/tools/index.tsx b/src/views/tools/index.tsx index 770c7383d..353436819 100644 --- a/src/views/tools/index.tsx +++ b/src/views/tools/index.tsx @@ -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() { + diff --git a/src/views/tools/stream-moderation.tsx b/src/views/tools/stream-moderation.tsx new file mode 100644 index 000000000..0aa9dcd8d --- /dev/null +++ b/src/views/tools/stream-moderation.tsx @@ -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 = ; + } else { + buttons = ; + } + } else { + buttons = ( + <> + + + + ); + } + + return ( + + {!isMuted && } + + + {buttons} + + + ); +} + +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 ( + + + + Users in chat + + + {peopleInChat.map((pubkey) => ( + + ))} + {mutedPubkeys.length > 0 && ( + <> + Muted + + {mutedPubkeys.map((pubkey) => ( + + ))} + + )} + + + + + + ); +} + +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(); + + return ( + + + + + {selected && ( + + + + )} + + ); +} + +export default function StreamModerationView() { + return ( + + + + ); +}