跳至主要內容
進階 Nostr NIP-38 狀態 status music

NIP-38: 用戶狀態

User Statuses - 分享用戶的即時活動狀態

10 分鐘

概述

NIP-38 定義了用戶狀態事件,讓用戶可以分享他們當前的活動狀態。這類似於傳統社交平台的「正在聽」、「正在做什麼」功能, 但以去中心化的方式實現。狀態可以是一般活動、正在播放的音樂,或任何自定義類型。

核心概念: 用戶狀態使用 kind 30315(可定址事件),透過 d 標籤區分不同類型的狀態, 並支援過期時間讓狀態自動消失。

事件結構

用戶狀態事件使用 kind 30315,是可定址事件(addressable event):

欄位 說明
kind 30315(用戶狀態)
content 狀態文字,空字串表示清除狀態
d 狀態類型標識符(必要)
expiration 過期時間戳(可選)
r 相關連結 URL(可選)

狀態類型

NIP-38 定義了兩種標準狀態類型,同時允許自定義類型:

💬 general

一般活動狀態,如「工作中」、「旅行中」、「開會中」等。

d: "general"

🎵 music

正在播放的音樂,過期時間應設為歌曲結束時間。

d: "music"

自定義類型: 除了標準類型外,應用可以定義自己的狀態類型,如 gamingreadingstreaming 等。

使用範例

一般狀態

{
  "kind": 30315,
  "tags": [
    ["d", "general"]
  ],
  "content": "正在寫程式 👨‍💻"
}

帶連結的狀態

{
  "kind": 30315,
  "tags": [
    ["d", "general"],
    ["r", "https://github.com/nostr-protocol/nips"]
  ],
  "content": "正在研究 Nostr NIPs"
}

音樂狀態(帶過期時間)

{
  "kind": 30315,
  "tags": [
    ["d", "music"],
    ["r", "spotify:track:4PTG3Z6ehGkBFwjybzWkR8"],
    ["expiration", "1704067200"]
  ],
  "content": "🎵 Never Gonna Give You Up - Rick Astley"
}

帶自訂表情的狀態

{
  "kind": 30315,
  "tags": [
    ["d", "general"],
    ["emoji", "bitcoin", "https://example.com/bitcoin.png"]
  ],
  "content": "Stacking sats :bitcoin:"
}

引用其他內容

{
  "kind": 30315,
  "tags": [
    ["d", "general"],
    ["e", "<event-id>", "wss://relay.example.com"],
    ["p", "<author-pubkey>"]
  ],
  "content": "正在閱讀這篇精彩的文章"
}

清除狀態

{
  "kind": 30315,
  "tags": [
    ["d", "general"]
  ],
  "content": ""
}

TypeScript 實作

import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'

// 狀態類型
type StatusType = 'general' | 'music' | string

interface StatusOptions {
  type: StatusType
  content: string
  url?: string
  expiration?: number
  eventRef?: { id: string; relay?: string }
  profileRef?: string
  emoji?: { shortcode: string; url: string }[]
}

// 創建用戶狀態事件
function createStatusEvent(
  options: StatusOptions,
  secretKey: Uint8Array
): object {
  const tags: string[][] = [['d', options.type]]

  // 添加 URL 連結
  if (options.url) {
    tags.push(['r', options.url])
  }

  // 添加過期時間
  if (options.expiration) {
    tags.push(['expiration', options.expiration.toString()])
  }

  // 添加事件引用
  if (options.eventRef) {
    tags.push(['e', options.eventRef.id, options.eventRef.relay || ''])
  }

  // 添加用戶引用
  if (options.profileRef) {
    tags.push(['p', options.profileRef])
  }

  // 添加自訂表情
  if (options.emoji) {
    for (const e of options.emoji) {
      tags.push(['emoji', e.shortcode, e.url])
    }
  }

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

  return finalizeEvent(event, secretKey)
}

// 設置一般狀態
function setGeneralStatus(
  content: string,
  secretKey: Uint8Array,
  options?: { url?: string; expiration?: number }
): object {
  return createStatusEvent({
    type: 'general',
    content,
    ...options,
  }, secretKey)
}

// 設置音樂狀態
function setMusicStatus(
  track: string,
  artist: string,
  secretKey: Uint8Array,
  options?: { url?: string; durationSeconds?: number }
): object {
  const expiration = options?.durationSeconds
    ? Math.floor(Date.now() / 1000) + options.durationSeconds
    : undefined

  return createStatusEvent({
    type: 'music',
    content: `🎵 ${track} - ${artist}`,
    url: options?.url,
    expiration,
  }, secretKey)
}

// 清除狀態
function clearStatus(
  type: StatusType,
  secretKey: Uint8Array
): object {
  return createStatusEvent({
    type,
    content: '',
  }, secretKey)
}

