(wip): add reactions
(qol): update todo
This commit is contained in:
parent
d57940d1b3
commit
7ba08090fe
20 changed files with 4533 additions and 5894 deletions
|
|
@ -1,7 +1,11 @@
|
||||||
import { List, Switch } from "@/features/settings/components";
|
import { List, Switch } from "@/features/settings/components";
|
||||||
import { Kbd } from "@tensamin/ui";
|
import { Button, Kbd } from "@tensamin/ui";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
import { storageDefaults } from "@tensamin/shared/data";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const { save } = useStorage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -29,6 +33,12 @@ export default function Page() {
|
||||||
Trusted embed domains can get your IP-Address! Only add domains if you
|
Trusted embed domains can get your IP-Address! Only add domains if you
|
||||||
really trust them!
|
really trust them!
|
||||||
</p>
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void save("reactions", storageDefaults.reactions)}
|
||||||
|
>
|
||||||
|
Reset Emoji Ranks
|
||||||
|
</Button>
|
||||||
<List label="Trusted embed domains" id="chat_trusted_domains" />
|
<List label="Trusted embed domains" id="chat_trusted_domains" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,13 @@
|
||||||
"@tanstack/react-virtual": "^3.0.0",
|
"@tanstack/react-virtual": "^3.0.0",
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"motion": "^12.42.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|
|
||||||
29
packages/chat/src/components/emojiPicker.tsx
Normal file
29
packages/chat/src/components/emojiPicker.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Button } from "@tensamin/ui";
|
||||||
|
import Emoji from "@tensamin/markdown/emoji";
|
||||||
|
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||||
|
|
||||||
|
export default function EmojiPicker({
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
onSelect: (emoji: string) => void;
|
||||||
|
}) {
|
||||||
|
const { ranks } = useEmojiRanks();
|
||||||
|
const emojis = getRecentEmojis(ranks, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-1 p-1">
|
||||||
|
{emojis.map((emoji) => (
|
||||||
|
<Button
|
||||||
|
key={emoji}
|
||||||
|
aria-label={`Select ${emoji}`}
|
||||||
|
className="h-10 w-10 p-0"
|
||||||
|
onClick={() => onSelect(emoji)}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Emoji shortcode={emoji} />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
packages/chat/src/components/emojiRanks.ts
Normal file
95
packages/chat/src/components/emojiRanks.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||||
|
|
||||||
|
const RANKS_CHANGED_EVENT = "tensamin-reaction-ranks-changed";
|
||||||
|
let recordQueue = Promise.resolve();
|
||||||
|
|
||||||
|
function normalizeRanks(ranks: Record<string, number>) {
|
||||||
|
return Object.entries(ranks).reduce<Record<string, number>>(
|
||||||
|
(normalized, [value, frequency]) => {
|
||||||
|
const shortcode = normalizeShortcode(value);
|
||||||
|
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized[shortcode] = (normalized[shortcode] ?? 0) + frequency;
|
||||||
|
return normalized;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankedEmojis(ranks: Record<string, number>) {
|
||||||
|
return Object.entries(ranks)
|
||||||
|
.filter(
|
||||||
|
([emoji, frequency]) =>
|
||||||
|
normalizeShortcode(emoji) !== undefined &&
|
||||||
|
Number.isFinite(frequency) &&
|
||||||
|
frequency > 0,
|
||||||
|
)
|
||||||
|
.sort(([emojiA, frequencyA], [emojiB, frequencyB]) =>
|
||||||
|
frequencyB === frequencyA
|
||||||
|
? emojiA.localeCompare(emojiB)
|
||||||
|
: frequencyB - frequencyA,
|
||||||
|
)
|
||||||
|
.flatMap(([emoji]) => {
|
||||||
|
const shortcode = normalizeShortcode(emoji);
|
||||||
|
return shortcode ? [shortcode] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentEmojis(ranks: Record<string, number>, amount: number) {
|
||||||
|
return rankedEmojis(ranks).slice(0, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEmojiRanks() {
|
||||||
|
const { load, save } = useStorage();
|
||||||
|
const [ranks, setRanks] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load("reactions").then((storedRanks) => {
|
||||||
|
const normalized = normalizeRanks(storedRanks);
|
||||||
|
setRanks(normalized);
|
||||||
|
if (JSON.stringify(normalized) !== JSON.stringify(storedRanks)) {
|
||||||
|
void save("reactions", normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleRanksChanged(event: Event) {
|
||||||
|
setRanks((event as CustomEvent<Record<string, number>>).detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(RANKS_CHANGED_EVENT, handleRanksChanged);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(RANKS_CHANGED_EVENT, handleRanksChanged);
|
||||||
|
}, [load, save]);
|
||||||
|
|
||||||
|
return { ranks };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecordEmojiUse() {
|
||||||
|
const { load, save } = useStorage();
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(emoji: string) => {
|
||||||
|
const shortcode = normalizeShortcode(emoji);
|
||||||
|
if (!shortcode) return;
|
||||||
|
|
||||||
|
recordQueue = recordQueue
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
const normalized = normalizeRanks(await load("reactions"));
|
||||||
|
const next = {
|
||||||
|
...normalized,
|
||||||
|
[shortcode]: (normalized[shortcode] ?? 0) + 1,
|
||||||
|
};
|
||||||
|
await save("reactions", next);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(RANKS_CHANGED_EVENT, { detail: next }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[load, save],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
|
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import GifPicker from "./gifPicker";
|
import GifPicker from "./gifPicker";
|
||||||
|
import EmojiPicker from "./emojiPicker";
|
||||||
|
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
|
|
||||||
|
|
@ -48,6 +50,9 @@ export default function InputComponent({
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
const gifPopoverRef = useRef<HTMLDivElement>(null);
|
const gifPopoverRef = useRef<HTMLDivElement>(null);
|
||||||
const [gifPopoverOpen, setGifPopoverOpen] = useState(false);
|
const [gifPopoverOpen, setGifPopoverOpen] = useState(false);
|
||||||
|
const [emojiPopoverOpen, setEmojiPopoverOpen] = useState(false);
|
||||||
|
const recordUse = useRecordEmojiUse();
|
||||||
|
const { ranks: emojiFrequencies } = useEmojiRanks();
|
||||||
const [gifPopoverSize, setGifPopoverSize] = useState<{
|
const [gifPopoverSize, setGifPopoverSize] = useState<{
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|
@ -257,6 +262,8 @@ export default function InputComponent({
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
invertEnterBehavior={invertEnterBehavior}
|
invertEnterBehavior={invertEnterBehavior}
|
||||||
|
emojiFrequencies={emojiFrequencies}
|
||||||
|
onEmojiSelect={recordUse}
|
||||||
/>
|
/>
|
||||||
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
@ -268,9 +275,31 @@ export default function InputComponent({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
<Popover
|
||||||
<Laugh size={20} />
|
open={emojiPopoverOpen}
|
||||||
</Button>
|
onOpenChange={setEmojiPopoverOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
aria-label="Open emoji picker"
|
||||||
|
className="w-9 h-9 p-0"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Laugh size={20} />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent className="w-auto p-0">
|
||||||
|
<EmojiPicker
|
||||||
|
onSelect={(shortcode) => {
|
||||||
|
setValue(`${value}${shortcode} `);
|
||||||
|
recordUse(shortcode);
|
||||||
|
setEmojiPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import Input from "@tensamin/markdown/input";
|
||||||
import { useChat } from "../context";
|
import { useChat } from "../context";
|
||||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
|
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||||
|
import { useRecordEmojiUse } from "./emojiRanks";
|
||||||
|
|
||||||
function MessageComponent({
|
function MessageComponent({
|
||||||
grouped,
|
grouped,
|
||||||
|
|
@ -133,7 +135,15 @@ function MessageComponent({
|
||||||
}, [message.Content]);
|
}, [message.Content]);
|
||||||
|
|
||||||
// Message editing
|
// Message editing
|
||||||
const { chatSecret, editMessage, userId, deleteMessage } = useChat();
|
const {
|
||||||
|
addReaction,
|
||||||
|
chatSecret,
|
||||||
|
editMessage,
|
||||||
|
userId,
|
||||||
|
deleteMessage,
|
||||||
|
removeReaction,
|
||||||
|
} = useChat();
|
||||||
|
const recordUse = useRecordEmojiUse();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editDraft, setEditDraft] = useState(message.Content);
|
const [editDraft, setEditDraft] = useState(message.Content);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -187,6 +197,39 @@ function MessageComponent({
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const groupedReactions = Object.entries(
|
||||||
|
(message.Reactions ?? []).reduce<
|
||||||
|
Record<string, { count: number; reactedByMe: boolean }>
|
||||||
|
>((groups, item) => {
|
||||||
|
const reaction = normalizeShortcode(item.Reaction);
|
||||||
|
if (!reaction) return groups;
|
||||||
|
|
||||||
|
const group = groups[reaction] ?? {
|
||||||
|
count: 0,
|
||||||
|
reactedByMe: false,
|
||||||
|
};
|
||||||
|
group.count += 1;
|
||||||
|
group.reactedByMe ||= item.SenderId === ownId;
|
||||||
|
groups[reaction] = group;
|
||||||
|
return groups;
|
||||||
|
}, {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleReaction(reaction: string) {
|
||||||
|
const reactedByMe = message.Reactions?.some(
|
||||||
|
(item) =>
|
||||||
|
normalizeShortcode(item.Reaction) === reaction &&
|
||||||
|
item.SenderId === ownId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reactedByMe) {
|
||||||
|
return removeReaction(message.SendTime, reaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordUse(reaction);
|
||||||
|
return addReaction(message.SendTime, reaction);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// pt-3 is to get a gap between messages
|
// pt-3 is to get a gap between messages
|
||||||
|
|
@ -198,6 +241,7 @@ function MessageComponent({
|
||||||
hideMiniMenu={editing}
|
hideMiniMenu={editing}
|
||||||
isOwnMessage={message.SenderId === ownId}
|
isOwnMessage={message.SenderId === ownId}
|
||||||
messageId={message.SendTime}
|
messageId={message.SendTime}
|
||||||
|
onReact={toggleReaction}
|
||||||
onSetEditing={setEditing}
|
onSetEditing={setEditing}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -305,6 +349,30 @@ function MessageComponent({
|
||||||
) : (
|
) : (
|
||||||
<Text value={message.Content} />
|
<Text value={message.Content} />
|
||||||
)}
|
)}
|
||||||
|
{groupedReactions.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
||||||
|
{groupedReactions.map(
|
||||||
|
([reaction, { count, reactedByMe }]) => (
|
||||||
|
<Button
|
||||||
|
key={reaction}
|
||||||
|
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
|
||||||
|
className={cn(
|
||||||
|
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
|
||||||
|
reactedByMe
|
||||||
|
? "border-(--primary-foreground-alt)/40!"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
onClick={() => void toggleReaction(reaction)}
|
||||||
|
size="xs"
|
||||||
|
variant={reactedByMe ? "subtleDefault" : "outline"}
|
||||||
|
>
|
||||||
|
<Emoji className="h-5 w-5" shortcode={reaction} />
|
||||||
|
<span className="text-sm">{count}</span>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -324,6 +392,8 @@ export default React.memo(MessageComponent, (prev, next) => {
|
||||||
prev.message.MessageState === next.message.MessageState &&
|
prev.message.MessageState === next.message.MessageState &&
|
||||||
prev.message.failed === next.message.failed &&
|
prev.message.failed === next.message.failed &&
|
||||||
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
||||||
|
JSON.stringify(prev.message.Reactions) ===
|
||||||
|
JSON.stringify(next.message.Reactions) &&
|
||||||
prev.grouped === next.grouped &&
|
prev.grouped === next.grouped &&
|
||||||
prev.user === next.user
|
prev.user === next.user
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,13 @@ import {
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
DrawerDescription,
|
DrawerDescription,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
Separator,
|
Separator,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@tensamin/ui";
|
} from "@tensamin/ui";
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,18 +29,23 @@ import {
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
Forward,
|
Forward,
|
||||||
Laugh,
|
Laugh,
|
||||||
|
Plus,
|
||||||
Pen,
|
Pen,
|
||||||
Pin,
|
Pin,
|
||||||
Reply,
|
Reply,
|
||||||
Trash,
|
Trash,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import type {
|
import type {
|
||||||
MouseEvent as ReactMouseEvent,
|
MouseEvent as ReactMouseEvent,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useChat } from "../context";
|
import { useChat } from "../context";
|
||||||
|
import Emoji from "@tensamin/markdown/emoji";
|
||||||
|
import EmojiPicker from "./emojiPicker";
|
||||||
|
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||||
|
|
||||||
async function copyText(text: string) {
|
async function copyText(text: string) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
@ -140,8 +151,31 @@ function getMobileMenuComponents({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReactionItems({ Item }: { Item: MenuComponents["Item"] }) {
|
function ReactionItems({
|
||||||
return <Item>Cool item</Item>;
|
Item,
|
||||||
|
emojis,
|
||||||
|
onMore,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
Item: MenuComponents["Item"];
|
||||||
|
emojis: string[];
|
||||||
|
onMore: () => void;
|
||||||
|
onSelect: (emoji: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{emojis.map((emoji) => (
|
||||||
|
<Item key={emoji} onClick={() => onSelect(emoji)}>
|
||||||
|
<Emoji className="w-4.5 h-4.5" shortcode={emoji} />
|
||||||
|
<span>{emoji}</span>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
<Item onClick={onMore}>
|
||||||
|
<Plus className="size-4.5" />
|
||||||
|
<span>More reactions</span>
|
||||||
|
</Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let shiftPressed = false;
|
let shiftPressed = false;
|
||||||
|
|
@ -190,11 +224,63 @@ function useShiftPressed() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeMiniMenuId: number | null = null;
|
||||||
|
let clearMiniMenuTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const miniMenuListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function setActiveMiniMenu(messageId: number | null) {
|
||||||
|
if (clearMiniMenuTimeout !== undefined) {
|
||||||
|
clearTimeout(clearMiniMenuTimeout);
|
||||||
|
clearMiniMenuTimeout = undefined;
|
||||||
|
}
|
||||||
|
if (activeMiniMenuId === messageId) return;
|
||||||
|
|
||||||
|
activeMiniMenuId = messageId;
|
||||||
|
miniMenuListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMiniMenuClose() {
|
||||||
|
clearMiniMenuTimeout = setTimeout(() => {
|
||||||
|
clearMiniMenuTimeout = undefined;
|
||||||
|
setActiveMiniMenu(null);
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useActiveMiniMenuId() {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => {
|
||||||
|
miniMenuListeners.add(listener);
|
||||||
|
return () => miniMenuListeners.delete(listener);
|
||||||
|
},
|
||||||
|
() => activeMiniMenuId,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniMenuTooltip({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
children: ReactElement;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={children} />
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MiniMessageMenu({
|
function MiniMessageMenu({
|
||||||
isOwnMessage,
|
isOwnMessage,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
onOpenMenu,
|
onOpenMenu,
|
||||||
|
onOpenPicker,
|
||||||
|
usePickerTrigger,
|
||||||
|
onReact,
|
||||||
|
quickReactions,
|
||||||
onReply,
|
onReply,
|
||||||
shiftIsPressed,
|
shiftIsPressed,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -202,54 +288,137 @@ function MiniMessageMenu({
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onOpenMenu: (event: ReactMouseEvent<HTMLElement>) => void;
|
onOpenMenu: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||||
|
onOpenPicker: () => void;
|
||||||
|
usePickerTrigger: boolean;
|
||||||
|
onReact: (emoji: string) => void;
|
||||||
|
quickReactions: string[];
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
shiftIsPressed: boolean;
|
shiftIsPressed: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="absolute right-4 -top-3 opacity-0 group-hover/message-menu:opacity-100 flex flex-row justify-end gap-0! z-10 p-0! shadow-lg rounded-lg!">
|
<motion.div
|
||||||
{[0, 1, 2].map((item) => (
|
layoutId="chat-mini-message-menu"
|
||||||
<Button key={item} variant="ghost" className="h-8 w-8">
|
className="absolute right-4 -top-3 z-10"
|
||||||
{item === 0 && "👍"}
|
initial={{ opacity: 0 }}
|
||||||
{item === 1 && "🔥"}
|
animate={{ opacity: 1 }}
|
||||||
{item === 2 && "✅"}
|
exit={{
|
||||||
</Button>
|
opacity: 0,
|
||||||
))}
|
transition: {
|
||||||
<Separator orientation="vertical" className="my-1" />
|
opacity: { duration: 0.12, delay: 0.08 },
|
||||||
<Button variant="ghost" className="h-8 w-8">
|
},
|
||||||
<Laugh />
|
}}
|
||||||
</Button>
|
transition={{
|
||||||
<Button
|
layout: {
|
||||||
variant="ghost"
|
type: "spring",
|
||||||
className="h-8 w-8"
|
stiffness: 650,
|
||||||
aria-label={isOwnMessage ? "Edit message" : "Reply to message"}
|
damping: 34,
|
||||||
onClick={isOwnMessage ? onEdit : onReply}
|
mass: 0.65,
|
||||||
>
|
},
|
||||||
{isOwnMessage ? <Pen /> : <Reply />}
|
opacity: { duration: 0.12 },
|
||||||
</Button>
|
}}
|
||||||
<Button variant="ghost" className="h-8 w-8">
|
>
|
||||||
<Forward />
|
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg">
|
||||||
</Button>
|
{quickReactions.map((emoji) => (
|
||||||
<Separator orientation="vertical" className="my-1" />
|
<MiniMenuTooltip
|
||||||
{isOwnMessage && shiftIsPressed ? (
|
key={emoji}
|
||||||
<Button
|
label={emoji}
|
||||||
variant="ghost"
|
children={
|
||||||
className="h-8 w-8 text-destructive"
|
<Button
|
||||||
aria-label="Delete message"
|
aria-label={`React with ${emoji}`}
|
||||||
onClick={onDelete}
|
variant="ghost"
|
||||||
>
|
className="h-8 w-8 p-0!"
|
||||||
<Trash />
|
onClick={() => onReact(emoji)}
|
||||||
</Button>
|
>
|
||||||
) : (
|
<Emoji className="h-5 w-5" shortcode={emoji} tooltip={false} />
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
}
|
||||||
className="h-8 w-8"
|
/>
|
||||||
aria-label="Open message menu"
|
))}
|
||||||
onClick={onOpenMenu}
|
<Separator orientation="vertical" className="my-1" />
|
||||||
>
|
<MiniMenuTooltip
|
||||||
<Ellipsis />
|
label="Add reaction"
|
||||||
</Button>
|
children={
|
||||||
)}
|
usePickerTrigger ? (
|
||||||
</Card>
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
aria-label="Add reaction"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Laugh />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
aria-label="Add reaction"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={onOpenPicker}
|
||||||
|
>
|
||||||
|
<Laugh />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MiniMenuTooltip
|
||||||
|
label={isOwnMessage ? "Edit message" : "Reply to message"}
|
||||||
|
children={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label={isOwnMessage ? "Edit message" : "Reply to message"}
|
||||||
|
onClick={isOwnMessage ? onEdit : onReply}
|
||||||
|
>
|
||||||
|
{isOwnMessage ? <Pen /> : <Reply />}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MiniMenuTooltip
|
||||||
|
label="Forward message"
|
||||||
|
children={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="Forward message"
|
||||||
|
>
|
||||||
|
<Forward />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator orientation="vertical" className="my-1" />
|
||||||
|
{isOwnMessage && shiftIsPressed ? (
|
||||||
|
<MiniMenuTooltip
|
||||||
|
label="Delete message"
|
||||||
|
children={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
aria-label="Delete message"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MiniMenuTooltip
|
||||||
|
label="Open message menu"
|
||||||
|
children={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="Open message menu"
|
||||||
|
onClick={onOpenMenu}
|
||||||
|
>
|
||||||
|
<Ellipsis />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +429,8 @@ function MessageMenuContent({
|
||||||
isOwnMessage,
|
isOwnMessage,
|
||||||
messageId,
|
messageId,
|
||||||
onAddReaction,
|
onAddReaction,
|
||||||
|
reactionEmojis,
|
||||||
|
onReact,
|
||||||
showReactionItems = true,
|
showReactionItems = true,
|
||||||
onSetEditing,
|
onSetEditing,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -269,6 +440,8 @@ function MessageMenuContent({
|
||||||
isOwnMessage: boolean;
|
isOwnMessage: boolean;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
onAddReaction?: () => void | Promise<void>;
|
onAddReaction?: () => void | Promise<void>;
|
||||||
|
reactionEmojis: string[];
|
||||||
|
onReact: (emoji: string) => void;
|
||||||
showReactionItems?: boolean;
|
showReactionItems?: boolean;
|
||||||
onSetEditing: (value: boolean) => void;
|
onSetEditing: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -281,10 +454,17 @@ function MessageMenuContent({
|
||||||
<Content>
|
<Content>
|
||||||
<Group>
|
<Group>
|
||||||
<Sub>
|
<Sub>
|
||||||
<SubTrigger onClick={onAddReaction}>Add Reaction</SubTrigger>
|
<SubTrigger onClick={showReactionItems ? undefined : onAddReaction}>
|
||||||
|
Add Reaction
|
||||||
|
</SubTrigger>
|
||||||
{showReactionItems && (
|
{showReactionItems && (
|
||||||
<SubContent>
|
<SubContent>
|
||||||
<ReactionItems Item={Item} />
|
<ReactionItems
|
||||||
|
Item={Item}
|
||||||
|
emojis={reactionEmojis}
|
||||||
|
onMore={() => void onAddReaction?.()}
|
||||||
|
onSelect={onReact}
|
||||||
|
/>
|
||||||
</SubContent>
|
</SubContent>
|
||||||
)}
|
)}
|
||||||
</Sub>
|
</Sub>
|
||||||
|
|
@ -359,6 +539,7 @@ export default function MessageContextMenu({
|
||||||
hideMiniMenu = false,
|
hideMiniMenu = false,
|
||||||
isOwnMessage,
|
isOwnMessage,
|
||||||
messageId,
|
messageId,
|
||||||
|
onReact,
|
||||||
onSetEditing,
|
onSetEditing,
|
||||||
}: {
|
}: {
|
||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
|
|
@ -366,10 +547,12 @@ export default function MessageContextMenu({
|
||||||
hideMiniMenu?: boolean;
|
hideMiniMenu?: boolean;
|
||||||
isOwnMessage: boolean;
|
isOwnMessage: boolean;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
|
onReact: (emoji: string) => void | Promise<void>;
|
||||||
onSetEditing: (value: boolean) => void;
|
onSetEditing: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const shiftIsPressed = useShiftPressed();
|
const shiftIsPressed = useShiftPressed();
|
||||||
|
const activeMenuId = useActiveMiniMenuId();
|
||||||
const { deleteMessage, setReplyTo } = useChat();
|
const { deleteMessage, setReplyTo } = useChat();
|
||||||
const devEnabled = useMemo(
|
const devEnabled = useMemo(
|
||||||
() => Number(localStorage.getItem("log_level")) >= 3,
|
() => Number(localStorage.getItem("log_level")) >= 3,
|
||||||
|
|
@ -377,6 +560,16 @@ export default function MessageContextMenu({
|
||||||
);
|
);
|
||||||
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
||||||
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const { ranks } = useEmojiRanks();
|
||||||
|
const quickReactions = getRecentEmojis(ranks, 3);
|
||||||
|
const menuReactions = getRecentEmojis(ranks, 5);
|
||||||
|
|
||||||
|
function selectReaction(emoji: string) {
|
||||||
|
void onReact(emoji);
|
||||||
|
setReactionDrawerOpen(false);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}
|
||||||
const mainDrawerComponents = useMemo(
|
const mainDrawerComponents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getMobileMenuComponents({
|
getMobileMenuComponents({
|
||||||
|
|
@ -400,18 +593,28 @@ export default function MessageContextMenu({
|
||||||
openMenu: (event: ReactMouseEvent<HTMLElement>) => void,
|
openMenu: (event: ReactMouseEvent<HTMLElement>) => void,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="group/message-menu relative w-full">
|
<div
|
||||||
|
className="group/message-menu relative w-full"
|
||||||
|
onPointerEnter={() => setActiveMiniMenu(messageId)}
|
||||||
|
onPointerLeave={scheduleMiniMenuClose}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
{!hideMiniMenu && (
|
<AnimatePresence>
|
||||||
<MiniMessageMenu
|
{!hideMiniMenu && activeMenuId === messageId && (
|
||||||
isOwnMessage={isOwnMessage}
|
<MiniMessageMenu
|
||||||
onDelete={() => deleteMessage(messageId)}
|
isOwnMessage={isOwnMessage}
|
||||||
onEdit={() => onSetEditing(true)}
|
onDelete={() => deleteMessage(messageId)}
|
||||||
onOpenMenu={openMenu}
|
onEdit={() => onSetEditing(true)}
|
||||||
onReply={() => setReplyTo(messageId)}
|
onOpenMenu={openMenu}
|
||||||
shiftIsPressed={shiftIsPressed}
|
onOpenPicker={() => setPickerOpen(true)}
|
||||||
/>
|
usePickerTrigger={!isMobile}
|
||||||
)}
|
onReact={selectReaction}
|
||||||
|
quickReactions={quickReactions}
|
||||||
|
onReply={() => setReplyTo(messageId)}
|
||||||
|
shiftIsPressed={shiftIsPressed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -438,6 +641,8 @@ export default function MessageContextMenu({
|
||||||
isOwnMessage={isOwnMessage}
|
isOwnMessage={isOwnMessage}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
onAddReaction={() => setReactionDrawerOpen(true)}
|
onAddReaction={() => setReactionDrawerOpen(true)}
|
||||||
|
onReact={selectReaction}
|
||||||
|
reactionEmojis={menuReactions}
|
||||||
showReactionItems={false}
|
showReactionItems={false}
|
||||||
onSetEditing={onSetEditing}
|
onSetEditing={onSetEditing}
|
||||||
/>
|
/>
|
||||||
|
|
@ -449,7 +654,26 @@ export default function MessageContextMenu({
|
||||||
Choose a reaction to add to this message.
|
Choose a reaction to add to this message.
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
<div className="p-3!">
|
<div className="p-3!">
|
||||||
<ReactionItems Item={reactionDrawerComponents.Item} />
|
<ReactionItems
|
||||||
|
Item={reactionDrawerComponents.Item}
|
||||||
|
emojis={menuReactions}
|
||||||
|
onMore={() => {
|
||||||
|
setReactionDrawerOpen(false);
|
||||||
|
setPickerOpen(true);
|
||||||
|
}}
|
||||||
|
onSelect={selectReaction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
|
||||||
|
<DrawerDescription className="sr-only">
|
||||||
|
Choose an emoji to react with.
|
||||||
|
</DrawerDescription>
|
||||||
|
<div className="p-3">
|
||||||
|
<EmojiPicker onSelect={selectReaction} />
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
@ -471,16 +695,24 @@ export default function MessageContextMenu({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<ContextMenuTrigger render={child} />
|
<ContextMenu>
|
||||||
<MessageMenuContent
|
<ContextMenuTrigger render={child} />
|
||||||
components={desktopMenuComponents}
|
<MessageMenuContent
|
||||||
content={content}
|
components={desktopMenuComponents}
|
||||||
devEnabled={devEnabled}
|
content={content}
|
||||||
isOwnMessage={isOwnMessage}
|
devEnabled={devEnabled}
|
||||||
messageId={messageId}
|
isOwnMessage={isOwnMessage}
|
||||||
onSetEditing={onSetEditing}
|
messageId={messageId}
|
||||||
/>
|
onAddReaction={() => setPickerOpen(true)}
|
||||||
</ContextMenu>
|
onReact={selectReaction}
|
||||||
|
reactionEmojis={menuReactions}
|
||||||
|
onSetEditing={onSetEditing}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
<PopoverContent className="w-auto p-0">
|
||||||
|
<EmojiPicker onSelect={selectReaction} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,10 @@ function bytesFromProtocol(value: unknown): Uint8Array {
|
||||||
|
|
||||||
type EditableMessage = RawMessage & { failed?: boolean };
|
type EditableMessage = RawMessage & { failed?: boolean };
|
||||||
type MessageEdit = Partial<
|
type MessageEdit = Partial<
|
||||||
Pick<EditableMessage, "Content" | "Edited" | "MessageState" | "failed">
|
Pick<
|
||||||
|
EditableMessage,
|
||||||
|
"Content" | "Edited" | "MessageState" | "Reactions" | "failed"
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function updateMessagesBySendTime<T extends EditableMessage>(
|
function updateMessagesBySendTime<T extends EditableMessage>(
|
||||||
|
|
@ -495,6 +498,91 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
[currentChatSecret, send, userIdValue],
|
[currentChatSecret, send, userIdValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setReaction = useCallback(
|
||||||
|
async (sendTime: number, reaction: string, add: boolean) => {
|
||||||
|
let previousReactions: RawMessage["Reactions"];
|
||||||
|
let foundMessage = false;
|
||||||
|
|
||||||
|
const applyOptimisticUpdate = (message: EditableMessage) => {
|
||||||
|
const current = message.Reactions ?? [];
|
||||||
|
previousReactions = current;
|
||||||
|
foundMessage = true;
|
||||||
|
|
||||||
|
return add
|
||||||
|
? [...current, { Reaction: reaction, SenderId: ownId }]
|
||||||
|
: current.filter(
|
||||||
|
(item) => item.Reaction !== reaction || item.SenderId !== ownId,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
setLiveMessagesState((current) =>
|
||||||
|
current.map((message) =>
|
||||||
|
message.SendTime === sendTime
|
||||||
|
? { ...message, Reactions: applyOptimisticUpdate(message) }
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryKey = [
|
||||||
|
"chat-messages",
|
||||||
|
String(userIdValue),
|
||||||
|
currentChatSecret !== null,
|
||||||
|
] as const;
|
||||||
|
queryClient.setQueryData<InfiniteData<RawMessages>>(
|
||||||
|
queryKey,
|
||||||
|
(current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
pages: current.pages.map((page) =>
|
||||||
|
page.map((message) =>
|
||||||
|
message.SendTime === sendTime
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
Reactions: applyOptimisticUpdate(message),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await send(
|
||||||
|
add ? "MessageReactionAdd" : "MessageReactionRemove",
|
||||||
|
{
|
||||||
|
ChatPartnerId: userIdValue,
|
||||||
|
Reaction: reaction,
|
||||||
|
SendTime: sendTime,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assertProtocolSuccess(
|
||||||
|
add ? "MessageReactionAdd" : "MessageReactionRemove",
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (foundMessage) {
|
||||||
|
editMessage(sendTime, { Reactions: previousReactions });
|
||||||
|
}
|
||||||
|
log(1, "chat", "red", "Failed to update reaction", err);
|
||||||
|
toast("error", "Failed to update reaction", String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentChatSecret, editMessage, ownId, send, userIdValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addReaction = useCallback(
|
||||||
|
(sendTime: number, reaction: string) =>
|
||||||
|
setReaction(sendTime, reaction, true),
|
||||||
|
[setReaction],
|
||||||
|
);
|
||||||
|
const removeReaction = useCallback(
|
||||||
|
(sendTime: number, reaction: string) =>
|
||||||
|
setReaction(sendTime, reaction, false),
|
||||||
|
[setReaction],
|
||||||
|
);
|
||||||
|
|
||||||
const addLiveMessage = useCallback(
|
const addLiveMessage = useCallback(
|
||||||
(message: RawMessage) => {
|
(message: RawMessage) => {
|
||||||
const localId =
|
const localId =
|
||||||
|
|
@ -578,6 +666,27 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === "MessageReactionLive") {
|
||||||
|
const rawData = message.data as {
|
||||||
|
ChatPartnerId: unknown;
|
||||||
|
SendTime: unknown;
|
||||||
|
};
|
||||||
|
const chatPartnerId = Number(rawData.ChatPartnerId);
|
||||||
|
const sendTime = Number(rawData.SendTime);
|
||||||
|
|
||||||
|
if (chatPartnerId !== userIdValue || !Number.isFinite(sendTime)) return;
|
||||||
|
|
||||||
|
void send("MessageGet", { SendTime: sendTime })
|
||||||
|
.then((response) => {
|
||||||
|
assertProtocolSuccess("MessageGet", response);
|
||||||
|
editMessage(sendTime, { Reactions: response.data.Reactions ?? [] });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
log(1, "chat", "red", "Failed to refresh message reactions", err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type !== "MessageState") return;
|
if (message.type !== "MessageState") return;
|
||||||
|
|
||||||
const rawData = message.data as {
|
const rawData = message.data as {
|
||||||
|
|
@ -623,7 +732,7 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
MessageState: nextState.MessageState,
|
MessageState: nextState.MessageState,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [currentChatSecret, editMessage, subscribePush, userIdValue]);
|
}, [currentChatSecret, editMessage, send, subscribePush, userIdValue]);
|
||||||
|
|
||||||
// Replys
|
// Replys
|
||||||
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
|
const [replyTo, setReplyTo] = useState<number | undefined>(undefined);
|
||||||
|
|
@ -638,6 +747,8 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
addLiveMessage,
|
addLiveMessage,
|
||||||
editMessage,
|
editMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
|
addReaction,
|
||||||
|
removeReaction,
|
||||||
clearLiveMessages,
|
clearLiveMessages,
|
||||||
chatSecret: currentChatSecret,
|
chatSecret: currentChatSecret,
|
||||||
userId: userIdValue,
|
userId: userIdValue,
|
||||||
|
|
@ -663,6 +774,8 @@ type contextType = {
|
||||||
};
|
};
|
||||||
editMessage: (sendTime: number, edit: MessageEdit) => void;
|
editMessage: (sendTime: number, edit: MessageEdit) => void;
|
||||||
deleteMessage: (sendTime: number) => void;
|
deleteMessage: (sendTime: number) => void;
|
||||||
|
addReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||||
|
removeReaction: (sendTime: number, reaction: string) => Promise<void>;
|
||||||
clearLiveMessages: () => void;
|
clearLiveMessages: () => void;
|
||||||
chatSecret: Uint8Array | null;
|
chatSecret: Uint8Array | null;
|
||||||
userId: number;
|
userId: number;
|
||||||
|
|
|
||||||
|
|
@ -186,34 +186,14 @@ export default function Screen() {
|
||||||
return [...liveMessageChunks, ...historicalMessageChunks];
|
return [...liveMessageChunks, ...historicalMessageChunks];
|
||||||
}, [historicalMessageChunks, liveMessageChunks]);
|
}, [historicalMessageChunks, liveMessageChunks]);
|
||||||
|
|
||||||
const shouldShowConversationStart =
|
const virtualRowCount = messageChunks.length;
|
||||||
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
|
||||||
const virtualRowCount =
|
|
||||||
messageChunks.length + (shouldShowConversationStart ? 1 : 0);
|
|
||||||
|
|
||||||
const getItemKey = React.useCallback(
|
const getItemKey = React.useCallback(
|
||||||
(index: number) => {
|
(index: number) => messageChunks[index]?.key ?? index,
|
||||||
if (shouldShowConversationStart && index === messageChunks.length) {
|
[messageChunks],
|
||||||
return "conversation-start";
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageChunks[index]?.key ?? index;
|
|
||||||
},
|
|
||||||
[messageChunks, shouldShowConversationStart],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const estimateSize = React.useCallback(
|
const estimateSize = React.useCallback(() => FALLBACK_MESSAGE_HEIGHT, []);
|
||||||
(index: number) => {
|
|
||||||
const isConversationStart =
|
|
||||||
shouldShowConversationStart && index === messageChunks.length;
|
|
||||||
if (isConversationStart) {
|
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FALLBACK_MESSAGE_HEIGHT;
|
|
||||||
},
|
|
||||||
[messageChunks, shouldShowConversationStart],
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
|
|
@ -464,32 +444,6 @@ export default function Screen() {
|
||||||
className="absolute bottom-0 left-0 h-px w-full"
|
className="absolute bottom-0 left-0 h-px w-full"
|
||||||
/>
|
/>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
if (
|
|
||||||
shouldShowConversationStart &&
|
|
||||||
virtualRow.index === messageChunks.length
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key="conversation-start"
|
|
||||||
data-index={virtualRow.index}
|
|
||||||
ref={virtualizer.measureElement}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="w-full flex justify-start scale-y-[-1]">
|
|
||||||
<div className="text-sm text-foreground/55 px-2.5">
|
|
||||||
Conversation start
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkIndex = virtualRow.index;
|
const chunkIndex = virtualRow.index;
|
||||||
const chunk = messageChunks[chunkIndex];
|
const chunk = messageChunks[chunkIndex];
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
- Implement context menu features
|
- Implement context menu features
|
||||||
|
- Forward
|
||||||
|
- Pin Message
|
||||||
|
- Reply
|
||||||
- Add default-emoji-hotkey
|
- Add default-emoji-hotkey
|
||||||
- Placeholder image if media fails to load
|
- Placeholder image if media fails to load
|
||||||
- Signature verifications via ed25519 key
|
- Signature verifications via ed25519 key
|
||||||
- Confirmation when exiting with text in the input box.
|
- Confirmation when exiting with text in the input box.
|
||||||
- Add arrow up hotkey to edit last message
|
- Add arrow up hotkey to edit last message
|
||||||
|
- Drop any unique reactions above 10
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./text": "./src/text.tsx",
|
"./text": "./src/text.tsx",
|
||||||
"./input": "./src/input.tsx"
|
"./input": "./src/input.tsx",
|
||||||
|
"./emoji": "./src/emoji.tsx"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
|
|
@ -13,10 +14,15 @@
|
||||||
"build": "tsc -p tsconfig.json --noEmit"
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.2",
|
"@codemirror/commands": "^6.10.2",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/view": "^6.41.1",
|
"@codemirror/view": "^6.41.1",
|
||||||
|
"@tensamin/ui": "*",
|
||||||
|
"@twemoji/api": "^17.0.3",
|
||||||
|
"emojibase-data": "^17.0.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
packages/markdown/src/emoji.test.ts
Normal file
138
packages/markdown/src/emoji.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("@tensamin/ui", () => ({
|
||||||
|
Tooltip: ({ children }: { children: ReactNode }) => children,
|
||||||
|
TooltipContent: ({ children }: { children: ReactNode }) => children,
|
||||||
|
TooltipTrigger: ({ render }: { render: ReactNode }) => render,
|
||||||
|
}));
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { CompletionContext } from "@codemirror/autocomplete";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { normalizeShortcode, resolveEmoji, searchEmojis } from "./emojiData";
|
||||||
|
import { parseEmojiText, parseInlineNodes } from "./markdown";
|
||||||
|
import {
|
||||||
|
createEmojiCompletionSource,
|
||||||
|
findEmojiRanges,
|
||||||
|
MAX_RENDERED_EMOJI_OPTIONS,
|
||||||
|
} from "./input";
|
||||||
|
|
||||||
|
describe("emoji shortcodes", () => {
|
||||||
|
it("normalizes aliases to their canonical shortcode", () => {
|
||||||
|
expect(normalizeShortcode(":flame:")).toBe(":fire:");
|
||||||
|
expect(normalizeShortcode("+1")).toBe(":thumbsup:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves every search result to a Twemoji hexcode", () => {
|
||||||
|
const results = searchEmojis("fire");
|
||||||
|
expect(results[0]?.shortcode).toBe(":fire:");
|
||||||
|
expect(results.every((emoji) => emoji.hexcode.length > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows all emojis for an empty query", () => {
|
||||||
|
expect(searchEmojis("").length).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the number of mounted autocomplete rows", () => {
|
||||||
|
expect(MAX_RENDERED_EMOJI_OPTIONS).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses known shortcodes and preserves unknown ones", () => {
|
||||||
|
expect(parseEmojiText("a :fire: b :not_an_emoji:")).toEqual([
|
||||||
|
{ type: "text", value: "a " },
|
||||||
|
{ type: "emoji", shortcode: ":fire:" },
|
||||||
|
{ type: "text", value: " b :not_an_emoji:" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a valid shortcode sharing an unknown closing colon", () => {
|
||||||
|
expect(parseEmojiText(":bla:thumbsup:")).toEqual([
|
||||||
|
{ type: "text", value: ":bla" },
|
||||||
|
{ type: "emoji", shortcode: ":thumbsup:" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: ":bla:thumbsup:",
|
||||||
|
extensions: [markdown()],
|
||||||
|
});
|
||||||
|
expect(findEmojiRanges(state)[0]?.from).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not parse underscores inside emoji shortcodes as emphasis", () => {
|
||||||
|
expect(parseInlineNodes("before :white_check_mark: after")).toEqual([
|
||||||
|
{ type: "text", value: "before " },
|
||||||
|
{ type: "emoji", shortcode: ":white_check_mark:" },
|
||||||
|
{ type: "text", value: " after" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains the picker defaults", () => {
|
||||||
|
expect(resolveEmoji(":thumbsup:")).toBeDefined();
|
||||||
|
expect(resolveEmoji(":white_check_mark:")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds completed emoji shortcodes in editor state", () => {
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: "before :fire: after :not_an_emoji:",
|
||||||
|
extensions: [markdown()],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
findEmojiRanges(state).map(({ from, shortcode, to }) => ({
|
||||||
|
from,
|
||||||
|
shortcode,
|
||||||
|
to,
|
||||||
|
})),
|
||||||
|
).toEqual([{ from: 7, shortcode: ":fire:", to: 13 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replace emoji shortcodes inside code", () => {
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: "`:fire:`\n\n```\n:fire:\n```\n\n:fire:",
|
||||||
|
extensions: [markdown()],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findEmojiRanges(state)).toHaveLength(1);
|
||||||
|
expect(findEmojiRanges(state)[0]?.from).toBe(26);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks frequently used emojis first for a bare colon", async () => {
|
||||||
|
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
|
||||||
|
const result = await createEmojiCompletionSource({
|
||||||
|
":fire:": 50,
|
||||||
|
":thumbsup:": 2,
|
||||||
|
})(new CompletionContext(state, 1, false));
|
||||||
|
|
||||||
|
expect(result?.options[0]?.displayLabel).toBe(":fire:");
|
||||||
|
expect(result?.options[1]?.displayLabel).toBe(":thumbsup:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps typed relevance above usage frequency", async () => {
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: ":fire",
|
||||||
|
extensions: [markdown()],
|
||||||
|
});
|
||||||
|
const result = await createEmojiCompletionSource({
|
||||||
|
":fire_engine:": 10000,
|
||||||
|
":fire:": 1,
|
||||||
|
})(new CompletionContext(state, 5, false));
|
||||||
|
|
||||||
|
expect(result?.options[0]?.displayLabel).toBe(":fire:");
|
||||||
|
expect(result?.options[0]?.boost).toBeGreaterThan(
|
||||||
|
result?.options[1]?.boost ?? 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges alias frequencies into canonical completions", async () => {
|
||||||
|
const state = EditorState.create({ doc: ":", extensions: [markdown()] });
|
||||||
|
const result = await createEmojiCompletionSource({
|
||||||
|
":fire:": 2,
|
||||||
|
":flame:": 3,
|
||||||
|
})(new CompletionContext(state, 1, false));
|
||||||
|
const fire = result?.options.find(
|
||||||
|
(option) => option.displayLabel === ":fire:",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fire?.boost).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
packages/markdown/src/emoji.tsx
Normal file
50
packages/markdown/src/emoji.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import twemoji from "@twemoji/api";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||||
|
import { resolveEmoji } from "./emojiData";
|
||||||
|
|
||||||
|
export {
|
||||||
|
emojis,
|
||||||
|
findEmojiShortcodes,
|
||||||
|
normalizeShortcode,
|
||||||
|
resolveEmoji,
|
||||||
|
searchEmojis,
|
||||||
|
} from "./emojiData";
|
||||||
|
export type { EmojiDefinition } from "./emojiData";
|
||||||
|
|
||||||
|
export function getEmojiUrl(shortcode: string): string | undefined {
|
||||||
|
const emoji = resolveEmoji(shortcode);
|
||||||
|
return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Emoji({
|
||||||
|
className = "h-6 w-6",
|
||||||
|
shortcode,
|
||||||
|
tooltip = true,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
shortcode: string;
|
||||||
|
tooltip?: boolean;
|
||||||
|
}) {
|
||||||
|
const emoji = resolveEmoji(shortcode);
|
||||||
|
if (!emoji) return <span>{shortcode}</span>;
|
||||||
|
|
||||||
|
const image = (
|
||||||
|
<img
|
||||||
|
alt={emoji.shortcode}
|
||||||
|
className={className}
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
src={`${twemoji.base}svg/${emoji.hexcode}.svg`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tooltip) return image;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={image} />
|
||||||
|
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
packages/markdown/src/emojiData.ts
Normal file
96
packages/markdown/src/emojiData.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
|
||||||
|
|
||||||
|
type ShortcodeValue = string | string[];
|
||||||
|
|
||||||
|
export type EmojiDefinition = {
|
||||||
|
aliases: readonly string[];
|
||||||
|
hexcode: string;
|
||||||
|
name: string;
|
||||||
|
shortcode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeName(value: string) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/^:+|:+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emojis: readonly EmojiDefinition[] = Object.entries(
|
||||||
|
shortcodeData as Record<string, ShortcodeValue>,
|
||||||
|
).map(([hexcode, value]) => {
|
||||||
|
const aliases = Array.isArray(value) ? value : [value];
|
||||||
|
const name = aliases[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
aliases,
|
||||||
|
hexcode: hexcode.toLowerCase().replaceAll("_", "-"),
|
||||||
|
name,
|
||||||
|
shortcode: `:${name}:`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const emojiByName = new Map<string, EmojiDefinition>();
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
for (const alias of emoji.aliases) {
|
||||||
|
emojiByName.set(normalizeName(alias), emoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEmoji(value: string): EmojiDefinition | undefined {
|
||||||
|
return emojiByName.get(normalizeName(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeShortcode(value: string): string | undefined {
|
||||||
|
return resolveEmoji(value)?.shortcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmojiShortcodes(value: string) {
|
||||||
|
const matches: Array<{
|
||||||
|
emoji: EmojiDefinition;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}> = [];
|
||||||
|
let searchFrom = 0;
|
||||||
|
|
||||||
|
while (searchFrom < value.length) {
|
||||||
|
const from = value.indexOf(":", searchFrom);
|
||||||
|
if (from === -1) break;
|
||||||
|
|
||||||
|
const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i);
|
||||||
|
if (!candidate) {
|
||||||
|
searchFrom = from + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = resolveEmoji(candidate[1]);
|
||||||
|
if (!emoji) {
|
||||||
|
// The closing colon may also open the next valid shortcode.
|
||||||
|
searchFrom = from + candidate[0].length - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = from + candidate[0].length;
|
||||||
|
matches.push({ emoji, from, to });
|
||||||
|
searchFrom = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchEmojis(query: string): EmojiDefinition[] {
|
||||||
|
const normalizedQuery = normalizeName(query);
|
||||||
|
if (!normalizedQuery) return [...emojis];
|
||||||
|
|
||||||
|
return emojis
|
||||||
|
.map((emoji) => {
|
||||||
|
const names = emoji.aliases.map(normalizeName);
|
||||||
|
const exact = names.includes(normalizedQuery);
|
||||||
|
const prefix = names.some((name) => name.startsWith(normalizedQuery));
|
||||||
|
const contains = names.some((name) => name.includes(normalizedQuery));
|
||||||
|
return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 };
|
||||||
|
})
|
||||||
|
.filter(({ rank }) => rank < 3)
|
||||||
|
.sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name))
|
||||||
|
.map(({ emoji }) => emoji);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
import { markdown } from "@codemirror/lang-markdown";
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import {
|
||||||
|
acceptCompletion,
|
||||||
|
autocompletion,
|
||||||
|
completionStatus,
|
||||||
|
pickedCompletion,
|
||||||
|
startCompletion,
|
||||||
|
type Completion,
|
||||||
|
type CompletionContext,
|
||||||
|
type CompletionResult,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
import {
|
import {
|
||||||
EditorState,
|
EditorState,
|
||||||
|
EditorSelection,
|
||||||
|
Annotation,
|
||||||
|
Compartment,
|
||||||
Prec,
|
Prec,
|
||||||
|
Transaction,
|
||||||
type Extension,
|
type Extension,
|
||||||
type Range,
|
type Range,
|
||||||
type SelectionRange,
|
type SelectionRange,
|
||||||
|
|
@ -12,6 +27,7 @@ import {
|
||||||
keymap,
|
keymap,
|
||||||
placeholder,
|
placeholder,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
|
WidgetType,
|
||||||
type DecorationSet,
|
type DecorationSet,
|
||||||
type KeyBinding,
|
type KeyBinding,
|
||||||
type ViewUpdate,
|
type ViewUpdate,
|
||||||
|
|
@ -24,8 +40,17 @@ import {
|
||||||
} from "@codemirror/commands";
|
} from "@codemirror/commands";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
|
||||||
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
|
||||||
|
import Emoji, {
|
||||||
|
findEmojiShortcodes,
|
||||||
|
getEmojiUrl,
|
||||||
|
resolveEmoji,
|
||||||
|
searchEmojis,
|
||||||
|
} from "./emoji";
|
||||||
|
|
||||||
|
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
|
||||||
|
|
||||||
export type InputProps = {
|
export type InputProps = {
|
||||||
ref?: HTMLDivElement;
|
ref?: HTMLDivElement;
|
||||||
|
|
@ -39,6 +64,8 @@ export type InputProps = {
|
||||||
paddingX?: CSSProperties["padding"];
|
paddingX?: CSSProperties["padding"];
|
||||||
paddingY?: CSSProperties["padding"];
|
paddingY?: CSSProperties["padding"];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
emojiFrequencies?: Readonly<Record<string, number>>;
|
||||||
|
onEmojiSelect?: (shortcode: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputStyle = CSSProperties & {
|
type InputStyle = CSSProperties & {
|
||||||
|
|
@ -76,6 +103,180 @@ const delDecoration = Decoration.mark({ class: "tm-md-del" });
|
||||||
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
|
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
|
||||||
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
|
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
|
||||||
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
|
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
|
||||||
|
const externalValueSync = Annotation.define<boolean>();
|
||||||
|
const widgetRoots = new WeakMap<HTMLElement, Root>();
|
||||||
|
|
||||||
|
type EmojiRange = {
|
||||||
|
from: number;
|
||||||
|
shortcode: string;
|
||||||
|
to: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EmojiWidget extends WidgetType {
|
||||||
|
readonly shortcode: string;
|
||||||
|
readonly url: string;
|
||||||
|
|
||||||
|
constructor(shortcode: string, url: string) {
|
||||||
|
super();
|
||||||
|
this.shortcode = shortcode;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: EmojiWidget) {
|
||||||
|
return other.shortcode === this.shortcode && other.url === this.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
const container = document.createElement("span");
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<Emoji className="tm-md-editor-emoji" shortcode={this.shortcode} />,
|
||||||
|
);
|
||||||
|
widgetRoots.set(container, root);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(dom: HTMLElement) {
|
||||||
|
widgetRoots.get(dom)?.unmount();
|
||||||
|
widgetRoots.delete(dom);
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeRanges(state: EditorState) {
|
||||||
|
const ranges: Array<{ from: number; to: number }> = [];
|
||||||
|
|
||||||
|
syntaxTree(state).iterate({
|
||||||
|
enter(node) {
|
||||||
|
if (
|
||||||
|
node.name === "InlineCode" ||
|
||||||
|
node.name === "FencedCode" ||
|
||||||
|
node.name === "CodeBlock"
|
||||||
|
) {
|
||||||
|
ranges.push({ from: node.from, to: node.to });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmojiRanges(state: EditorState): EmojiRange[] {
|
||||||
|
const document = state.doc.toString();
|
||||||
|
const excluded = codeRanges(state);
|
||||||
|
const ranges: EmojiRange[] = [];
|
||||||
|
|
||||||
|
for (const match of findEmojiShortcodes(document)) {
|
||||||
|
const { from, to } = match;
|
||||||
|
const inCode = excluded.some((range) => from < range.to && to > range.from);
|
||||||
|
const emoji = inCode ? undefined : match.emoji;
|
||||||
|
const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined;
|
||||||
|
|
||||||
|
if (emoji && url) {
|
||||||
|
ranges.push({ from, shortcode: emoji.shortcode, to, url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmojiPluginValue {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
ranges: EmojiRange[];
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.ranges = findEmojiRanges(view.state);
|
||||||
|
this.decorations = this.buildDecorations();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||||
|
) {
|
||||||
|
this.ranges = findEmojiRanges(update.state);
|
||||||
|
this.decorations = this.buildDecorations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDecorations() {
|
||||||
|
return Decoration.set(
|
||||||
|
this.ranges.map((range) =>
|
||||||
|
Decoration.replace({
|
||||||
|
inclusive: false,
|
||||||
|
widget: new EmojiWidget(range.shortcode, range.url),
|
||||||
|
}).range(range.from, range.to),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, {
|
||||||
|
decorations: (instance) => instance.decorations,
|
||||||
|
provide: (plugin) =>
|
||||||
|
EditorView.atomicRanges.of(
|
||||||
|
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteEmoji(view: EditorView, direction: "backward" | "forward") {
|
||||||
|
const ranges = view.plugin(emojiDecorations)?.ranges ?? [];
|
||||||
|
const deletions: Array<{ from: number; to: number }> = [];
|
||||||
|
|
||||||
|
for (const selection of view.state.selection.ranges) {
|
||||||
|
if (selection.empty) {
|
||||||
|
const emoji = ranges.find((range) =>
|
||||||
|
direction === "backward"
|
||||||
|
? selection.from > range.from && selection.from <= range.to
|
||||||
|
: selection.from >= range.from && selection.from < range.to,
|
||||||
|
);
|
||||||
|
if (emoji) deletions.push({ from: emoji.from, to: emoji.to });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = selection.from;
|
||||||
|
let to = selection.to;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const emoji of ranges) {
|
||||||
|
if (from < emoji.to && to > emoji.from) {
|
||||||
|
from = Math.min(from, emoji.from);
|
||||||
|
to = Math.max(to, emoji.to);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) deletions.push({ from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletions.length === 0) return false;
|
||||||
|
|
||||||
|
const merged = deletions
|
||||||
|
.sort((a, b) => a.from - b.from)
|
||||||
|
.reduce<Array<{ from: number; to: number }>>((result, deletion) => {
|
||||||
|
const previous = result.at(-1);
|
||||||
|
if (previous && deletion.from <= previous.to) {
|
||||||
|
previous.to = Math.max(previous.to, deletion.to);
|
||||||
|
} else {
|
||||||
|
result.push({ ...deletion });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: merged.map((range) => ({ from: range.from, to: range.to })),
|
||||||
|
selection: EditorSelection.cursor(merged[0].from),
|
||||||
|
scrollIntoView: true,
|
||||||
|
userEvent: direction === "backward" ? "delete.backward" : "delete.forward",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds markdown styling decorations every time the document or cursor selection changes.
|
* Builds markdown styling decorations every time the document or cursor selection changes.
|
||||||
|
|
@ -115,14 +316,20 @@ export default function Input(props: InputProps) {
|
||||||
|
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<EditorView | undefined>(undefined);
|
const viewRef = useRef<EditorView | undefined>(undefined);
|
||||||
const ignoreSyncRef = useRef(false);
|
|
||||||
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
|
||||||
|
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
|
||||||
|
props.onEmojiSelect,
|
||||||
|
);
|
||||||
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
|
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
|
||||||
|
const completionCompartmentRef = useRef<Compartment | null>(null);
|
||||||
|
completionCompartmentRef.current ??= new Compartment();
|
||||||
|
const completionCompartment = completionCompartmentRef.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onSubmitRef.current = props.onSubmit;
|
onSubmitRef.current = props.onSubmit;
|
||||||
|
onEmojiSelectRef.current = props.onEmojiSelect;
|
||||||
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
|
||||||
}, [props.onSubmit, props.invertEnterBehavior]);
|
}, [props.onEmojiSelect, props.onSubmit, props.invertEnterBehavior]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!elementRef.current) return;
|
if (!elementRef.current) return;
|
||||||
|
|
@ -131,12 +338,14 @@ export default function Input(props: InputProps) {
|
||||||
doc: props.value,
|
doc: props.value,
|
||||||
extensions: createEditorExtensions(
|
extensions: createEditorExtensions(
|
||||||
(value) => {
|
(value) => {
|
||||||
ignoreSyncRef.current = true;
|
|
||||||
props.setValue(value);
|
props.setValue(value);
|
||||||
},
|
},
|
||||||
() => props.placeholder,
|
() => props.placeholder,
|
||||||
() => invertEnterBehaviorRef.current,
|
() => invertEnterBehaviorRef.current,
|
||||||
() => onSubmitRef.current?.(),
|
() => onSubmitRef.current?.(),
|
||||||
|
completionCompartment,
|
||||||
|
props.emojiFrequencies,
|
||||||
|
(shortcode) => onEmojiSelectRef.current?.(shortcode),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,11 +369,6 @@ export default function Input(props: InputProps) {
|
||||||
const next = props.value;
|
const next = props.value;
|
||||||
const current = editor.state.doc.toString();
|
const current = editor.state.doc.toString();
|
||||||
|
|
||||||
if (ignoreSyncRef.current) {
|
|
||||||
ignoreSyncRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next === current) return;
|
if (next === current) return;
|
||||||
|
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
|
|
@ -173,9 +377,30 @@ export default function Input(props: InputProps) {
|
||||||
to: current.length,
|
to: current.length,
|
||||||
insert: next,
|
insert: next,
|
||||||
},
|
},
|
||||||
|
annotations: [
|
||||||
|
externalValueSync.of(true),
|
||||||
|
Transaction.addToHistory.of(false),
|
||||||
|
],
|
||||||
|
filter: false,
|
||||||
});
|
});
|
||||||
}, [props.value]);
|
}, [props.value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = viewRef.current;
|
||||||
|
const compartment = completionCompartmentRef.current;
|
||||||
|
if (!editor || !compartment) return;
|
||||||
|
|
||||||
|
const wasActive = completionStatus(editor.state) === "active";
|
||||||
|
editor.dispatch({
|
||||||
|
effects: compartment.reconfigure(
|
||||||
|
createEmojiAutocomplete(props.emojiFrequencies, (shortcode) =>
|
||||||
|
onEmojiSelectRef.current?.(shortcode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (wasActive) startCompletion(editor);
|
||||||
|
}, [props.emojiFrequencies]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={elementRef}
|
ref={elementRef}
|
||||||
|
|
@ -207,6 +432,9 @@ function createEditorExtensions(
|
||||||
getPlaceholder: () => string | undefined,
|
getPlaceholder: () => string | undefined,
|
||||||
getInvertEnterBehavior: () => boolean,
|
getInvertEnterBehavior: () => boolean,
|
||||||
onSubmit: () => void,
|
onSubmit: () => void,
|
||||||
|
completionCompartment: Compartment,
|
||||||
|
emojiFrequencies: Readonly<Record<string, number>> | undefined,
|
||||||
|
onEmojiSelect: (shortcode: string) => void,
|
||||||
): Extension[] {
|
): Extension[] {
|
||||||
const editorKeymap = [
|
const editorKeymap = [
|
||||||
...defaultKeymap,
|
...defaultKeymap,
|
||||||
|
|
@ -217,7 +445,10 @@ function createEditorExtensions(
|
||||||
const customEnterKeymap = keymap.of([
|
const customEnterKeymap = keymap.of([
|
||||||
{
|
{
|
||||||
key: "Shift-Enter",
|
key: "Shift-Enter",
|
||||||
run: () => {
|
run: (view) => {
|
||||||
|
if (completionStatus(view.state) === "active") {
|
||||||
|
return acceptCompletion(view);
|
||||||
|
}
|
||||||
if (!getInvertEnterBehavior()) {
|
if (!getInvertEnterBehavior()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +459,10 @@ function createEditorExtensions(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Enter",
|
key: "Enter",
|
||||||
run: () => {
|
run: (view) => {
|
||||||
|
if (completionStatus(view.state) === "active") {
|
||||||
|
return acceptCompletion(view);
|
||||||
|
}
|
||||||
if (getInvertEnterBehavior()) {
|
if (getInvertEnterBehavior()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -238,16 +472,48 @@ function createEditorExtensions(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const completionTabKeymap = keymap.of([
|
||||||
|
{
|
||||||
|
key: "Tab",
|
||||||
|
run: (view) =>
|
||||||
|
completionStatus(view.state) === "active"
|
||||||
|
? acceptCompletion(view)
|
||||||
|
: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const emojiDeletionKeymap = keymap.of([
|
||||||
|
{
|
||||||
|
key: "Backspace",
|
||||||
|
run: (view) => deleteEmoji(view, "backward"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Delete",
|
||||||
|
run: (view) => deleteEmoji(view, "forward"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
history(),
|
history(),
|
||||||
markdown(),
|
markdown(),
|
||||||
|
completionCompartment.of(
|
||||||
|
createEmojiAutocomplete(emojiFrequencies, onEmojiSelect),
|
||||||
|
),
|
||||||
|
emojiDecorations,
|
||||||
keymap.of(editorKeymap),
|
keymap.of(editorKeymap),
|
||||||
|
Prec.highest(completionTabKeymap),
|
||||||
|
Prec.highest(emojiDeletionKeymap),
|
||||||
Prec.highest(customEnterKeymap),
|
Prec.highest(customEnterKeymap),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
placeholder(getPlaceholder() ?? ""),
|
placeholder(getPlaceholder() ?? ""),
|
||||||
EditorView.updateListener.of((update: ViewUpdate) => {
|
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||||
if (!update.docChanged) return;
|
if (!update.docChanged) return;
|
||||||
|
if (
|
||||||
|
update.transactions.some(
|
||||||
|
(transaction) => transaction.annotation(externalValueSync) === true,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange(update.state.doc.toString());
|
onChange(update.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
|
|
@ -267,6 +533,126 @@ function createEditorExtensions(
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEmojiAutocomplete(
|
||||||
|
frequencies: Readonly<Record<string, number>> | undefined,
|
||||||
|
onEmojiSelect: (shortcode: string) => void,
|
||||||
|
) {
|
||||||
|
return autocompletion({
|
||||||
|
activateOnTyping: true,
|
||||||
|
addToOptions: [
|
||||||
|
{
|
||||||
|
position: 20,
|
||||||
|
render(completion) {
|
||||||
|
const container = document.createElement("span");
|
||||||
|
createRoot(container).render(
|
||||||
|
<Emoji
|
||||||
|
className="tm-md-completion-emoji"
|
||||||
|
shortcode={completion.label}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS,
|
||||||
|
override: [createEmojiCompletionSource(frequencies, onEmojiSelect)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFrequencies(
|
||||||
|
frequencies: Readonly<Record<string, number>> | undefined,
|
||||||
|
) {
|
||||||
|
const normalized = new Map<string, number>();
|
||||||
|
for (const [value, frequency] of Object.entries(frequencies ?? {})) {
|
||||||
|
const shortcode = resolveEmoji(value)?.shortcode;
|
||||||
|
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue;
|
||||||
|
normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmojiCompletionSource(
|
||||||
|
frequencies?: Readonly<Record<string, number>>,
|
||||||
|
onEmojiSelect: (shortcode: string) => void = () => undefined,
|
||||||
|
) {
|
||||||
|
const normalized = normalizedFrequencies(frequencies);
|
||||||
|
const maxFrequency = Math.max(0, ...normalized.values());
|
||||||
|
|
||||||
|
return (context: CompletionContext): CompletionResult | null => {
|
||||||
|
const token = context.matchBefore(/:[a-z0-9_+-]*$/i);
|
||||||
|
if (!token) return null;
|
||||||
|
if (
|
||||||
|
codeRanges(context.state).some(
|
||||||
|
(range) => token.from < range.to && token.to > range.from,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterBefore = context.state.sliceDoc(
|
||||||
|
Math.max(0, token.from - 1),
|
||||||
|
token.from,
|
||||||
|
);
|
||||||
|
if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null;
|
||||||
|
|
||||||
|
const query = token.text.slice(1).toLowerCase();
|
||||||
|
const options: Completion[] = searchEmojis(query)
|
||||||
|
.map((emoji) => {
|
||||||
|
const aliases = emoji.aliases.map((alias) => alias.toLowerCase());
|
||||||
|
const matchedAlias =
|
||||||
|
aliases.find((alias) => alias === query) ??
|
||||||
|
aliases.find((alias) => alias.startsWith(query)) ??
|
||||||
|
aliases.find((alias) => alias.includes(query)) ??
|
||||||
|
emoji.name;
|
||||||
|
const relevance = !query
|
||||||
|
? 0
|
||||||
|
: matchedAlias === query
|
||||||
|
? 80
|
||||||
|
: matchedAlias.startsWith(query)
|
||||||
|
? 40
|
||||||
|
: 0;
|
||||||
|
const frequency = normalized.get(emoji.shortcode) ?? 0;
|
||||||
|
const usage =
|
||||||
|
maxFrequency > 0
|
||||||
|
? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
apply(view, completion, from, to) {
|
||||||
|
view.dispatch({
|
||||||
|
annotations: pickedCompletion.of(completion),
|
||||||
|
changes: { from, insert: `${emoji.shortcode} `, to },
|
||||||
|
selection: EditorSelection.cursor(
|
||||||
|
from + emoji.shortcode.length + 1,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
onEmojiSelect(emoji.shortcode);
|
||||||
|
},
|
||||||
|
boost: relevance + usage,
|
||||||
|
displayLabel: emoji.shortcode,
|
||||||
|
label: `:${matchedAlias}:`,
|
||||||
|
type: "text",
|
||||||
|
frequency,
|
||||||
|
relevance,
|
||||||
|
} satisfies Completion & { frequency: number; relevance: number };
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.relevance - a.relevance ||
|
||||||
|
b.frequency - a.frequency ||
|
||||||
|
(a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: token.from,
|
||||||
|
options,
|
||||||
|
validFor: /^:[a-z0-9_+-]*$/i,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emojiCompletionSource = createEmojiCompletionSource();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes buildDecorations.
|
* Executes buildDecorations.
|
||||||
* @param view Parameter view.
|
* @param view Parameter view.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import Emoji from "./emoji";
|
||||||
|
import { findEmojiShortcodes } from "./emojiData";
|
||||||
|
|
||||||
type InlineNode =
|
type InlineNode =
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
|
| { type: "emoji"; shortcode: string }
|
||||||
| { type: "strong"; value: string }
|
| { type: "strong"; value: string }
|
||||||
| { type: "em"; value: string }
|
| { type: "em"; value: string }
|
||||||
| { type: "del"; value: string }
|
| { type: "del"; value: string }
|
||||||
|
|
@ -73,14 +76,14 @@ type MarkdownBlock =
|
||||||
| TableBlock;
|
| TableBlock;
|
||||||
|
|
||||||
const INLINE_TOKEN_REGEX =
|
const INLINE_TOKEN_REGEX =
|
||||||
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|_([^_\n]+)_/g;
|
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes parseInlineNodes.
|
* Executes parseInlineNodes.
|
||||||
* @param input Parameter input.
|
* @param input Parameter input.
|
||||||
* @returns InlineNode[].
|
* @returns InlineNode[].
|
||||||
*/
|
*/
|
||||||
function parseInlineNodes(input: string): InlineNode[] {
|
export function parseInlineNodes(input: string): InlineNode[] {
|
||||||
const nodes: InlineNode[] = [];
|
const nodes: InlineNode[] = [];
|
||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
@ -91,7 +94,7 @@ function parseInlineNodes(input: string): InlineNode[] {
|
||||||
const raw = match[0];
|
const raw = match[0];
|
||||||
|
|
||||||
if (index > cursor) {
|
if (index > cursor) {
|
||||||
nodes.push({ type: "text", value: input.slice(cursor, index) });
|
nodes.push(...parseEmojiText(input.slice(cursor, index)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match[1] !== undefined && match[2] !== undefined) {
|
if (match[1] !== undefined && match[2] !== undefined) {
|
||||||
|
|
@ -111,7 +114,7 @@ function parseInlineNodes(input: string): InlineNode[] {
|
||||||
} else if (match[9] !== undefined || match[10] !== undefined) {
|
} else if (match[9] !== undefined || match[10] !== undefined) {
|
||||||
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
|
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
|
||||||
} else {
|
} else {
|
||||||
nodes.push({ type: "text", value: raw });
|
nodes.push(...parseEmojiText(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor = index + raw.length;
|
cursor = index + raw.length;
|
||||||
|
|
@ -119,13 +122,33 @@ function parseInlineNodes(input: string): InlineNode[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor < input.length) {
|
if (cursor < input.length) {
|
||||||
nodes.push({ type: "text", value: input.slice(cursor) });
|
nodes.push(...parseEmojiText(input.slice(cursor)));
|
||||||
}
|
}
|
||||||
|
|
||||||
INLINE_TOKEN_REGEX.lastIndex = 0;
|
INLINE_TOKEN_REGEX.lastIndex = 0;
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseEmojiText(input: string): InlineNode[] {
|
||||||
|
const nodes: InlineNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const match of findEmojiShortcodes(input)) {
|
||||||
|
if (match.from > cursor) {
|
||||||
|
nodes.push({ type: "text", value: input.slice(cursor, match.from) });
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push({ type: "emoji", shortcode: match.emoji.shortcode });
|
||||||
|
cursor = match.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < input.length) {
|
||||||
|
nodes.push({ type: "text", value: input.slice(cursor) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes collectInlineRanges.
|
* Executes collectInlineRanges.
|
||||||
* @param input Parameter input.
|
* @param input Parameter input.
|
||||||
|
|
@ -375,10 +398,16 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
||||||
return node.value;
|
return node.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === "emoji") {
|
||||||
|
return (
|
||||||
|
<Emoji key={index} className="tm-md-emoji" shortcode={node.shortcode} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === "strong") {
|
if (node.type === "strong") {
|
||||||
return (
|
return (
|
||||||
<strong key={index} className="tm-md-strong">
|
<strong key={index} className="tm-md-strong">
|
||||||
{node.value}
|
{renderInline(parseEmojiText(node.value))}
|
||||||
</strong>
|
</strong>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +415,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
||||||
if (node.type === "em") {
|
if (node.type === "em") {
|
||||||
return (
|
return (
|
||||||
<em key={index} className="tm-md-em">
|
<em key={index} className="tm-md-em">
|
||||||
{node.value}
|
{renderInline(parseEmojiText(node.value))}
|
||||||
</em>
|
</em>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -394,7 +423,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
||||||
if (node.type === "del") {
|
if (node.type === "del") {
|
||||||
return (
|
return (
|
||||||
<del key={index} className="tm-md-del">
|
<del key={index} className="tm-md-del">
|
||||||
{node.value}
|
{renderInline(parseEmojiText(node.value))}
|
||||||
</del>
|
</del>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -416,7 +445,7 @@ function renderInline(nodes: InlineNode[]): React.ReactNode[] {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{node.label}
|
{renderInline(parseEmojiText(node.label))}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -637,7 +666,7 @@ function readTable(
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownStyles = `
|
const markdownStyles = `
|
||||||
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.55; font-size: 0.95rem; }
|
.tm-md-root { color: hsl(var(--foreground)); line-height: 1.65; font-size: 1rem; }
|
||||||
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
|
||||||
.tm-md-h1 { font-size: 1.65rem; }
|
.tm-md-h1 { font-size: 1.65rem; }
|
||||||
.tm-md-h2 { font-size: 1.45rem; }
|
.tm-md-h2 { font-size: 1.45rem; }
|
||||||
|
|
@ -655,6 +684,7 @@ const markdownStyles = `
|
||||||
.tm-md-del { text-decoration: line-through; }
|
.tm-md-del { text-decoration: line-through; }
|
||||||
.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; }
|
.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; }
|
||||||
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
|
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
|
||||||
|
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
|
||||||
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
|
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
|
||||||
.tm-md-li { margin: 0.2rem 0; }
|
.tm-md-li { margin: 0.2rem 0; }
|
||||||
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
|
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
|
||||||
|
|
@ -670,8 +700,21 @@ const markdownStyles = `
|
||||||
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
|
||||||
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
|
||||||
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
|
.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
|
||||||
|
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
|
||||||
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
|
||||||
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
|
||||||
|
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; }
|
||||||
|
.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export default function Text(props: TextProps) {
|
||||||
() => parseMarkdownBlocks(props.value),
|
() => parseMarkdownBlocks(props.value),
|
||||||
[props.value],
|
[props.value],
|
||||||
);
|
);
|
||||||
|
const renderedBlocks = React.useMemo(() => renderBlocks(blocks), [blocks]);
|
||||||
|
|
||||||
return <div className="tm-md-root">{renderBlocks(blocks)}</div>;
|
return <div className="tm-md-root">{renderedBlocks}</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ export function Provider(props: {
|
||||||
const unsubscribers = [
|
const unsubscribers = [
|
||||||
"MessageLive",
|
"MessageLive",
|
||||||
"MessageEditLive",
|
"MessageEditLive",
|
||||||
|
"MessageReactionLive",
|
||||||
"MessageState",
|
"MessageState",
|
||||||
"CallInvite",
|
"CallInvite",
|
||||||
"ErrorNoIota",
|
"ErrorNoIota",
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,7 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
reactions: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageDefaults: Storage = {
|
export const storageDefaults: Storage = {
|
||||||
|
|
@ -513,6 +514,11 @@ export const storageDefaults: Storage = {
|
||||||
chat_picker_saved_media: [],
|
chat_picker_saved_media: [],
|
||||||
chat_picker_last_tab: "gif",
|
chat_picker_last_tab: "gif",
|
||||||
chat_picker_size: null,
|
chat_picker_size: null,
|
||||||
|
reactions: {
|
||||||
|
":thumbsup:": 3,
|
||||||
|
":fire:": 2,
|
||||||
|
":white_check_mark:": 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// User Status
|
// User Status
|
||||||
|
|
|
||||||
8487
pnpm-lock.yaml
generated
8487
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue