13 changed files with 408 additions and 20 deletions
@ -0,0 +1,33 @@
@@ -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 @@
@@ -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 @@
@@ -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