// 使用範例
const sk = generateSecretKey()
const pk = getPublicKey(sk)

// 設置一般狀態
const generalStatus = setGeneralStatus(
  '正在開發 Nostr 客戶端 🚀',
  sk,
  { url: 'https://github.com/myproject' }
)

// 設置音樂狀態(歌曲長度 3 分 30 秒)
const musicStatus = setMusicStatus(
  'Bohemian Rhapsody',
  'Queen',
  sk,
  {
    url: 'spotify:track:7tFiyTwD0nx5a1eklYtX2J',
    durationSeconds: 210,
  }
)

// 設置帶表情的狀態
const emojiStatus = createStatusEvent({
  type: 'general',
  content: 'HODLing :bitcoin: forever!',
  emoji: [
    { shortcode: 'bitcoin', url: 'https://example.com/bitcoin.png' }
  ],
}, sk)

// 清除狀態
const cleared = clearStatus('general', sk)

console.log('General status:', generalStatus)
console.log('Music status:', musicStatus)
console.log('Emoji status:', emojiStatus)
console.log('Cleared:', cleared)

查詢狀態

import { SimplePool } from 'nostr-tools'

const pool = new SimplePool()
const relays = ['wss://relay.example.com']

// 查詢用戶的所有狀態
async function getUserStatuses(pubkey: string) {
  const statuses = await pool.querySync(relays, {
    kinds: [30315],
    authors: [pubkey],
  })
  return statuses
}

// 查詢特定類型的狀態
async function getUserStatusByType(pubkey: string, type: string) {
  const statuses = await pool.querySync(relays, {
    kinds: [30315],
    authors: [pubkey],
    '#d': [type],
  })
  return statuses[0] || null
}

// 查詢多個用戶的一般狀態
async function getGeneralStatuses(pubkeys: string[]) {
  const statuses = await pool.querySync(relays, {
    kinds: [30315],
    authors: pubkeys,
    '#d': ['general'],
  })
  return statuses
}

// 查詢正在聽音樂的用戶
async function getMusicStatuses(pubkeys: string[]) {
  const statuses = await pool.querySync(relays, {
    kinds: [30315],
    authors: pubkeys,
    '#d': ['music'],
  })

  // 過濾掉已過期的狀態
  const now = Math.floor(Date.now() / 1000)
  return statuses.filter(status => {
    const expTag = status.tags.find((t: string[]) => t[0] === 'expiration')
    if (!expTag) return true
    return parseInt(expTag[1]) > now
  })
}

// 解析狀態事件
function parseStatus(event: any): {
  type: string
  content: string
  url?: string
  expiration?: number
  isExpired: boolean
} {
  const type = event.tags.find((t: string[]) => t[0] === 'd')?.[1] || 'general'
  const url = event.tags.find((t: string[]) => t[0] === 'r')?.[1]
  const expTag = event.tags.find((t: string[]) => t[0] === 'expiration')
  const expiration = expTag ? parseInt(expTag[1]) : undefined
  const isExpired = expiration ? expiration < Math.floor(Date.now() / 1000) : false

  return {
    type,
    content: event.content,
    url,
    expiration,
    isExpired,
  }
}

// 訂閱狀態更新
function subscribeToStatuses(
  pubkeys: string[],
  onStatus: (event: any, parsed: ReturnType<typeof parseStatus>) => void
) {
  return pool.subscribeMany(
    relays,
    [{
      kinds: [30315],
      authors: pubkeys,
    }],
    {
      onevent(event) {
        const parsed = parseStatus(event)
        if (!parsed.isExpired && parsed.content) {
          onStatus(event, parsed)
        }
      },
    }
  )
}

// 使用範例
const targetPubkey = 'abc123...'

// 獲取用戶的一般狀態
const generalStatus = await getUserStatusByType(targetPubkey, 'general')
if (generalStatus) {
  const parsed = parseStatus(generalStatus)
  console.log(`狀態: ${parsed.content}`)
  if (parsed.url) {
    console.log(`連結: ${parsed.url}`)
  }
}

// 訂閱關注列表的狀態
const followingPubkeys = ['pubkey1', 'pubkey2', 'pubkey3']
const sub = subscribeToStatuses(followingPubkeys, (event, parsed) => {
  console.log(`${event.pubkey} 的 ${parsed.type} 狀態: ${parsed.content}`)
})

應用場景

🎵 音樂分享

與 Spotify、Apple Music 等整合,自動分享正在聽的歌曲,過期時間設為歌曲長度。

📅 日曆整合

日曆應用自動更新狀態為「開會中」、「忙碌」等,過期時間設為會議結束時間。

🎙️ 直播/語音房

Nostr Nests 等語音房應用可以顯示用戶正在參與的房間連結。

🎮 遊戲狀態

顯示正在玩的遊戲,邀請朋友一起玩。

