Compare commits

..

5 commits

Author SHA1 Message Date
414b79565c chore(base): delete nostr-metadata-service + retire webapp-side kind-0 broadcast paths
Lnbits's cascade now publishes kind-0 user metadata server-side on
account creation AND on every PATCH /api/v1/auth (aiolabs/lnbits commit
869f67c3 folded into PR #26, deployed to aio-demo via server-deploy
e2eed9c). The webapp no longer needs its own kind-0 publish surface.

Changes:
- Delete src/modules/base/nostr/nostr-metadata-service.ts (162 lines).
  Server now owns kind-0 lifecycle via NostrSigner.sign_event.
- Delete src/modules/base/composables/useNostrMetadata.ts (had zero
  callers; was just a thin wrapper around the deleted service).
- Remove NOSTR_METADATA_SERVICE token from di-container.ts.
- Remove all NostrMetadataService imports / instantiations /
  registrations / dispose calls from src/modules/base/index.ts.
- src/modules/base/auth/auth-service.ts:
  - Drop the broadcastNostrMetadata() helper entirely.
  - Drop its callers in login() (was line 118 pre-edit) and register()
    (was line 142 pre-edit) — both flagged for removal by lnbits in the
    01:45Z coordination handoff. Login-time republish was always
    redundant for kind-0 (replaceable event); register-time is covered
    by lnbits's create_user_account -> _publish_nostr_metadata_event
    path.
  - Drop the auto-broadcast in updateProfile() too — covered by the
    PATCH /api/v1/auth handler's _publish_nostr_metadata_event call
    per the gap-fill commit.
  - Leave the prvkey/pubkey preservation in updateProfile() in place
    for now; the prvkey field removal is the atomic phase-1 final PR
    per design doc Q1.2 Option (b).
- src/modules/base/components/ProfileSettings.vue:
  - Remove the "Broadcast to Nostr" button + isBroadcasting state +
    Radio icon + broadcastMetadata() handler. Manual re-broadcast was
    a local-testing safety net for relay resets that's no longer
    needed once the server publishes automatically on profile save.
  - Simplify the post-save toast to a generic "Profile updated!".
  - Update the helper text accordingly.

This is webapp's bucket-A leg per aiolabs/lnbits#9 phase-1 plan.

Refs:
- log:2026-05-29T01:45Z (lnbits handoff identifying the auth-service
  line numbers to drop)
