Add time durations for muting users

This commit is contained in:
hzrd149 2023-09-27 08:20:38 -05:00
parent 34748aed80
commit 9fd16ea7e9
22 changed files with 568 additions and 77 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add time durations for muting users

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add ghost mode

View File

@ -427,3 +427,9 @@ export const CommunityIcon = createIcon({
d: "M9.55 11.5C8.30736 11.5 7.3 10.4926 7.3 9.25C7.3 8.00736 8.30736 7 9.55 7C10.7926 7 11.8 8.00736 11.8 9.25C11.8 10.4926 10.7926 11.5 9.55 11.5ZM10 19.748V16.4C10 15.9116 10.1442 15.4627 10.4041 15.0624C10.1087 15.0213 9.80681 15 9.5 15C7.93201 15 6.49369 15.5552 5.37091 16.4797C6.44909 18.0721 8.08593 19.2553 10 19.748ZM4.45286 14.66C5.86432 13.6168 7.61013 13 9.5 13C10.5435 13 11.5431 13.188 12.4667 13.5321C13.3447 13.1888 14.3924 13 15.5 13C17.1597 13 18.6849 13.4239 19.706 14.1563C19.8976 13.4703 20 12.7471 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 12.9325 4.15956 13.8278 4.45286 14.66ZM18.8794 16.0859C18.4862 15.5526 17.1708 15 15.5 15C13.4939 15 12 15.7967 12 16.4V20C14.9255 20 17.4843 18.4296 18.8794 16.0859ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM15.5 12.5C14.3954 12.5 13.5 11.6046 13.5 10.5C13.5 9.39543 14.3954 8.5 15.5 8.5C16.6046 8.5 17.5 9.39543 17.5 10.5C17.5 11.6046 16.6046 12.5 15.5 12.5Z",
defaultProps,
});
export const GhostIcon = createIcon({
displayName: "GhostIcon",
d: "M12 2C16.9706 2 21 6.02944 21 11V18.5C21 20.433 19.433 22 17.5 22C16.3001 22 15.2413 21.3962 14.6107 20.476C14.0976 21.3857 13.1205 22 12 22C10.8795 22 9.9024 21.3857 9.38728 20.4754C8.75869 21.3962 7.69985 22 6.5 22C4.63144 22 3.10487 20.5357 3.00518 18.692L3 18.5V11C3 6.02944 7.02944 2 12 2ZM12 4C8.21455 4 5.1309 7.00478 5.00406 10.7593L5 11L4.99927 18.4461L5.00226 18.584C5.04504 19.3751 5.70251 20 6.5 20C6.95179 20 7.36652 19.8007 7.64704 19.4648L7.73545 19.3478C8.57033 18.1248 10.3985 18.2016 11.1279 19.4904C11.3053 19.8038 11.6345 20 12 20C12.3651 20 12.6933 19.8044 12.8687 19.4934C13.5692 18.2516 15.2898 18.1317 16.1636 19.2151L16.2606 19.3455C16.5401 19.7534 16.9976 20 17.5 20C18.2797 20 18.9204 19.4051 18.9931 18.6445L19 18.5V11C19 7.13401 15.866 4 12 4ZM12 12C13.1046 12 14 13.1193 14 14.5C14 15.8807 13.1046 17 12 17C10.8954 17 10 15.8807 10 14.5C10 13.1193 10.8954 12 12 12ZM9.5 8C10.3284 8 11 8.67157 11 9.5C11 10.3284 10.3284 11 9.5 11C8.67157 11 8 10.3284 8 9.5C8 8.67157 8.67157 8 9.5 8ZM14.5 8C15.3284 8 16 8.67157 16 9.5C16 10.3284 15.3284 11 14.5 11C13.6716 11 13 10.3284 13 9.5C13 8.67157 13.6716 8 14.5 8Z",
defaultProps,
});

View File

