mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 09:41:32 +02:00
feat: implement NIP-38 user status functionality in ProfileInfoCard component (#84)
Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
63
.github/prompts/nostr-nip38.prompt.md
vendored
Normal file
63
.github/prompts/nostr-nip38.prompt.md
vendored
Normal 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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user