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
12 changed files with 39 additions and 1978 deletions

View file

@ -139,16 +139,11 @@ export const SERVICE_TOKENS = {
// Tasks services // Tasks services
TASK_SERVICE: Symbol('taskService'), TASK_SERVICE: Symbol('taskService'),
/** @deprecated Use TASK_SERVICE instead */
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
// Links services // Links services
SUBMISSION_SERVICE: Symbol('submissionService'), SUBMISSION_SERVICE: Symbol('submissionService'),
LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'), LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'),
// Nostr metadata services
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
// Nostr transport (kind-21000 RPC over relays — LNbits backend) // Nostr transport (kind-21000 RPC over relays — LNbits backend)
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'), NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),

View file

@ -2,9 +2,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { eventBus } from '@/core/event-bus' import { eventBus } from '@/core/event-bus'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits' import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits' import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits'
export class AuthService extends BaseService { export class AuthService extends BaseService {
@ -114,9 +112,6 @@ export class AuthService extends BaseService {
eventBus.emit('auth:login', { user: userData }, 'auth-service') eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on login
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'login') const err = this.handleError(error, 'login')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service') eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -138,9 +133,6 @@ export class AuthService extends BaseService {
eventBus.emit('auth:login', { user: userData }, 'auth-service') eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on registration
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'register') const err = this.handleError(error, 'register')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service') eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -195,10 +187,9 @@ export class AuthService extends BaseService {
prvkey: this.user.value?.prvkey || updatedUser.prvkey prvkey: this.user.value?.prvkey || updatedUser.prvkey
} }
// Auto-broadcast Nostr metadata when profile is updated // Kind-0 metadata is published server-side by lnbits's PATCH /auth handler
// Note: ProfileSettings component will also manually broadcast, // (aiolabs/lnbits commit 869f67c3) once the cascade is deployed. The webapp
// but this ensures metadata stays in sync even if updated elsewhere // no longer maintains its own broadcast path.
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'updateProfile') const err = this.handleError(error, 'updateProfile')
@ -208,26 +199,6 @@ export class AuthService extends BaseService {
} }
} }
/**
* Broadcast user metadata to Nostr relays (NIP-01 kind 0)
* Called automatically on login, registration, and profile updates
*/
private async broadcastNostrMetadata(): Promise<void> {
try {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
if (metadataService && this.user.value?.pubkey) {
// Broadcast in background - don't block login/update
metadataService.publishMetadata().catch(error => {
console.warn('Failed to broadcast Nostr metadata:', error)
// Don't throw - this is a non-critical background operation
})
}
} catch (error) {
// If service isn't available yet, silently skip
console.debug('Nostr metadata service not yet available')
}
}
/** /**
* Cleanup when service is disposed * Cleanup when service is disposed
*/ */

View file