- ~/dev/coordination/webapp-design-questions.md Q2.3 (decision context)
- aiolabs/lnbits PR #26 commit 869f67c3 (server-side kind-0 publish)
- aiolabs/lnbits dev tip 861f427c, deployed to aio-demo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:38:01 +02:00
114d2837c9 Merge pull request 'chore(nostr-feed): delete legacy ScheduledEventService duplicate' (#81) from chore/dedup-scheduled-event-service into dev
Reviewed-on: #81
2026-05-29 19:33:40 +00:00
221c927c74 Merge pull request 'chore(nostr-feed): delete dead-code ReactionService + useReactions duplicates' (#80) from chore/dedup-reaction-service into dev
Reviewed-on: #80
2026-05-29 19:33:27 +00:00
e2a1f024e4 chore(nostr-feed): delete legacy ScheduledEventService duplicate
ScheduledEventService and useScheduledEvents were a legacy duplicate
of TaskService and useTasks. The DI token was already marked
@deprecated, and FeedService routed runtime events to TASK_SERVICE
already — only the publish-side and the NostrFeed view hadn't been
repointed yet.

Changes:
- Delete nostr-feed/services/ScheduledEventService.ts (1067 lines)
- Delete nostr-feed/composables/useScheduledEvents.ts
- Remove SCHEDULED_EVENT_SERVICE token from di-container.ts
- Repoint NostrFeed.vue to useTasks from the tasks module, with
  autoSubscribe: false (FeedService still owns the relay subscription
  for kinds 31922/31925/5)
- Rename FeedService.scheduledEventService field to taskService for
  honesty (the alias was already pointing at TASK_SERVICE)
- Drop tryInjectService legacy-fallback shim — strict-from-the-start
  per workspace pre-launch policy. The tasks module is required;
  inject hard-fails on absence.
- Remove now-dead defensive null guards around taskService and
  reactionService calls in route methods

Closes #79.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:12:21 +02:00
99ca0bf64a chore(nostr-feed): delete dead-code ReactionService + useReactions duplicates
The nostr-feed module had its own copies of ReactionService and
useReactions that were never wired in — the live implementations
live in src/modules/base/. The nostr-feed copy of ReactionService
was a strict subset of the base copy (missing toggleLikeEvent /
toggleDislikeEvent) and was never registered in DI. The
nostr-feed copy of useReactions was identical to the base copy
modulo the type import path; the one consumer (NostrFeed.vue)
already imports from the base path.

Closes #78.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:51:47 +02:00
7 changed files with 22 additions and 1678 deletions

View file

@ -139,8 +139,6 @@ export const SERVICE_TOKENS = {
// Tasks services
TASK_SERVICE: Symbol('taskService'),
/** @deprecated Use TASK_SERVICE instead */
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
// Links services
SUBMISSION_SERVICE: Symbol('submissionService'),

View file

@ -13,7 +13,7 @@ import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lu
import { useFeed } from '../composables/useFeed'
import { useProfiles } from '@/modules/base/composables/useProfiles'
import { useReactions } from '@/modules/base/composables/useReactions'
import { useScheduledEvents } from '../composables/useScheduledEvents'
import { useTasks } from '@/modules/tasks/composables/useTasks'
import ThreadedPost from './ThreadedPost.vue'
import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config'
@ -98,7 +98,9 @@ const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Use scheduled events service
// Task service is shared with the standalone tasks app; FeedService
// already routes kind 31922/31925/5 events to it, so opt out of the
// composable's own subscription lifecycle.
const {
getEventsForSpecificDate,
getCompletion,
@ -109,7 +111,7 @@ const {
unclaimTask,
deleteTask,
allCompletions
} = useScheduledEvents()
} = useTasks({ autoSubscribe: false })
// Selected date for viewing scheduled tasks (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0])
@ -405,7 +407,7 @@ async function confirmDeletePost() {
const userPrivkey = authService?.user.value?.prvkey
if (!userPrivkey) {
toast.error("User private key not available")
toast.error("User private key not available") // pragma: allowlist secret
showDeleteDialog.value = false
postToDelete.value = null
return

View file

@ -1,102 +0,0 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ReactionService, EventReactions } from '../services/ReactionService'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing reactions in the feed
*/
export function useReactions() {
const reactionService = injectService<ReactionService>(SERVICE_TOKENS.REACTION_SERVICE)
const toast = useToast()
/**
* Get reactions for a specific event
*/
const getEventReactions = (eventId: string): EventReactions => {
if (!reactionService) {
return {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
}
}
return reactionService.getEventReactions(eventId)
}
/**
* Subscribe to reactions for a list of event IDs
*/
const subscribeToReactions = async (eventIds: string[]): Promise<void> => {
if (!reactionService || eventIds.length === 0) return
try {
await reactionService.subscribeToReactions(eventIds)
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
const toggleLike = async (eventId: string, eventPubkey: string, eventKind: number): Promise<void> => {
if (!reactionService) {
toast.error('Reaction service not available')
return
}
try {
await reactionService.toggleLikeEvent(eventId, eventPubkey, eventKind)
// Check if we liked or unliked
const eventReactions = reactionService.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
toast.success('Post liked!')
} else {
toast.success('Like removed')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle reaction'
if (message.includes('authenticated')) {
toast.error('Please sign in to react to posts')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('Failed to toggle like:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return reactionService?.isLoading ?? false
})
/**
* Get all event reactions (for debugging)
*/
const allEventReactions = computed(() => {
return reactionService?.eventReactions ?? new Map()
})
return {
// Methods
getEventReactions,
subscribeToReactions,
toggleLike,
// State
isLoading,
allEventReactions
}
}

View file

@ -1,261 +0,0 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import type { AuthService } from '@/modules/base/auth/auth-service'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing scheduled events in the feed
*/
export function useScheduledEvents() {
const scheduledEventService = injectService<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const toast = useToast()
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
/**
* Get all scheduled events
*/
const getScheduledEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getScheduledEvents()
}
/**
* Get events for a specific date (YYYY-MM-DD)
*/
const getEventsForDate = (date: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForDate(date)
}
/**
* Get events for a specific date (filtered by current user participation)
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
*/
const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value)
}
/**
* Get today's scheduled events (filtered by current user participation)
*/
const getTodaysEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getTodaysEvents(currentUserPubkey.value)
}
/**
* Get completion status for an event
*/
const getCompletion = (eventAddress: string): EventCompletion | undefined => {
if (!scheduledEventService) return undefined
return scheduledEventService.getCompletion(eventAddress)
}
/**
* Check if an event is completed
*/
const isCompleted = (eventAddress: string): boolean => {
if (!scheduledEventService) return false
return scheduledEventService.isCompleted(eventAddress)
}
/**
* Get task status for an event
*/
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
if (!scheduledEventService) return null
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
}
/**
* Claim a task
*/
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.claimTask(event, notes, occurrence)
toast.success('Task claimed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to claim task'
if (message.includes('authenticated')) {
toast.error('Please sign in to claim tasks')
} else {
toast.error(message)
}
console.error('Failed to claim task:', error)
}
}
/**
* Start a task (mark as in-progress)
*/
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.startTask(event, notes, occurrence)
toast.success('Task started!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start task'
toast.error(message)
console.error('Failed to start task:', error)
}
}
/**
* Unclaim a task (remove task status)
*/
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
toast.error(message)
console.error('Failed to unclaim task:', error)
}
}
/**
* Toggle completion status of an event (optionally for a specific occurrence)
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
*/
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
if (!scheduledEventService) {
console.error('❌ useScheduledEvents: Scheduled event service not available')
toast.error('Scheduled event service not available')
return
}
try {
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence)
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
if (currentlyCompleted) {
console.log('⬇️ useScheduledEvents: Unclaiming task...')
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} else {
console.log('⬆️ useScheduledEvents: Marking as complete...')
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
if (message.includes('authenticated')) {
toast.error('Please sign in to complete tasks')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('❌ useScheduledEvents: Failed to toggle completion:', error)
}
}
/**
* Complete an event with optional notes
*/
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to complete task'
toast.error(message)
console.error('Failed to complete task:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return scheduledEventService?.isLoading ?? false
})
/**
* Get all scheduled events (reactive)
*/
const allScheduledEvents = computed(() => {
return scheduledEventService?.scheduledEvents ?? new Map()
})
/**
* Delete a task (only author can delete)
*/
const deleteTask = async (event: ScheduledEvent): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.deleteTask(event)
toast.success('Task deleted!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete task'
toast.error(message)
console.error('Failed to delete task:', error)
}
}
/**
* Get all completions (reactive) - returns array for better reactivity
*/
const allCompletions = computed(() => {
if (!scheduledEventService?.completions) return []
return Array.from(scheduledEventService.completions.values())
})
return {
// Methods - Getters
getScheduledEvents,
getEventsForDate,
getEventsForSpecificDate,
getTodaysEvents,
getCompletion,
isCompleted,
getTaskStatus,
// Methods - Actions
claimTask,
startTask,
completeEvent,
unclaimTask,
deleteTask,
toggleComplete, // DEPRECATED: Use specific actions instead
// State
isLoading,
allScheduledEvents,
allCompletions
}
}

