add emoji pack views

This commit is contained in:
hzrd149 2023-08-31 21:23:43 -05:00
parent 3a2745ebdd
commit 7a5a4b1753
25 changed files with 630 additions and 146 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add emoji pack views

View File

@ -1,6 +1,8 @@
import React, { Suspense } from "react";
import { createHashRouter, Outlet, RouterProvider, ScrollRestoration } from "react-router-dom";
import { Spinner } from "@chakra-ui/react";
import { css, Global } from "@emotion/react";
import { ErrorBoundary } from "./components/error-boundary";
import Layout from "./components/layout";
@ -38,9 +40,11 @@ import ListsView from "./views/lists";
import ListView from "./views/lists/list";
import UserListsTab from "./views/user/lists";
import "./services/emoji-packs";
import BrowseListView from "./views/lists/browse";
import { css, Global } from "@emotion/react";
import EmojiPacksBrowseView from "./views/emoji-packs/browse";
import EmojiPackView from "./views/emoji-packs/pack";
import UserEmojiPacksTab from "./views/user/emoji-packs";
import EmojiPacksView from "./views/emoji-packs";
const StreamsView = React.lazy(() => import("./views/streams"));
const StreamView = React.lazy(() => import("./views/streams/stream"));
@ -127,6 +131,7 @@ const router = createHashRouter([
{ path: "lists", element: <UserListsTab /> },
{ path: "followers", element: <UserFollowersTab /> },
{ path: "following", element: <UserFollowingTab /> },
{ path: "emojis", element: <UserEmojiPacksTab /> },
{ path: "relays", element: <UserRelaysTab /> },
{ path: "reports", element: <UserReportsTab /> },
],
@ -156,6 +161,14 @@ const router = createHashRouter([
{ path: ":addr", element: <ListView /> },
],
},
{
path: "emojis",
children: [
{ path: "", element: <EmojiPacksView /> },
{ path: "browse", element: <EmojiPacksBrowseView /> },
{ path: ":addr", element: <EmojiPackView /> },
],
},
{
path: "streams",
element: <StreamsView />,

View File

@ -367,3 +367,9 @@ export const AddReactionIcon = createIcon({
d: "M19.0001 13.9999V16.9999H22.0001V18.9999H18.9991L19.0001 21.9999H17.0001L16.9991 18.9999H14.0001V16.9999H17.0001V13.9999H19.0001ZM20.2426 4.75736C22.505 7.0244 22.5829 10.636 20.4795 12.992L19.06 11.574C20.3901 10.0499 20.3201 7.65987 18.827 6.1701C17.3244 4.67092 14.9076 4.60701 13.337 6.01688L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L13.412 20.069L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736Z",
defaultProps,
});
export const EmojiIcon = createIcon({
displayName: "EmojiIcon",
d: "M12 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 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 12H9C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12Z",
defaultProps,
});

View File

@ -2,6 +2,7 @@ import { AbsoluteCenter, Box, Button, Divider } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import {
ChatIcon,
EmojiIcon,
FeedIcon,
ListIcon,
LiveStreamIcon,
@ -48,6 +49,9 @@ export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean
<Button onClick={() => navigate("/lists")} leftIcon={<ListIcon />} justifyContent="flex-start">
Lists
</Button>
<Button onClick={() => navigate("/emojis")} leftIcon={<EmojiIcon />} justifyContent="flex-start">
Emojis
</Button>
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />} justifyContent="flex-start">
Map
</Button>

View File

@ -1,26 +1,26 @@
import { Button, Divider, Flex, IconButton, Image, Input, Text } from "@chakra-ui/react";
import { DislikeIcon, LikeIcon } from "./icons";
import { useCurrentAccount } from "../hooks/use-current-account";
import useUserEmojiPacks from "../hooks/use-users-emoji-packs";
import useEmojiPack from "../hooks/use-emoji-pack";
import useReplaceableEvent from "../hooks/use-replaceable-event";
import { getEmojisFromPack, getPackCordsFromFavorites, getPackName } from "../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../hooks/use-favorite-emoji-packs";
export type ReactionPickerProps = {
onSelect: (emoji: string, url?: string) => void;
};
function EmojiPack({ addr, onSelect }: { addr: string; onSelect: ReactionPickerProps["onSelect"] }) {
const pack = useEmojiPack(addr);
function EmojiPack({ cord, onSelect }: { cord: string; onSelect: ReactionPickerProps["onSelect"] }) {
const pack = useReplaceableEvent(cord);
if (!pack) return null;
return (
<>
<Flex gap="2" alignItems="center">
<Text whiteSpace="pre">{pack.name}</Text>
<Text whiteSpace="pre">{getPackName(pack)}</Text>
<Divider />
</Flex>
<Flex wrap="wrap" gap="2">
{pack.emojis.map((emoji) => (
{getEmojisFromPack(pack).map((emoji) => (
<IconButton
key={emoji.name}
icon={<Image src={emoji.url} height="1.2rem" />}
@ -38,7 +38,7 @@ function EmojiPack({ addr, onSelect }: { addr: string; onSelect: ReactionPickerP
export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
const account = useCurrentAccount();
const { packs = [] } = useUserEmojiPacks(account?.pubkey) ?? {};
const favoritePacks = useFavoriteEmojiPacks(account?.pubkey);
return (
<Flex direction="column" gap="2">
@ -72,9 +72,10 @@ export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
</Button>
</Flex>
</Flex>
{packs.map((addr) => (
<EmojiPack key={addr} addr={addr} onSelect={onSelect} />
))}
{favoritePacks &&
getPackCordsFromFavorites(favoritePacks).map((cord) => (
<EmojiPack key={cord} cord={cord} onSelect={onSelect} />
))}
</Flex>
);
}

View File

@ -1,7 +1,18 @@
import { NostrEvent } from "../../types/nostr-event";
import { NostrEvent, isATag } from "../../types/nostr-event";
export const EMOJI_PACK_KIND = 30030;
export const USER_EMOJI_LIST_KIND = 10030;
export function getPackName(event: NostrEvent) {
return event.tags.find((t) => t[0] === "d")?.[1];
}
export function getEmojisFromPack(pack: NostrEvent) {
return pack.tags
.filter((t) => t[0] === "emoji" && t[1] && t[2])
.map((t) => ({ name: t[1] as string, url: t[2] as string }));
}
export function getPackCordsFromFavorites(event: NostrEvent) {
return event.tags.filter(isATag).map((t) => t[1]);
}

View File

@ -187,3 +187,27 @@ export function parseCoordinate(a: string): CustomEventPointer | null {
identifier: d,
};
}
export function draftAddCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string, relay?: string) {
if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("event already in list");
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, relay ? ["a", coordinate, relay] : ["a", coordinate]],
};
return draft;
}
export function draftRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: list.tags.filter((t) => !(t[0] === "a" && t[1] === coordinate)),
};
return draft;
}

View File

@ -93,27 +93,3 @@ export function draftRemoveEvent(list: NostrEvent | DraftNostrEvent, event: stri
return draft;
}
export function draftAddCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string, relay?: string) {
if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("event already in list");
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: [...list.tags, relay ? ["a", coordinate, relay] : ["a", coordinate]],
};
return draft;
}
export function draftRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string) {
const draft: DraftNostrEvent = {
created_at: dayjs().unix(),
kind: list.kind,
content: list.content,
tags: list.tags.filter((t) => !(t[0] === "a" && t[1] === coordinate)),
};
return draft;
}

View File

@ -1,11 +0,0 @@
import { useMemo } from "react";
import { useReadRelayUrls } from "./use-client-relays";
import emojiPacksService from "../services/emoji-packs";
import useSubject from "./use-subject";
export default function useEmojiPack(addr: string, additionalRelays?: string[]) {
const readRelays = useReadRelayUrls(additionalRelays);
const subject = useMemo(() => emojiPacksService.requestEmojiPack(addr, readRelays), [addr, readRelays.join("|")]);
return useSubject(subject);
}

View File

@ -0,0 +1,17 @@
import useReplaceableEvent from "./use-replaceable-event";
import { useCurrentAccount } from "./use-current-account";
import { USER_EMOJI_LIST_KIND } from "../helpers/nostr/emoji-packs";
export const FAVORITE_LISTS_IDENTIFIER = "nostrudel-favorite-lists";
export default function useFavoriteEmojiPacks(pubkey?: string, additionalRelays: string[] = [], alwaysFetch = false) {
const account = useCurrentAccount();
const key = pubkey || account?.pubkey;
const favoritePacks = useReplaceableEvent(
key ? { kind: USER_EMOJI_LIST_KIND, pubkey: key } : undefined,
additionalRelays,
alwaysFetch,
);
return favoritePacks;
}

View File

@ -1,13 +0,0 @@
import { useMemo } from "react";
import { useReadRelayUrls } from "./use-client-relays";
import emojiPacksService from "../services/emoji-packs";
import useSubject from "./use-subject";
export default function useUserEmojiPacks(pubkey?: string, additionalRelays?: string[], alwaysFetch = false) {
const readRelays = useReadRelayUrls(additionalRelays);
const subject = useMemo(() => {
if (pubkey) return emojiPacksService.requestUserEmojiList(pubkey, readRelays, alwaysFetch);
}, [pubkey, readRelays.join("|")]);
return useSubject(subject);
}

View File

@ -1,9 +1,10 @@
import { PropsWithChildren, createContext, useContext } from "react";
import { lib } from "emojilib";
import useUserEmojiPacks from "../hooks/use-users-emoji-packs";
import useReplaceableEvents from "../hooks/use-replaceable-events";
import { useCurrentAccount } from "../hooks/use-current-account";
import { isEmojiTag } from "../types/nostr-event";
import useFavoriteEmojiPacks from "../hooks/use-favorite-emoji-packs";
import { getPackCordsFromFavorites } from "../helpers/nostr/emoji-packs";
const defaultEmojis = Object.entries(lib).map(([name, emojiObject]) => ({
...emojiObject,
@ -25,8 +26,8 @@ export function DefaultEmojiProvider({ children }: PropsWithChildren) {
export function UserEmojiProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) {
const account = useCurrentAccount();
const userPacks = useUserEmojiPacks(pubkey || account?.pubkey, [], true);
const events = useReplaceableEvents(userPacks?.packs);
const favoritePacks = useFavoriteEmojiPacks(pubkey || account?.pubkey, [], true);
const events = useReplaceableEvents(favoritePacks && getPackCordsFromFavorites(favoritePacks));
const emojis = events
.map((event) =>

View File

@ -1,71 +0,0 @@
import Subject from "../classes/subject";
import { SuperMap } from "../classes/super-map";
import { getEmojisFromPack } from "../helpers/nostr/emoji-packs";
import { NostrEvent } from "../types/nostr-event";
import replaceableEventLoaderService from "./replaceable-event-requester";
const EMOJI_PACK_KIND = 30030;
const USER_EMOJI_LIST_KIND = 10030;
class EmojiPacksService {
emojiPacks = new SuperMap(
() => new Subject<{ event: NostrEvent; name: string; emojis: { name: string; url: string }[] }>(),
);
userEmojiPacks = new SuperMap(() => new Subject<{ packs: string[]; event: NostrEvent }>());
getEmojiPacks(pubkey: string) {
return this.emojiPacks.get(pubkey);
}
requestEmojiPack(addr: string, relays: string[]) {
const [kind, pubkey, name] = addr.split(":");
const sub = this.emojiPacks.get(addr);
if (!sub.value) {
const request = replaceableEventLoaderService.requestEvent(relays, EMOJI_PACK_KIND, pubkey, name);
sub.connectWithHandler(request, (event, next) => {
const name = event.tags.find((t) => t[0] === "d" && t[1])?.[1];
if (!name) return;
next({
name,
emojis: getEmojisFromPack(event),
event,
});
});
}
return sub;
}
requestUserEmojiList(pubkey: string, relays: string[], alwaysFetch = false) {
const sub = this.userEmojiPacks.get(pubkey);
const request = replaceableEventLoaderService.requestEvent(
relays,
USER_EMOJI_LIST_KIND,
pubkey,
undefined,
alwaysFetch,
);
if (!sub.value) {
sub.connectWithHandler(request, (event, next) => {
next({
packs: event.tags.filter((t) => t[0] === "a" && t[1]).map((t) => t[1] as string),
event,
});
});
}
return sub;
}
}
const emojiPacksService = new EmojiPacksService();
if (import.meta.env.DEV) {
//@ts-ignore
window.emojiPacksService = emojiPacksService;
}
export default emojiPacksService;

View File

@ -0,0 +1,64 @@
import { useCallback } from "react";
import { Flex, SimpleGrid, Switch, useDisclosure } from "@chakra-ui/react";
import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { NostrEvent } from "../../types/nostr-event";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import useSubject from "../../hooks/use-subject";
import EmojiPackCard from "./components/emoji-pack-card";
import { getEventUID } from "../../helpers/nostr/events";
import { EMOJI_PACK_KIND, getEmojisFromPack } from "../../helpers/nostr/emoji-packs";
function EmojiPacksBrowsePage() {
const { filter, listId } = usePeopleListContext();
const showEmpty = useDisclosure();
const eventFilter = useCallback(
(event: NostrEvent) => {
if (!showEmpty.isOpen && getEmojisFromPack(event).length === 0) return false;
return true;
},
[showEmpty.isOpen],
);
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(
`${listId}-browse-emoji-packs`,
readRelays,
{ ...filter, kinds: [EMOJI_PACK_KIND] },
{ enabled: !!filter, eventFilter },
);
const packs = useSubject(timeline.timeline);
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<Flex direction="column" gap="2" p="2" pb="10">
<Flex gap="2" alignItems="center" wrap="wrap">
<PeopleListSelection />
<Switch checked={showEmpty.isOpen} onChange={showEmpty.onToggle} whiteSpace="pre">
Show Empty
</Switch>
</Flex>
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{packs.map((event) => (
<EmojiPackCard key={getEventUID(event)} pack={event} />
))}
</SimpleGrid>
</Flex>
</IntersectionObserverProvider>
);
}
export default function EmojiPacksBrowseView() {
return (
<PeopleListProvider>
<EmojiPacksBrowsePage />
</PeopleListProvider>
);
}

View File

@ -0,0 +1,67 @@
import { useRef } from "react";
import { Link as RouterLink } from "react-router-dom";
import {
ButtonGroup,
Card,
CardBody,
CardFooter,
CardHeader,
CardProps,
Flex,
Heading,
Image,
Link,
Text,
Tooltip,
} from "@chakra-ui/react";
import dayjs from "dayjs";
import { UserAvatarLink } from "../../../components/user-avatar-link";
import { UserLink } from "../../../components/user-link";
import { getSharableEventNaddr } from "../../../helpers/nip19";
import { NostrEvent } from "../../../types/nostr-event";
import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import EmojiPackFavoriteButton from "./emoji-pack-favorite-button";
import { getEventUID } from "../../../helpers/nostr/events";
import { getEmojisFromPack, getPackName } from "../../../helpers/nostr/emoji-packs";
import EmojiPackMenu from "./emoji-pack-menu";
export default function EmojiPackCard({ pack, ...props }: Omit<CardProps, "children"> & { pack: NostrEvent }) {
const emojis = getEmojisFromPack(pack);
const naddr = getSharableEventNaddr(pack);
// if there is a parent intersection observer, register this card
const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, getEventUID(pack));
return (
<Card ref={ref} {...props}>
<CardHeader display="flex" gap="2" alignItems="center" p="2" pb="0" flexWrap="wrap">
<Heading size="md">
<Link as={RouterLink} to={`/emojis/${naddr}`}>
{getPackName(pack)}
</Link>
</Heading>
<Text>by</Text>
<UserAvatarLink pubkey={pack.pubkey} size="xs" />
<UserLink pubkey={pack.pubkey} isTruncated fontWeight="bold" fontSize="md" />
<ButtonGroup size="sm" ml="auto">
<EmojiPackFavoriteButton pack={pack} />
<EmojiPackMenu pack={pack} aria-label="emoji pack menu" />
</ButtonGroup>
</CardHeader>
<CardBody p="2">
{emojis.length > 0 && (
<Flex mb="2" wrap="wrap" gap="2">
{emojis.map(({ name, url }) => (
<Image key={name + url} src={url} title={name} w={8} h={8} />
))}
</Flex>
)}
</CardBody>
<CardFooter p="2" display="flex" pt="0">
<Text>Updated: {dayjs.unix(pack.created_at).fromNow()}</Text>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,61 @@
import { useState } from "react";
import { IconButton, IconButtonProps, useToast } from "@chakra-ui/react";
import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { StarEmptyIcon, StarFullIcon } from "../../../components/icons";
import { draftAddCoordinate, draftRemoveCoordinate, getEventCoordinate } from "../../../helpers/nostr/events";
import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";
import replaceableEventLoaderService from "../../../services/replaceable-event-requester";
import { USER_EMOJI_LIST_KIND } from "../../../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../../../hooks/use-favorite-emoji-packs";
export default function EmojiPackFavoriteButton({
pack,
...props
}: { pack: NostrEvent } & Omit<IconButtonProps, "children" | "aria-label" | "isLoading" | "onClick">) {
const toast = useToast();
const { requestSignature } = useSigningContext();
const favoritePacks = useFavoriteEmojiPacks();
const coordinate = getEventCoordinate(pack);
const isFavorite = favoritePacks?.tags.some((t) => t[1] === coordinate);
const [loading, setLoading] = useState(false);
const handleClick = async () => {
const prev: DraftNostrEvent = favoritePacks || {
kind: USER_EMOJI_LIST_KIND,
created_at: dayjs().unix(),
content: "",
tags: [],
};
try {
setLoading(true);
const draft = isFavorite ? draftRemoveCoordinate(prev, coordinate) : draftAddCoordinate(prev, coordinate);
const signed = await requestSignature(draft);
const pub = new NostrPublishAction(
isFavorite ? "Unfavorite Emoji pack" : "Favorite emoji pack",
clientRelaysService.getWriteUrls(),
signed,
);
replaceableEventLoaderService.handleEvent(signed);
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
setLoading(false);
};
return (
<IconButton
icon={isFavorite ? <StarFullIcon /> : <StarEmptyIcon />}
aria-label={isFavorite ? "Unfavorite" : "Favorite"}
title={isFavorite ? "Unfavorite" : "Favorite"}
onClick={handleClick}
isLoading={loading}
color={isFavorite ? "yellow.400" : undefined}
{...props}
/>
);
}

View File

@ -0,0 +1,54 @@
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 { useCurrentAccount } from "../../../hooks/use-current-account";
import NoteDebugModal from "../../../components/debug-modals/note-debug-modal";
import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons";
import { getSharableEventNaddr } from "../../../helpers/nip19";
import { buildAppSelectUrl } from "../../../helpers/nostr/apps";
import { useDeleteEventContext } from "../../../providers/delete-event-provider";
export default function EmojiPackMenu({
pack,
...props
}: { pack: NostrEvent } & Omit<MenuIconButtonProps, "children">) {
const account = useCurrentAccount();
const infoModal = useDisclosure();
const { deleteEvent } = useDeleteEventContext();
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const naddr = getSharableEventNaddr(pack);
return (
<>
<MenuIconButton {...props}>
{naddr && (
<>
<MenuItem onClick={() => window.open(buildAppSelectUrl(naddr), "_blank")} icon={<ExternalLinkIcon />}>
View in app...
</MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + naddr)} icon={<RepostIcon />}>
Copy Share Link
</MenuItem>
</>
)}
{account?.pubkey === pack.pubkey && (
<MenuItem icon={<TrashIcon />} color="red.500" onClick={() => deleteEvent(pack)}>
Delete Pack
</MenuItem>
)}
<MenuItem onClick={infoModal.onOpen} icon={<CodeIcon />}>
View Raw
</MenuItem>
</MenuIconButton>
{infoModal.isOpen && (
<NoteDebugModal event={pack} isOpen={infoModal.isOpen} onClose={infoModal.onClose} size="6xl" />
)}
</>
);
}

View File

@ -0,0 +1,86 @@
import { Button, Divider, Flex, Heading, Link, SimpleGrid, Spacer } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { ExternalLinkIcon } from "../../components/icons";
import { getEventCoordinate, getEventUID } from "../../helpers/nostr/events";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import { EMOJI_PACK_KIND, getPackCordsFromFavorites } from "../../helpers/nostr/emoji-packs";
import useSubject from "../../hooks/use-subject";
import EmojiPackCard from "./components/emoji-pack-card";
import useFavoriteEmojiPacks from "../../hooks/use-favorite-emoji-packs";
import useReplaceableEvents from "../../hooks/use-replaceable-events";
function UserEmojiPackMangerPage() {
const account = useCurrentAccount()!;
const favoritePacks = useFavoriteEmojiPacks(account.pubkey);
const readRelays = useReadRelayUrls();
const timeline = useTimelineLoader(
`${account.pubkey}-emoji-packs`,
readRelays,
{
authors: [account.pubkey],
kinds: [EMOJI_PACK_KIND],
},
{ enabled: !!account.pubkey },
);
const favorites = useReplaceableEvents(favoritePacks && getPackCordsFromFavorites(favoritePacks));
const packs = useSubject(timeline.timeline).filter((pack) => {
const cord = getEventCoordinate(pack);
return !favorites.some((e) => getEventCoordinate(e) === cord);
});
return (
<>
{favorites.length > 0 && (
<>
<Heading size="md" mt="2">
Favorite packs
</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{favorites.map((event) => (
<EmojiPackCard key={getEventUID(event)} pack={event} />
))}
</SimpleGrid>
</>
)}
{packs.length > 0 && (
<>
<Heading size="md" mt="2">
Emoji packs
</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{packs.map((event) => (
<EmojiPackCard key={getEventUID(event)} pack={event} />
))}
</SimpleGrid>
</>
)}
</>
);
}
export default function EmojiPacksView() {
const account = useCurrentAccount();
return (
<Flex direction="column" pt="2" pb="10" gap="2" px={["2", "2", 0]}>
<Flex gap="2">
<Button as={RouterLink} to="/emojis/browse">
Find packs
</Button>
<Spacer />
<Button as={Link} href="https://emojis-iota.vercel.app/" isExternal rightIcon={<ExternalLinkIcon />}>
Emoji pack manager
</Button>
</Flex>
{account && <UserEmojiPackMangerPage />}
</Flex>
);
}

View File

@ -0,0 +1,111 @@
import { useNavigate, useParams } from "react-router-dom";
import { nip19 } from "nostr-tools";
import { UserLink } from "../../components/user-link";
import {
Button,
ButtonGroup,
Card,
CardBody,
Divider,
Flex,
Heading,
Image,
SimpleGrid,
Spacer,
Tag,
Tooltip,
} from "@chakra-ui/react";
import { ArrowLeftSIcon } from "../../components/icons";
import { useCurrentAccount } from "../../hooks/use-current-account";
import { useDeleteEventContext } from "../../providers/delete-event-provider";
import useReplaceableEvent from "../../hooks/use-replaceable-event";
import EmojiPackMenu from "./components/emoji-pack-menu";
import EmojiPackFavoriteButton from "./components/emoji-pack-favorite-button";
import { getEmojisFromPack, getPackName } from "../../helpers/nostr/emoji-packs";
import { useState } from "react";
function useListCoordinate() {
const { addr } = useParams() as { addr: string };
const parsed = nip19.decode(addr);
if (parsed.type !== "naddr") throw new Error(`Unknown type ${parsed.type}`);
return parsed.data;
}
export default function EmojiPackView() {
const navigate = useNavigate();
const coordinate = useListCoordinate();
const { deleteEvent } = useDeleteEventContext();
const account = useCurrentAccount();
const [scale, setScale] = useState(10);
const pack = useReplaceableEvent(coordinate);
if (!pack)
return (
<>
Looking for pack "{coordinate.identifier}" created by <UserLink pubkey={coordinate.pubkey} />
</>
);
const isAuthor = account?.pubkey === pack.pubkey;
const emojis = getEmojisFromPack(pack);
return (
<Flex direction="column" px="2" pt="2" pb="8" overflowY="auto" overflowX="hidden" h="full" gap="2">
<Flex gap="2" alignItems="center">
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
Back
</Button>
<Heading size="md" isTruncated>
{getPackName(pack)}
</Heading>
<EmojiPackFavoriteButton pack={pack} size="sm" />
<Spacer />
{isAuthor && (
<Button colorScheme="red" onClick={() => deleteEvent(pack).then(() => navigate("/lists"))}>
Delete
</Button>
)}
<EmojiPackMenu aria-label="More options" pack={pack} />
</Flex>
{emojis.length > 0 && (
<>
<Flex alignItems="flex-end">
<Heading size="md">Emojis</Heading>
<ButtonGroup size="sm" isAttached ml="auto">
<Button variant={scale === 10 ? "solid" : "outline"} onClick={() => setScale(10)}>
SM
</Button>
<Button variant={scale === 16 ? "solid" : "outline"} onClick={() => setScale(16)}>
MD
</Button>
<Button variant={scale === 24 ? "solid" : "outline"} onClick={() => setScale(24)}>
LG
</Button>
</ButtonGroup>
</Flex>
<Divider />
<Card variant="elevated">
<CardBody p="2">
{/* <Flex gap="2" wrap="wrap"> */}
<SimpleGrid columns={{ base: 2, sm: 3, md: 2, lg: 4, xl: 6 }} gap="2">
{emojis.map(({ name, url }) => (
<Flex gap="2" alignItems="center">
<Image key={name + url} src={url} title={name} w={scale} h={scale} />
<Tag>{name}</Tag>
</Flex>
))}
</SimpleGrid>
{/* </Flex> */}
</CardBody>
</Card>
</>
)}
</Flex>
);
}

View File

@ -1,5 +1,16 @@
import { Link as RouterLink } from "react-router-dom";
import { AvatarGroup, Card, CardBody, CardFooter, CardHeader, Flex, Heading, Link, Text } from "@chakra-ui/react";
import {
AvatarGroup,
ButtonGroup,
Card,
CardBody,
CardFooter,
CardHeader,
Flex,
Heading,
Link,
Text,
} from "@chakra-ui/react";
import { Kind } from "nostr-tools";
import dayjs from "dayjs";
@ -16,6 +27,7 @@ import { useRegisterIntersectionEntity } from "../../../providers/intersection-o
import { useRef } from "react";
import ListFavoriteButton from "./list-favorite-button";
import { getEventUID } from "../../../helpers/nostr/events";
import ListMenu from "./list-menu";
function ListCardRender({ event }: { event: NostrEvent }) {
const people = getPubkeysFromList(event);
@ -29,12 +41,16 @@ function ListCardRender({ event }: { event: NostrEvent }) {
return (
<Card ref={ref}>
<CardHeader p="2" pb="0">
<CardHeader display="flex" alignItems="center" p="2" pb="0">
<Heading size="md">
<Link as={RouterLink} to={`/lists/${link}`}>
{getListName(event)}
</Link>
</Heading>
<ButtonGroup size="sm" ml="auto">
<ListFavoriteButton list={event} />
<ListMenu list={event} aria-label="list menu" />
</ButtonGroup>
</CardHeader>
<CardBody p="2">
<Flex gap="2">
@ -65,7 +81,6 @@ function ListCardRender({ event }: { event: NostrEvent }) {
)}
</CardBody>
<CardFooter p="2" display="flex" pt="0">
<ListFavoriteButton list={event} size="sm" />
<EventRelays event={event} ml="auto" />
</CardFooter>
</Card>

View File

@ -4,8 +4,7 @@ import dayjs from "dayjs";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import { StarEmptyIcon, StarFullIcon } from "../../../components/icons";
import { getEventCoordinate } from "../../../helpers/nostr/events";
import { draftAddCoordinate, draftRemoveCoordinate } from "../../../helpers/nostr/lists";
import { draftAddCoordinate, draftRemoveCoordinate, getEventCoordinate } from "../../../helpers/nostr/events";
import { useSigningContext } from "../../../providers/signing-provider";
import NostrPublishAction from "../../../classes/nostr-publish-action";
import clientRelaysService from "../../../services/client-relays";

View File

@ -54,7 +54,7 @@ export default function ListView() {
return (
<Flex direction="column" px="2" pt="2" pb="8" overflowY="auto" overflowX="hidden" h="full" gap="2">
<Flex gap="2" alignItems="center">
<Button as={RouterList} to="/lists" leftIcon={<ArrowLeftSIcon />}>
<Button onClick={() => navigate(-1)} leftIcon={<ArrowLeftSIcon />}>
Back
</Button>

View File

@ -0,0 +1,62 @@
import { useOutletContext } from "react-router-dom";
import { Divider, Flex, Heading, SimpleGrid, Text } from "@chakra-ui/react";
import { useAdditionalRelayContext } from "../../providers/additional-relay-context";
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import { getEventUID } from "../../helpers/nostr/events";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import EmojiPackCard from "../emoji-packs/components/emoji-pack-card";
import { EMOJI_PACK_KIND, getPackCordsFromFavorites } from "../../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../../hooks/use-favorite-emoji-packs";
import useReplaceableEvents from "../../hooks/use-replaceable-events";
export default function UserEmojiPacksTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const timeline = useTimelineLoader(pubkey + "-emoji-packs", readRelays, {
authors: [pubkey],
kinds: [EMOJI_PACK_KIND],
});
const packs = useSubject(timeline.timeline);
const favoritePacks = useFavoriteEmojiPacks(pubkey);
const favorites = useReplaceableEvents(favoritePacks && getPackCordsFromFavorites(favoritePacks));
const callback = useTimelineCurserIntersectionCallback(timeline);
return (
<IntersectionObserverProvider callback={callback}>
<Flex gap="2" pt="2" pb="10" px={["2", "2", 0]} direction="column">
{packs.length > 0 && (
<>
<Heading size="md" mt="2">
Created packs
</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{packs.map((pack) => (
<EmojiPackCard key={getEventUID(pack)} pack={pack} />
))}
</SimpleGrid>
</>
)}
{favorites.length > 0 && (
<>
<Heading size="md" mt="2">
Favorite packs
</Heading>
<Divider />
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing="2">
{favorites.map((event) => (
<EmojiPackCard key={getEventUID(event)} pack={event} />
))}
</SimpleGrid>
</>
)}
</Flex>
</IntersectionObserverProvider>
);
}

View File

@ -50,6 +50,7 @@ const tabs = [
{ label: "Following", path: "following" },
{ label: "Likes", path: "likes" },
{ label: "Relays", path: "relays" },
{ label: "Emoji Packs", path: "emojis" },
{ label: "Reports", path: "reports" },
{ label: "Followers", path: "followers" },
];
@ -116,7 +117,9 @@ const UserView = () => {
>
<TabList overflowX="auto" overflowY="hidden" flexShrink={0}>
{tabs.map(({ label }) => (
<Tab key={label}>{label}</Tab>
<Tab key={label} whiteSpace="pre">
{label}
</Tab>
))}
</TabList>

View File

@ -5,18 +5,17 @@ import { useAdditionalRelayContext } from "../../providers/additional-relay-cont
import useTimelineLoader from "../../hooks/use-timeline-loader";
import useSubject from "../../hooks/use-subject";
import { MUTE_LIST_KIND, NOTE_LIST_KIND, PEOPLE_LIST_KIND, PIN_LIST_KIND } from "../../helpers/nostr/lists";
import { getEventUID, truncatedId } from "../../helpers/nostr/events";
import { getEventUID } from "../../helpers/nostr/events";
import ListCard from "../lists/components/list-card";
import IntersectionObserverProvider from "../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
import TimelineActionAndStatus from "../../components/timeline-page/timeline-action-and-status";
import { Kind } from "nostr-tools";
export default function UserListsTab() {
const { pubkey } = useOutletContext() as { pubkey: string };
const readRelays = useAdditionalRelayContext();
const timeline = useTimelineLoader(truncatedId(pubkey) + "-lists", readRelays, {
const timeline = useTimelineLoader(pubkey + "-lists", readRelays, {
authors: [pubkey],
kinds: [PEOPLE_LIST_KIND, NOTE_LIST_KIND],
});