mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add custom emoji picker
Some checks failed
Some checks failed
This commit is contained in:
parent
b77700b8b8
commit
2b72a2a5ea
6
bun.lock
6
bun.lock
|
|
@ -32,6 +32,8 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-vue": "^8.6.0",
|
"embla-carousel-vue": "^8.6.0",
|
||||||
|
"emojibase": "^16.0.0",
|
||||||
|
"emojibase-data": "^16.0.3",
|
||||||
"fuzzysort": "^3.1.0",
|
"fuzzysort": "^3.1.0",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"lucide-vue-next": "^0.511.0",
|
"lucide-vue-next": "^0.511.0",
|
||||||
|
|
@ -1178,6 +1180,10 @@
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"emojibase": ["emojibase@16.0.0", "", {}, "sha512-Nw2m7JLIO4Ou2X/yZPRNscHQXVbbr6SErjkJ7EooG7MbR3yDZszCv9KTizsXFc7yZl0n3WF+qUKIC/Lw6H9xaQ=="],
|
||||||
|
|
||||||
|
"emojibase-data": ["emojibase-data@16.0.3", "", { "peerDependencies": { "emojibase": "*" } }, "sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q=="],
|
||||||
|
|
||||||
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="relation" class="rounded border overflow-auto max-h-72">
|
<div v-if="relation" class="overflow-auto max-h-72">
|
||||||
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
<Note :note="relation.note" :hide-actions="true" :small-layout="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
{{ numberFormat(reblogCount) }}
|
{{ numberFormat(reblogCount) }}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" />
|
<ActionButton :icon="Quote" @click="emit('quote')" :title="m.true_shy_jackal_drip()" :disabled="!identity" />
|
||||||
|
<Picker @pick="react">
|
||||||
|
<ActionButton :icon="Smile" :title="m.bald_cool_kangaroo_jump()" :disabled="!identity" />
|
||||||
|
</Picker>
|
||||||
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
|
<Menu :api-note-string="apiNoteString" :url="url" :remote-url="remoteUrl" :is-remote="isRemote" :author-id="authorId" @edit="emit('edit')" :note-id="noteId" @delete="emit('delete')">
|
||||||
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
|
<ActionButton :icon="Ellipsis" :title="m.busy_merry_cowfish_absorb()" />
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
@ -17,8 +20,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { CustomEmoji, Status } from "@versia/client/schemas";
|
||||||
import { Ellipsis, Heart, Quote, Repeat, Reply } from "lucide-vue-next";
|
import { Ellipsis, Heart, Quote, Repeat, Reply, Smile } from "lucide-vue-next";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
|
|
@ -26,6 +29,8 @@ import { getLocale } from "~/paraglide/runtime";
|
||||||
import { confirmModalService } from "../modals/composable";
|
import { confirmModalService } from "../modals/composable";
|
||||||
import ActionButton from "./action-button.vue";
|
import ActionButton from "./action-button.vue";
|
||||||
import Menu from "./menu.vue";
|
import Menu from "./menu.vue";
|
||||||
|
import type { UnicodeEmoji } from "./reactions/picker/emoji";
|
||||||
|
import Picker from "./reactions/picker/index.vue";
|
||||||
|
|
||||||
const { noteId } = defineProps<{
|
const { noteId } = defineProps<{
|
||||||
replyCount: number;
|
replyCount: number;
|
||||||
|
|
@ -46,6 +51,7 @@ const emit = defineEmits<{
|
||||||
reply: [];
|
reply: [];
|
||||||
quote: [];
|
quote: [];
|
||||||
delete: [];
|
delete: [];
|
||||||
|
react: [];
|
||||||
}>();
|
}>();
|
||||||
const { play } = useAudio();
|
const { play } = useAudio();
|
||||||
|
|
||||||
|
|
@ -137,6 +143,19 @@ const unreblog = async () => {
|
||||||
useEvent("note:edit", data);
|
useEvent("note:edit", data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const react = async (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
|
||||||
|
const id = toast.loading(m.gray_stale_antelope_roam());
|
||||||
|
const text = (emoji as UnicodeEmoji).hexcode
|
||||||
|
? (emoji as UnicodeEmoji).unicode
|
||||||
|
: `:${(emoji as z.infer<typeof CustomEmoji>).shortcode}:`;
|
||||||
|
|
||||||
|
const { data } = await client.value.createEmojiReaction(noteId, text);
|
||||||
|
|
||||||
|
toast.dismiss(id);
|
||||||
|
toast.success(m.main_least_turtle_fall());
|
||||||
|
useEvent("note:edit", data);
|
||||||
|
};
|
||||||
|
|
||||||
const numberFormat = (number = 0) =>
|
const numberFormat = (number = 0) =>
|
||||||
number !== 0
|
number !== 0
|
||||||
? new Intl.NumberFormat(getLocale(), {
|
? new Intl.NumberFormat(getLocale(), {
|
||||||
|
|
|
||||||
15
components/notes/reactions/picker/category-header.vue
Normal file
15
components/notes/reactions/picker/category-header.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<template>
|
||||||
|
<div class="sticky top-2 z-10 flex items-center justify-center p-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ categoryName }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
const { categoryName } = defineProps<{
|
||||||
|
categoryName: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
21
components/notes/reactions/picker/display.vue
Normal file
21
components/notes/reactions/picker/display.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<template>
|
||||||
|
<div class="p-2 text-sm font-semibold border-0 rounded-none text-center flex flex-row items-center gap-2 truncate">
|
||||||
|
<img v-if="(emoji as InferredEmoji)?.url" :src="(emoji as InferredEmoji)?.url"
|
||||||
|
:alt="(emoji as InferredEmoji)?.shortcode" class="h-8 align-middle inline not-prose" />
|
||||||
|
<span v-else-if="(emoji as UnicodeEmoji)?.unicode" class="text-2xl align-middle inline not-prose">
|
||||||
|
{{ (emoji as UnicodeEmoji)?.unicode }}
|
||||||
|
</span>
|
||||||
|
{{ (emoji as InferredEmoji)?.shortcode || (emoji as UnicodeEmoji)?.shortcode }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { CustomEmoji } from "@versia/client/schemas";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import type { UnicodeEmoji } from "./emoji.ts";
|
||||||
|
|
||||||
|
type InferredEmoji = z.infer<typeof CustomEmoji>;
|
||||||
|
const { emoji } = defineProps<{
|
||||||
|
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji | null;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
140
components/notes/reactions/picker/emoji.ts
Normal file
140
components/notes/reactions/picker/emoji.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
* Adapted from Cinny's code
|
||||||
|
* @see https://github.com/cinnyapp/cinny/blob/e6f4eeca8edc85ab64179e545b4e2e8c15763633/src/app/plugins/emoji.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CompactEmoji } from "emojibase";
|
||||||
|
import emojisData from "emojibase-data/en/compact.json";
|
||||||
|
import joypixels from "emojibase-data/en/shortcodes/joypixels.json";
|
||||||
|
|
||||||
|
export type UnicodeEmoji = CompactEmoji & {
|
||||||
|
shortcode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum EmojiGroupId {
|
||||||
|
People = "People",
|
||||||
|
Nature = "Nature",
|
||||||
|
Food = "Food",
|
||||||
|
Activity = "Activity",
|
||||||
|
Travel = "Travel",
|
||||||
|
Object = "Object",
|
||||||
|
Symbol = "Symbol",
|
||||||
|
Flag = "Flag",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnicodeEmojiGroup = {
|
||||||
|
id: EmojiGroupId;
|
||||||
|
order: number;
|
||||||
|
emojis: UnicodeEmoji[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getShortcodesFor = (
|
||||||
|
hexcode: string,
|
||||||
|
): string[] | string | undefined => joypixels[hexcode];
|
||||||
|
|
||||||
|
export const getShortcodeFor = (hexcode: string): string | undefined => {
|
||||||
|
const shortcode = joypixels[hexcode];
|
||||||
|
return Array.isArray(shortcode) ? shortcode[0] : shortcode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emojiGroups: UnicodeEmojiGroup[] = [
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.People,
|
||||||
|
order: 0,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Nature,
|
||||||
|
order: 1,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Food,
|
||||||
|
order: 2,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Activity,
|
||||||
|
order: 3,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Travel,
|
||||||
|
order: 4,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Object,
|
||||||
|
order: 5,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Symbol,
|
||||||
|
order: 6,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: EmojiGroupId.Flag,
|
||||||
|
order: 7,
|
||||||
|
emojis: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const emojis: UnicodeEmoji[] = [];
|
||||||
|
|
||||||
|
function addEmojiToGroup(groupIndex: number, emoji: UnicodeEmoji) {
|
||||||
|
emojiGroups[groupIndex]?.emojis.push(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupIndex(emoji: UnicodeEmoji): number | undefined {
|
||||||
|
switch (emoji.group) {
|
||||||
|
case 0:
|
||||||
|
case 1:
|
||||||
|
return 0; // People
|
||||||
|
case 3:
|
||||||
|
return 1; // Nature
|
||||||
|
case 4:
|
||||||
|
return 2; // Food
|
||||||
|
case 6:
|
||||||
|
return 3; // Activity
|
||||||
|
case 5:
|
||||||
|
return 4; // Travel
|
||||||
|
case 7:
|
||||||
|
return 5; // Object
|
||||||
|
case 8:
|
||||||
|
case undefined:
|
||||||
|
return 6; // Symbol
|
||||||
|
case 9:
|
||||||
|
return 7; // Flag
|
||||||
|
default:
|
||||||
|
return undefined; // Unknown group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const emoji of emojisData) {
|
||||||
|
const myShortCodes = getShortcodesFor(emoji.hexcode);
|
||||||
|
|
||||||
|
if (!myShortCodes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const em: UnicodeEmoji = {
|
||||||
|
...emoji,
|
||||||
|
shortcode: Array.isArray(myShortCodes)
|
||||||
|
? (myShortCodes[0] as string)
|
||||||
|
: myShortCodes,
|
||||||
|
shortcodes: Array.isArray(myShortCodes)
|
||||||
|
? myShortCodes
|
||||||
|
: emoji.shortcodes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupIndex = getGroupIndex(em);
|
||||||
|
|
||||||
|
if (groupIndex !== undefined) {
|
||||||
|
addEmojiToGroup(groupIndex, em);
|
||||||
|
emojis.push(em);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
components/notes/reactions/picker/emoji.vue
Normal file
27
components/notes/reactions/picker/emoji.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<template>
|
||||||
|
<Button @focus="() => emit('select', emoji)" @mouseenter="() => emit('select', emoji)" @click="() => emit('pick', emoji)" size="icon" variant="ghost"
|
||||||
|
class="size-12">
|
||||||
|
<img v-if="(emoji as InferredEmoji).url" :src="(emoji as InferredEmoji).url"
|
||||||
|
:alt="(emoji as InferredEmoji).shortcode" class="h-8 align-middle inline not-prose" />
|
||||||
|
<span v-else-if="(emoji as UnicodeEmoji).unicode" class="text-2xl align-middle inline not-prose">
|
||||||
|
{{ (emoji as UnicodeEmoji).unicode }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { CustomEmoji } from "@versia/client/schemas";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import type { UnicodeEmoji } from "./emoji";
|
||||||
|
|
||||||
|
const { emoji } = defineProps<{
|
||||||
|
emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji;
|
||||||
|
}>();
|
||||||
|
type InferredEmoji = z.infer<typeof CustomEmoji>;
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||||
|
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
138
components/notes/reactions/picker/index.vue
Normal file
138
components/notes/reactions/picker/index.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<template>
|
||||||
|
<Popover v-model:open="open">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<slot />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="p-0 w-fit">
|
||||||
|
<div class="grid-cols-[minmax(0,1fr)_auto] gap-0 grid divide-x *:h-112 *:overflow-y-auto"
|
||||||
|
orientation="vertical">
|
||||||
|
<div class="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-0" ref="emojiContainer">
|
||||||
|
<div class="p-2">
|
||||||
|
<Input placeholder="Search" v-model="filter" />
|
||||||
|
</div>
|
||||||
|
<VList :data="virtualizedItems" #default="{ item }" class="relative" :style="{
|
||||||
|
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
|
||||||
|
}">
|
||||||
|
<CategoryHeader :key="item.headerId" v-if="item.type === 'header'" :category-name="item.name" />
|
||||||
|
<div v-else-if="item.type === 'emoji-row'" :key="item.rowId" class="flex gap-1 p-2">
|
||||||
|
<Emoji v-for="emoji in item.emojis" :key="getEmojiKey(emoji)" :emoji="emoji"
|
||||||
|
@select="(e) => selectedEmoji = e" @pick="e => {
|
||||||
|
emit('pick', e); open = false;
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
</VList>
|
||||||
|
<EmojiDisplay :emoji="selectedEmoji" :style="{
|
||||||
|
width: `calc(var(--spacing) * ((12 * ${EMOJI_PER_ROW}) + (${EMOJI_PER_ROW} - 1)) + var(--spacing) * 4)`,
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
<Sidebar :categories="categories" @select="scrollToCategory" />
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { CustomEmoji } from "@versia/client/schemas";
|
||||||
|
import { VList } from "virtua/vue";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "~/components/ui/popover";
|
||||||
|
import CategoryHeader from "./category-header.vue";
|
||||||
|
import EmojiDisplay from "./display.vue";
|
||||||
|
import { type EmojiGroupId, type UnicodeEmoji, emojiGroups } from "./emoji.ts";
|
||||||
|
import Emoji from "./emoji.vue";
|
||||||
|
import Sidebar from "./sidebar.vue";
|
||||||
|
import { EMOJI_PER_ROW, getVirtualizedItems } from "./virtual.ts";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
pick: [emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const selectedEmoji = ref<z.infer<typeof CustomEmoji> | UnicodeEmoji | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const emojiContainer = useTemplateRef<HTMLDivElement>("emojiContainer");
|
||||||
|
const filter = ref("");
|
||||||
|
|
||||||
|
const customEmojis = computed(() => identity.value?.emojis ?? []);
|
||||||
|
|
||||||
|
const customEmojiCategories = computed(() => {
|
||||||
|
const categories: Record<string, z.infer<typeof CustomEmoji>[]> = {};
|
||||||
|
|
||||||
|
for (const emoji of customEmojis.value) {
|
||||||
|
const categoryName = emoji.category || "Uncategorized";
|
||||||
|
|
||||||
|
if (!categories[categoryName]) {
|
||||||
|
categories[categoryName] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
categories[categoryName]?.push(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = computed(() => {
|
||||||
|
const customCategories = Object.entries(customEmojiCategories.value).map(
|
||||||
|
([name, emojis]) => ({
|
||||||
|
name,
|
||||||
|
src: (emojis[0]?.url as string) || "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupCategories = emojiGroups.map((group) => ({
|
||||||
|
name: group.id,
|
||||||
|
groupId: group.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...customCategories, ...groupCategories];
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualizedItems = computed(() =>
|
||||||
|
getVirtualizedItems(customEmojiCategories.value, filter.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getEmojiKey = (emoji: z.infer<typeof CustomEmoji> | UnicodeEmoji) => {
|
||||||
|
if ("url" in emoji) {
|
||||||
|
return `custom-${emoji.shortcode}`;
|
||||||
|
}
|
||||||
|
return `unicode-${emoji.shortcode}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToCategory = (category: {
|
||||||
|
name: string;
|
||||||
|
groupId?: EmojiGroupId;
|
||||||
|
src?: string;
|
||||||
|
}) => {
|
||||||
|
const categoryId = category.groupId || `custom-${category.name}`;
|
||||||
|
const headerIndex = virtualizedItems.value.findIndex(
|
||||||
|
(item) => item.type === "header" && item.categoryId === categoryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const child = emojiContainer.value?.children[1];
|
||||||
|
|
||||||
|
if (headerIndex !== -1 && child) {
|
||||||
|
// Estimate scroll position based on item heights
|
||||||
|
// Headers are approximately 38px, emoji rows are approximately 64px
|
||||||
|
let scrollTop = 0;
|
||||||
|
for (let i = 0; i < headerIndex; i++) {
|
||||||
|
const item = virtualizedItems.value[i];
|
||||||
|
if (item?.type === "header") {
|
||||||
|
scrollTop += 38;
|
||||||
|
} else if (item?.type === "emoji-row") {
|
||||||
|
scrollTop += 64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child.scrollTo({
|
||||||
|
top: scrollTop,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
47
components/notes/reactions/picker/sidebar.vue
Normal file
47
components/notes/reactions/picker/sidebar.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-1 bg-transparent p-2">
|
||||||
|
<Button v-for="category in categories" :key="category.name" size="icon" variant="ghost" @click="() => emit('select', category)">
|
||||||
|
<component v-if="category.groupId" :is="emojiGroupIconMap[category.groupId]" class="size-6 text-primary" />
|
||||||
|
<img v-else-if="category.src" :src="category.src" class="size-6 align-middle inline not-prose" role="presentation" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
CarFront,
|
||||||
|
Flag,
|
||||||
|
Leaf,
|
||||||
|
Percent,
|
||||||
|
Pizza,
|
||||||
|
Smile,
|
||||||
|
Volleyball,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
import type { FunctionalComponent } from "vue";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { EmojiGroupId } from "./emoji";
|
||||||
|
|
||||||
|
const { categories } = defineProps<{
|
||||||
|
categories: {
|
||||||
|
name: string;
|
||||||
|
groupId?: EmojiGroupId;
|
||||||
|
src?: string;
|
||||||
|
}[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [category: { name: string; groupId?: EmojiGroupId; src?: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emojiGroupIconMap: Record<EmojiGroupId, FunctionalComponent> = {
|
||||||
|
[EmojiGroupId.People]: Smile,
|
||||||
|
[EmojiGroupId.Nature]: Leaf,
|
||||||
|
[EmojiGroupId.Food]: Pizza,
|
||||||
|
[EmojiGroupId.Activity]: Volleyball,
|
||||||
|
[EmojiGroupId.Travel]: CarFront,
|
||||||
|
[EmojiGroupId.Object]: Box,
|
||||||
|
[EmojiGroupId.Symbol]: Percent,
|
||||||
|
[EmojiGroupId.Flag]: Flag,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
117
components/notes/reactions/picker/virtual.ts
Normal file
117
components/notes/reactions/picker/virtual.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import type { CustomEmoji } from "@versia/client/schemas";
|
||||||
|
import { go } from "fuzzysort";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { type UnicodeEmoji, emojiGroups } from "./emoji";
|
||||||
|
|
||||||
|
export const EMOJI_PER_ROW = 7;
|
||||||
|
export type VirtualizedItem =
|
||||||
|
| { headerId: string; type: "header"; name: string; categoryId: string }
|
||||||
|
| {
|
||||||
|
rowId: string;
|
||||||
|
type: "emoji-row";
|
||||||
|
emojis: (z.infer<typeof CustomEmoji> | UnicodeEmoji)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVirtualizedItems = (
|
||||||
|
customCategories: Record<string, z.infer<typeof CustomEmoji>[]>,
|
||||||
|
searchQuery?: string,
|
||||||
|
): VirtualizedItem[] => {
|
||||||
|
const items: VirtualizedItem[] = [];
|
||||||
|
|
||||||
|
// Add custom emoji categories first
|
||||||
|
for (const [categoryName, categoryEmojis] of Object.entries(
|
||||||
|
customCategories,
|
||||||
|
)) {
|
||||||
|
// Add category header
|
||||||
|
items.push({
|
||||||
|
headerId: nanoid(),
|
||||||
|
type: "header",
|
||||||
|
name: categoryName,
|
||||||
|
categoryId: `custom-${categoryName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add emoji rows for this category
|
||||||
|
for (let i = 0; i < categoryEmojis.length; i += EMOJI_PER_ROW) {
|
||||||
|
items.push({
|
||||||
|
rowId: nanoid(),
|
||||||
|
type: "emoji-row",
|
||||||
|
emojis: categoryEmojis.slice(i, i + EMOJI_PER_ROW),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add unicode emoji groups
|
||||||
|
for (const group of emojiGroups) {
|
||||||
|
if (group.emojis.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add group header
|
||||||
|
items.push({
|
||||||
|
headerId: nanoid(),
|
||||||
|
type: "header",
|
||||||
|
name: group.id,
|
||||||
|
categoryId: group.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add emoji rows for this group
|
||||||
|
for (let i = 0; i < group.emojis.length; i += EMOJI_PER_ROW) {
|
||||||
|
items.push({
|
||||||
|
rowId: nanoid(),
|
||||||
|
type: "emoji-row",
|
||||||
|
emojis: group.emojis.slice(i, i + EMOJI_PER_ROW),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If search query is provided, add extra category for search results
|
||||||
|
// with emojis that contain the search query in their shortcode
|
||||||
|
// ordered with fuzzysort
|
||||||
|
if (searchQuery) {
|
||||||
|
const customEmojiMatches = Object.values(customCategories)
|
||||||
|
.flat()
|
||||||
|
.filter((emoji) =>
|
||||||
|
emoji.shortcode
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
const unicodeEmojiMatches = emojiGroups
|
||||||
|
.flatMap((group) => group.emojis)
|
||||||
|
.filter((emoji) =>
|
||||||
|
emoji.shortcode
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = go(
|
||||||
|
searchQuery,
|
||||||
|
[...customEmojiMatches, ...unicodeEmojiMatches],
|
||||||
|
{
|
||||||
|
key: "shortcode",
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
items.splice(0, 0, {
|
||||||
|
headerId: nanoid(),
|
||||||
|
type: "header",
|
||||||
|
name: "Search Results",
|
||||||
|
categoryId: "search-results",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i += EMOJI_PER_ROW) {
|
||||||
|
const emojis = results
|
||||||
|
.slice(i, i + EMOJI_PER_ROW)
|
||||||
|
.map((result) => result.obj);
|
||||||
|
|
||||||
|
items.splice(1 + i / EMOJI_PER_ROW, 0, {
|
||||||
|
rowId: nanoid(),
|
||||||
|
type: "emoji-row",
|
||||||
|
emojis,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<HoverCard @update:open="(open) => open && accounts === null && refreshReactions()">
|
<HoverCard @update:open="(open) => open && accounts === null && refreshReactions()">
|
||||||
<HoverCardTrigger as-child>
|
<HoverCardTrigger as-child>
|
||||||
<Button variant="secondary" size="sm" class="gap-2">
|
<Button @click="reaction.me ? unreact() : react()" :variant="reaction.me ? 'secondary' : 'outline'" size="sm" class="gap-2">
|
||||||
<img v-if="emoji" :src="emoji.url" :alt="emoji.shortcode"
|
<img v-if="emoji" :src="emoji.url" :alt="emoji.shortcode"
|
||||||
class="h-[1lh] align-middle inline not-prose" />
|
class="h-[1lh] align-middle inline not-prose" />
|
||||||
<span v-else>
|
<span v-else>
|
||||||
|
|
@ -34,6 +34,7 @@ import type {
|
||||||
CustomEmoji,
|
CustomEmoji,
|
||||||
NoteReaction,
|
NoteReaction,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
|
import { toast } from "vue-sonner";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import Spinner from "~/components/graphics/spinner.vue";
|
import Spinner from "~/components/graphics/spinner.vue";
|
||||||
import Avatar from "~/components/profiles/avatar.vue";
|
import Avatar from "~/components/profiles/avatar.vue";
|
||||||
|
|
@ -43,6 +44,7 @@ import {
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
HoverCardTrigger,
|
HoverCardTrigger,
|
||||||
} from "~/components/ui/hover-card";
|
} from "~/components/ui/hover-card";
|
||||||
|
import * as m from "~/paraglide/messages.js";
|
||||||
import { getLocale } from "~/paraglide/runtime.js";
|
import { getLocale } from "~/paraglide/runtime.js";
|
||||||
|
|
||||||
const { reaction, emoji, statusId } = defineProps<{
|
const { reaction, emoji, statusId } = defineProps<{
|
||||||
|
|
@ -70,4 +72,30 @@ const refreshReactions = async () => {
|
||||||
|
|
||||||
accounts.value = accountsData;
|
accounts.value = accountsData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const react = async () => {
|
||||||
|
const id = toast.loading(m.gray_stale_antelope_roam());
|
||||||
|
|
||||||
|
const { data } = await client.value.createEmojiReaction(
|
||||||
|
statusId,
|
||||||
|
reaction.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.dismiss(id);
|
||||||
|
toast.success(m.main_least_turtle_fall());
|
||||||
|
useEvent("note:edit", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unreact = async () => {
|
||||||
|
const id = toast.loading(m.many_weary_bat_intend());
|
||||||
|
|
||||||
|
const { data } = await client.value.deleteEmojiReaction(
|
||||||
|
statusId,
|
||||||
|
reaction.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.dismiss(id);
|
||||||
|
toast.success(m.aware_even_oryx_race());
|
||||||
|
useEvent("note:edit", data);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -332,5 +332,10 @@
|
||||||
"sharp_alive_anteater_fade": "Which instance?",
|
"sharp_alive_anteater_fade": "Which instance?",
|
||||||
"noble_misty_rook_slide": "Put your instance's domain name here.",
|
"noble_misty_rook_slide": "Put your instance's domain name here.",
|
||||||
"next_hour_jurgen_sprout": "Are you sure you want to delete {amount} emojis?",
|
"next_hour_jurgen_sprout": "Are you sure you want to delete {amount} emojis?",
|
||||||
"equal_only_crow_file": "Deleting {amount} emojis..."
|
"equal_only_crow_file": "Deleting {amount} emojis...",
|
||||||
|
"bald_cool_kangaroo_jump": "React",
|
||||||
|
"gray_stale_antelope_roam": "Creating reaction...",
|
||||||
|
"main_least_turtle_fall": "Reaction added!",
|
||||||
|
"many_weary_bat_intend": "Removing reaction...",
|
||||||
|
"aware_even_oryx_race": "Reaction removed!"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ in
|
||||||
|
|
||||||
pnpmDeps = pnpm.fetchDeps {
|
pnpmDeps = pnpm.fetchDeps {
|
||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
hash = "sha256-oYouqCoLTyVemqURLW0j5MVKqupqRDxP5rkR3reMQvk=";
|
hash = "sha256-0SForaAvRv84MrJ0ohI924+JImxkMiChHhl10rcyHQM=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-vue": "^8.6.0",
|
"embla-carousel-vue": "^8.6.0",
|
||||||
|
"emojibase": "^16.0.0",
|
||||||
|
"emojibase-data": "^16.0.3",
|
||||||
"fuzzysort": "^3.1.0",
|
"fuzzysort": "^3.1.0",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"lucide-vue-next": "^0.511.0",
|
"lucide-vue-next": "^0.511.0",
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ importers:
|
||||||
embla-carousel-vue:
|
embla-carousel-vue:
|
||||||
specifier: ^8.6.0
|
specifier: ^8.6.0
|
||||||
version: 8.6.0(vue@3.5.14(typescript@5.8.3))
|
version: 8.6.0(vue@3.5.14(typescript@5.8.3))
|
||||||
|
emojibase:
|
||||||
|
specifier: ^16.0.0
|
||||||
|
version: 16.0.0
|
||||||
|
emojibase-data:
|
||||||
|
specifier: ^16.0.3
|
||||||
|
version: 16.0.3(emojibase@16.0.0)
|
||||||
fuzzysort:
|
fuzzysort:
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
|
|
@ -2893,6 +2899,15 @@ packages:
|
||||||
emoji-regex@9.2.2:
|
emoji-regex@9.2.2:
|
||||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||||
|
|
||||||
|
emojibase-data@16.0.3:
|
||||||
|
resolution: {integrity: sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q==}
|
||||||
|
peerDependencies:
|
||||||
|
emojibase: '*'
|
||||||
|
|
||||||
|
emojibase@16.0.0:
|
||||||
|
resolution: {integrity: sha512-Nw2m7JLIO4Ou2X/yZPRNscHQXVbbr6SErjkJ7EooG7MbR3yDZszCv9KTizsXFc7yZl0n3WF+qUKIC/Lw6H9xaQ==}
|
||||||
|
engines: {node: '>=18.12.0'}
|
||||||
|
|
||||||
enabled@2.0.0:
|
enabled@2.0.0:
|
||||||
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
|
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
|
||||||
|
|
||||||
|
|
@ -8870,6 +8885,12 @@ snapshots:
|
||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
|
|
||||||
|
emojibase-data@16.0.3(emojibase@16.0.0):
|
||||||
|
dependencies:
|
||||||
|
emojibase: 16.0.0
|
||||||
|
|
||||||
|
emojibase@16.0.0: {}
|
||||||
|
|
||||||
enabled@2.0.0: {}
|
enabled@2.0.0: {}
|
||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue