feat: implement NIP-38 user status functionality in ProfileInfoCard component (#84)

Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
mroxso
2025-05-24 23:29:38 +02:00
committed by GitHub
parent bca7f09129
commit 9543c319e4
2 changed files with 165 additions and 2 deletions

63
.github/prompts/nostr-nip38.prompt.md vendored Normal file
View File

@@ -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

View File

@@ -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<ProfileInfoCardProps> = React.memo(({ pubkey }) => {
let userPubkey = '';
@@ -38,6 +50,45 @@ const ProfileInfoCard: React.FC<ProfileInfoCardProps> = 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<ProfileInfoCardProps> = 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 (
<div className="flex flex-col gap-2 my-3">
{generalStatus && (
<Badge
variant="outline"
className="flex items-center gap-2 px-3 text-sm font-normal bg-primary/5 hover:bg-primary/10 transition-colors"
>
<ActivityIcon size={16} className="text-primary" />
<span>{generalStatus.content}</span>
{getStatusReference(generalStatus) && (
<Link href={getStatusReference(generalStatus) as string} target="_blank" className="text-primary hover:underline ml-1 text-xs">
Link
</Link>
)}
</Badge>
)}
{musicStatus && (
<Badge
variant="outline"
className="flex items-center gap-2 px-3 text-sm font-normal bg-primary/5 hover:bg-primary/10 transition-colors"
>
<MusicIcon size={16} className="text-primary" />
<span className="italic">{musicStatus.content}</span>
{getStatusReference(musicStatus) && (
<Link href={getStatusReference(musicStatus) as string} target="_blank" className="text-primary hover:underline ml-1 text-xs">
Listen
</Link>
)}
</Badge>
)}
</div>
);
};
return (
<div className='py-2'>
<Card>
@@ -140,6 +239,7 @@ const ProfileInfoCard: React.FC<ProfileInfoCardProps> = React.memo(({ pubkey })
<span>{website}</span>
</div>
)}
{renderUserStatus()}
</div>
</div>
<div>