@ -48,17 +48,19 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
{account ? (
<>
<ProfileButton />
<IconButton
icon={<EditIcon />}
aria-label="New note"
title="New note"
w="3rem"
h="3rem"
fontSize="1.5rem"
colorScheme="brand"
onClick={() => openModal()}
flexShrink={0}
/>
{!account.readonly && (
<IconButton
icon={<EditIcon />}
aria-label="New note"
title="New note"
w="3rem"
h="3rem"
fontSize="1.5rem"
colorScheme="brand"
onClick={() => openModal()}
flexShrink={0}
/>
)}
</>
) : (
<Button as={RouterLink} to="/login" state={{ from: location.pathname }} colorScheme="brand" w="full">

View File

@ -0,0 +1,127 @@
import { useCallback, useState } from "react";
import { Box, BoxProps, Card, CloseButton, Divider, Flex, FlexProps, Spacer, Text } from "@chakra-ui/react";
import { Kind, nip18, nip19, nip25 } from "nostr-tools";
import { useNavigate } from "react-router-dom";
import { useInterval } from "react-use";
import { useCurrentAccount } from "../../hooks/use-current-account";
import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
import { UserAvatar } from "../user-avatar";
import { UserLink } from "../user-link";
import { GhostIcon } from "../icons";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import dayjs from "dayjs";
import { TimelineLoader } from "../../classes/timeline-loader";
import { NostrEvent } from "../../types/nostr-event";
import { getSharableEventAddress } from "../../helpers/nip19";
import { safeRelayUrls } from "../../helpers/url";
const kindColors: Record<number, FlexProps["bg"]> = {
[Kind.Text]: "blue.500",
[Kind.RecommendRelay]: "pink",
[Kind.EncryptedDirectMessage]: "orange.500",
[Kind.Repost]: "yellow",
[Kind.Reaction]: "green.500",
[Kind.Article]: "purple.500",
};
function EventChunk({ event, ...props }: { event: NostrEvent } & Omit<FlexProps, "children">) {
const navigate = useNavigate();
const handleClick = useCallback(() => {
switch (event.kind) {
case Kind.Reaction: {
const pointer = nip25.getReactedEventPointer(event);
if (pointer) navigate(`/l/${nip19.neventEncode(pointer)}`);
return;
}
case Kind.Repost: {
const pointer = nip18.getRepostedEventPointer(event);
if (pointer?.relays) pointer.relays = safeRelayUrls(pointer.relays);
if (pointer) navigate(`/l/${nip19.neventEncode(pointer)}`);
return;
}
}
navigate(`/l/${getSharableEventAddress(event)}`);
}, [event]);
const getTitle = () => {
switch (event.kind) {
case Kind.Text:
return "Note";
case Kind.Reaction:
return "Reaction";
case Kind.EncryptedDirectMessage:
return "Direct Message";
}
};
return (
<Flex alignItems="center" cursor="pointer" onClick={handleClick} title={getTitle()} overflow="hidden" {...props}>
<Box bg={kindColors[event.kind] || "gray.500"} h="8" p="2" fontSize="sm">
{getTitle()}
</Box>
<Divider />
</Flex>
);
}
function CompactEventTimeline({ timeline, ...props }: { timeline: TimelineLoader } & Omit<FlexProps, "children">) {
const events = useSubject(timeline.timeline);
const [now, setNow] = useState(dayjs().unix());
useInterval(() => setNow(dayjs().unix()), 1000 * 10);
return (
<Flex {...props}>
{Array.from(events)
.reverse()
.map((event, i, arr) => {
const next = arr[i + 1];
return (
<EventChunk
key={event.id}
event={event}
flex={next ? next.created_at - event.created_at : now - event.created_at}
/>
);
})}
</Flex>
);
}
export default function GhostToolbar() {
const account = useCurrentAccount()!;
const isGhost = useSubject(accountService.isGhost);
const readRelays = useReadRelayUrls();
const [since] = useState(dayjs().subtract(6, "hours").unix());
const timeline = useTimelineLoader(`${account.pubkey}-ghost`, readRelays, { since, authors: [account.pubkey] });
const events = useSubject(timeline.timeline);
return (
<Card
p="2"
display="flex"
flexDirection="row"
alignItems="center"
gap="2"
position="fixed"
bottom="0"
left="0"
right="0"
>
<GhostIcon fontSize="2rem" />
<Text>Ghosting: </Text>
<UserAvatar pubkey={account.pubkey} size="sm" />
<UserLink pubkey={account.pubkey} fontWeight="bold" />
<Spacer />
<CompactEventTimeline w="70%" timeline={timeline} />
<Spacer />
<CloseButton onClick={() => accountService.stopGhost()} />
</Card>
);
}

View File

@ -5,9 +5,13 @@ import { ErrorBoundary } from "../error-boundary";
import { ReloadPrompt } from "../reload-prompt";
import DesktopSideNav from "./desktop-side-nav";
import MobileBottomNav from "./mobile-bottom-nav";
import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
import GhostToolbar from "./ghost-toolbar";
export default function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useBreakpointValue({ base: true, md: false });
const isGhost = useSubject(accountService.isGhost);
return (
<>
@ -41,6 +45,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
)}
<Spacer display={["none", null, "block"]} />
</Flex>
{isGhost && <GhostToolbar />}
</>
);
}

