mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-22 14:34:06 +02:00
Add simple stream moderation tool
This commit is contained in:
parent
88dba19bc9
commit
cff1e8b49a
5
.changeset/chilly-carrots-knock.md
Normal file
5
.changeset/chilly-carrots-knock.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add simple stream moderation tool
|
@ -58,6 +58,7 @@ import DrawerSubViewProvider from "./providers/drawer-sub-view-provider";
|
|||||||
import CommunitiesHomeView from "./views/communities";
|
import CommunitiesHomeView from "./views/communities";
|
||||||
import CommunityFindByNameView from "./views/community/find-by-name";
|
import CommunityFindByNameView from "./views/community/find-by-name";
|
||||||
import CommunityView from "./views/community/index";
|
import CommunityView from "./views/community/index";
|
||||||
|
import StreamModerationView from "./views/tools/stream-moderation";
|
||||||
|
|
||||||
const NetworkView = React.lazy(() => import("./views/tools/network"));
|
const NetworkView = React.lazy(() => import("./views/tools/network"));
|
||||||
const NetworkGraphView = React.lazy(() => import("./views/tools/network-mute-graph"));
|
const NetworkGraphView = React.lazy(() => import("./views/tools/network-mute-graph"));
|
||||||
@ -173,6 +174,7 @@ const router = createHashRouter([
|
|||||||
{ path: "", element: <ToolsHomeView /> },
|
{ path: "", element: <ToolsHomeView /> },
|
||||||
{ path: "network", element: <NetworkView /> },
|
{ path: "network", element: <NetworkView /> },
|
||||||
{ path: "network-graph", element: <NetworkGraphView /> },
|
{ path: "network-graph", element: <NetworkGraphView /> },
|
||||||
|
{ path: "stream-moderation", element: <StreamModerationView /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
|
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) {
|
export function getPubkeysFromMuteList(muteList: NostrEvent | DraftNostrEvent) {
|
||||||
const expirations = getPubkeysExpiration(muteList);
|
const expirations = getPubkeysExpiration(muteList);
|
||||||
@ -20,6 +20,17 @@ export function getPubkeysExpiration(muteList: NostrEvent | DraftNostrEvent) {
|
|||||||
return dir;
|
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 {
|
export function createEmptyMuteList(): DraftNostrEvent {
|
||||||
return {
|
return {
|
||||||
|
@ -2,6 +2,7 @@ import NostrPublishAction from "../classes/nostr-publish-action";
|
|||||||
import { isPubkeyInList } from "../helpers/nostr/lists";
|
import { isPubkeyInList } from "../helpers/nostr/lists";
|
||||||
import {
|
import {
|
||||||
createEmptyMuteList,
|
createEmptyMuteList,
|
||||||
|
getPubkeyExpiration,
|
||||||
muteListAddPubkey,
|
muteListAddPubkey,
|
||||||
muteListRemovePubkey,
|
muteListRemovePubkey,
|
||||||
pruneExpiredPubkeys,
|
pruneExpiredPubkeys,
|
||||||
@ -19,6 +20,7 @@ export default function useUserMuteFunctions(pubkey: string) {
|
|||||||
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
|
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
|
||||||
|
|
||||||
const isMuted = isPubkeyInList(muteList, pubkey);
|
const isMuted = isPubkeyInList(muteList, pubkey);
|
||||||
|
const expiration = muteList ? getPubkeyExpiration(muteList, pubkey) : 0;
|
||||||
|
|
||||||
const mute = useAsyncErrorHandler(async () => {
|
const mute = useAsyncErrorHandler(async () => {
|
||||||
let draft = muteListAddPubkey(muteList || createEmptyMuteList(), pubkey);
|
let draft = muteListAddPubkey(muteList || createEmptyMuteList(), pubkey);
|
||||||
@ -37,5 +39,5 @@ export default function useUserMuteFunctions(pubkey: string) {
|
|||||||
replaceableEventLoaderService.handleEvent(signed);
|
replaceableEventLoaderService.handleEvent(signed);
|
||||||
}, [requestSignature, muteList]);
|
}, [requestSignature, muteList]);
|
||||||
|
|
||||||
return { isMuted, mute, unmute };
|
return { isMuted, expiration, mute, unmute };
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import { NostrQuery } from "../../../../types/nostr-query";
|
|||||||
import useUserMuteFilter from "../../../../hooks/use-user-mute-filter";
|
import useUserMuteFilter from "../../../../hooks/use-user-mute-filter";
|
||||||
import useClientSideMuteFilter from "../../../../hooks/use-client-side-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 streamRelays = useRelaySelectionRelays();
|
||||||
|
|
||||||
const hostMuteFilter = useUserMuteFilter(stream.host);
|
const hostMuteFilter = useUserMuteFilter(stream.host);
|
||||||
@ -21,7 +21,8 @@ export default function useStreamChatTimeline(stream: ParsedStream) {
|
|||||||
(event: NostrEvent) => {
|
(event: NostrEvent) => {
|
||||||
if (stream.starts && event.created_at < stream.starts) return false;
|
if (stream.starts && event.created_at < stream.starts) return false;
|
||||||
if (stream.ends && event.created_at > stream.ends) 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],
|
[hostMuteFilter, muteFilter],
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Button, Divider, Flex, Heading, Image, Link } from "@chakra-ui/react";
|
import { Button, Divider, Flex, Heading, Image, Link } from "@chakra-ui/react";
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
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 VerticalPageLayout from "../../components/vertical-page-layout";
|
||||||
import { ConnectedRelays } from "../../components/connected-relays";
|
import { ConnectedRelays } from "../../components/connected-relays";
|
||||||
|
|
||||||
@ -21,6 +21,9 @@ export default function ToolsHomeView() {
|
|||||||
<Button as={RouterLink} to="/map" leftIcon={<MapIcon />}>
|
<Button as={RouterLink} to="/map" leftIcon={<MapIcon />}>
|
||||||
Map
|
Map
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button as={RouterLink} to="/tools/stream-moderation" leftIcon={<LiveStreamIcon />}>
|
||||||
|
Stream Moderation
|
||||||
|
</Button>
|
||||||
<ConnectedRelays />
|
<ConnectedRelays />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
169
src/views/tools/stream-moderation.tsx
Normal file
169
src/views/tools/stream-moderation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user