跳至主要內容

NIP-27: 文字引用

在 Nostr 文字內容中引用其他事件和用戶的標準方法

概述

NIP-27 標準化了客戶端如何在可讀文字內容(如 kind 1 短文和 kind 30023 長文)中 處理對用戶和事件的內嵌引用。透過使用 nostr: URI 前綴, 客戶端可以將引用轉換為可點擊的連結、預覽卡片或其他豐富的呈現方式。

提及格式

提及使用 NIP-21 的 nostr: URI 格式:

用戶提及

nostr:npub1xxx...
nostr:nprofile1qqsw3dy8cpu...

事件引用

nostr:note1xxx...
nostr:nevent1qqsxxx...
nostr:naddr1qqsxxx...

範例

提及用戶

{
  "kind": 1,
  "content": "剛看到 nostr:npub1qqqqqq... 發的精彩文章!",
  "tags": [
    ["p", "<mentioned-pubkey>"]
  ]
}

引用事件

{
  "kind": 1,
  "content": "這個討論很有意思:nostr:nevent1qqsxxx...",
  "tags": [
    ["q", "<event-id>", "wss://relay.example.com", "<author-pubkey>"]
  ]
}

引用長文

{
  "kind": 1,
  "content": "推薦這篇教學:nostr:naddr1qqxnzd3exsmxxxx...",
  "tags": [
    ["q", "30023:<author-pubkey>:article-slug", "wss://relay.example.com"]
  ]
}

標籤要求

包含標籤是可選的,但在以下情況建議添加:

  • 希望通知被提及的用戶
  • 希望被引用的事件能識別此提及為回覆

用戶提及標籤

["p", "<pubkey>", "<relay-url>"]

事件引用標籤 (q 標籤)

// 引用一般事件
["q", "<event-id>", "<relay-url>", "<author-pubkey>"]

// 引用可定址事件
["q", "<kind>:<pubkey>:<d-tag>", "<relay-url>"]

客戶端行為

建立事件時

  • 將提及直接插入 .content 中,使用 NIP-21 格式
  • 可以提供自動完成功能幫助用戶插入提及
  • 根據需要決定是否添加通知標籤

讀取事件時

  • 解析內容中的 nostr: URI
  • 將用戶提及轉換為可點擊的個人資料連結
  • 將事件引用轉換為預覽卡片或嵌入內容
  • 連結到內部頁面或外部 Web 客戶端

TypeScript 實作

建立帶引用的事件

import { finalizeEvent, generateSecretKey, nip19 } from 'nostr-tools';

interface MentionedUser {
  pubkey: string;
  relay?: string;
}

interface QuotedEvent {
  eventId?: string;
  kind?: number;
  pubkey?: string;
  identifier?: string;
  relay?: string;
}

function createNoteWithReferences(
  content: string,
  mentions: MentionedUser[] = [],
  quotes: QuotedEvent[] = [],
  secretKey: Uint8Array
) {
  const tags: string[][] = [];

  // 添加用戶提及標籤
  mentions.forEach((mention) => {
    const pTag = ['p', mention.pubkey];
    if (mention.relay) pTag.push(mention.relay);
    tags.push(pTag);
  });

  // 添加事件引用標籤
  quotes.forEach((quote) => {
    if (quote.eventId) {
      // 一般事件
      const qTag = ['q', quote.eventId];
      if (quote.relay) qTag.push(quote.relay);
      if (quote.pubkey) qTag.push(quote.pubkey);
      tags.push(qTag);
    } else if (quote.kind && quote.pubkey && quote.identifier) {
      // 可定址事件
      const address = `${quote.kind}:${quote.pubkey}:${quote.identifier}`;
      const qTag = ['q', address];
      if (quote.relay) qTag.push(quote.relay);
      tags.push(qTag);
    }
  });

  const event = {
    kind: 1,
    content,
    tags,
    created_at: Math.floor(Date.now() / 1000),
  };

  return finalizeEvent(event, secretKey);
}

// 使用範例
const sk = generateSecretKey();

