Files
boris/src/services/reactionService.ts
Gigi 6c0a2439ad feat: instant mark-as-read with checkmark animation and read status checking
- Add functions to check if article/URL was already marked as read via NIP-25 reactions
- Make mark-as-read action instant with fire-and-forget publishing
- Add checkmark icon animation when marking as read
- Display read status on load by querying kind:7 (nostr events) and kind:17 (websites) reactions
- Add green styling for already-read state
- Button shows checkmark and is disabled when article is already marked as read
2025-10-13 10:17:16 +02:00

200 lines
5.3 KiB
TypeScript

import { EventFactory } from 'applesauce-factory'
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
const MARK_AS_READ_EMOJI = '📚'
export { MARK_AS_READ_EMOJI }
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
* @param eventId The ID of the event being reacted to
* @param eventAuthor The pubkey of the event author
* @param eventKind The kind of the event being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createEventReaction(
eventId: string,
eventAuthor: string,
eventKind: number,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['p', eventAuthor],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 7, // Reaction
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
return signed
}
/**
* Creates a kind:17 reaction to a website (for external URLs)
* @param url The URL being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createWebsiteReaction(
url: string,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
// Normalize URL (remove fragments, trailing slashes as per NIP-25)
let normalizedUrl = url
try {
const parsed = new URL(url)
// Remove fragment
parsed.hash = ''
normalizedUrl = parsed.toString()
// Remove trailing slash if present
if (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1)
}
} catch (error) {
console.warn('Failed to normalize URL:', error)
}
const tags: string[][] = [
['r', normalizedUrl]
]
const draft = await factory.create(async () => ({
kind: 17, // Reaction to a website
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
return signed
}
/**
* Checks if the user has already marked a nostr event as read
* @param eventId The ID of the event to check
* @param userPubkey The user's pubkey
* @param relayPool The relay pool for querying
* @returns True if the user has already reacted with the mark-as-read emoji
*/
export async function hasMarkedEventAsRead(
eventId: string,
userPubkey: string,
relayPool: RelayPool
): Promise<boolean> {
try {
const filter = {
kinds: [7],
authors: [userPubkey],
'#e': [eventId]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
return hasReadReaction
} catch (error) {
console.error('Error checking read status:', error)
return false
}
}
/**
* Checks if the user has already marked a website as read
* @param url The URL to check
* @param userPubkey The user's pubkey
* @param relayPool The relay pool for querying
* @returns True if the user has already reacted with the mark-as-read emoji
*/
export async function hasMarkedWebsiteAsRead(
url: string,
userPubkey: string,
relayPool: RelayPool
): Promise<boolean> {
try {
// Normalize URL the same way as when creating reactions
let normalizedUrl = url
try {
const parsed = new URL(url)
parsed.hash = ''
normalizedUrl = parsed.toString()
if (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1)
}
} catch (error) {
console.warn('Failed to normalize URL:', error)
}
const filter = {
kinds: [17],
authors: [userPubkey],
'#r': [normalizedUrl]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
return hasReadReaction
} catch (error) {
console.error('Error checking read status:', error)
return false
}
}