update emoji picker

add tenor gif picker
This commit is contained in:
hzrd149 2025-01-03 13:50:13 -06:00
parent 69efbeee2c
commit 931ea613be
22 changed files with 1064 additions and 781 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add tenor gif picker

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add new emoji picker

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Remove quick reactions from settings

View File

@ -27,18 +27,20 @@
"@chakra-ui/theme-tools": "^2.2.6",
"@codemirror/autocomplete": "^6.18.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/language": "^6.10.7",
"@codemirror/language": "^6.10.8",
"@codemirror/view": "^6.36.1",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@getalby/bitcoin-connect": "^3.6.3",
"@getalby/bitcoin-connect-react": "^3.6.3",
"@noble/ciphers": "^1.1.3",
"@noble/curves": "^1.7.0",
"@noble/hashes": "^1.6.1",
"@noble/ciphers": "^1.2.0",
"@noble/curves": "^1.8.0",
"@noble/hashes": "^1.7.0",
"@noble/secp256k1": "^1.7.1",
"@scure/base": "^1.2.1",
"@snort/worker-relay": "^1.3.0",
"@snort/worker-relay": "^1.3.1",
"@uiw/codemirror-theme-github": "^4.23.7",
"@uiw/react-codemirror": "^4.23.7",
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
@ -64,8 +66,8 @@
"debug": "^4.4.0",
"easymde": "^2.18.0",
"emoji-regex": "^10.4.0",
"emojilib": "^3.0.12",
"framer-motion": "^10.18.0",
"gif-picker-react": "^1.4.0",
"handlebars": "^4.7.8",
"hls.js": "^1.5.18",
"i18n-iso-countries": "^7.13.0",
@ -87,18 +89,18 @@
"nuka-carousel": "^8.1.1",
"prettier": "^3.4.2",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-chartjs-2": "^5.3.0",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-force-graph-2d": "^1.26.1",
"react-force-graph-3d": "^1.25.1",
"react-hook-form": "^7.54.1",
"react-hook-form": "^7.54.2",
"react-markdown": "^9.0.1",
"react-mosaic-component": "^6.1.0",
"react-mosaic-component": "^6.1.1",
"react-photo-album": "^2.4.1",
"react-qr-barcode-scanner": "^2.0.0",
"react-router-dom": "^6.28.0",
"react-router-dom": "^6.28.1",
"react-simplemde-editor": "^5.2.0",
"react-singleton-hook": "^4.0.1",
"react-use": "^17.6.0",
@ -133,7 +135,7 @@
"@types/leaflet.locatecontrol": "^0.74.6",
"@types/lodash.throttle": "^4.1.9",
"@types/ngeohash": "^0.6.8",
"@types/react": "^18.3.17",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-window": "^1.8.8",
"@types/three": "^0.160.0",