View file

@ -1,6 +1,6 @@
import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { eventBus } from '@/core/event-bus'
import type { Event as NostrEvent, Filter } from 'nostr-tools'
@ -47,7 +47,7 @@ export class FeedService extends BaseService {
protected relayHub: any = null
protected visibilityService: any = null
protected reactionService: any = null
protected scheduledEventService: any = null
protected taskService: any = null
// Event ID tracking for deduplication
private seenEventIds = new Set<string>()
@ -73,13 +73,12 @@ export class FeedService extends BaseService {
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
// ScheduledEventService moved to tasks module - use tryInjectService for backward compat
this.scheduledEventService = tryInjectService(SERVICE_TOKENS.TASK_SERVICE)
this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService)
console.log('FeedService: TaskService injected:', !!this.scheduledEventService)
console.log('FeedService: TaskService injected:', !!this.taskService)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
@ -261,28 +260,19 @@ export class FeedService extends BaseService {
// Route reaction events (kind 7) to ReactionService
if (event.kind === 7) {
if (this.reactionService) {
this.reactionService.handleReactionEvent(event)
}
this.reactionService.handleReactionEvent(event)
return
}
// Route scheduled events (kind 31922) to ScheduledEventService
// Route scheduled events (kind 31922) to TaskService
if (event.kind === 31922) {
if (this.scheduledEventService) {
this.scheduledEventService.handleScheduledEvent(event)
}
this.taskService.handleScheduledEvent(event)
return
}
// Route RSVP/completion events (kind 31925) to ScheduledEventService
// Route RSVP/completion events (kind 31925) to TaskService
if (event.kind === 31925) {
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleCompletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
this.taskService.handleCompletionEvent(event)
return
}
@ -378,31 +368,19 @@ export class FeedService extends BaseService {
// Route to ReactionService for reaction deletions (kind 7)
if (deletedKind === '7') {
if (this.reactionService) {
this.reactionService.handleDeletionEvent(event)
}
this.reactionService.handleDeletionEvent(event)
return
}
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
// Route to TaskService for completion/RSVP deletions (kind 31925)
if (deletedKind === '31925') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleDeletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
this.taskService.handleDeletionEvent(event)
return
}
// Route to ScheduledEventService for scheduled event deletions (kind 31922)
// Route to TaskService for scheduled event deletions (kind 31922)
if (deletedKind === '31922') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleTaskDeletion(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
this.taskService.handleTaskDeletion(event)
return
}
@ -623,16 +601,8 @@ export class FeedService extends BaseService {
* Get like count for a post from ReactionService
*/
private getLikeCount(postId: string): number {
try {
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') {
const reactions = this.reactionService.getEventReactions(postId)
return reactions?.likes || 0
}
} catch (error) {
// Silently fail if reaction service is not available
console.debug('FeedService: Could not get like count for post', postId, error)
}
return 0
const reactions = this.reactionService.getEventReactions(postId)
return reactions?.likes || 0
}
/**

View file

@ -1,585 +0,0 @@
import { ref, reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
export interface Reaction {
id: string
eventId: string // The event being reacted to
pubkey: string // Who reacted
content: string // The reaction content ('+', '-', emoji)
created_at: number
}
export interface EventReactions {
eventId: string
likes: number
dislikes: number
totalReactions: number
userHasLiked: boolean
userHasDisliked: boolean
userReactionId?: string // Track the user's reaction ID for deletion
reactions: Reaction[]
}
export class ReactionService extends BaseService {
protected readonly metadata = {
name: 'ReactionService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Reaction state - indexed by event ID
private _eventReactions = reactive(new Map<string, EventReactions>())
private _isLoading = ref(false)
// Track reaction subscription
private currentSubscription: string | null = null
private currentUnsubscribe: (() => void) | null = null
// Track which events we're monitoring
private monitoredEvents = new Set<string>()
// Track deleted reactions to hide them
private deletedReactions = new Set<string>()
protected async onInitialize(): Promise<void> {
console.log('ReactionService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
// Deletion monitoring is now handled by FeedService's consolidated subscription
console.log('ReactionService: Initialization complete (deletion monitoring handled by FeedService)')
}
/**
* Get reactions for a specific event
*/
getEventReactions(eventId: string): EventReactions {
if (!this._eventReactions.has(eventId)) {
this._eventReactions.set(eventId, {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
})
}
return this._eventReactions.get(eventId)!
}
/**
* Subscribe to reactions for a list of event IDs
*/
async subscribeToReactions(eventIds: string[]): Promise<void> {
if (eventIds.length === 0) return
// Filter out events we're already monitoring
const newEventIds = eventIds.filter(id => !this.monitoredEvents.has(id))
if (newEventIds.length === 0) return
console.log(`ReactionService: Subscribing to reactions for ${newEventIds.length} events`)
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
// Add to monitored set
newEventIds.forEach(id => this.monitoredEvents.add(id))
const subscriptionId = `reactions-${Date.now()}`
// Subscribe to reactions (kind 7) for these events
// Deletions (kind 5) are now handled by FeedService's consolidated subscription
const filters = [
{
kinds: [7], // Reactions
'#e': newEventIds, // Events being reacted to
limit: 1000
}
]
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: filters,
onEvent: (event: NostrEvent) => {
this.handleReactionEvent(event)
},
onEose: () => {
console.log(`ReactionService: Subscription ${subscriptionId} ready`)
}
})
// Store subscription info (we can have multiple)
if (!this.currentSubscription) {
this.currentSubscription = subscriptionId
this.currentUnsubscribe = unsubscribe
}
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Handle incoming reaction event
* Made public so FeedService can route kind 7 events to this service
*/
public handleReactionEvent(event: NostrEvent): void {
try {
// Find the event being reacted to
const eTag = event.tags.find(tag => tag[0] === 'e')
if (!eTag || !eTag[1]) {
console.warn('Reaction event missing e tag:', event.id)
return
}
const eventId = eTag[1]
const content = event.content.trim()
// Create reaction object
const reaction: Reaction = {
id: event.id,
eventId,
pubkey: event.pubkey,
content,
created_at: event.created_at
}
// Update event reactions
const eventReactions = this.getEventReactions(eventId)
// Check if this reaction already exists (deduplication) or is deleted
const existingIndex = eventReactions.reactions.findIndex(r => r.id === reaction.id)
if (existingIndex >= 0) {
return // Already have this reaction
}
// Check if this reaction has been deleted
if (this.deletedReactions.has(reaction.id)) {
return // This reaction was deleted
}
// IMPORTANT: Remove any previous reaction from the same user
// This ensures one reaction per user per event, even if deletion events aren't processed
const previousReactionIndex = eventReactions.reactions.findIndex(r =>
r.pubkey === reaction.pubkey &&
r.content === reaction.content
)
if (previousReactionIndex >= 0) {
// Replace the old reaction with the new one
eventReactions.reactions[previousReactionIndex] = reaction
} else {
// Add as new reaction
eventReactions.reactions.push(reaction)
}
// Recalculate counts and user state
this.recalculateEventReactions(eventId)
} catch (error) {
console.error('Failed to handle reaction event:', error)
}
}
/**
* Handle deletion event (called by FeedService when a kind 5 event with k=7 is received)
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
try {
// Process each deleted event
const eTags = event.tags.filter(tag => tag[0] === 'e')
const deletionAuthor = event.pubkey
for (const eTag of eTags) {
const deletedEventId = eTag[1]
if (deletedEventId) {
// Add to deleted set
this.deletedReactions.add(deletedEventId)
// Find and remove the reaction from all event reactions
for (const [eventId, eventReactions] of this._eventReactions) {
const reactionIndex = eventReactions.reactions.findIndex(r => r.id === deletedEventId)
if (reactionIndex >= 0) {
const reaction = eventReactions.reactions[reactionIndex]
// IMPORTANT: Only process deletion if it's from the same user who created the reaction
// This follows NIP-09 spec: "Relays SHOULD delete or stop publishing any referenced events
// that have an identical `pubkey` as the deletion request"
if (reaction.pubkey === deletionAuthor) {
eventReactions.reactions.splice(reactionIndex, 1)
// Recalculate counts for this event
this.recalculateEventReactions(eventId)
}
}
}
}
}
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Recalculate reaction counts and user state for an event
*/
private recalculateEventReactions(eventId: string): void {
const eventReactions = this.getEventReactions(eventId)
const userPubkey = this.authService?.user?.value?.pubkey
// Use Sets to track unique users who liked/disliked
const likedUsers = new Set<string>()
const dislikedUsers = new Set<string>()
let userHasLiked = false
let userHasDisliked = false
let userReactionId: string | undefined
// Group reactions by user, keeping only the most recent
const latestReactionsByUser = new Map<string, Reaction>()
for (const reaction of eventReactions.reactions) {
// Skip deleted reactions
if (this.deletedReactions.has(reaction.id)) {
continue
}
// Keep only the latest reaction from each user
const existing = latestReactionsByUser.get(reaction.pubkey)
if (!existing || reaction.created_at > existing.created_at) {
latestReactionsByUser.set(reaction.pubkey, reaction)
}
}
// Now count unique reactions
for (const reaction of latestReactionsByUser.values()) {
const isLike = reaction.content === '+' || reaction.content === '❤️' || reaction.content === ''
const isDislike = reaction.content === '-'
if (isLike) {
likedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasLiked = true
userReactionId = reaction.id
}
} else if (isDislike) {
dislikedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasDisliked = true
userReactionId = reaction.id
}
}
}
// Update the reactive state with unique user counts
eventReactions.likes = likedUsers.size
eventReactions.dislikes = dislikedUsers.size
eventReactions.totalReactions = latestReactionsByUser.size
eventReactions.userHasLiked = userHasLiked
eventReactions.userHasDisliked = userHasDisliked
eventReactions.userReactionId = userReactionId
}
/**
* Send a heart reaction (like) to an event
*/
async likeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already liked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
throw new Error('Already liked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '+', // Like reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to like event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a like from an event (unlike) using NIP-09 deletion events
*/
async unlikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasLiked || !eventReactions.userReactionId) {
throw new Error('No reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to unlike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
async toggleLikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
// Unlike the event
await this.unlikeEvent(eventId)
} else {
// Like the event
await this.likeEvent(eventId, eventPubkey, eventKind)
}
}
/**
* Send a dislike reaction to an event
*/
async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already disliked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasDisliked) {
throw new Error('Already disliked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '-', // Dislike reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to dislike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a dislike from an event using NIP-09 deletion events
*/
async undislikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasDisliked || !eventReactions.userReactionId) {
throw new Error('No dislike reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Dislike deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to remove dislike:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Get all event reactions
*/
get eventReactions(): Map<string, EventReactions> {
return this._eventReactions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
if (this.currentUnsubscribe) {
this.currentUnsubscribe()
}
// deletionUnsubscribe is no longer used - deletions handled by FeedService
this._eventReactions.clear()
this.monitoredEvents.clear()
this.deletedReactions.clear()
}
}

View file

@ -1,678 +0,0 @@
import { ref, reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
export interface RecurrencePattern {
frequency: 'daily' | 'weekly'
dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
endDate?: string // ISO date string - when to stop recurring (optional)
}
export interface ScheduledEvent {
id: string
pubkey: string
created_at: number
dTag: string // Unique identifier from 'd' tag
title: string
start: string // ISO date string (YYYY-MM-DD or ISO datetime)
end?: string
description?: string
location?: string
status: string
eventType?: string // 'task' for completable events, 'announcement' for informational
participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
content: string
tags: string[][]
recurrence?: RecurrencePattern // Optional: for recurring events
}
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
export interface EventCompletion {
id: string
eventAddress: string // "31922:pubkey:d-tag"
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
pubkey: string // Who claimed/completed it
created_at: number
taskStatus: TaskStatus
completedAt?: number // Unix timestamp when completed
notes: string
}
export class ScheduledEventService extends BaseService {
protected readonly metadata = {
name: 'ScheduledEventService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Scheduled events state - indexed by event address
private _scheduledEvents = reactive(new Map<string, ScheduledEvent>())
private _completions = reactive(new Map<string, EventCompletion>())
private _isLoading = ref(false)
protected async onInitialize(): Promise<void> {
console.log('ScheduledEventService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('ScheduledEventService: Initialization complete')
}
/**
* Handle incoming scheduled event (kind 31922)
* Made public so FeedService can route kind 31922 events to this service
*/
public handleScheduledEvent(event: NostrEvent): void {
try {
// Extract event data from tags
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
if (!dTag) {
console.warn('Scheduled event missing d tag:', event.id)
return
}
const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
const start = event.tags.find(tag => tag[0] === 'start')?.[1]
const end = event.tags.find(tag => tag[0] === 'end')?.[1]
const description = event.tags.find(tag => tag[0] === 'description')?.[1]
const location = event.tags.find(tag => tag[0] === 'location')?.[1]
const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
// Parse participant tags: ["p", "<pubkey>", "<relay-hint>", "<participation-type>"]
const participantTags = event.tags.filter(tag => tag[0] === 'p')
const participants = participantTags.map(tag => ({
pubkey: tag[1],
type: tag[3] // 'required', 'optional', 'organizer'
}))
// Parse recurrence tags
const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
let recurrence: RecurrencePattern | undefined
if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
recurrence = {
frequency: recurrenceFreq,
dayOfWeek: recurrenceDayOfWeek,
endDate: recurrenceEndDate
}
}
if (!start) {
console.warn('Scheduled event missing start date:', event.id)
return
}
// Create event address: "kind:pubkey:d-tag"
const eventAddress = `31922:${event.pubkey}:${dTag}`
const scheduledEvent: ScheduledEvent = {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
dTag,
title,
start,
end,
description,
location,
status,
eventType,
participants: participants.length > 0 ? participants : undefined,
content: event.content,
tags: event.tags,
recurrence
}
// Store or update the event (replaceable by d-tag)
this._scheduledEvents.set(eventAddress, scheduledEvent)
} catch (error) {
console.error('Failed to handle scheduled event:', error)
}
}
/**
* Handle RSVP/completion event (kind 31925)
* Made public so FeedService can route kind 31925 events to this service
*/
public handleCompletionEvent(event: NostrEvent): void {
console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
try {
// Find the event being responded to
const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
if (!aTag) {
console.warn('Completion event missing a tag:', event.id)
return
}
// Parse task status (new approach)
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
// Backward compatibility: check old 'completed' tag if task-status not present
let taskStatus: TaskStatus
if (taskStatusTag) {
taskStatus = taskStatusTag
} else {
// Legacy support: convert old 'completed' tag to new taskStatus
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
taskStatus = completed ? 'completed' : 'claimed'
}
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
console.log('📋 Completion details:', {
aTag,
occurrence,
taskStatus,
pubkey: event.pubkey,
eventId: event.id
})
const completion: EventCompletion = {
id: event.id,
eventAddress: aTag,
occurrence,
pubkey: event.pubkey,
created_at: event.created_at,
taskStatus,
completedAt,
notes: event.content
}
// Store completion (most recent one wins)
// For recurring events, include occurrence in the key: "eventAddress:occurrence"
// For non-recurring, just use eventAddress
const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
const existing = this._completions.get(completionKey)
if (!existing || event.created_at > existing.created_at) {
this._completions.set(completionKey, completion)
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
} else {
console.log('⏭️ Skipped older completion for:', completionKey)
}
} catch (error) {
console.error('Failed to handle completion event:', error)
}
}
/**
* Handle deletion event (kind 5) for completion events
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
try {
// Extract event IDs to delete from 'e' tags
const eventIdsToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'e')
.map((tag: string[]) => tag[1]) || []
if (eventIdsToDelete.length === 0) {
console.warn('Deletion event missing e tags:', event.id)
return
}
console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
// Find and remove completions that match the deleted event IDs
let deletedCount = 0
for (const [completionKey, completion] of this._completions.entries()) {
// Only delete if:
// 1. The completion event ID matches one being deleted
// 2. The deletion request comes from the same author (NIP-09 validation)
if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
this._completions.delete(completionKey)
console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
deletedCount++
}
}
console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Handle deletion event (kind 5) for scheduled events (kind 31922)
* Made public so FeedService can route deletion events to this service
*/
public handleTaskDeletion(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
try {
// Extract event addresses to delete from 'a' tags
const eventAddressesToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'a')
.map((tag: string[]) => tag[1]) || []
if (eventAddressesToDelete.length === 0) {
console.warn('Task deletion event missing a tags:', event.id)
return
}
console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
// Find and remove tasks that match the deleted event addresses
let deletedCount = 0
for (const eventAddress of eventAddressesToDelete) {
const task = this._scheduledEvents.get(eventAddress)
// Only delete if:
// 1. The task exists
// 2. The deletion request comes from the task author (NIP-09 validation)
if (task && task.pubkey === event.pubkey) {
this._scheduledEvents.delete(eventAddress)
console.log('✅ Deleted task:', eventAddress)
deletedCount++
} else if (task) {
console.warn('⚠️ Deletion request not from task author:', eventAddress)
}
}
console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
} catch (error) {
console.error('Failed to handle task deletion event:', error)
}
}
/**
* Get all scheduled events
*/
getScheduledEvents(): ScheduledEvent[] {
return Array.from(this._scheduledEvents.values())
}
/**
* Get events scheduled for a specific date (YYYY-MM-DD)
*/
getEventsForDate(date: string): ScheduledEvent[] {
return this.getScheduledEvents().filter(event => {
// Simple date matching (start date)
// For ISO datetime strings, extract just the date part
const eventDate = event.start.split('T')[0]
return eventDate === date
})
}
/**
* Check if a recurring event occurs on a specific date
*/
private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
if (!event.recurrence) return false
const target = new Date(targetDate)
const eventStart = new Date(event.start.split('T')[0]) // Get date part only
// Check if target date is before the event start date
if (target < eventStart) return false
// Check if target date is after the event end date (if specified)
if (event.recurrence.endDate) {
const endDate = new Date(event.recurrence.endDate)
if (target > endDate) return false
}
// Check frequency-specific rules
if (event.recurrence.frequency === 'daily') {
// Daily events occur every day within the range
return true
} else if (event.recurrence.frequency === 'weekly') {
// Weekly events occur on specific day of week
const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
return targetDayOfWeek === eventDayOfWeek
}
return false
}
/**
* Get events for a specific date, optionally filtered by user participation
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
* @param userPubkey - Optional user pubkey to filter by participation
*/
getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
const targetDate = date || new Date().toISOString().split('T')[0]
// Get one-time events for the date (exclude recurring events to avoid duplicates)
const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
// Get all events and check for recurring events that occur on this date
const allEvents = this.getScheduledEvents()
const recurringEventsOnDate = allEvents.filter(event =>
event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
)
// Combine one-time and recurring events
let events = [...oneTimeEvents, ...recurringEventsOnDate]
// Filter events based on participation (if user pubkey provided)
if (userPubkey) {
events = events.filter(event => {
// If event has no participants, it's community-wide (show to everyone)
if (!event.participants || event.participants.length === 0) return true
// Otherwise, only show if user is a participant
return event.participants.some(p => p.pubkey === userPubkey)
})
}
// Sort by start time (ascending order)
events.sort((a, b) => {
// ISO datetime strings can be compared lexicographically
return a.start.localeCompare(b.start)
})
return events
}
/**
* Get events for today, optionally filtered by user participation
*/
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
return this.getEventsForSpecificDate(undefined, userPubkey)
}
/**
* Get completion status for an event (optionally for a specific occurrence)
*/
getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
return this._completions.get(completionKey)
}
/**
* Check if an event is completed (optionally for a specific occurrence)
*/
isCompleted(eventAddress: string, occurrence?: string): boolean {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus === 'completed'
}
/**
* Get task status for an event
*/
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus || null
}
/**
* Claim a task (mark as claimed)
*/
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
}
/**
* Start a task (mark as in-progress)
*/
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
}
/**
* Mark an event as complete (optionally for a specific occurrence)
*/
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'completed', notes, occurrence)
}
/**
* Internal method to update task status
*/
private async updateTaskStatus(
event: ScheduledEvent,
taskStatus: TaskStatus,
notes: string = '',
occurrence?: string
): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to update task status')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create RSVP event with task-status tag
const tags: string[][] = [
['a', eventAddress],
['task-status', taskStatus]
]
// Add completed_at timestamp if task is completed
if (taskStatus === 'completed') {
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
}
// Add occurrence tag if provided (for recurring events)
if (occurrence) {
tags.push(['occurrence', occurrence])
}
const eventTemplate: EventTemplate = {
kind: 31925, // Calendar Event RSVP
content: notes,
tags,
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the status update
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
// Update local state (publishEvent throws if no relays accepted)
console.log('🔄 Updating local state (event published successfully)')
this.handleCompletionEvent(signedEvent)
} catch (error) {
console.error('Failed to update task status:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Unclaim/reset a task (removes task status - makes it unclaimed)
* Note: In Nostr, we can't truly "delete" an event, but we can publish
* a deletion request (kind 5) to ask relays to remove our RSVP
*/
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to unclaim tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
const completion = this._completions.get(completionKey)
if (!completion) {
console.log('No completion to unclaim')
return
}
// Create deletion event (kind 5) for the RSVP
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task unclaimed',
tags: [
['e', completion.id], // Reference to the RSVP event being deleted
['k', '31925'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._completions.delete(completionKey)
console.log('🗑️ Removed completion from local state:', completionKey)
} catch (error) {
console.error('Failed to unclaim task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Delete a scheduled event (kind 31922)
* Only the author can delete their own event
*/
async deleteTask(event: ScheduledEvent): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to delete tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
const userPubkey = this.authService.user.value?.pubkey
if (!userPrivkey || !userPubkey) {
throw new Error('User credentials not available')
}
// Only author can delete
if (userPubkey !== event.pubkey) {
throw new Error('Only the task author can delete this task')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create deletion event (kind 5) for the scheduled event
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task deleted',
tags: [
['a', eventAddress], // Reference to the parameterized replaceable event being deleted
['k', '31922'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task:', eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._scheduledEvents.delete(eventAddress)
console.log('🗑️ Removed task from local state:', eventAddress)
} catch (error) {
console.error('Failed to delete task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Get all scheduled events
*/
get scheduledEvents(): Map<string, ScheduledEvent> {
return this._scheduledEvents
}
/**
* Get all completions
*/
get completions(): Map<string, EventCompletion> {
return this._completions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
this._scheduledEvents.clear()
this._completions.clear()
}
}