mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 13:21:44 +01:00
update emoji picker
add tenor gif picker
This commit is contained in:
parent
69efbeee2c
commit
931ea613be
5
.changeset/pretty-numbers-talk.md
Normal file
5
.changeset/pretty-numbers-talk.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add tenor gif picker
|
5
.changeset/sweet-garlics-itch.md
Normal file
5
.changeset/sweet-garlics-itch.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add new emoji picker
|
5
.changeset/wicked-poems-judge.md
Normal file
5
.changeset/wicked-poems-judge.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Remove quick reactions from settings
|
24
package.json
24
package.json
@ -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
1102
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
84
src/components/gif/tenor-gif-icon-button.tsx
Normal file
84
src/components/gif/tenor-gif-icon-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
@ -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} />
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
27
src/components/reactions/emoji-picker.tsx
Normal file
27
src/components/reactions/emoji-picker.tsx
Normal 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} />;
|
||||
}
|
92
src/components/reactions/reaction-icon-button.tsx
Normal file
92
src/components/reactions/reaction-icon-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
51
src/components/reactions/reaction-picker.tsx
Normal file
51
src/components/reactions/reaction-picker.tsx
Normal 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} />;
|
||||
}
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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) : [];
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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>
|
||||
|
70
src/services/notifications.ts
Normal file
70
src/services/notifications.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
4
src/types/emojilib.d.ts
vendored
4
src/types/emojilib.d.ts
vendored
@ -1,4 +0,0 @@
|
||||
declare module "emojilib" {
|
||||
const lib: { [char: string]: string[] };
|
||||
export default lib;
|
||||
}
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user