// 建立 nostr: URI
const userNpub = nip19.npubEncode('pubkey-hex...');
const eventNevent = nip19.neventEncode({
  id: 'event-id-hex...',
  relays: ['wss://relay.example.com'],
  author: 'author-pubkey...',
});

const content = `
剛看到 nostr:${userNpub} 的精彩分享!

這篇文章值得一讀:nostr:${eventNevent}
`;

const event = createNoteWithReferences(
  content,
  [{ pubkey: 'pubkey-hex...', relay: 'wss://relay.example.com' }],
  [{ eventId: 'event-id-hex...', relay: 'wss://relay.example.com', pubkey: 'author-pubkey...' }],
  sk
);

解析內容中的引用

import { nip19 } from 'nostr-tools';

interface ParsedReference {
  type: 'npub' | 'nprofile' | 'note' | 'nevent' | 'naddr';
  data: any;
  raw: string;
  start: number;
  end: number;
}

function parseReferences(content: string): ParsedReference[] {
  const regex = /nostr:(n(?:pub|profile|ote|event|addr)1[a-z0-9]+)/gi;
  const references: ParsedReference[] = [];

  let match;
  while ((match = regex.exec(content)) !== null) {
    const raw = match[1];
    try {
      const decoded = nip19.decode(raw);
      references.push({
        type: decoded.type as ParsedReference['type'],
        data: decoded.data,
        raw: match[0],
        start: match.index,
        end: match.index + match[0].length,
      });
    } catch {
      // 無效的 bech32,忽略
    }
  }

  return references;
}

// 使用範例
const refs = parseReferences(content);
refs.forEach((ref) => {
  console.log(`類型: ${ref.type}`);
  console.log(`資料:`, ref.data);
});

渲染豐富內容

interface RenderOptions {
  onProfileClick?: (pubkey: string) => void;
  onEventClick?: (eventId: string) => void;
}

function renderRichContent(
  content: string,
  options: RenderOptions = {}
): string {
  const references = parseReferences(content);

  // 從後往前替換,避免位置偏移
  let result = content;
  for (let i = references.length - 1; i >= 0; i--) {
    const ref = references[i];
    let replacement = '';

    switch (ref.type) {
      case 'npub':
        replacement = `@${ref.data.slice(0, 8)}...`;
        break;
      case 'nprofile':
        replacement = `@${ref.data.pubkey.slice(0, 8)}...`;
        break;
      case 'note':
        replacement = `
載入中...
`; break; case 'nevent': replacement = `
載入中...
`; break; case 'naddr': replacement = `
載入中...
`; break; } result = result.slice(0, ref.start) + replacement + result.slice(ref.end); } return result; } // React 元件範例 function RichTextContent({ content }: { content: string }) { const references = parseReferences(content); const parts: JSX.Element[] = []; let lastIndex = 0; references.forEach((ref, i) => { // 添加引用前的普通文字 if (ref.start > lastIndex) { parts.push( {content.slice(lastIndex, ref.start)} ); } // 添加引用元件 switch (ref.type) { case 'npub': case 'nprofile': const pubkey = ref.type === 'npub' ? ref.data : ref.data.pubkey; parts.push( ); break; case 'note': case 'nevent': const eventId = ref.type === 'note' ? ref.data : ref.data.id; parts.push( ); break; case 'naddr': parts.push( ); break; } lastIndex = ref.end; }); // 添加剩餘文字 if (lastIndex < content.length) { parts.push({content.slice(lastIndex)}); } return <>{parts}; }

最佳實踐

  • 使用 nprofile/nevent:包含中繼器提示,提高可發現性
  • 添加通知標籤:讓被提及的用戶收到通知
  • 提供自動完成:幫助用戶輕鬆插入提及
  • 優雅降級:無法解析時顯示原始 URI
  • 快取引用內容:避免重複抓取同一事件
  • NIP-21:nostr: URI - URI 格式定義
  • NIP-19:bech32 編碼 - npub、nevent 等格式
  • NIP-10:回覆與標記 - 回覆機制
  • NIP-18:轉發 - 引用轉發

參考資源

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