入門
NIP-30 自訂表情
Nostr 自訂表情符號標準,讓用戶可以在貼文和反應中使用自訂圖片表情。
8 分鐘
概述
NIP-30 定義了 Nostr 的自訂表情符號標準,讓用戶可以使用圖片作為表情符號。 與 Discord 或 Slack
的自訂表情類似,用戶可以在內容中使用 :shortcode:
格式引用自訂表情,客戶端會將其渲染為對應的圖片。
使用場景
自訂表情可用於個人資料、貼文內容和反應。社群可以建立專屬的表情包, 增加互動的趣味性和個性化。
支援的事件類型
| Kind | 類型 | 表情位置 |
|---|---|---|
| 0 | 個人資料 | name 和 about 欄位 |
| 1 | 文字貼文 | content 欄位 |
| 7 | 反應 | content 欄位 |
| 30315 | 用戶狀態 | content 欄位 |
表情標籤格式
自訂表情使用 emoji 標籤定義:
{`["emoji", "", ""]`} shortcode- 表情代碼,只能包含字母、數字和底線image-url- 表情圖片的 URL
命名規則
- 只能使用 a-z、A-Z、0-9、_
- 不能包含空格或特殊字符
- 建議使用小寫以保持一致性
{`// ✓ 正確
["emoji", "pepe_happy", "https://example.com/pepe_happy.gif"]
["emoji", "bitcoin", "https://example.com/btc.png"]
["emoji", "nostr_logo", "https://example.com/nostr.svg"]
// ✗ 錯誤
["emoji", "pepe-happy", "..."] // 包含連字號
["emoji", "pepe happy", "..."] // 包含空格
["emoji", "🚀", "..."] // 使用 Unicode 表情`} 使用方式
在內容中使用 :shortcode: 格式引用表情:
{`{
"kind": 1,
"content": "今天心情超好 :pepe_happy: 來點比特幣 :bitcoin:",
"tags": [
["emoji", "pepe_happy", "https://example.com/pepe_happy.gif"],
["emoji", "bitcoin", "https://example.com/btc.png"]
],
...
}`} 事件範例
個人資料 (Kind 0)
{`{
"kind": 0,
"content": "{\"name\":\"Alice :nostr:\",\"about\":\"Nostr 愛好者 :bitcoin: :lightning:\"}",
"tags": [
["emoji", "nostr", "https://example.com/nostr.png"],
["emoji", "bitcoin", "https://example.com/btc.png"],
["emoji", "lightning", "https://example.com/ln.png"]
],
...
}`} 貼文 (Kind 1)
{`{
"kind": 1,
"content": "剛剛完成了新功能!:ship_it: :party_parrot:",
"tags": [
["emoji", "ship_it", "https://example.com/ship.gif"],
["emoji", "party_parrot", "https://example.com/parrot.gif"]
],
...
}`} 自訂表情反應 (Kind 7)
{`{
"kind": 7,
"content": ":pepe_love:",
"tags": [
["e", "", ""],
["p", ""],
["emoji", "pepe_love", "https://example.com/pepe_love.png"]
],
...
}`} 實作
建立帶表情的事件
{`import { finalizeEvent } from 'nostr-tools';
interface CustomEmoji {
shortcode: string;
url: string;
}
// 驗證 shortcode 格式
function isValidShortcode(shortcode: string): boolean {
return /^[a-zA-Z0-9_]+$/.test(shortcode);
}
// 建立帶自訂表情的貼文
function createPostWithEmojis(
privateKey: Uint8Array,
content: string,
emojis: CustomEmoji[]
) {
// 驗證所有 shortcode
for (const emoji of emojis) {
if (!isValidShortcode(emoji.shortcode)) {
throw new Error(\`Invalid shortcode: \${emoji.shortcode}\`);
}
}
const emojiTags = emojis.map(e => ['emoji', e.shortcode, e.url]);
return finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: emojiTags,
content,
}, privateKey);
}
// 使用範例
const post = createPostWithEmojis(
privateKey,
'新年快樂!:fireworks: :party:',
[
{ shortcode: 'fireworks', url: 'https://example.com/fireworks.gif' },
{ shortcode: 'party', url: 'https://example.com/party.png' },
]
);`} 建立自訂表情反應
{`// 使用自訂表情反應
function createCustomEmojiReaction(
privateKey: Uint8Array,
targetEventId: string,
targetAuthorPubkey: string,
emoji: CustomEmoji,
relay?: string
) {
if (!isValidShortcode(emoji.shortcode)) {
throw new Error(\`Invalid shortcode: \${emoji.shortcode}\`);
}
const tags: string[][] = [
['e', targetEventId, relay || ''],
['p', targetAuthorPubkey],
['emoji', emoji.shortcode, emoji.url],
];
return finalizeEvent({
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags,
content: \`:\${emoji.shortcode}:\`,
}, privateKey);
}
// 使用範例
const reaction = createCustomEmojiReaction(
privateKey,
'abc123...',
'def456...',
{ shortcode: 'pepe_thumbs_up', url: 'https://example.com/pepe_up.gif' }
);`} 解析並渲染表情
{`interface EmojiMap {
[shortcode: string]: string;
}
// 從事件標籤提取表情映射
function extractEmojis(tags: string[][]): EmojiMap {
const emojiMap: EmojiMap = {};
for (const tag of tags) {
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
emojiMap[tag[1]] = tag[2];
}
}
return emojiMap;
}
// 將內容中的 :shortcode: 替換為圖片
function renderEmojis(content: string, emojiMap: EmojiMap): string {
return content.replace(/:([a-zA-Z0-9_]+):/g, (match, shortcode) => {
const url = emojiMap[shortcode];
if (url) {
return \`
\`;
}
return match; // 沒有對應的表情,保持原樣
});
}
// 使用範例
const event = {
content: '太棒了 :party: :fire:',
tags: [
['emoji', 'party', 'https://example.com/party.gif'],
['emoji', 'fire', 'https://example.com/fire.gif'],
],
};
const emojiMap = extractEmojis(event.tags);
const rendered = renderEmojis(event.content, emojiMap);
// 太棒了
`} React 組件範例
{`interface CustomEmojiProps {
shortcode: string;
url: string;
}
function CustomEmoji({ shortcode, url }: CustomEmojiProps) {
return (
);
}
interface EmojifiedTextProps {
content: string;
emojis: EmojiMap;
}
function EmojifiedText({ content, emojis }: EmojifiedTextProps) {
const parts: (string | JSX.Element)[] = [];
let lastIndex = 0;
const regex = /:([a-zA-Z0-9_]+):/g;
let match;
while ((match = regex.exec(content)) !== null) {
// 添加表情前的文字
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
const shortcode = match[1];
const url = emojis[shortcode];
if (url) {
parts.push(
);
} else {
// 沒有找到對應的表情
parts.push(match[0]);
}
lastIndex = regex.lastIndex;
}
// 添加剩餘的文字
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
return <>{parts}>;
}
// 使用範例
`} 表情包
社群可以建立並分享表情包。常見的做法是將表情圖片託管在穩定的伺服器上, 並提供 shortcode 列表供用戶使用。
{`// 表情包定義
interface EmojiPack {
name: string;
author: string;
emojis: CustomEmoji[];
}
const pepePack: EmojiPack = {
name: 'Pepe Pack',
author: 'npub1...',
emojis: [
{ shortcode: 'pepe_happy', url: 'https://cdn.example.com/pepe/happy.gif' },
{ shortcode: 'pepe_sad', url: 'https://cdn.example.com/pepe/sad.gif' },
{ shortcode: 'pepe_angry', url: 'https://cdn.example.com/pepe/angry.gif' },
{ shortcode: 'pepe_love', url: 'https://cdn.example.com/pepe/love.gif' },
{ shortcode: 'pepe_think', url: 'https://cdn.example.com/pepe/think.gif' },
],
};
// 使用表情包建立貼文
function createPostWithPack(
privateKey: Uint8Array,
content: string,
pack: EmojiPack
) {
// 只包含內容中實際使用的表情
const usedEmojis = pack.emojis.filter(emoji =>
content.includes(\`:\${emoji.shortcode}:\`)
);
return createPostWithEmojis(privateKey, content, usedEmojis);
}`} 最佳實踐
- 圖片大小:建議 64x64 或 128x128 像素
- 檔案格式:支援 PNG、GIF、WebP、SVG
- 動態表情:GIF 和 WebP 支援動畫
- 穩定託管:使用可靠的 CDN 託管表情圖片
- 只包含使用的:不要在標籤中包含未使用的表情
- CSS 樣式:確保表情在行內正確顯示
CSS 樣式建議
{`.custom-emoji {
display: inline-block;
height: 1.2em;
width: 1.2em;
vertical-align: text-bottom;
object-fit: contain;
}
/* 較大的表情 */
.custom-emoji-lg {
height: 2em;
width: 2em;
}
/* 反應中的表情 */
.reaction-emoji {
height: 1.5em;
width: 1.5em;
}`} 注意事項
- 圖片載入:使用 lazy loading 避免影響效能
- fallback:無法載入圖片時顯示 :shortcode:
- 無障礙:使用 alt 屬性提供文字描述
- XSS 防護:確保 URL 經過驗證和清理
安全提醒
表情圖片 URL 由用戶提供,可能指向惡意內容。 客戶端應該驗證 URL 格式,並考慮使用內容安全策略(CSP)。
相關 NIP
參考資源
已複製連結