mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-09 20:29:17 +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 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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -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 {
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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],
|
||||
);
|
||||
|
@ -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>
|
||||
|
||||
|
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