@ -122,32 +122,17 @@
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3"> <Button
<Button type="submit"
type="submit" :disabled="isUpdating || !isFormValid"
:disabled="isUpdating || !isFormValid" class="w-full"
class="flex-1" >
> <span v-if="isUpdating">Updating...</span>
<span v-if="isUpdating">Updating...</span> <span v-else>Update Profile</span>
<span v-else>Update Profile</span> </Button>
</Button>
<Button
type="button"
variant="outline"
:disabled="isBroadcasting"
@click="broadcastMetadata"
class="flex-1"
>
<Radio class="mr-2 h-4 w-4" :class="{ 'animate-pulse': isBroadcasting }" />
<span v-if="isBroadcasting">Broadcasting...</span>
<span v-else>Broadcast to Nostr</span>
</Button>
</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Your profile is automatically broadcast to Nostr when you update it or log in. Your profile is broadcast to Nostr automatically when you save changes.
Use the "Broadcast to Nostr" button to manually re-broadcast your profile.
</p> </p>
</form> </form>
@ -189,7 +174,7 @@ import * as z from 'zod'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { User, Zap, Hash, Radio } from 'lucide-vue-next' import { User, Zap, Hash } from 'lucide-vue-next'
import { import {
FormControl, FormControl,
FormDescription, FormDescription,
@ -215,19 +200,16 @@ import { useAuth } from '@/composables/useAuthService'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService' import type { ImageUploadService } from '../services/ImageUploadService'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
// Services // Services
const { user, updateProfile, logout } = useAuth() const { user, updateProfile, logout } = useAuth()
const router = useRouter() const router = useRouter()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
const toast = useToast() const toast = useToast()
// Local state // Local state
const isUpdating = ref(false) const isUpdating = ref(false)
const isBroadcasting = ref(false)
const updateError = ref<string | null>(null) const updateError = ref<string | null>(null)
const updateSuccess = ref(false) const updateSuccess = ref(false)
const uploadedPicture = ref<any[]>([]) const uploadedPicture = ref<any[]>([])
@ -323,18 +305,12 @@ const updateUserProfile = async (formData: any) => {
} }
} }
// Update profile via AuthService (which updates LNbits) // Update profile via AuthService (which updates LNbits).
// Kind-0 metadata publishing happens server-side as part of the
// PATCH /api/v1/auth handler (aiolabs/lnbits 869f67c3).
await updateProfile(updateData) await updateProfile(updateData)
// Broadcast to Nostr automatically toast.success('Profile updated!')
try {
await metadataService.publishMetadata()
toast.success('Profile updated and broadcast to Nostr!')
} catch (nostrError) {
console.error('Failed to broadcast to Nostr:', nostrError)
toast.warning('Profile updated, but failed to broadcast to Nostr')
}
updateSuccess.value = true updateSuccess.value = true
// Clear success message after 3 seconds // Clear success message after 3 seconds
@ -352,22 +328,6 @@ const updateUserProfile = async (formData: any) => {
} }
} }
// Manually broadcast metadata to Nostr
const broadcastMetadata = async () => {
isBroadcasting.value = true
try {
const result = await metadataService.publishMetadata()
toast.success(`Profile broadcast to ${result.success}/${result.total} relays!`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to broadcast metadata'
console.error('Error broadcasting metadata:', error)
toast.error(`Failed to broadcast: ${errorMessage}`)
} finally {
isBroadcasting.value = false
}
}
// Log out + redirect to /login on this app's origin. // Log out + redirect to /login on this app's origin.
const onLogout = async () => { const onLogout = async () => {
try { try {

View file

@ -1,39 +0,0 @@
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrMetadataService, NostrMetadata } from '../nostr/nostr-metadata-service'
/**
* Composable for accessing Nostr metadata service
*
* @example
* ```ts
* const { publishMetadata, getMetadata } = useNostrMetadata()
*
* // Get current metadata
* const metadata = getMetadata()
*
* // Publish metadata to Nostr relays
* await publishMetadata()
* ```
*/
export function useNostrMetadata() {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*/
const publishMetadata = async (): Promise<{ success: number; total: number }> => {
return await metadataService.publishMetadata()
}
/**
* Get current user's Nostr metadata
*/
const getMetadata = (): NostrMetadata => {
return metadataService.getMetadata()
}
return {
publishMetadata,
getMetadata
}
}

View file

@ -2,7 +2,6 @@ import type { App } from 'vue'
import type { ModulePlugin } from '@/core/types' import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container' import { container, SERVICE_TOKENS } from '@/core/di-container'
import { relayHub } from './nostr/relay-hub' import { relayHub } from './nostr/relay-hub'
import { NostrMetadataService } from './nostr/nostr-metadata-service'
import { ProfileService } from './nostr/ProfileService' import { ProfileService } from './nostr/ProfileService'
import { ReactionService } from './nostr/ReactionService' import { ReactionService } from './nostr/ReactionService'
import { NostrTransportService } from './services/NostrTransportService' import { NostrTransportService } from './services/NostrTransportService'
@ -30,7 +29,6 @@ import ProfileSettings from './components/ProfileSettings.vue'
const invoiceService = new InvoiceService() const invoiceService = new InvoiceService()
const lnbitsAPI = new LnbitsAPI() const lnbitsAPI = new LnbitsAPI()
const imageUploadService = new ImageUploadService() const imageUploadService = new ImageUploadService()
const nostrMetadataService = new NostrMetadataService()
const profileService = new ProfileService() const profileService = new ProfileService()
const reactionService = new ReactionService() const reactionService = new ReactionService()
const nostrTransportService = new NostrTransportService() const nostrTransportService = new NostrTransportService()
@ -48,7 +46,6 @@ export const baseModule: ModulePlugin = {
// Register core Nostr services // Register core Nostr services
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub) container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService)
// Register auth service // Register auth service
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth) container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
@ -113,10 +110,6 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ImageUploadService depends on ToastService waitForDependencies: true, // ImageUploadService depends on ToastService
maxRetries: 3 maxRetries: 3
}) })
await nostrMetadataService.initialize({
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
maxRetries: 3
})
await profileService.initialize({ await profileService.initialize({
waitForDependencies: true, // ProfileService depends on RelayHub waitForDependencies: true, // ProfileService depends on RelayHub
maxRetries: 3 maxRetries: 3
@ -145,7 +138,6 @@ export const baseModule: ModulePlugin = {
await storageService.dispose() await storageService.dispose()
await toastService.dispose() await toastService.dispose()
await imageUploadService.dispose() await imageUploadService.dispose()
await nostrMetadataService.dispose()
await profileService.dispose() await profileService.dispose()
await reactionService.dispose() await reactionService.dispose()
await nostrTransportService.dispose() await nostrTransportService.dispose()
@ -156,7 +148,6 @@ export const baseModule: ModulePlugin = {
container.remove(SERVICE_TOKENS.LNBITS_API) container.remove(SERVICE_TOKENS.LNBITS_API)
container.remove(SERVICE_TOKENS.INVOICE_SERVICE) container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
container.remove(SERVICE_TOKENS.PROFILE_SERVICE) container.remove(SERVICE_TOKENS.PROFILE_SERVICE)
container.remove(SERVICE_TOKENS.REACTION_SERVICE) container.remove(SERVICE_TOKENS.REACTION_SERVICE)
@ -173,7 +164,6 @@ export const baseModule: ModulePlugin = {
invoiceService, invoiceService,
pwaService, pwaService,
imageUploadService, imageUploadService,
nostrMetadataService,
profileService, profileService,
reactionService reactionService
}, },

View file

@ -1,162 +0,0 @@
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
/**
* Nostr User Metadata (NIP-01 kind 0)
* https://github.com/nostr-protocol/nips/blob/master/01.md
*/
export interface NostrMetadata {
name?: string // Display name (from username)
display_name?: string // Alternative display name
about?: string // Bio/description
picture?: string // Profile picture URL
banner?: string // Profile banner URL
nip05?: string // NIP-05 identifier (username@domain)
lud16?: string // Lightning Address (same as nip05)
website?: string // Personal website
}
/**
* Service for publishing and managing Nostr user metadata (NIP-01 kind 0)
*
* This service handles:
* - Publishing user profile metadata to Nostr relays
* - Syncing LNbits user data with Nostr profile
* - Auto-broadcasting metadata on login and profile updates
*/
export class NostrMetadataService extends BaseService {
protected readonly metadata = {
name: 'NostrMetadataService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub']
}
protected authService: AuthService | null = null
protected relayHub: RelayHub | null = null
protected async onInitialize(): Promise<void> {
console.log('NostrMetadataService: Starting initialization...')
this.authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
this.relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
if (!this.authService) {
throw new Error('AuthService not available')
}
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('NostrMetadataService: Initialization complete')
}
/**
* Build Nostr metadata from LNbits user data
*/
private buildMetadata(): NostrMetadata {
const user = this.authService?.user.value
if (!user) {
throw new Error('No authenticated user')
}
const lightningDomain = import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
const username = user.username || user.id.slice(0, 8)
const metadata: NostrMetadata = {
name: username,
nip05: `${username}@${lightningDomain}`,
lud16: `${username}@${lightningDomain}`
}
// Add optional fields from user.extra if they exist
if (user.extra?.display_name) {
metadata.display_name = user.extra.display_name
}
if (user.extra?.picture) {
metadata.picture = user.extra.picture
}
return metadata
}
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*
* This creates a replaceable event that updates the user's profile.
* Only the latest kind 0 event for a given pubkey is kept by relays.
*/
async publishMetadata(): Promise<{ success: number; total: number }> {
if (!this.authService?.isAuthenticated.value) {
throw new Error('Must be authenticated to publish metadata')
}
if (!this.relayHub?.isConnected.value) {
throw new Error('Not connected to relays')
}
const user = this.authService.user.value
if (!user?.prvkey) {
throw new Error('User private key not available')
}
try {
const metadata = this.buildMetadata()
console.log('📤 Publishing Nostr metadata (kind 0):', metadata)
// Create kind 0 event (user metadata)
// Content is JSON-stringified metadata
const eventTemplate: EventTemplate = {
kind: 0,
content: JSON.stringify(metadata),
tags: [],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(user.prvkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('✅ Metadata event signed:', signedEvent.id)
console.log('📋 Full signed event:', JSON.stringify(signedEvent, null, 2))
// Publish to all connected relays
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`✅ Metadata published to ${result.success}/${result.total} relays`)
return result
} catch (error) {
console.error('Failed to publish metadata:', error)
throw error
}
}
/**
* Get current user's Nostr metadata
*/
getMetadata(): NostrMetadata {
return this.buildMetadata()
}
/**
* 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
}
protected async onDestroy(): Promise<void> {
// Cleanup if needed
}
}

View file

@ -13,7 +13,7 @@ import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lu
import { useFeed } from '../composables/useFeed' import { useFeed } from '../composables/useFeed'
import { useProfiles } from '@/modules/base/composables/useProfiles' import { useProfiles } from '@/modules/base/composables/useProfiles'
import { useReactions } from '@/modules/base/composables/useReactions' 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 ThreadedPost from './ThreadedPost.vue'
import ScheduledEventCard from './ScheduledEventCard.vue' import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config' import appConfig from '@/app.config'
@ -98,7 +98,9 @@ const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts // Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() 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 { const {
getEventsForSpecificDate, getEventsForSpecificDate,
getCompletion, getCompletion,
@ -109,7 +111,7 @@ const {
unclaimTask, unclaimTask,
deleteTask, deleteTask,
allCompletions allCompletions
} = useScheduledEvents() } = useTasks({ autoSubscribe: false })
// Selected date for viewing scheduled tasks (defaults to today) // Selected date for viewing scheduled tasks (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0]) const selectedDate = ref(new Date().toISOString().split('T')[0])
@ -405,7 +407,7 @@ async function confirmDeletePost() {
const userPrivkey = authService?.user.value?.prvkey const userPrivkey = authService?.user.value?.prvkey
if (!userPrivkey) { if (!userPrivkey) {
toast.error("User private key not available") toast.error("User private key not available") // pragma: allowlist secret
showDeleteDialog.value = false showDeleteDialog.value = false
postToDelete.value = null postToDelete.value = null
return 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 { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' 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 { eventBus } from '@/core/event-bus'
import type { Event as NostrEvent, Filter } from 'nostr-tools' import type { Event as NostrEvent, Filter } from 'nostr-tools'
@ -47,7 +47,7 @@ export class FeedService extends BaseService {
protected relayHub: any = null protected relayHub: any = null
protected visibilityService: any = null protected visibilityService: any = null
protected reactionService: any = null protected reactionService: any = null
protected scheduledEventService: any = null protected taskService: any = null
// Event ID tracking for deduplication // Event ID tracking for deduplication
private seenEventIds = new Set<string>() private seenEventIds = new Set<string>()
@ -73,13 +73,12 @@ export class FeedService extends BaseService {
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE) this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
// ScheduledEventService moved to tasks module - use tryInjectService for backward compat this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE)
this.scheduledEventService = tryInjectService(SERVICE_TOKENS.TASK_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub) console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService) console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService) console.log('FeedService: ReactionService injected:', !!this.reactionService)
console.log('FeedService: TaskService injected:', !!this.scheduledEventService) console.log('FeedService: TaskService injected:', !!this.taskService)
if (!this.relayHub) { if (!this.relayHub) {
throw new Error('RelayHub service not available') throw new Error('RelayHub service not available')
@ -261,28 +260,19 @@ export class FeedService extends BaseService {
// Route reaction events (kind 7) to ReactionService // Route reaction events (kind 7) to ReactionService
if (event.kind === 7) { if (event.kind === 7) {
if (this.reactionService) { this.reactionService.handleReactionEvent(event)
this.reactionService.handleReactionEvent(event)
}
return return
} }
// Route scheduled events (kind 31922) to ScheduledEventService // Route scheduled events (kind 31922) to TaskService
if (event.kind === 31922) { if (event.kind === 31922) {
if (this.scheduledEventService) { this.taskService.handleScheduledEvent(event)
this.scheduledEventService.handleScheduledEvent(event)
}
return return
} }
// Route RSVP/completion events (kind 31925) to ScheduledEventService // Route RSVP/completion events (kind 31925) to TaskService
if (event.kind === 31925) { if (event.kind === 31925) {
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService') this.taskService.handleCompletionEvent(event)
if (this.scheduledEventService) {
this.scheduledEventService.handleCompletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
@ -378,31 +368,19 @@ export class FeedService extends BaseService {
// Route to ReactionService for reaction deletions (kind 7) // Route to ReactionService for reaction deletions (kind 7)
if (deletedKind === '7') { if (deletedKind === '7') {
if (this.reactionService) { this.reactionService.handleDeletionEvent(event)
this.reactionService.handleDeletionEvent(event)
}
return return
} }
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925) // Route to TaskService for completion/RSVP deletions (kind 31925)
if (deletedKind === '31925') { if (deletedKind === '31925') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService') this.taskService.handleDeletionEvent(event)
if (this.scheduledEventService) {
this.scheduledEventService.handleDeletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
// Route to ScheduledEventService for scheduled event deletions (kind 31922) // Route to TaskService for scheduled event deletions (kind 31922)
if (deletedKind === '31922') { if (deletedKind === '31922') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService') this.taskService.handleTaskDeletion(event)
if (this.scheduledEventService) {
this.scheduledEventService.handleTaskDeletion(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
@ -623,16 +601,8 @@ export class FeedService extends BaseService {
* Get like count for a post from ReactionService * Get like count for a post from ReactionService
*/ */
private getLikeCount(postId: string): number { private getLikeCount(postId: string): number {
try { const reactions = this.reactionService.getEventReactions(postId)
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') { return reactions?.likes || 0
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
} }
/** /**

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()
}
}