13 changed files with 408 additions and 20 deletions
@ -0,0 +1,33 @@ |
|||||||
|
import { MessageCircle } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import Notification from './Notification' |
||||||
|
|
||||||
|
export function DiscussionNotification({ |
||||||
|
notification, |
||||||
|
isNew = false |
||||||
|
}: { |
||||||
|
notification: Event |
||||||
|
isNew?: boolean |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
// Get the topic from t-tags
|
||||||
|
const topicTags = notification.tags.filter(tag => tag[0] === 't' && tag[1]) |
||||||
|
const topics = topicTags.map(tag => tag[1]) |
||||||
|
const topicString = topics.length > 0 ? topics.join(', ') : t('general') |
||||||
|
|
||||||
|
return ( |
||||||
|
<Notification |
||||||
|
notificationId={notification.id} |
||||||
|
sender={notification.pubkey} |
||||||
|
sentAt={notification.created_at} |
||||||
|
description={t('started a discussion in {{topic}}', { topic: topicString })} |
||||||
|
icon={<MessageCircle className="w-4 h-4 text-primary" />} |
||||||
|
targetEvent={notification} |
||||||
|
isNew={isNew} |
||||||
|
showStats={false} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,90 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { useInterestList } from '@/providers/InterestListProvider' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { Bell, BellOff, Loader2 } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
interface TopicSubscribeButtonProps { |
||||||
|
topic: string |
||||||
|
variant?: 'default' | 'outline' | 'ghost' | 'icon' |
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon' |
||||||
|
showLabel?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export default function TopicSubscribeButton({ |
||||||
|
topic, |
||||||
|
variant = 'outline', |
||||||
|
size = 'sm', |
||||||
|
showLabel = true |
||||||
|
}: TopicSubscribeButtonProps) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const { isSubscribed, subscribe, unsubscribe, changing } = useInterestList() |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
const subscribed = isSubscribed(topic) |
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
e.preventDefault() |
||||||
|
|
||||||
|
if (changing) return |
||||||
|
|
||||||
|
if (subscribed) { |
||||||
|
await unsubscribe(topic) |
||||||
|
} else { |
||||||
|
await subscribe(topic) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (variant === 'icon' || !showLabel) { |
||||||
|
return ( |
||||||
|
<Button |
||||||
|
variant={subscribed ? 'default' : 'outline'} |
||||||
|
size={size === 'icon' ? 'icon' : size} |
||||||
|
onClick={handleClick} |
||||||
|
disabled={changing} |
||||||
|
title={subscribed ? t('Unsubscribe') : t('Subscribe')} |
||||||
|
> |
||||||
|
{changing ? ( |
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> |
||||||
|
) : subscribed ? ( |
||||||
|
<Bell className="h-4 w-4" fill="currentColor" /> |
||||||
|
) : ( |
||||||
|
<BellOff className="h-4 w-4" /> |
||||||
|
)} |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Button |
||||||
|
variant={subscribed ? 'default' : variant} |
||||||
|
size={size} |
||||||
|
onClick={handleClick} |
||||||
|
disabled={changing} |
||||||
|
className="flex items-center gap-2" |
||||||
|
> |
||||||
|
{changing ? ( |
||||||
|
<> |
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> |
||||||
|
{subscribed ? t('Unsubscribing...') : t('Subscribing...')} |
||||||
|
</> |
||||||
|
) : subscribed ? ( |
||||||
|
<> |
||||||
|
<Bell className="h-4 w-4" fill="currentColor" /> |
||||||
|
{t('Subscribed')} |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<BellOff className="h-4 w-4" /> |
||||||
|
{t('Subscribe')} |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,155 @@ |
|||||||
|
import { createInterestListDraftEvent } from '@/lib/draft-event' |
||||||
|
import { normalizeTopic } from '@/lib/discussion-topics' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
import { useNostr } from './NostrProvider' |
||||||
|
|
||||||
|
type TInterestListContext = { |
||||||
|
subscribedTopics: Set<string> |
||||||
|
changing: boolean |
||||||
|
isSubscribed: (topic: string) => boolean |
||||||
|
subscribe: (topic: string) => Promise<void> |
||||||
|
unsubscribe: (topic: string) => Promise<void> |
||||||
|
getSubscribedTopics: () => string[] |
||||||
|
} |
||||||
|
|
||||||
|
const InterestListContext = createContext<TInterestListContext | undefined>(undefined) |
||||||
|
|
||||||
|
export const useInterestList = () => { |
||||||
|
const context = useContext(InterestListContext) |
||||||
|
if (!context) { |
||||||
|
throw new Error('useInterestList must be used within an InterestListProvider') |
||||||
|
} |
||||||
|
return context |
||||||
|
} |
||||||
|
|
||||||
|
export function InterestListProvider({ children }: { children: React.ReactNode }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey: accountPubkey, interestListEvent, publish, updateInterestListEvent } = useNostr() |
||||||
|
const [topics, setTopics] = useState<string[]>([]) |
||||||
|
const subscribedTopics = useMemo(() => new Set(topics), [topics]) |
||||||
|
const [changing, setChanging] = useState(false) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const updateTopics = () => { |
||||||
|
if (!interestListEvent) { |
||||||
|
setTopics([]) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Extract t-tags from the interest list
|
||||||
|
const topicTags = interestListEvent.tags |
||||||
|
.filter(tag => tag[0] === 't' && tag[1]) |
||||||
|
.map(tag => normalizeTopic(tag[1])) |
||||||
|
|
||||||
|
setTopics(topicTags) |
||||||
|
} |
||||||
|
updateTopics() |
||||||
|
}, [interestListEvent]) |
||||||
|
|
||||||
|
const getSubscribedTopics = useCallback(() => { |
||||||
|
return Array.from(subscribedTopics) |
||||||
|
}, [subscribedTopics]) |
||||||
|
|
||||||
|
const isSubscribed = useCallback( |
||||||
|
(topic: string): boolean => { |
||||||
|
return subscribedTopics.has(normalizeTopic(topic)) |
||||||
|
}, |
||||||
|
[subscribedTopics] |
||||||
|
) |
||||||
|
|
||||||
|
const publishNewInterestListEvent = async (newTopics: string[]) => { |
||||||
|
const newInterestListEvent = createInterestListDraftEvent(newTopics) |
||||||
|
const publishedEvent = await publish(newInterestListEvent) |
||||||
|
return publishedEvent |
||||||
|
} |
||||||
|
|
||||||
|
const subscribe = async (topic: string) => { |
||||||
|
if (!accountPubkey || changing) return |
||||||
|
|
||||||
|
const normalizedTopic = normalizeTopic(topic) |
||||||
|
if (subscribedTopics.has(normalizedTopic)) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
setChanging(true) |
||||||
|
try { |
||||||
|
const interestListEvent = await client.fetchInterestListEvent(accountPubkey) |
||||||
|
const currentTopics = interestListEvent |
||||||
|
? interestListEvent.tags |
||||||
|
.filter(tag => tag[0] === 't' && tag[1]) |
||||||
|
.map(tag => normalizeTopic(tag[1])) |
||||||
|
: [] |
||||||
|
|
||||||
|
if (currentTopics.includes(normalizedTopic)) { |
||||||
|
// Already subscribed
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const newTopics = [...currentTopics, normalizedTopic] |
||||||
|
const newInterestListEvent = await publishNewInterestListEvent(newTopics) |
||||||
|
await updateInterestListEvent(newInterestListEvent) |
||||||
|
|
||||||
|
toast.success(t('Subscribed to topic')) |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to subscribe to topic:', error) |
||||||
|
toast.error(t('Failed to subscribe to topic') + ': ' + (error as Error).message) |
||||||
|
} finally { |
||||||
|
setChanging(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const unsubscribe = async (topic: string) => { |
||||||
|
if (!accountPubkey || changing) return |
||||||
|
|
||||||
|
const normalizedTopic = normalizeTopic(topic) |
||||||
|
if (!subscribedTopics.has(normalizedTopic)) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
setChanging(true) |
||||||
|
try { |
||||||
|
const interestListEvent = await client.fetchInterestListEvent(accountPubkey) |
||||||
|
if (!interestListEvent) return |
||||||
|
|
||||||
|
const currentTopics = interestListEvent.tags |
||||||
|
.filter(tag => tag[0] === 't' && tag[1]) |
||||||
|
.map(tag => normalizeTopic(tag[1])) |
||||||
|
|
||||||
|
const newTopics = currentTopics.filter(t => t !== normalizedTopic) |
||||||
|
|
||||||
|
if (newTopics.length === currentTopics.length) { |
||||||
|
// Topic wasn't in the list
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const newInterestListEvent = await publishNewInterestListEvent(newTopics) |
||||||
|
await updateInterestListEvent(newInterestListEvent) |
||||||
|
|
||||||
|
toast.success(t('Unsubscribed from topic')) |
||||||
|
} catch (error) { |
||||||
|
console.error('Failed to unsubscribe from topic:', error) |
||||||
|
toast.error(t('Failed to unsubscribe from topic') + ': ' + (error as Error).message) |
||||||
|
} finally { |
||||||
|
setChanging(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<InterestListContext.Provider |
||||||
|
value={{ |
||||||
|
subscribedTopics, |
||||||
|
changing, |
||||||
|
isSubscribed, |
||||||
|
subscribe, |
||||||
|
unsubscribe, |
||||||
|
getSubscribedTopics |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</InterestListContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue