Skip to content

Commit a9873dd

Browse files
committed
Emoji type
1 parent b83b6be commit a9873dd

File tree

8 files changed

+179
-25
lines changed

8 files changed

+179
-25
lines changed

CHANGES.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ To be released.
1919
- Added `CustomEmojiFromUrl` interface.
2020
- Added `CustomEmojiFromFile` interface.
2121
- Added `CustomEmoji` type.
22-
- Added `DeferredEmoji` type.
23-
- Added `Emoji` class.
22+
- Added `DeferredCustomEmoji` type.
23+
24+
- Added `Emoji` type.
25+
26+
- Added `isEmoji()` predicate function.
2427

2528
- Added `SessionGetOutboxOptions` interface.
2629

src/bot-impl.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import { getLogger } from "@logtape/logtape";
5757
import { extension } from "@std/media-types/extension";
5858
import metadata from "../deno.json" with { type: "json" };
5959
import type { Bot, CreateBotOptions, PagesOptions } from "./bot.ts";
60-
import type { CustomEmoji, DeferredEmoji } from "./emoji.ts";
60+
import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts";
6161
import type {
6262
AcceptEventHandler,
6363
FollowEventHandler,
@@ -879,7 +879,7 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
879879
addCustomEmoji<TEmojiName extends string>(
880880
name: TEmojiName,
881881
data: CustomEmoji,
882-
): DeferredEmoji<TContextData> {
882+
): DeferredCustomEmoji<TContextData> {
883883
if (!name.match(/^[a-z0-9-_]+$/i)) {
884884
throw new TypeError(
885885
`Invalid custom emoji name: ${name}. It must match /^[a-z0-9-_]+$/i.`,
@@ -900,8 +900,11 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
900900

901901
addCustomEmojis<TEmojiName extends string>(
902902
emojis: Readonly<Record<TEmojiName, CustomEmoji>>,
903-
): Readonly<Record<TEmojiName, DeferredEmoji<TContextData>>> {
904-
const emojiMap = {} as Record<TEmojiName, DeferredEmoji<TContextData>>;
903+
): Readonly<Record<TEmojiName, DeferredCustomEmoji<TContextData>>> {
904+
const emojiMap = {} as Record<
905+
TEmojiName,
906+
DeferredCustomEmoji<TContextData>
907+
>;
905908
for (const name in emojis) {
906909
emojiMap[name] = this.addCustomEmoji(name, emojis[name]);
907910
}

src/bot.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type {
2222
import type { Software } from "@fedify/fedify/nodeinfo";
2323
import type { Application, Image, Service } from "@fedify/fedify/vocab";
2424
import { BotImpl } from "./bot-impl.ts";
25-
import type { CustomEmoji, DeferredEmoji } from "./emoji.ts";
25+
import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts";
2626
import type {
2727
AcceptEventHandler,
2828
FollowEventHandler,
@@ -103,7 +103,7 @@ export interface Bot<TContextData> {
103103
*/
104104
addCustomEmojis<TEmojiName extends string>(
105105
emojis: Readonly<Record<TEmojiName, CustomEmoji>>,
106-
): Readonly<Record<TEmojiName, DeferredEmoji<TContextData>>>;
106+
): Readonly<Record<TEmojiName, DeferredCustomEmoji<TContextData>>>;
107107

108108
/**
109109
* An event handler for a follow request to the bot.
@@ -372,7 +372,7 @@ export function createBot<TContextData = void>(
372372
},
373373
addCustomEmojis<TEmojiName extends string>(
374374
emojis: Readonly<Record<TEmojiName, CustomEmoji>>,
375-
): Readonly<Record<TEmojiName, DeferredEmoji<TContextData>>> {
375+
): Readonly<Record<TEmojiName, DeferredCustomEmoji<TContextData>>> {
376376
return bot.addCustomEmojis(emojis);
377377
},
378378
get onFollow() {

src/emoji.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// BotKit by Fedify: A framework for creating ActivityPub bots
2+
// Copyright (C) 2025 Hong Minhee <https://hongminhee.org/>
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as
6+
// published by the Free Software Foundation, either version 3 of the
7+
// License, or (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
import { assert } from "@std/assert/assert";
17+
import { assertFalse } from "@std/assert/false";
18+
import { isEmoji } from "./emoji.ts";
19+
20+
Deno.test("isEmoji() with valid emojis", () => {
21+
const validEmojis = [
22+
"😀", // simple emoji
23+
"👍", // thumbs up
24+
"🚀", // rocket
25+
"🏳️‍🌈", // pride flag (complex emoji with ZWJ sequence)
26+
"👨‍👩‍👧‍👦", // family (complex emoji with multiple ZWJ sequences)
27+
"👩🏽‍🔬", // woman scientist with medium skin tone
28+
"🧘🏻‍♀️", // woman in lotus position with light skin tone
29+
"🤦‍♂️", // man facepalming
30+
"🇯🇵", // flag
31+
];
32+
33+
for (const emoji of validEmojis) {
34+
assert(
35+
isEmoji(emoji),
36+
`Expected '${emoji}' to be recognized as an emoji`,
37+
);
38+
}
39+
});
40+
41+
Deno.test("isEmoji() with invalid inputs", () => {
42+
const invalidInputs = [
43+
// Multiple emojis
44+
"😀😀",
45+
"👍🏻👎🏻",
46+
// Regular text
47+
"hello",
48+
"a",
49+
// Mixed content
50+
"hi😀",
51+
"👍awesome",
52+
// Empty string
53+
"",
54+
// Non-string values
55+
42,
56+
null,
57+
undefined,
58+
true,
59+
false,
60+
{},
61+
[],
62+
new Date(),
63+
];
64+
65+
for (const input of invalidInputs) {
66+
assertFalse(
67+
isEmoji(input),
68+
`Expected '${input}' not to be recognized as an emoji`,
69+
);
70+
}
71+
});
72+
73+
Deno.test("isEmoji() with additional edge cases", () => {
74+
const edgeCaseEmojis = [
75+
"5️⃣", // key cap sequence
76+
"❤️", // emoji with presentation variation selector
77+
"☺️", // older emoji with variation selector
78+
"👩‍🦰", // woman with red hair (hair modifier)
79+
"🏊‍♀️", // woman swimming (gender modifier)
80+
"🧙‍♂️", // man mage (gender modifier)
81+
"🔢", // input numbers symbol (legacy input emoji)
82+
"↔️", // arrow with variation selector
83+
"📧", // e-mail symbol
84+
"📱", // mobile phone
85+
];
86+
87+
for (const emoji of edgeCaseEmojis) {
88+
assert(
89+
isEmoji(emoji),
90+
`Expected '${emoji}' to be recognized as an emoji`,
91+
);
92+
}
93+
});
94+
95+
Deno.test("isEmoji() with tricky invalid inputs", () => {
96+
const trickyInvalidInputs = [
97+
" 😀", // emoji with leading space
98+
"😀 ", // emoji with trailing space
99+
"\u200B😀", // emoji with zero-width space
100+
// Note: Single regional indicators like "🇺" are technically valid emojis
101+
// even though they're usually paired to form flags
102+
"\u{1F3F4}\uE0067\uE0062", // incomplete tag sequence
103+
"\uFE0F", // variation selector alone
104+
"\u200D", // zero width joiner alone
105+
"♀️♂️", // gender symbols together (should be two separate graphemes)
106+
];
107+
108+
for (const input of trickyInvalidInputs) {
109+
assertFalse(
110+
isEmoji(input),
111+
`Expected '${input}' not to be recognized as an emoji`,
112+
);
113+
}
114+
});

src/emoji.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,38 @@
1313
//
1414
// You should have received a copy of the GNU Affero General Public License
1515
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16-
import type { Emoji } from "@fedify/fedify/vocab";
16+
import type { Emoji as EmojiObject } from "@fedify/fedify/vocab";
1717
import type { Session } from "./session.ts";
18-
export { Emoji } from "@fedify/fedify/vocab";
18+
19+
/**
20+
* A branded type for a single emoji character (more exactly, a single
21+
* Unicode grapheme cluster of emoji). This is used to represent a single emoji
22+
* in a string format. It is not a full-fledged emoji object, but rather a
23+
* string that is guaranteed to be a single emoji.
24+
*
25+
* You can narrow a string to an {@link Emoji} type using the {@link isEmoji}
26+
* predicate function.
27+
* @since 0.2.0
28+
*/
29+
export type Emoji = string & { readonly __emoji: unique symbol };
30+
31+
/**
32+
* A type guard that checks if a value is a single emoji character.
33+
* @param value The value to check.
34+
* @returns `true` if the value is a single emoji character, `false` otherwise.
35+
* @since 0.2.0
36+
*/
37+
export function isEmoji(value: unknown): value is Emoji {
38+
if (typeof value !== "string") return false;
39+
40+
// First check if we have exactly one grapheme cluster
41+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
42+
const segments = [...segmenter.segment(value)];
43+
if (segments.length !== 1) return false;
44+
45+
// Then check if this grapheme cluster has the emoji property
46+
return /\p{Emoji}/u.test(segments[0].segment);
47+
}
1948

2049
/**
2150
* The common interface for defining custom emojis.
@@ -58,13 +87,13 @@ export interface CustomEmojiFromFile extends CustomEmojiBase {
5887
export type CustomEmoji = CustomEmojiFromUrl | CustomEmojiFromFile;
5988

6089
/**
61-
* A deferred {@link Emoji}, which is a function that takes a {@link Session}
62-
* and returns an {@link Emoji}. This is useful for creating emojis that
63-
* depend on the session data.
90+
* A deferred `Emoji` (provided by Fedify), which is a function that
91+
* takes a {@link Session} and returns an `Emoji`. This is useful for
92+
* creating emojis that depend on the session data.
6493
* @since 0.2.0
6594
* @param TContextData The type of the context data.
66-
* @return The {@link Emoji} object.
95+
* @return The `Emoji` object.
6796
*/
68-
export type DeferredEmoji<TContextData> = (
97+
export type DeferredCustomEmoji<TContextData> = (
6998
session: Session<TContextData>,
70-
) => Emoji;
99+
) => EmojiObject;

src/mod.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ export {
3232
Service,
3333
type Software,
3434
} from "./bot.ts";
35-
export { type CustomEmoji, Emoji } from "./emoji.ts";
35+
export {
36+
type CustomEmoji,
37+
type DeferredCustomEmoji,
38+
type Emoji,
39+
isEmoji,
40+
} from "./emoji.ts";
3641
export type * from "./events.ts";
3742
export type { FollowRequest } from "./follow.ts";
3843
export {

src/text.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
} from "@std/assert";
3030
import { BotImpl } from "./bot-impl.ts";
3131
import type { BotWithVoidContextData } from "./bot.ts";
32-
import type { CustomEmoji, DeferredEmoji } from "./emoji.ts";
32+
import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts";
3333
import type { Session } from "./session.ts";
3434
import {
3535
code,
@@ -156,8 +156,8 @@ const bot: BotWithVoidContextData = {
156156
},
157157
addCustomEmojis<TEmojiName extends string>(
158158
_emojis: Record<TEmojiName, CustomEmoji>,
159-
): Record<TEmojiName, DeferredEmoji<void>> {
160-
return {} as Record<TEmojiName, DeferredEmoji<void>>;
159+
): Record<TEmojiName, DeferredCustomEmoji<void>> {
160+
return {} as Record<TEmojiName, DeferredCustomEmoji<void>>;
161161
},
162162
};
163163

src/text.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
} from "@fedify/markdown-it-mention";
3131
import { escape } from "@std/html/entities";
3232
import MarkdownIt from "markdown-it";
33-
import type { DeferredEmoji } from "./emoji.ts";
33+
import type { DeferredCustomEmoji } from "./emoji.ts";
3434
import type { Session } from "./session.ts";
3535

3636
/**
@@ -723,13 +723,13 @@ export function code<TContextData>(
723723
export class CustomEmojiText<TContextData>
724724
implements Text<"inline", TContextData> {
725725
readonly type = "inline";
726-
readonly #emoji: Emoji | DeferredEmoji<TContextData>;
726+
readonly #emoji: Emoji | DeferredCustomEmoji<TContextData>;
727727

728728
/**
729729
* Creates a {@link CustomEmojiText} tree with a custom emoji.
730730
* @param emoji The custom emoji to render.
731731
*/
732-
constructor(emoji: Emoji | DeferredEmoji<TContextData>) {
732+
constructor(emoji: Emoji | DeferredCustomEmoji<TContextData>) {
733733
this.#emoji = emoji;
734734
}
735735

@@ -772,7 +772,7 @@ export class CustomEmojiText<TContextData>
772772
* @since 0.2.0
773773
*/
774774
export function customEmoji<TContextData>(
775-
emoji: Emoji | DeferredEmoji<TContextData>,
775+
emoji: Emoji | DeferredCustomEmoji<TContextData>,
776776
): Text<"inline", TContextData> {
777777
return new CustomEmojiText(emoji);
778778
}

0 commit comments

Comments
 (0)