跳至主要內容
入門

NIP-30 自訂表情

Nostr 自訂表情符號標準,讓用戶可以在貼文和反應中使用自訂圖片表情。

8 分鐘

概述

NIP-30 定義了 Nostr 的自訂表情符號標準,讓用戶可以使用圖片作為表情符號。 與 Discord 或 Slack 的自訂表情類似,用戶可以在內容中使用 :shortcode: 格式引用自訂表情,客戶端會將其渲染為對應的圖片。

使用場景

自訂表情可用於個人資料、貼文內容和反應。社群可以建立專屬的表情包, 增加互動的趣味性和個性化。

支援的事件類型

Kind 類型 表情位置
0 個人資料 nameabout 欄位
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 \`:\${shortcode}:\`;
    }
    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 (
    {\`:\${shortcode}:\`}
  );
}

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-01 - 基本協議與事件結構
  • NIP-25 - 反應(自訂表情反應)
  • NIP-94 - 檔案中繼資料

參考資源

已複製連結
已複製到剪貼簿