View File

@ -5,11 +5,11 @@ export type MenuIconButtonProps = IconButtonProps & {
children: MenuListProps["children"];
};
export function MenuIconButton({ children, ...props }: MenuIconButtonProps) {
export function CustomMenuIconButton({ children, ...props }: MenuIconButtonProps) {
return (
<Menu isLazy>
<MenuButton as={IconButton} icon={<MoreIcon />} {...props} />
<MenuList>{children}</MenuList>
<MenuList zIndex={100}>{children}</MenuList>
</Menu>
);
}

View File

@ -5,7 +5,7 @@ import { nip19 } from "nostr-tools";
import { getSharableEventAddress } from "../../helpers/nip19";
import { NostrEvent } from "../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
import { CustomMenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
import {
ClipboardIcon,
@ -27,12 +27,14 @@ import clientRelaysService from "../../services/client-relays";
import { handleEventFromRelay } from "../../services/event-relays";
import NostrPublishAction from "../../classes/nostr-publish-action";
import useUserMuteFunctions from "../../hooks/use-user-mute-functions";
import { useMuteModalContext } from "../../providers/mute-modal-provider";
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
const account = useCurrentAccount();
const infoModal = useDisclosure();
const reactionsModal = useDisclosure();
const { isMuted, mute, unmute } = useUserMuteFunctions(event.pubkey);
const { openModal } = useMuteModalContext();
const { deleteEvent } = useDeleteEventContext();
@ -51,14 +53,18 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
return (
<>
<MenuIconButton {...props}>
<CustomMenuIconButton {...props}>
{address && (
<MenuItem onClick={() => window.open(buildAppSelectUrl(address), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
)}
{account?.pubkey !== event.pubkey && (
<MenuItem onClick={isMuted ? unmute : mute} icon={isMuted ? <UnmuteIcon /> : <MuteIcon />} color="red.500">
<MenuItem
onClick={isMuted ? unmute : () => openModal(event.pubkey)}
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
color="red.500"
>
{isMuted ? "Unmute User" : "Mute User"}
</MenuItem>
)}
@ -84,7 +90,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
Zaps/Reactions
</MenuItem>
</MenuIconButton>
</CustomMenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={event} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />

View File

@ -34,6 +34,7 @@ import replaceableEventLoaderService from "../services/replaceable-event-request
import useAsyncErrorHandler from "../hooks/use-async-error-handler";
import NewListModal from "../views/lists/components/new-list-modal";
import useUserMuteFunctions from "../hooks/use-user-mute-functions";
import { useMuteModalContext } from "../providers/mute-modal-provider";
function UsersLists({ pubkey }: { pubkey: string }) {
const toast = useToast();
@ -117,7 +118,8 @@ export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButt
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext();
const contacts = useUserContactList(account?.pubkey, [], { ignoreCache: true });
const { isMuted, mute, unmute } = useUserMuteFunctions(pubkey);
const { isMuted, unmute } = useUserMuteFunctions(pubkey);
const { openModal } = useMuteModalContext();
const isFollowing = isPubkeyInList(contacts, pubkey);
const isDisabled = account?.readonly ?? true;
@ -153,7 +155,7 @@ export const UserFollowButton = ({ pubkey, showLists, ...props }: UserFollowButt
)}
{account?.pubkey !== pubkey && (
<MenuItem
onClick={isMuted ? unmute : mute}
onClick={isMuted ? unmute : () => openModal(pubkey)}
icon={isMuted ? <UnmuteIcon /> : <MuteIcon />}
color="red.500"
isDisabled={isDisabled}

View File

@ -2,7 +2,7 @@ import dayjs from "dayjs";
import { Kind } from "nostr-tools";
import { AddressPointer } from "nostr-tools/lib/nip19";
import { DraftNostrEvent, NostrEvent, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event";
import { DraftNostrEvent, NostrEvent, PTag, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event";
import { parseCoordinate } from "./events";
export const PEOPLE_LIST_KIND = 30000;
@ -31,19 +31,28 @@ export function isSpecialListKind(kind: number) {
return kind === Kind.Contacts || kind === PIN_LIST_KIND || kind === MUTE_LIST_KIND;
}
export function getPubkeysFromList(event: NostrEvent) {
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2] }));
export function cloneList(list: NostrEvent, keepCreatedAt = false): DraftNostrEvent {
return {
kind: list.kind,
content: list.content,
tags: Array.from(list.tags),
created_at: keepCreatedAt ? list.created_at : dayjs().unix(),
};
}
export function getEventsFromList(event: NostrEvent) {
export function getPubkeysFromList(event: NostrEvent | DraftNostrEvent) {
return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2], petname: t[3] }));
}
export function getEventsFromList(event: NostrEvent | DraftNostrEvent) {
return event.tags.filter(isETag).map((t) => ({ id: t[1], relay: t[2] }));
}
export function getReferencesFromList(event: NostrEvent) {
export function getReferencesFromList(event: NostrEvent | DraftNostrEvent) {
return event.tags.filter(isRTag).map((t) => ({ url: t[1], petname: t[2] }));
}
export function getCoordinatesFromList(event: NostrEvent) {
export function getCoordinatesFromList(event: NostrEvent | DraftNostrEvent) {
return event.tags.filter(isATag).map((t) => ({ coordinate: t[1], relay: t[2] }));
}
export function getParsedCordsFromList(event: NostrEvent) {
export function getParsedCordsFromList(event: NostrEvent | DraftNostrEvent) {
const pointers: AddressPointer[] = [];
for (const tag of event.tags) {
@ -58,9 +67,9 @@ export function getParsedCordsFromList(event: NostrEvent) {
return pointers;
}
export function isPubkeyInList(event?: NostrEvent, pubkey?: string) {
if (!pubkey || !event) return false;
return event.tags.some((t) => t[0] === "p" && t[1] === pubkey);
export function isPubkeyInList(list?: NostrEvent, pubkey?: string) {
if (!pubkey || !list) return false;
return list.tags.some((t) => t[0] === "p" && t[1] === pubkey);
}
export function createEmptyContactList(): DraftNostrEvent {
@ -71,22 +80,22 @@ export function createEmptyContactList(): DraftNostrEvent {
kind: Kind.Contacts,
};
}
export function createEmptyMuteList(): DraftNostrEvent {
return {
created_at: dayjs().unix(),
content: "",
tags: [],
kind: MUTE_LIST_KIND,
};
}
export function listAddPerson(list: NostrEvent | DraftNostrEvent, pubkey: string, relay?: string): DraftNostrEvent {
export function listAddPerson(
list: NostrEvent | DraftNostrEvent,
pubkey: string,
relay?: string,
petname?: string,
): DraftNostrEvent {
if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("person already in list");
const pTag: PTag = ["p", pubkey, relay ?? "", petname ?? ""];
while (pTag[pTag.length - 1] === "") pTag.pop();
return {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, relay ? ["p", pubkey, relay] : ["p", pubkey]],
tags: [...list.tags, pTag],
};
}

View File

@ -0,0 +1,75 @@
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent, isPTag } from "../../types/nostr-event";
import { MUTE_LIST_KIND, getPubkeysFromList, listAddPerson, listRemovePerson } from "./lists";
export function getPubkeysFromMuteList(muteList: NostrEvent | DraftNostrEvent) {
const expirations = getPubkeysExpiration(muteList);
return getPubkeysFromList(muteList).map((p) => ({
pubkey: p.pubkey,
expiration: expirations[p.pubkey] ?? Infinity,
}));
}
export function getPubkeysExpiration(muteList: NostrEvent | DraftNostrEvent) {
return muteList.tags.reduce<Record<string, number>>((dir, tag) => {
if (tag[0] === "mute_expiration" && tag[1] && tag[2]) {
const date = parseInt(tag[2]);
if (dayjs.unix(date).isValid()) {
return { ...dir, [tag[1]]: date };
}
}
return dir;
}, {});
}
export function createEmptyMuteList(): DraftNostrEvent {
return {
created_at: dayjs().unix(),
content: "",
tags: [],
kind: MUTE_LIST_KIND,
};
}
export function muteListAddPubkey(muteList: NostrEvent | DraftNostrEvent, pubkey: string, expiration = Infinity) {
let draft = listAddPerson(muteList, pubkey);
if (expiration < Infinity) {
draft = {
...draft,
tags: [...draft.tags, ["mute_expiration", pubkey, String(expiration)]],
};
}
return draft;
}
export function muteListRemovePubkey(muteList: NostrEvent | DraftNostrEvent, pubkey: string) {
let draft = listRemovePerson(muteList, pubkey);
draft = {
...draft,
tags: draft.tags.filter((t) => {
if (t[0] === "mute_expiration" && t[1] === pubkey) return false;
return true;
}),
};
return draft;
}
export function pruneExpiredPubkeys(muteList: NostrEvent | DraftNostrEvent) {
const expirations = getPubkeysExpiration(muteList);
const now = dayjs().unix();
const draft: DraftNostrEvent = {
kind: MUTE_LIST_KIND,
content: muteList.content,
created_at: now,
tags: muteList.tags.filter((tag) => {
// remove expired "expiration" tags
if (tag[0] === "mute_expiration" && parseInt(tag[2]) < now) return false;
// remove expired "p" tags
if (isPTag(tag) && expirations[tag[1]] < now) return false;
return true;
}),
};
return draft;
}

View File

@ -1,5 +1,11 @@
import NostrPublishAction from "../classes/nostr-publish-action";
import { createEmptyMuteList, listAddPerson, listRemovePerson, isPubkeyInList } from "../helpers/nostr/lists";
import { isPubkeyInList } from "../helpers/nostr/lists";
import {
createEmptyMuteList,
muteListAddPubkey,
muteListRemovePubkey,
pruneExpiredPubkeys,
} from "../helpers/nostr/mute-list";
import { useSigningContext } from "../providers/signing-provider";
import clientRelaysService from "../services/client-relays";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
@ -15,13 +21,17 @@ export default function useUserMuteFunctions(pubkey: string) {
const isMuted = isPubkeyInList(muteList, pubkey);
const mute = useAsyncErrorHandler(async () => {
const draft = listAddPerson(muteList || createEmptyMuteList(), pubkey);
let draft = muteListAddPubkey(muteList || createEmptyMuteList(), pubkey);
draft = pruneExpiredPubkeys(draft);
const signed = await requestSignature(draft);
new NostrPublishAction("Mute", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);
}, [requestSignature, muteList]);
const unmute = useAsyncErrorHandler(async () => {
const draft = listRemovePerson(muteList || createEmptyMuteList(), pubkey);
let draft = muteListRemovePubkey(muteList || createEmptyMuteList(), pubkey);
draft = pruneExpiredPubkeys(draft);
const signed = await requestSignature(draft);
new NostrPublishAction("Unmute", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);

View File

@ -10,6 +10,7 @@ import NotificationTimelineProvider from "./notification-timeline";
import PostModalProvider from "./post-modal-provider";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { UserContactsUserDirectoryProvider } from "./user-directory-provider";
import MuteModalProvider from "./mute-modal-provider";
// Top level providers, should be render as close to the root as possible
export const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
@ -31,17 +32,19 @@ export function PageProviders({ children }: { children: React.ReactNode }) {
return (
<SigningProvider>
<DeleteEventProvider>
<InvoiceModalProvider>
<NotificationTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<UserContactsUserDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider>
</UserContactsUserDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</NotificationTimelineProvider>
</InvoiceModalProvider>
<MuteModalProvider>
<InvoiceModalProvider>
<NotificationTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<UserContactsUserDirectoryProvider>
<PostModalProvider>{children}</PostModalProvider>
</UserContactsUserDirectoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
</NotificationTimelineProvider>
</InvoiceModalProvider>
</MuteModalProvider>
</DeleteEventProvider>
</SigningProvider>
);

View File

@ -0,0 +1,206 @@
import {
Button,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
ModalProps,
SimpleGrid,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
import dayjs from "dayjs";
import { getUserDisplayName } from "../helpers/user-metadata";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { useCurrentAccount } from "../hooks/use-current-account";
import {
createEmptyMuteList,
getPubkeysExpiration,
muteListAddPubkey,
pruneExpiredPubkeys,
} from "../helpers/nostr/mute-list";
import { cloneList } from "../helpers/nostr/lists";
import { useSigningContext } from "./signing-provider";
import NostrPublishAction from "../classes/nostr-publish-action";
import clientRelaysService from "../services/client-relays";
import replaceableEventLoaderService from "../services/replaceable-event-requester";
import useUserMuteList from "../hooks/use-user-mute-list";
import { useInterval } from "react-use";
import { DraftNostrEvent } from "../types/nostr-event";
import { UserAvatar } from "../components/user-avatar";
import { UserLink } from "../components/user-link";
type MuteModalContextType = {
openModal: (pubkey: string) => void;
};
const MuteModalContext = createContext<MuteModalContextType>({
openModal: () => {},
});
export function useMuteModalContext() {
return useContext(MuteModalContext);
}
function MuteModal({ pubkey, onClose, ...props }: Omit<ModalProps, "children"> & { pubkey: string }) {
const metadata = useUserMetadata(pubkey);
const toast = useToast();
const { requestSignature } = useSigningContext();
const account = useCurrentAccount();
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
const handleClick = async (expiration: number) => {
try {
// mute user
let draft = muteList ? cloneList(muteList) : createEmptyMuteList();
draft = pruneExpiredPubkeys(draft);
draft = muteListAddPubkey(draft, pubkey, expiration);
const signed = await requestSignature(draft);
new NostrPublishAction("Mute", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);
onClose();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
return (
<Modal onClose={onClose} size="lg" {...props}>
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Mute {getUserDisplayName(metadata, pubkey)} for:</ModalHeader>
<ModalCloseButton />
<ModalBody px="4" py="0">
<SimpleGrid columns={3} spacing="2">
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "minute").unix())}>
1 Minute
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(5, "minutes").unix())}>
5 Minutes
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(30, "minutes").unix())}>
30 Minutes
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "hour").unix())}>
1 Hour
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(5, "hours").unix())}>
5 Hours
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "day").unix())}>
1 Day
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(3, "days").unix())}>
3 Days
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(1, "week").unix())}>
1 Week
</Button>
<Button variant="outline" onClick={() => handleClick(dayjs().add(2, "weeks").unix())}>
2 Weeks
</Button>
</SimpleGrid>
<Button variant="outline" onClick={() => handleClick(Infinity)} w="full" mt="2">
Forever
</Button>
</ModalBody>
<ModalFooter p="4">
<Button onClick={onClose}>Cancel</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
function UnmuteModal({}) {
const toast = useToast();
const account = useCurrentAccount()!;
const { requestSignature } = useSigningContext();
const muteList = useUserMuteList(account?.pubkey, [], { ignoreCache: true });
const modal = useDisclosure();
const removeExpiredMutes = async () => {
if (!muteList) return;
try {
// unmute users
let draft: DraftNostrEvent = cloneList(muteList);
draft = pruneExpiredPubkeys(muteList);
const signed = await requestSignature(draft);
new NostrPublishAction("Unmute Users", clientRelaysService.getWriteUrls(), signed);
replaceableEventLoaderService.handleEvent(signed);
modal.onClose();
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
};
const getExpiredPubkeys = () => {
if (!muteList) return [];
const now = dayjs().unix();
const expirations = getPubkeysExpiration(muteList);
return Object.entries(expirations)
.filter(([pubkey, ex]) => ex < now)
.map(([pubkey]) => pubkey);
};
useInterval(() => {
if (!muteList) return;
if (!modal.isOpen && getExpiredPubkeys().length > 0) {
modal.onOpen();
}
}, 30 * 1000);
return (
<Modal onClose={modal.onClose} size="lg" isOpen={modal.isOpen}>
<ModalOverlay />
<ModalContent>
<ModalHeader p="4">Unmute temporary muted users</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" flexWrap="wrap" gap="2" px="4" py="0">
{getExpiredPubkeys().map((pubkey) => (
<Flex gap="2" key={pubkey} alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" />
<UserLink pubkey={pubkey} fontWeight="bold" />
</Flex>
))}
</ModalBody>
<ModalFooter p="4">
<Button onClick={modal.onClose} mr="3">
Cancel
</Button>
<Button colorScheme="brand" onClick={removeExpiredMutes}>
Unmute all
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default function MuteModalProvider({ children }: PropsWithChildren) {
const [muteUser, setMuteUser] = useState("");
const openModal = useCallback(
(pubkey: string) => {
setMuteUser(pubkey);
},
[setMuteUser],
);
const context = useMemo(() => ({ openModal }), [openModal]);
return (
<MuteModalContext.Provider value={context}>
{children}
<UnmuteModal />
{muteUser && <MuteModal isOpen onClose={() => setMuteUser("")} pubkey={muteUser} />}
</MuteModalContext.Provider>
);
}

View File

@ -16,6 +16,7 @@ class AccountService {
loading = new PersistentSubject(true);
accounts = new PersistentSubject<Account[]>([]);
current = new PersistentSubject<Account | null>(null);
isGhost = new PersistentSubject(false);
constructor() {
db.getAll("accounts").then((accounts) => {
@ -30,8 +31,26 @@ class AccountService {
});
}
startGhost(pubkey: string) {
const ghostAccount: Account = {
pubkey,
readonly: true,
};
const lastPubkey = this.current.value?.pubkey;
if (lastPubkey && this.hasAccount(lastPubkey)) localStorage.setItem("lastAccount", lastPubkey);
this.current.next(ghostAccount);
this.isGhost.next(true);
}
stopGhost() {
const lastAccount = localStorage.getItem("lastAccount");
if (lastAccount && this.hasAccount(lastAccount)) {
this.switchAccount(lastAccount);
} else this.logout();
}
hasAccount(pubkey: string) {
return this.accounts.value.some((acc) => acc.pubkey === pubkey);
return this.accounts.value.some((account) => account.pubkey === pubkey);
}
addAccount(account: Account) {
if (this.hasAccount(account.pubkey)) {
@ -41,6 +60,7 @@ class AccountService {
// if this is the current account. update it
if (this.current.value?.pubkey === account.pubkey) {
this.current.next(account);
this.isGhost.next(false);
}
} else {
// add account
@ -69,15 +89,14 @@ class AccountService {
const account = this.accounts.value.find((acc) => acc.pubkey === pubkey);
if (account) {
this.current.next(account);
this.isGhost.next(false);
localStorage.setItem("lastAccount", pubkey);
}
}
switchToTemporary(account: Account) {
this.current.next(account);
}
logout() {
this.current.next(null);
this.isGhost.next(false);
localStorage.removeItem("lastAccount");
}
}

View File

@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
@ -22,7 +22,7 @@ export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & O
return (
<>
<MenuIconButton {...props}>
<CustomMenuIconButton {...props}>
{naddr && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
@ -41,7 +41,7 @@ export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & O
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
</CustomMenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={badge} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />

View File

@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
@ -25,7 +25,7 @@ export default function EmojiPackMenu({
return (
<>
<MenuIconButton {...props}>
<CustomMenuIconButton {...props}>
{naddr && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
@ -44,7 +44,7 @@ export default function EmojiPackMenu({
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
</CustomMenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={pack} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />

View File

@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
@ -21,7 +21,7 @@ export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit
return (
<>
<MenuIconButton {...props}>
<CustomMenuIconButton {...props}>
{nevent && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(nevent), "_blank")} icon={<ExternalLinkIcon />}>
@ -40,7 +40,7 @@ export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
</CustomMenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={goal} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />

View File

@ -2,7 +2,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use";
import { NostrEvent } from "../../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { useCurrentAccount } from "../../../hooks/use-current-account";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
@ -23,7 +23,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
return (
<>
<MenuIconButton {...props}>
<CustomMenuIconButton {...props}>
{naddr && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
@ -42,7 +42,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
</CustomMenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={list} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />

View File

@ -13,6 +13,7 @@ import {
getParsedCordsFromList,
getPubkeysFromList,
getReferencesFromList,
isSpecialListKind,
} from "../../helpers/nostr/lists";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import UserCard from "./components/user-card";
@ -77,7 +78,7 @@ export default function ListDetailsView() {
<Spacer />
<ListFeedButton list={list} />
{isAuthor && (
{isAuthor && !isSpecialListKind(list.kind) && (
<Button colorScheme="red" onClick={() => deleteEvent(list).then(() => navigate("/lists"))}>
Delete
</Button>

View File

@ -1,6 +1,6 @@
import { Flex, Heading, IconButton, Spacer, useBreakpointValue } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { EditIcon } from "../../../components/icons";
import { EditIcon, GhostIcon } from "../../../components/icons";
import { UserAvatar } from "../../../components/user-avatar";
import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon";
import { getUserDisplayName } from "../../../helpers/user-metadata";
@ -8,6 +8,7 @@ import { useCurrentAccount } from "../../../hooks/use-current-account";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { UserProfileMenu } from "./user-profile-menu";
import { UserFollowButton } from "../../../components/user-follow-button";
import accountService from "../../../services/account";
export default function Header({
pubkey,
@ -22,7 +23,7 @@ export default function Header({
const account = useCurrentAccount();
const isSelf = pubkey === account?.pubkey;
const showFollowButton = useBreakpointValue({ base: false, sm: true });
const showExtraButtons = useBreakpointValue({ base: false, sm: true });
const showFullNip05 = useBreakpointValue({ base: false, md: true });
@ -35,7 +36,7 @@ export default function Header({
</Heading>
<UserDnsIdentityIcon pubkey={pubkey} onlyIcon={showFullNip05} />
<Spacer />
{isSelf && (
{isSelf && !account.readonly && (
<IconButton
icon={<EditIcon />}
aria-label="Edit profile"
@ -45,7 +46,16 @@ export default function Header({
onClick={() => navigate("/profile")}
/>
)}
{showFollowButton && !isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
{showExtraButtons && !isSelf && <UserFollowButton pubkey={pubkey} size="sm" />}
{showExtraButtons && !isSelf && (
<IconButton
icon={<GhostIcon />}
size="sm"
aria-label="ghost user"
title="ghost user"
onClick={() => accountService.startGhost(pubkey)}
/>
)}
<UserProfileMenu
pubkey={pubkey}
aria-label="More Options"

View File

@ -3,7 +3,7 @@ import { Link as RouterLink } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { useCopyToClipboard } from "react-use";
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { CustomMenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import {
ChatIcon,
ClipboardIcon,
@ -54,7 +54,7 @@ export const UserProfileMenu = ({
return (
<>
<MenuIconButton {...props}>
<CustomMenuIconButton {...props}>
<MenuItem onClick={() => window.open(buildAppSelectUrl(sharableId), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
@ -80,7 +80,7 @@ export const UserProfileMenu = ({
Relay selection
</MenuItem>
)}
</MenuIconButton>
</CustomMenuIconButton>
{infoModal.isOpen && (
<UserDebugModal pubkey={pubkey} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}