From 9543c319e4248e9b2b40a273cdafb9e95d62f7a0 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sat, 24 May 2025 23:29:38 +0200 Subject: [PATCH] feat: implement NIP-38 user status functionality in ProfileInfoCard component (#84) Co-authored-by: highperfocused --- .github/prompts/nostr-nip38.prompt.md | 63 ++++++++++++++++ components/ProfileInfoCard.tsx | 104 +++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 .github/prompts/nostr-nip38.prompt.md diff --git a/.github/prompts/nostr-nip38.prompt.md b/.github/prompts/nostr-nip38.prompt.md new file mode 100644 index 0000000..f3d47c5 --- /dev/null +++ b/.github/prompts/nostr-nip38.prompt.md @@ -0,0 +1,63 @@ + +NIP-38 +====== + +User Statuses +------------- + +`draft` `optional` + +## Abstract + +This NIP enables a way for users to share live statuses such as what music they are listening to, as well as what they are currently doing: work, play, out of office, etc. + +## Live Statuses + +A special event with `kind:30315` "User Status" is defined as an *optionally expiring* _addressable event_, where the `d` tag represents the status type: + +For example: + +```json +{ + "kind": 30315, + "content": "Sign up for nostrasia!", + "tags": [ + ["d", "general"], + ["r", "https://nostr.world"] + ], +} +``` + +```json +{ + "kind": 30315, + "content": "Intergalatic - Beastie Boys", + "tags": [ + ["d", "music"], + ["r", "spotify:search:Intergalatic%20-%20Beastie%20Boys"], + ["expiration", "1692845589"] + ], +} +``` + +Two common status types are defined: `general` and `music`. `general` represent general statuses: "Working", "Hiking", etc. + +`music` status events are for live streaming what you are currently listening to. The expiry of the `music` status should be when the track will stop playing. + +Any other status types can be used but they are not defined by this NIP. + +The status MAY include an `r`, `p`, `e` or `a` tag linking to a URL, profile, note, or addressable event. + +The `content` MAY include emoji(s), or [NIP-30](30.md) custom emoji(s). If the `content` is an empty string then the client should clear the status. + +# Client behavior + +Clients MAY display this next to the username on posts or profiles to provide live user status information. + +# Use Cases + +* Calendar nostr apps that update your general status when you're in a meeting +* Nostr Nests that update your general status with a link to the nest when you join +* Nostr music streaming services that update your music status when you're listening +* Podcasting apps that update your music status when you're listening to a podcast, with a link for others to listen as well +* Clients can use the system media player to update playing music status \ No newline at end of file diff --git a/components/ProfileInfoCard.tsx b/components/ProfileInfoCard.tsx index 32e6a84..a7e4bce 100644 --- a/components/ProfileInfoCard.tsx +++ b/components/ProfileInfoCard.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; -import { useProfile } from "nostr-react"; +import { useProfile, useNostrEvents } from "nostr-react"; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { AvatarImage } from '@radix-ui/react-avatar'; import { Avatar } from '@/components/ui/avatar'; import NIP05 from '@/components/nip05'; -import { nip19 } from "nostr-tools"; +import { nip19, type Event as NostrEvent } from "nostr-tools"; import Link from 'next/link'; import { Button } from './ui/button'; import { ImStatsDots } from "react-icons/im"; @@ -23,11 +23,23 @@ import { Input } from './ui/input'; import { Share1Icon, LightningBoltIcon, GlobeIcon } from '@radix-ui/react-icons'; import { toast } from './ui/use-toast'; import { Globe } from 'lucide-react'; +import { Badge } from './ui/badge'; +import { MusicIcon, ActivityIcon } from 'lucide-react'; + +// NIP-38 Status types +const STATUS_TYPES = { + GENERAL: 'general', + MUSIC: 'music' +}; interface ProfileInfoCardProps { pubkey: string; } +interface StatusMap { + [key: string]: NostrEvent; +} + const ProfileInfoCard: React.FC = React.memo(({ pubkey }) => { let userPubkey = ''; @@ -38,6 +50,45 @@ const ProfileInfoCard: React.FC = React.memo(({ pubkey }) } const { data: userData, isLoading } = useProfile({ pubkey }); + + // Fetch user status events (NIP-38) + const { events: statusEvents } = useNostrEvents({ + filter: { + authors: [pubkey], + kinds: [30315], // NIP-38 user status event kind + limit: 1, + }, + }); + + // Get the latest status events by type + const userStatuses = useMemo(() => { + const statuses: StatusMap = {}; + + // Process status events + for (const event of statusEvents) { + const dTag = event.tags.find(tag => tag[0] === 'd'); + if (!dTag || !dTag[1]) continue; + + const statusType = dTag[1]; + + // Check if event has expiration + const expirationTag = event.tags.find(tag => tag[0] === 'expiration'); + if (expirationTag && expirationTag[1]) { + const expirationTime = parseInt(expirationTag[1]); + const now = Math.floor(Date.now() / 1000); + + // Skip expired statuses + if (expirationTime < now) continue; + } + + // Set/update status (most recent one for the type) + if (!statuses[statusType] || statuses[statusType].created_at < event.created_at) { + statuses[statusType] = event; + } + } + + return statuses; + }, [statusEvents]); const npubShortened = useMemo(() => { let encoded = nip19.npubEncode(pubkey); @@ -113,6 +164,54 @@ const ProfileInfoCard: React.FC = React.memo(({ pubkey }) window.open(url, '_blank'); }; + // Get reference URL from status event + const getStatusReference = (event: NostrEvent): string | null => { + const refTag = event.tags.find(tag => tag[0] === 'r'); + return refTag ? refTag[1] : null; + }; + + // Render user status component + const renderUserStatus = () => { + const generalStatus = userStatuses[STATUS_TYPES.GENERAL]; + const musicStatus = userStatuses[STATUS_TYPES.MUSIC]; + + if (!generalStatus && !musicStatus) return null; + + return ( +
+ {generalStatus && ( + + + {generalStatus.content} + {getStatusReference(generalStatus) && ( + + Link + + )} + + )} + + {musicStatus && ( + + + {musicStatus.content} + {getStatusReference(musicStatus) && ( + + Listen + + )} + + )} +
+ ); + }; + return (
@@ -140,6 +239,7 @@ const ProfileInfoCard: React.FC = React.memo(({ pubkey }) {website}
)} + {renderUserStatus()}