1102
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,12 @@
import { useCallback } from "react";
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { getTagValue } from "applesauce-core/helpers";
import TenorGifIconButton from "./tenor-gif-icon-button";
import Clapperboard from "../icons/clapperboard";
import GifPickerModal from "./gif-picker-modal";
import { NostrEvent } from "nostr-tools";
import { useCallback } from "react";
import { getTagValue } from "applesauce-core/helpers";
import { TENOR_API_KEY } from "../../const";
export default function InsertGifButton({
onSelect,
@ -26,10 +29,13 @@ export default function InsertGifButton({
[onSelect, onSelectURL],
);
return (
<>
<IconButton icon={<Clapperboard boxSize={5} />} onClick={modal.onOpen} {...props} />
{modal.isOpen && <GifPickerModal onClose={modal.onClose} isOpen onSelect={handleSelect} />}
</>
);
if (TENOR_API_KEY) {
return <TenorGifIconButton onSelect={onSelectURL} {...props} />;
} else
return (
<>
<IconButton icon={<Clapperboard boxSize={5} />} onClick={modal.onOpen} {...props} />
{modal.isOpen && <GifPickerModal onClose={modal.onClose} isOpen onSelect={handleSelect} />}
</>
);
}

View File

@ -0,0 +1,84 @@
import {
IconButton,
IconButtonProps,
Modal,
ModalContent,
ModalOverlay,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Portal,
useBoolean,
useColorMode,
} from "@chakra-ui/react";
import GifPicker, { TenorImage, Theme } from "gif-picker-react";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import Clapperboard from "../icons/clapperboard";
import { TENOR_API_KEY } from "../../const";
export default function TenorGifIconButton({
portal = false,
onSelect,
...props
}: { portal?: boolean; onSelect?: (gif: string) => void } & Omit<
IconButtonProps,
"children" | "aria-label" | "onSelect"
>) {
const useModal = useBreakpointValue({ base: true, md: false });
const { colorMode } = useColorMode();
const [isOpen, open] = useBoolean();
const handleSelect = (gif: TenorImage) => {
onSelect?.(gif.url);
open.off();
};
const picker = (
<GifPicker
tenorApiKey={TENOR_API_KEY!}
onGifClick={handleSelect}
theme={colorMode === "light" ? Theme.LIGHT : Theme.DARK}
/>
);
if (useModal) {
return (
<>
<IconButton
icon={<Clapperboard boxSize={5} />}
aria-label="Add Gif"
title="Add Gif"
onClick={open.on}
{...props}
/>
<Modal isOpen={isOpen} onClose={open.off}>
<ModalOverlay />
<ModalContent w="auto">{picker}</ModalContent>
</Modal>
</>
);
} else
return (
<Popover isLazy isOpen={isOpen} onOpen={open.on} onClose={open.off}>
<PopoverTrigger>
<IconButton icon={<Clapperboard boxSize={5} />} aria-label="Add Gif" title="Add Gif" {...props} />
</PopoverTrigger>
{portal ? (
<Portal>
<PopoverContent w="350px" border="none" rounded="xl">
<PopoverArrow />
{picker}
</PopoverContent>
</Portal>
) : (
<PopoverContent w="350px" border="none" rounded="xl">
<PopoverArrow />
{picker}
</PopoverContent>
)}
</Popover>
);
}

View File

@ -1,4 +1,4 @@
import React, { LegacyRef, forwardRef } from "react";
import React, { LegacyRef, forwardRef, useMemo } from "react";
// NOTE: Do not remove Textarea or Input from the imports. they are used
import { Image, InputProps, Textarea, Input, TextareaProps } from "@chakra-ui/react";
import ReactTextareaAutocomplete, {
@ -10,17 +10,23 @@ import "@webscopeio/react-textarea-autocomplete/style.css";
import { nip19 } from "nostr-tools";
import { matchSorter } from "match-sorter";
import { useObservable } from "applesauce-react/hooks";
import { type EmojiMartData } from "@emoji-mart/data";
import { useAsync, useLocalStorage } from "react-use";
import { Emoji, useContextEmojis } from "../providers/global/emoji-provider";
import { useContextEmojis } from "../providers/global/emoji-provider";
import UserAvatar from "./user/user-avatar";
import UserDnsIdentity from "./user/user-dns-identity";
import { useWebOfTrust } from "../providers/global/web-of-trust-provider";
import { userSearchDirectory } from "../services/username-search";
export type PeopleToken = { pubkey: string; names: string[] };
type Token = Emoji | PeopleToken;
// Referencing Textarea and Input so they are not removed from the imports
[Textarea, Input];
function isEmojiToken(token: Token): token is Emoji {
export type PeopleToken = { pubkey: string; names: string[] };
export type EmojiToken = { id: string; name: string; keywords: string[]; char: string; url?: string };
type Token = EmojiToken | PeopleToken;
function isEmojiToken(token: Token): token is EmojiToken {
return Reflect.has(token, "char");
}
function isPersonToken(token: Token): token is PeopleToken {
@ -60,15 +66,63 @@ const Loading: ReactTextareaAutocompleteProps<
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>["loadingComponent"] = ({ data }) => <div>Loading</div>;
function useEmojiTokens() {
const customEmojis = useContextEmojis();
const customEmojiTokens = useMemo(
() =>
customEmojis.map(
(emoji) =>
({
id: emoji.name,
name: emoji.name,
url: emoji.url,
keywords: [emoji.name],
char: `:${emoji.name}:`,
}) satisfies EmojiToken,
),
[customEmojis],
);
const { value: native } = useAsync(() => import("@emoji-mart/data") as Promise<{ default: EmojiMartData }>);
const nativeEmojisTokens = useMemo(() => {
if (!native) return [];
return Object.values(native.default.emojis).map(
(emoji) =>
({
id: emoji.id,
name: emoji.name,
keywords: [emoji.id, emoji.name, ...emoji.keywords],
char: emoji.skins[0].native,
}) satisfies EmojiToken,
);
}, [native]);
// load local reaction frequency
const [frequently] = useLocalStorage("emoji-mart.frequently", {} as Record<string, number>, {
raw: false,
serializer: (v) => JSON.stringify(v),
deserializer: (str) => JSON.parse(str),
});
return useMemo(() => {
const all = [...nativeEmojisTokens, ...customEmojiTokens];
if (frequently) return all.sort((a, b) => (frequently[b.id] ?? 0) - (frequently[a.id] ?? 0));
else return all;
}, [nativeEmojisTokens, customEmojiTokens]);
}
function useAutocompleteTriggers() {
const webOfTrust = useWebOfTrust();
const emojis = useContextEmojis();
const directory = useObservable(userSearchDirectory) ?? [];
const emojis = useEmojiTokens();
const triggers: TriggerType<Token> = {
":": {
dataProvider: (token: string) => {
return matchSorter(emojis, token.trim(), { keys: ["keywords"] }).slice(0, 10);
if (!token) return emojis.slice(0, 10);
else return matchSorter(emojis, token.trim(), { keys: ["keywords"] }).slice(0, 10);
},
component: Item,
output,
@ -102,7 +156,7 @@ const MagicInput = forwardRef<HTMLInputElement, InputProps & { instanceRef?: Leg
const triggers = useAutocompleteTriggers();
return (
// @ts-ignore
// @ts-expect-error
<ReactTextareaAutocomplete<Token, InputProps>
{...props}
textAreaComponent={Input}
@ -121,7 +175,7 @@ const MagicTextArea = forwardRef<HTMLTextAreaElement, TextareaProps & { instance
const triggers = useAutocompleteTriggers();
return (
// @ts-ignore
// @ts-expect-error
<ReactTextareaAutocomplete<Token, TextareaProps>
{...props}
ref={instanceRef}

View File

@ -37,7 +37,7 @@ export default function MessageBubble({
const actions = (
<>
<NoteZapButton event={message} />
<AddReactionButton event={message} portal />
<AddReactionButton event={message} />
{showThreadButton && <IconThreadButton event={message} aria-label="Open Thread" />}
</>
);
@ -69,7 +69,7 @@ export default function MessageBubble({
{hasReactions && (
<CardFooter alignItems="center" display="flex" gap="2" px="2" pt="0" pb="2">
<ButtonGroup size="xs" variant="ghost">
{actionPosition === "footer" ? actions : <AddReactionButton event={message} portal />}
{actionPosition === "footer" ? actions : <AddReactionButton event={message} />}
<EventReactionButtons event={message} />
</ButtonGroup>
<Timestamp ml="auto" timestamp={message.created_at} />

View File

@ -1,65 +1,29 @@
import { useState } from "react";
import {
ButtonProps,
IconButton,
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
useBoolean,
} from "@chakra-ui/react";
import { ButtonProps, useToast } from "@chakra-ui/react";
import { NostrEvent } from "nostr-tools";
import { Emoji } from "applesauce-core/helpers";
import { useEventFactory } from "applesauce-react/hooks";
import useEventReactions from "../../../../hooks/use-event-reactions";
import { AddReactionIcon } from "../../../icons";
import ReactionPicker from "../../../reaction-picker";
import { draftEventReaction } from "../../../../helpers/nostr/reactions";
import { getEventUID } from "../../../../helpers/nostr/event";
import { usePublishEvent } from "../../../../providers/global/publish-provider";
import ReactionIconButton from "../../../reactions/reaction-icon-button";
export default function AddReactionButton({
event,
portal = false,
...props
}: { event: NostrEvent; portal?: boolean } & Omit<ButtonProps, "children">) {
const factory = useEventFactory();
const toast = useToast();
const publish = usePublishEvent();
const reactions = useEventReactions(event) ?? [];
const [popover, setPopover] = useBoolean();
const [loading, setLoading] = useState(false);
const addReaction = async (emoji = "+", url?: string) => {
const addReaction = async (emoji: string | Emoji) => {
setLoading(true);
const draft = draftEventReaction(event, emoji, url);
await publish("Reaction", draft);
setPopover.off();
try {
const draft = await factory.reaction(event, emoji);
await publish("Reaction", draft);
} catch (error) {
if (error instanceof Error) toast({ description: error.message, status: "error" });
}
setLoading(false);
};
const content = (
<PopoverContent>
<PopoverArrow />
<PopoverBody>
<ReactionPicker onSelect={addReaction} />
</PopoverBody>
</PopoverContent>
);
return (
<Popover isLazy isOpen={popover} onOpen={setPopover.on} onClose={setPopover.off}>
<PopoverTrigger>
<IconButton
icon={<AddReactionIcon />}
aria-label="Add Reaction"
title="Add Reaction"
isLoading={loading}
{...props}
>
{reactions?.length ?? 0}
</IconButton>
</PopoverTrigger>
{portal ? <Portal>{content}</Portal> : content}
</Popover>
);
return <ReactionIconButton onSelect={addReaction} isLoading={loading} />;
}

View File

@ -1,75 +0,0 @@
import { Divider, Flex, IconButton, Image, Text } from "@chakra-ui/react";
import { getEmojis, getPackName } from "applesauce-core/helpers/emoji";
import { DislikeIcon, LikeIcon } from "./icons";
import useCurrentAccount from "../hooks/use-current-account";
import useReplaceableEvent from "../hooks/use-replaceable-event";
import { getPackCordsFromFavorites } from "../helpers/nostr/emoji-packs";
import useFavoriteEmojiPacks from "../hooks/use-favorite-emoji-packs";
import useAppSettings from "../hooks/use-app-settings";
export type ReactionPickerProps = {
onSelect: (emoji: string, url?: string) => void;
};
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">{getPackName(pack)}</Text>
<Divider />
</Flex>
<Flex wrap="wrap" gap="2">
{getEmojis(pack).map((emoji) => (
<IconButton
key={emoji.name}
icon={<Image src={emoji.url} height="1.2rem" />}
aria-label={emoji.name}
title={emoji.name}
variant="ghost"
size="sm"
onClick={() => onSelect(emoji.name, emoji.url)}
/>
))}
</Flex>
</>
);
}
export default function ReactionPicker({ onSelect }: ReactionPickerProps) {
const account = useCurrentAccount();
const favoritePacks = useFavoriteEmojiPacks(account?.pubkey);
const { quickReactions } = useAppSettings();
return (
<Flex direction="column" gap="2">
<Flex wrap="wrap" gap="2">
<IconButton icon={<LikeIcon />} aria-label="Like" variant="ghost" size="sm" onClick={() => onSelect("+")} />
<IconButton
icon={<DislikeIcon />}
aria-label="Dislike"
variant="ghost"
size="sm"
onClick={() => onSelect("-")}
/>
{quickReactions.map((emoji) => (
<IconButton
key={emoji}
icon={<span>{emoji}</span>}
aria-label="Shaka"
variant="ghost"
size="sm"
onClick={() => onSelect(emoji)}
/>
))}
</Flex>
{favoritePacks &&
getPackCordsFromFavorites(favoritePacks).map((cord) => (
<EmojiPack key={cord} cord={cord} onSelect={onSelect} />
))}
</Flex>
);
}

View File

@ -0,0 +1,27 @@
import { useColorMode } from "@chakra-ui/react";
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
export type NativeEmoji = {
id: string;
keywords: string[];
name: string;
native?: string;
src?: string;
};
export const defaultCategories = ["people", "nature", "foods", "activity", "places", "objects", "symbols", "flags"];
export default function EmojiPicker({
custom,
categories = defaultCategories,
...props
}: {
autoFocus?: boolean;
onEmojiSelect?: (emoji: NativeEmoji) => void;
custom?: { id: string; name: string; emojis: any[] }[];
categories?: string[];
}) {
const { colorMode } = useColorMode();
return <Picker data={data} custom={custom} categories={["frequent", ...categories]} theme={colorMode} {...props} />;
}

View File

@ -0,0 +1,92 @@
import { lazy, Suspense } from "react";
import {
Flex,
IconButton,
IconButtonProps,
Modal,
ModalContent,
ModalOverlay,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Portal,
Spinner,
Text,
useBoolean,
} from "@chakra-ui/react";
import { Emoji } from "applesauce-core/helpers/emoji";
import { AddReactionIcon } from "../icons";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
const ReactionPicker = lazy(() => import("./reaction-picker"));
export default function ReactionIconButton({
portal = false,
onSelect,
...props
}: { portal?: boolean; onSelect?: (emoji: string | Emoji) => void } & Omit<
IconButtonProps,
"children" | "aria-label" | "onSelect"
>) {
const useModal = useBreakpointValue({ base: true, md: false });
const [isOpen, open] = useBoolean();
const handleSelect = (emoji: string | Emoji) => {
onSelect?.(emoji);
open.off();
};
const picker = (
<Suspense
fallback={
<Flex p="4" gap="2">
<Spinner />
<Text>Loading emojis...</Text>
</Flex>
}
>
<ReactionPicker onSelect={handleSelect} autoFocus />
</Suspense>
);
if (useModal) {
return (
<>
<IconButton
icon={<AddReactionIcon />}
aria-label="Add Reaction"
title="Add Reaction"
onClick={open.on}
{...props}
/>
<Modal isOpen={isOpen} onClose={open.off}>
<ModalOverlay />
<ModalContent w="auto">{picker}</ModalContent>
</Modal>
</>
);
} else
return (
<Popover isLazy isOpen={isOpen} onOpen={open.on} onClose={open.off}>
<PopoverTrigger>
<IconButton icon={<AddReactionIcon />} aria-label="Add Reaction" title="Add Reaction" {...props} />
</PopoverTrigger>
{portal ? (
<Portal>
<PopoverContent w="350px" border="none" rounded="xl">
<PopoverArrow />
{picker}
</PopoverContent>
</Portal>
) : (
<PopoverContent w="350px" border="none" rounded="xl">
<PopoverArrow />
{picker}
</PopoverContent>
)}
</Popover>
);
}

View File

@ -0,0 +1,51 @@
import { useMemo } from "react";
import { Emoji, getEmojis, getEventUID, getPackName } from "applesauce-core/helpers";
import { getAddressPointersFromList } from "applesauce-lists/helpers";
import EmojiPicker, { defaultCategories, NativeEmoji } from "./emoji-picker";
import useFavoriteEmojiPacks from "../../hooks/use-favorite-emoji-packs";
import useReplaceableEvents from "../../hooks/use-replaceable-events";
import useCurrentAccount from "../../hooks/use-current-account";
export default function ReactionPicker({
autoFocus,
onSelect,
}: {
autoFocus?: boolean;
onSelect?: (emoji: string | Emoji) => void;
}) {
const account = useCurrentAccount();
const favoritePacks = useFavoriteEmojiPacks(account?.pubkey);
const packs = useReplaceableEvents(favoritePacks ? getAddressPointersFromList(favoritePacks) : []);
const custom = useMemo(
() =>
packs.map((pack) => {
const id = getEventUID(pack);
const name = getPackName(pack) || "Unnamed";
const emojis = getEmojis(pack);
return {
id,
name,
emojis: emojis.map((e) => ({
id: e.name,
name: e.name,
keywords: [e.name, e.name.toUpperCase(), e.name.replaceAll("_", "")],
skins: [{ src: e.url }],
})),
};
}),
[packs],
);
const categories = useMemo(() => [...packs.map((p) => getEventUID(p)), ...defaultCategories], [packs]);
const handleSelect = (emoji: NativeEmoji) => {
if (emoji.src) onSelect?.({ name: emoji.name, url: emoji.src });
else if (emoji.id === "+1") onSelect?.("+");
else if (emoji.id === "-1") onSelect?.("-");
else if (emoji.native) onSelect?.(emoji.native);
};
return <EmojiPicker autoFocus={autoFocus} onEmojiSelect={handleSelect} custom={custom} categories={categories} />;
}

View File

@ -58,3 +58,5 @@ export const NIP_89_CLIENT_APP: EventFactoryClient = {
};
export const SUPPORT_PUBKEY = "713978c3094081b34fcf2f5491733b0c22728cd3b7a6946519d40f5f08598af8";
export const TENOR_API_KEY = import.meta.env.VITE_TENOR_API_KEY as string | undefined;

View File

@ -47,10 +47,14 @@ export type AppSettingsV10 = Omit<AppSettingsV9, "version" | "defaultRelays"> &
showPubkeyColor: "none" | "avatar" | "underline";
};
export type AppSettings = AppSettingsV10;
export type AppSettingsV11 = Omit<AppSettingsV10, "quickReactions" | "version"> & {
version: 11;
};
export type AppSettings = AppSettingsV11;
export const defaultSettings: AppSettings = {
version: 10,
version: 11,
// display
theme: "default",
@ -75,7 +79,6 @@ export const defaultSettings: AppSettings = {
showReactions: true,
autoDecryptDMs: false,
quickReactions: ["🤙", "❤️", "🤣", "😍", "🔥"],
mediaUploadService: "nostr.build",
// lightning

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo } from "react";
import { NostrEvent } from "nostr-tools";
import { useStoreQuery } from "applesauce-react/hooks";
import { ReplaceableSetQuery } from "applesauce-core/queries";
@ -10,7 +11,7 @@ export default function useReplaceableEvents(
coordinates: string[] | CustomAddressPointer[] | undefined,
additionalRelays?: Iterable<string>,
opts: RequestOptions = {},
) {
): NostrEvent[] {
const readRelays = useReadRelays(additionalRelays);
const pointers = useMemo(() => {
@ -39,6 +40,6 @@ export default function useReplaceableEvents(
}
}, [pointers, readRelays.urls.join("|")]);
const map = useStoreQuery(ReplaceableSetQuery, pointers && [pointers]);
return Array.from(map?.values() ?? []);
const events = useStoreQuery(ReplaceableSetQuery, pointers && [pointers]);
return events ? Object.values(events) : [];
}

View File

@ -1,30 +1,17 @@
import { PropsWithChildren, createContext, useContext } from "react";
import lib from "emojilib";
import { Emoji, getEmojis } from "applesauce-core/helpers";
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(([char, [name, ...keywords]]) => ({
name,
keywords: [name, ...keywords],
char,
}));
export type Emoji = { name: string; keywords: string[]; char: string; url?: string };
const EmojiContext = createContext<Emoji[]>([]);
export function useContextEmojis() {
return useContext(EmojiContext);
}
export function DefaultEmojiProvider({ children }: PropsWithChildren) {
return <EmojiProvider emojis={defaultEmojis}>{children}</EmojiProvider>;
}
export function UserEmojiProvider({ children, pubkey }: PropsWithChildren & { pubkey?: string }) {
const account = useCurrentAccount();
const favoriteList = useFavoriteEmojiPacks(pubkey || account?.pubkey, [], {
@ -33,12 +20,7 @@ export function UserEmojiProvider({ children, pubkey }: PropsWithChildren & { pu
});
const favoritePacks = useReplaceableEvents(favoriteList && getPackCordsFromFavorites(favoriteList));
const emojis = favoritePacks
.map((event) =>
event.tags.filter(isEmojiTag).map((t) => ({ name: t[1], url: t[2], keywords: [t[1]], char: `:${t[1]}:` })),
)
.flat();
const emojis = favoritePacks.map((pack) => getEmojis(pack)).flat();
return <EmojiProvider emojis={emojis}>{children}</EmojiProvider>;
}

View File

@ -6,7 +6,7 @@ import { SigningProvider } from "./signing-provider";
import buildTheme from "../../theme";
import useAppSettings from "../../hooks/use-app-settings";
import NotificationsProvider from "./notifications-provider";
import { DefaultEmojiProvider, UserEmojiProvider } from "./emoji-provider";
import { UserEmojiProvider } from "./emoji-provider";
import BreakpointProvider from "./breakpoint-provider";
import DMTimelineProvider from "./dms-provider";
import PublishProvider from "./publish-provider";
@ -37,13 +37,11 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
<PublishProvider>
<NotificationsProvider>
<DMTimelineProvider>
<DefaultEmojiProvider>
<UserEmojiProvider>
<EventFactoryProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</EventFactoryProvider>
</UserEmojiProvider>
</DefaultEmojiProvider>
<UserEmojiProvider>
<EventFactoryProvider>
<WebOfTrustProvider>{children}</WebOfTrustProvider>
</EventFactoryProvider>
</UserEmojiProvider>
</DMTimelineProvider>
</NotificationsProvider>
</PublishProvider>

View File

@ -0,0 +1,70 @@
import { getEventPointerFromETag, getEventPointerFromQTag, processTags } from "applesauce-core/helpers";
import { TimelineQuery } from "applesauce-core/queries";
import { kinds, NostrEvent } from "nostr-tools";
import singleEventService from "./single-event";
import clientRelaysService from "./client-relays";
import { combineLatest, filter, from, map, mergeMap, shareReplay, tap } from "rxjs";
import accountService from "./account";
import { queryStore } from "./event-store";
import { TORRENT_COMMENT_KIND } from "../helpers/nostr/torrents";
async function handleTextNote(event: NostrEvent) {
// request quotes
const quotes = processTags(event.tags, (t) => (t[0] === "q" ? t : undefined), getEventPointerFromQTag);
for (const pointer of quotes) {
singleEventService.requestEvent(pointer.id, [...clientRelaysService.readRelays.value, ...(pointer.relays ?? [])]);
}
// request other event pointers
const pointers = processTags(
event.tags,
(t) => (t[0] === "e" || t[0] === "E" ? t : undefined),
getEventPointerFromETag,
);
for (const pointer of pointers) {
singleEventService.requestEvent(pointer.id, [...clientRelaysService.readRelays.value, ...(pointer.relays ?? [])]);
}
}
async function handleShare(event: NostrEvent) {
const pointers = processTags(event.tags, (t) => (t[0] === "e" ? t : undefined), getEventPointerFromETag);
for (const pointer of pointers) {
singleEventService.requestEvent(pointer.id, [...clientRelaysService.readRelays.value, ...(pointer.relays ?? [])]);
}
}
const notifications = combineLatest([accountService.current]).pipe(
mergeMap(([account]) => {
if (account)
return queryStore.createQuery(TimelineQuery, {
"#p": [account.pubkey],
kinds: [
kinds.ShortTextNote,
kinds.Repost,
kinds.GenericRepost,
kinds.Reaction,
kinds.Zap,
TORRENT_COMMENT_KIND,
kinds.LongFormArticle,
kinds.EncryptedDirectMessage,
1111, //NIP-22
],
});
else return [];
}),
tap((timeline) => {
// handle loading dependencies of each event
for (const event of timeline) {
switch (event.kind) {
case kinds.ShortTextNote:
handleTextNote(event);
break;
case kinds.Report:
case kinds.GenericRepost:
handleShare(event);
break;
}
}
}),
);

View File

@ -1,4 +0,0 @@
declare module "emojilib" {
const lib: { [char: string]: string[] };
export default lib;
}

View File

@ -1,4 +1,3 @@
import { useMemo, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import {
Flex,
@ -6,11 +5,6 @@ import {
FormLabel,
FormHelperText,
Input,
Tag,
TagLabel,
TagCloseButton,
useDisclosure,
IconButton,
Button,
Select,
Link,
@ -21,11 +15,8 @@ import {
Heading,
Switch,
} from "@chakra-ui/react";
import { matchSorter } from "match-sorter";
import { useObservable } from "applesauce-react/hooks";
import { EditIcon } from "../../../components/icons";
import { useContextEmojis } from "../../../providers/global/emoji-provider";
import useUsersMediaServers from "../../../hooks/use-user-media-servers";
import useCurrentAccount from "../../../hooks/use-current-account";
import useSettingsForm from "../use-settings-form";
@ -34,38 +25,10 @@ import localSettings from "../../../services/local-settings";
export default function PostSettings() {
const account = useCurrentAccount();
const { register, setValue, getValues, watch, submit, formState } = useSettingsForm();
const emojiPicker = useDisclosure();
const { register, getValues, watch, submit, formState } = useSettingsForm();
const { servers: mediaServers } = useUsersMediaServers(account?.pubkey);
const emojis = useContextEmojis();
const [emojiSearch, setEmojiSearch] = useState("");
watch("quickReactions");
watch("mediaUploadService");
const filteredEmojis = useMemo(() => {
const values = getValues();
if (emojiSearch.trim()) {
const noCustom = emojis.filter((e) => e.char && !e.url && !values.quickReactions.includes(e.char));
return matchSorter(noCustom, emojiSearch.trim(), { keys: ["keywords", "char"] }).slice(0, 10);
}
return [];
}, [emojiSearch, getValues().quickReactions]);
const addEmoji = (char: string) => {
const values = getValues();
if (values.quickReactions.includes(char)) return;
setValue("quickReactions", values.quickReactions.concat(char), { shouldTouch: true, shouldDirty: true });
};
const removeEmoji = (char: string) => {
const values = getValues();
if (!values.quickReactions.includes(char)) return;
setValue(
"quickReactions",
values.quickReactions.filter((e) => e !== char),
{ shouldTouch: true, shouldDirty: true },
);
};
const addClientTag = useObservable(localSettings.addClientTag);
@ -73,50 +36,6 @@ export default function PostSettings() {
<VerticalPageLayout as="form" onSubmit={submit} flex={1}>
<Heading size="md">Post Settings</Heading>
<Flex direction="column" gap="4">
<FormControl>
<FormLabel htmlFor="quickReactions" mb="0">
Quick Reactions
</FormLabel>
<Flex gap="2" wrap="wrap">
{getValues().quickReactions.map((char, i) => (
<Tag key={char + i} size="lg">
<TagLabel>{char}</TagLabel>
{emojiPicker.isOpen && <TagCloseButton onClick={() => removeEmoji(char)} />}
</Tag>
))}
{!emojiPicker.isOpen && (
<Button size="sm" onClick={emojiPicker.onOpen} leftIcon={<EditIcon />}>
Customize
</Button>
)}
</Flex>
{emojiPicker.isOpen && (
<>
<Input
type="search"
w="sm"
h="8"
value={emojiSearch}
onChange={(e) => setEmojiSearch(e.target.value)}
my="2"
/>
<Flex gap="2" wrap="wrap">
{filteredEmojis.map((emoji) => (
<IconButton
key={emoji.char}
icon={<span>{emoji.char}</span>}
aria-label={`Add ${emoji.name}`}
title={`Add ${emoji.name}`}
variant="outline"
size="sm"
fontSize="lg"
onClick={() => addEmoji(emoji.char)}
/>
))}
</Flex>
</>
)}
</FormControl>
<FormControl>
<FormLabel htmlFor="theme" mb="0">
Media upload service