🎧 Podcast 分享

Podcast 應用分享正在收聽的節目,方便朋友發現好內容。

📍 位置分享

分享當前位置或旅行狀態,如「在東京旅行」。

React 元件範例

import { useState, useEffect } from 'react'
import { SimplePool } from 'nostr-tools'

interface UserStatusProps {
  pubkey: string
  relays: string[]
}

interface Status {
  type: string
  content: string
  url?: string
  isExpired: boolean
}

function UserStatus({ pubkey, relays }: UserStatusProps) {
  const [statuses, setStatuses] = useState<Map<string, Status>>(new Map())
  const pool = new SimplePool()

  useEffect(() => {
    const sub = pool.subscribeMany(
      relays,
      [{
        kinds: [30315],
        authors: [pubkey],
      }],
      {
        onevent(event) {
          const type = event.tags.find(t => t[0] === 'd')?.[1] || 'general'
          const url = event.tags.find(t => t[0] === 'r')?.[1]
          const expTag = event.tags.find(t => t[0] === 'expiration')
          const expiration = expTag ? parseInt(expTag[1]) : undefined
          const isExpired = expiration
            ? expiration < Math.floor(Date.now() / 1000)
            : false

          if (!isExpired && event.content) {
            setStatuses(prev => new Map(prev).set(type, {
              type,
              content: event.content,
              url,
              isExpired,
            }))
          }
        },
      }
    )

    return () => sub.close()
  }, [pubkey, relays])

  if (statuses.size === 0) return null

  return (
    <div className="space-y-2">
      {Array.from(statuses.entries()).map(([type, status]) => (
        <div
          key={type}
          className="flex items-center gap-2 text-sm text-gray-400"
        >
          <StatusIcon type={type} />
          {status.url ? (
            <a
              href={status.url}
              target="_blank"
              rel="noopener noreferrer"
              className="hover:text-purple-400"
            >
              {status.content}
            </a>
          ) : (
            <span>{status.content}</span>
          )}
        </div>
      ))}
    </div>
  )
}

function StatusIcon({ type }: { type: string }) {
  switch (type) {
    case 'music':
      return <span>🎵</span>
    case 'gaming':
      return <span>🎮</span>
    case 'streaming':
      return <span>🎙️</span>
    default:
      return <span>💬</span>
  }
}

// 狀態設置元件
function StatusSetter({ onSubmit }: { onSubmit: (status: StatusOptions) => void }) {
  const [type, setType] = useState('general')
  const [content, setContent] = useState('')
  const [url, setUrl] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    onSubmit({
      type,
      content,
      url: url || undefined,
    })
    setContent('')
    setUrl('')
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <select
        value={type}
        onChange={e => setType(e.target.value)}
        className="w-full p-2 rounded bg-gray-800 border border-gray-700"
      >
        <option value="general">💬 一般</option>
        <option value="music">🎵 音樂</option>
        <option value="gaming">🎮 遊戲</option>
        <option value="streaming">🎙️ 直播</option>
      </select>

      <input
        type="text"
        value={content}
        onChange={e => setContent(e.target.value)}
        placeholder="你在做什麼?"
        className="w-full p-2 rounded bg-gray-800 border border-gray-700"
      />

      <input
        type="url"
        value={url}
        onChange={e => setUrl(e.target.value)}
        placeholder="相關連結(可選)"
        className="w-full p-2 rounded bg-gray-800 border border-gray-700"
      />

      <button
        type="submit"
        disabled={!content}
        className="w-full py-2 px-4 bg-purple-600 hover:bg-purple-700
                   disabled:opacity-50 rounded font-medium"
      >
        更新狀態
      </button>
    </form>
  )
}

最佳實踐

✓ 設置適當的過期時間

對於音樂等臨時狀態,設置合理的過期時間讓狀態自動消失,避免顯示過時資訊。

✓ 使用標準類型

優先使用 generalmusic 等標準類型,確保跨客戶端相容性。

✓ 提供有用的連結

當狀態有相關連結時(如音樂 URL、直播間連結),使用 r 標籤提供連結讓其他用戶可以參與。

⚠ 客戶端應過濾過期狀態

中繼器可能不會自動刪除過期事件,客戶端應檢查 expiration 標籤並過濾已過期的狀態。

⚠ 空內容清除狀態

要清除狀態,發送相同類型但 content 為空字串的事件。客戶端應將空內容視為無狀態。

相關 NIP

總結

NIP-38 用戶狀態提供了一種簡單而靈活的方式,讓用戶分享他們的即時活動。 透過標準化的狀態類型和過期機制,應用可以整合各種服務來自動更新用戶狀態。

Kind 30315
可定址狀態事件
d 標籤
區分狀態類型
自動過期
expiration 標籤
已複製連結
已複製到剪貼簿