diff --git a/.changeset/beige-cougars-float.md b/.changeset/beige-cougars-float.md new file mode 100644 index 000000000..a7d075650 --- /dev/null +++ b/.changeset/beige-cougars-float.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add time durations for muting users diff --git a/.changeset/twelve-wombats-perform.md b/.changeset/twelve-wombats-perform.md new file mode 100644 index 000000000..8b7b738e6 --- /dev/null +++ b/.changeset/twelve-wombats-perform.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add ghost mode diff --git a/src/components/icons.tsx b/src/components/icons.tsx index f66124f15..cc4fc8162 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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, +}); diff --git a/src/components/layout/desktop-side-nav.tsx b/src/components/layout/desktop-side-nav.tsx index ff317edd0..e3688b436 100644 --- a/src/components/layout/desktop-side-nav.tsx +++ b/src/components/layout/desktop-side-nav.tsx @@ -48,17 +48,19 @@ export default function DesktopSideNav(props: Omit) { {account ? ( <> - } - aria-label="New note" - title="New note" - w="3rem" - h="3rem" - fontSize="1.5rem" - colorScheme="brand" - onClick={() => openModal()} - flexShrink={0} - /> + {!account.readonly && ( + } + aria-label="New note" + title="New note" + w="3rem" + h="3rem" + fontSize="1.5rem" + colorScheme="brand" + onClick={() => openModal()} + flexShrink={0} + /> + )} ) : ( + + + + + + + + + + + + + + + + + ); +} + +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 ( + + + + Unmute temporary muted users + + + {getExpiredPubkeys().map((pubkey) => ( + + + + + ))} + + + + + + + + ); +} + +export default function MuteModalProvider({ children }: PropsWithChildren) { + const [muteUser, setMuteUser] = useState(""); + + const openModal = useCallback( + (pubkey: string) => { + setMuteUser(pubkey); + }, + [setMuteUser], + ); + + const context = useMemo(() => ({ openModal }), [openModal]); + + return ( + + {children} + + {muteUser && setMuteUser("")} pubkey={muteUser} />} + + ); +} diff --git a/src/services/account.ts b/src/services/account.ts index 83078322f..fb90d6926 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -16,6 +16,7 @@ class AccountService { loading = new PersistentSubject(true); accounts = new PersistentSubject([]); current = new PersistentSubject(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"); } } diff --git a/src/views/badges/components/badge-menu.tsx b/src/views/badges/components/badge-menu.tsx index 55064d836..5d30f7f2f 100644 --- a/src/views/badges/components/badge-menu.tsx +++ b/src/views/badges/components/badge-menu.tsx @@ -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 ( <> - + {naddr && ( <> window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> @@ -41,7 +41,7 @@ export default function BadgeMenu({ badge, ...props }: { badge: NostrEvent } & O }> View Raw - + {infoModal.isOpen && ( diff --git a/src/views/emoji-packs/components/emoji-pack-menu.tsx b/src/views/emoji-packs/components/emoji-pack-menu.tsx index cff102ad7..6c564566f 100644 --- a/src/views/emoji-packs/components/emoji-pack-menu.tsx +++ b/src/views/emoji-packs/components/emoji-pack-menu.tsx @@ -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 ( <> - + {naddr && ( <> window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> @@ -44,7 +44,7 @@ export default function EmojiPackMenu({ }> View Raw - + {infoModal.isOpen && ( diff --git a/src/views/goals/components/goal-menu.tsx b/src/views/goals/components/goal-menu.tsx index dfeb83e87..b3719a789 100644 --- a/src/views/goals/components/goal-menu.tsx +++ b/src/views/goals/components/goal-menu.tsx @@ -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 ( <> - + {nevent && ( <> window.open(buildAppSelectUrl(nevent), "_blank")} icon={}> @@ -40,7 +40,7 @@ export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit }> View Raw - + {infoModal.isOpen && ( diff --git a/src/views/lists/components/list-menu.tsx b/src/views/lists/components/list-menu.tsx index 975ae98f2..15a236325 100644 --- a/src/views/lists/components/list-menu.tsx +++ b/src/views/lists/components/list-menu.tsx @@ -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 ( <> - + {naddr && ( <> window.open(buildAppSelectUrl(naddr), "_blank")} icon={}> @@ -42,7 +42,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit }> View Raw - + {infoModal.isOpen && ( diff --git a/src/views/lists/list-details.tsx b/src/views/lists/list-details.tsx index 724ee678f..c2ebe73e4 100644 --- a/src/views/lists/list-details.tsx +++ b/src/views/lists/list-details.tsx @@ -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() { - {isAuthor && ( + {isAuthor && !isSpecialListKind(list.kind) && ( diff --git a/src/views/user/components/header.tsx b/src/views/user/components/header.tsx index 533b9ff1b..159e2f6e8 100644 --- a/src/views/user/components/header.tsx +++ b/src/views/user/components/header.tsx @@ -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({ - {isSelf && ( + {isSelf && !account.readonly && ( } aria-label="Edit profile" @@ -45,7 +46,16 @@ export default function Header({ onClick={() => navigate("/profile")} /> )} - {showFollowButton && !isSelf && } + {showExtraButtons && !isSelf && } + {showExtraButtons && !isSelf && ( + } + size="sm" + aria-label="ghost user" + title="ghost user" + onClick={() => accountService.startGhost(pubkey)} + /> + )} - + window.open(buildAppSelectUrl(sharableId), "_blank")} icon={}> View in app... @@ -80,7 +80,7 @@ export const UserProfileMenu = ({ Relay selection )} - + {infoModal.isOpen && ( )}