diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cf26833..74d5aaa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -This is an Angular 20 project, make sure to always use signals and effects. Also always use most modern TypeScript, with async/await. +This is an Angular 19 project, make sure to always use signals and effects. Also always use most modern TypeScript, with async/await. Make sure to use new flow syntax of latest Angular, which is @if instead of *ngIf, @for instead of *ngFor, and @let instead of *ngLet. @@ -23,6 +23,8 @@ box-shadow: var(--mat-sys-level5) Never set the font-weight in CSS. The current font for headlines does not support different font weights. +Make sure you don't use outdated variables for Angular Material, such as "--mat-sys-color-surface-container-high" and "--mat-sys-color-primary-container" and "--mat-sys-color-on-primary-container". + I'm using a Windows computer, so make sure that paths and commands are compatible with Windows. When waiting for background terminal output for "npm start", wait another 3 extra seconds to ensure build completes. diff --git a/src/app/components/user-profile/user-profile.component.ts b/src/app/components/user-profile/user-profile.component.ts index 83d9294..5c18632 100644 --- a/src/app/components/user-profile/user-profile.component.ts +++ b/src/app/components/user-profile/user-profile.component.ts @@ -67,13 +67,16 @@ export class UserProfileComponent implements AfterViewInit, OnDestroy { constructor() { // Set up scroll detection - this.setupScrollDetection(); - - // Set up an effect to watch for changes to npub input + this.setupScrollDetection(); // Set up an effect to watch for changes to npub input effect(() => { const pubkey = this.pubkey(); if (pubkey) { + // If the pubkey changed, reset the profile data to force reload + if (this.publicKey && this.publicKey !== pubkey) { + this.profile.set(null); + } + this.publicKey = this.pubkey(); // console.debug('LOCATION 1:', pubkey); const npub = this.utilities.getNpubFromPubkey(pubkey); diff --git a/src/app/pages/messages/messages.component.html b/src/app/pages/messages/messages.component.html index 47f3c88..bc3384c 100644 --- a/src/app/pages/messages/messages.component.html +++ b/src/app/pages/messages/messages.component.html @@ -1,200 +1,245 @@
@if (isLoading() && !selectedChat()) { -
- -
+
+ +
} @else if (error()) { -
- error -

{{ error() }}

- -
- } @else { -
- -
-
-

Messages

- +
} @else { +
+ + +
+ @if (!selectedChat()) { +
+ textsms +

Select a conversation

+

Choose an existing conversation or start a new one

+
- - -
- @if (!selectedChat()) { -
- textsms -

Select a conversation

-

Choose an existing conversation or start a new one

- + } + +
+ +
+ + + + + + + + + + +
+
+
+ @if (isLoading()) { +
+ +

Loading messages...

+
+ } @else if (isDecryptingMessages()) { +
+ +

Decrypting messages... + @if (decryptionQueueLength() > 0) { + ({{ decryptionQueueLength() }} remaining) + } +

+
+ } @else if (messages().length === 0) { +
+ mail +

No messages yet

+ @if (selectedChat()?.encryptionType === 'nip44') { +

This conversation is encrypted end-to-end using NIP-44.

+ } @else if (selectedChat()?.encryptionType === 'nip04') { +

This conversation uses legacy encryption (NIP-04).

+ } @else { +

This conversation will be encrypted end-to-end.

+ } +
} @else { - -
-
- @if (showMobileList() === false) { - - } - -
-

{{ activePubkey() | npub }}

-
-
- -
- -
-
- - -
- @if (isLoading()) { -
- -

Loading messages...

-
- } @else if (messages().length === 0) { -
- mail -

No messages yet

-

This conversation is encrypted end-to-end using NIP-17.

-
+ + @if (shouldShowEncryptionWarning(selectedChat()!)) { +
+ warning + {{ getEncryptionStatusMessage(selectedChat()!) }} +
+ } + +
+ @if (hasMoreMessages()) { + - } - - @for (message of messages(); track message.id) { -
-
- {{ message.content }} - -
- @if (message.pending) { - - } @else if (message.failed) { - error_outline - } @else if (message.isOutgoing && message.read) { - done_all - } @else if (message.isOutgoing && message.received) { - done - } - {{ message.created_at | timestamp }} -
-
- - @if (message.failed) { - - } -
+ Load older messages + } + + } + + @for (message of messages(); track message.id) { +
+
+ {{ message.content }} + +
+ @if (message.pending) { + + } @else if (message.failed) { + error_outline + } @else if (message.isOutgoing && message.read) { + done_all + } @else if (message.isOutgoing && message.received) { + done } + {{ message.created_at | timestamp }}
- } -
- - -
- - - Messages are encrypted using NIP-44 - - -
+ + @if (message.failed) { + + }
+ } +
}
+ +
+ + + @if (selectedChat()?.encryptionType === 'nip44') { + Messages are encrypted using NIP-44 (modern encryption) + } @else if (selectedChat()?.encryptionType === 'nip04') { + Messages use NIP-04 (legacy encryption) + } @else { + Messages will be encrypted end-to-end + } + + + +
+ }
+
}
\ No newline at end of file diff --git a/src/app/pages/messages/messages.component.scss b/src/app/pages/messages/messages.component.scss index 533e6ab..9fe108b 100644 --- a/src/app/pages/messages/messages.component.scss +++ b/src/app/pages/messages/messages.component.scss @@ -1,5 +1,5 @@ .messages-container { - height: 100%; + height: calc(100vh - 80px); width: 100%; display: flex; flex-direction: column; @@ -40,7 +40,6 @@ display: none; } } - .chat-list-container { width: 320px; min-width: 320px; @@ -111,7 +110,7 @@ .chat-info { flex: 1; - min-width: 0; + // min-width: 0; .chat-name-row { display: flex; @@ -188,6 +187,7 @@ display: flex; flex-direction: column; height: 100%; + width: 100%; .no-chat-selected { display: flex; @@ -221,8 +221,8 @@ display: flex; justify-content: space-between; align-items: center; - padding: 12px 16px; border-bottom: 1px solid var(--mat-divider-color); + width: 100%; .chat-header-user { display: flex; @@ -233,14 +233,6 @@ margin-right: 4px; display: none; } - - .chat-header-info { - h3 { - margin: 0; - font-size: 16px; - font-weight: 500; - } - } } } @@ -299,6 +291,9 @@ display: flex; align-items: flex-end; margin-bottom: 4px; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; &.outgoing { flex-direction: row-reverse; @@ -306,15 +301,20 @@ .message-bubble { max-width: 70%; + min-width: 0; padding: 12px 16px; border-radius: 18px; background-color: var(--mat-sys-color-surface-container-high); position: relative; box-shadow: var(--mat-sys-level1); + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + white-space: pre-wrap; &.outgoing { - background-color: var(--mat-sys-color-primary-container); - color: var(--mat-sys-color-on-primary-container); + background-color: var(--mat-sys-on-primary-container); + color: var(--mat-sys-on-secondary); } &.pending { @@ -346,7 +346,8 @@ width: 14px; } - .read-icon, .received-icon { + .read-icon, + .received-icon { font-size: 14px; height: 14px; width: 14px; @@ -391,14 +392,73 @@ margin: 8px; } } + + // Encryption warning styles + .warning-hint { + color: var(--mat-sys-color-error) !important; + } + } + + // Encryption warning banner + .encryption-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: var(--mat-sys-color-error-container); + color: var(--mat-sys-color-on-error-container); + border-radius: 8px; + margin: 8px 16px; + font-size: 14px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + // Warning text in no-messages state + .hint.warning { + color: var(--mat-sys-color-error); + } + + // Decryption status indicator + .decrypting-messages { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 16px; + background-color: var(--mat-sys-color-surface-container); + color: var(--mat-sys-color-on-surface); + border-radius: 8px; + margin: 16px; + font-size: 14px; + box-shadow: var(--mat-sys-level1); + + mat-spinner { + ::ng-deep circle { + stroke: var(--mat-sys-color-primary); + } + } + + p { + margin: 0; + font-weight: 500; + } + + .queue-info { + color: var(--mat-sys-color-on-surface-variant); + font-weight: 400; + } } } } @media (max-width: 768px) { .messages-container { - .messages-layout { - &.mobile-view .chat-list-container { + .messages-layout { &.mobile-view .chat-list-container { width: 100%; min-width: 100%; } @@ -410,4 +470,11 @@ } } } +} + + +@media (max-width: 599px) { + .messages-container { + height: calc(100vh - 136px); // Include mobile footer menu. + } } \ No newline at end of file diff --git a/src/app/pages/messages/messages.component.ts b/src/app/pages/messages/messages.component.ts index 9f68d1e..a914282 100644 --- a/src/app/pages/messages/messages.component.ts +++ b/src/app/pages/messages/messages.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, inject, signal, computed, effect, untracked } from '@angular/core'; +import { Component, OnInit, OnDestroy, AfterViewInit, ViewChild, ElementRef, inject, signal, computed, effect, untracked } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -27,12 +27,17 @@ import { NPubPipe } from '../../pipes/npub.pipe'; import { TimestampPipe } from '../../pipes/timestamp.pipe'; import { AgoPipe } from '../../pipes/ago.pipe'; import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component'; -import { kinds, SimplePool, getPublicKey, nip04, nip44, generateSecretKey, finalizeEvent, Event as NostrEvent, Filter } from 'nostr-tools'; -import { v2 } from 'nostr-tools/nip44'; -import { hexToBytes } from '@noble/hashes/utils'; +import { kinds, SimplePool, getPublicKey, generateSecretKey, finalizeEvent, Event as NostrEvent, Filter } from 'nostr-tools'; import { ApplicationService } from '../../services/application.service'; import { UtilitiesService } from '../../services/utilities.service'; import { AccountStateService } from '../../services/account-state.service'; +import { EncryptionService } from '../../services/encryption.service'; +import { DataService } from '../../services/data.service'; +import { MessagingService } from '../../services/messaging.service'; +import { UserRelayFactoryService } from '../../services/user-relay-factory.service'; +import { UserRelayService } from '../../services/user-relay.service'; +import { AccountRelayService } from '../../services/account-relay.service'; +import { LayoutService } from '../../services/layout.service'; // Define interfaces for our DM data structures interface Chat { @@ -41,6 +46,8 @@ interface Chat { unreadCount: number; lastMessage?: DirectMessage | null; relays?: string[]; + encryptionType?: 'nip04' | 'nip44'; + isLegacy?: boolean; // true for NIP-04 chats } interface DirectMessage { @@ -54,13 +61,17 @@ interface DirectMessage { failed?: boolean; received?: boolean; read?: boolean; + encryptionType?: 'nip04' | 'nip44'; } -// Constants for NIP-17 events -const DIRECT_MESSAGE_KIND = 14; // Chat messages in NIP-17 -const SEALED_MESSAGE_KIND = 13; // Sealed messages in NIP-17 -const GIFT_WRAPPED_KIND = 1059; // Gift wrapped messages in NIP-17 -const RECEIPT_KIND = 1405; // For read receipts +interface DecryptionQueueItem { + id: string; + event: NostrEvent; + type: 'nip04' | 'nip44'; + senderPubkey: string; + resolve: (result: any | null) => void; + reject: (error: Error) => void; +} @Component({ selector: 'app-messages', @@ -91,11 +102,13 @@ const RECEIPT_KIND = 1405; // For read receipts templateUrl: './messages.component.html', styleUrl: './messages.component.scss' }) -export class MessagesComponent implements OnInit, OnDestroy { +export class MessagesComponent implements OnInit, OnDestroy, AfterViewInit { + private data = inject(DataService); private nostr = inject(NostrService); private relay = inject(RelayService); - private logger = inject(LoggerService); + private logger = inject(LoggerService); messaging = inject(MessagingService); private notifications = inject(NotificationService); + private userRelayFactory = inject(UserRelayFactoryService); private dialog = inject(MatDialog); private storage = inject(StorageService); private router = inject(Router); @@ -104,47 +117,47 @@ export class MessagesComponent implements OnInit, OnDestroy { private readonly app = inject(ApplicationService); readonly utilities = inject(UtilitiesService); private readonly accountState = inject(AccountStateService); - - // UI state signals + private readonly encryption = inject(EncryptionService); + layout = inject(LayoutService);// UI state signals isLoading = signal(false); isLoadingMore = signal(false); isSending = signal(false); error = signal(null); showMobileList = signal(true); + isDecryptingMessages = signal(false); + decryptionQueueLength = signal(0); + private accountRelayService = inject(AccountRelayService); // Data signals - chats = signal([]); - selectedChatId = signal(null); - selectedChat = computed(() => { - debugger; + // chats = signal([]); + selectedChatId = signal(null); selectedChat = computed(() => { const chatId = this.selectedChatId(); if (!chatId) return null; - return this.chats().find(chat => chat.id === chatId) || null; + return this.messaging.getChat(chatId) || null; }); - activePubkey = computed(() => this.selectedChat()?.pubkey || ''); - + // activePubkey = computed(() => this.selectedChat()?.pubkey || ''); messages = signal([]); newMessageText = signal(''); - hasMoreMessages = signal(false); - - // Computed helpers - hasChats = computed(() => this.chats().length > 0); - - // Clean up subscriptions + hasMoreMessages = signal(false); // Computed helpers + hasChats = computed(() => this.messaging.sortedChats().length > 0); // Subscription management private messageSubscription: any = null; - private chatSubscription: any = null; - private relayPool: SimplePool | null = null; - private preferredRelays = signal([]); + private chatSubscription: any = null; // Decryption queue management + private decryptionQueue: DecryptionQueueItem[] = []; + private isProcessingQueue = false; + + // ViewChild for scrolling functionality + @ViewChild('messagesWrapper', { static: false }) messagesWrapper?: ElementRef; - constructor() { - // Set up effect to load messages when chat is selected + constructor() { // Set up effect to load messages when chat is selected effect(() => { - debugger; const chat = this.selectedChat(); if (chat) { untracked(() => { - this.loadMessages(chat.pubkey); + const chatMessages = this.messaging.getChatMessages(chat.pubkey); + this.messages.set(chatMessages || []); + // Scroll to bottom to show latest messages + this.scrollToBottom(); // Mark this chat as read when selected // TODO: FIX, this will trigger selectedChat signal and cause infinite loop // this.markChatAsRead(chat.id); @@ -154,7 +167,6 @@ export class MessagesComponent implements OnInit, OnDestroy { // Listen to connection status changes effect(() => { - debugger; if (this.appState.isOnline()) { this.error.set(null); } else { @@ -163,518 +175,56 @@ export class MessagesComponent implements OnInit, OnDestroy { }); effect(async () => { - if (this.app.initialized()) { - debugger; - await this.loadPreferredRelays(); - await this.loadChats(); - this.subscribeToMessages(); + if (this.accountState.initialized()) { + await this.messaging.loadChats(); + // this.subscribeToMessages(); } }); } ngOnInit(): void { - // Initialize relay pool - this.relayPool = new SimplePool(); - - // Load user's preferred message relays - // this.loadPreferredRelays().then(() => { - // debugger; - // // Load chats after relays are loaded - // this.loadChats(); - // // Subscribe to new messages - // this.subscribeToMessages(); - // }); - } - - ngOnDestroy(): void { - // Clean up subscriptions - if (this.messageSubscription) { - this.messageSubscription.close(); - // this.relayPool?.unsubscribe(this.messageSubscription); - } - - if (this.chatSubscription) { - this.chatSubscription.close(); - // this.relayPool?.unsubscribe(this.chatSubscription); - } - // Close relay pool - if (this.relayPool) { - this.relayPool.close(this.relays); - } - } - - /** - * Load user's preferred relays for messaging - */ - async loadPreferredRelays() { - this.preferredRelays.set(this.relay.relays.map(relay => relay.url)); - - // try { - // const myPubkey = this.nostr.activeAccount()?.pubkey; - // if (!myPubkey) { - // throw new Error('Not logged in'); - // } - - // // First check for kind 10002 (relay list metadata) - // const relayListEvents = await this.relayPool?.list(this.getConnectedRelays(), [{ - // kinds: [10002], - // authors: [myPubkey], - // limit: 1 - // }]); - - // if (relayListEvents && relayListEvents.length > 0) { - // // Parse relay list from the event - // const relayList = relayListEvents[0].tags - // .filter(tag => tag[0] === 'r') - // .map(tag => tag[1]); - - // if (relayList.length > 0) { - // this.preferredRelays.set(relayList); - // return; - // } - // } - - // // Fallback to connected relays - // this.preferredRelays.set(this.getConnectedRelays()); - - // } catch (err) { - // this.logger.error('Failed to load preferred relays', err); - // // Fallback to connected relays - // this.preferredRelays.set(this.getConnectedRelays()); - // } } - /** - * Get currently connected relays - */ - getConnectedRelays(): string[] { - return this.relay.relays - .filter(relay => relay.status === 'connected') - .map(relay => relay.url); + ngAfterViewInit(): void { + // Initial scroll to bottom if there are messages + if (this.messages().length > 0) { + this.scrollToBottom(); + } } - relays: string[] = []; - /** - * Load all chats for the current user + * Scroll the messages wrapper to the bottom to show latest messages */ - async loadChats(): Promise { - this.isLoading.set(true); - this.error.set(null); - - try { - const myPubkey = this.accountState.pubkey(); - if (!myPubkey) { - this.error.set('You need to be logged in to view messages'); - this.isLoading.set(false); - return; - } - - // Get relays to fetch from - const relays = this.preferredRelays().length > 0 - ? this.preferredRelays() - : this.getConnectedRelays(); - - this.relays = relays; - - if (relays.length === 0) { - this.error.set('No connected relays available.'); - this.isLoading.set(false); - return; + private scrollToBottom(): void { + // Use setTimeout to ensure DOM is updated + setTimeout(() => { + if (this.messagesWrapper?.nativeElement) { + const element = this.messagesWrapper.nativeElement; + element.scrollTop = element.scrollHeight; } - - const filter: Filter = { - kinds: [GIFT_WRAPPED_KIND], - '#p': [myPubkey], - limit: 100 - }; - - // Store pubkeys of people who've messaged us - const chatPubkeys = new Set(); - - // First, look for existing gift-wrapped messages - const sub = this.relayPool?.subscribe(relays, filter, { - maxWait: 5000, - label: 'loadChats', - onevent: async (event: NostrEvent) => { - debugger; - // Handle incoming wrapped events - if (event.kind === GIFT_WRAPPED_KIND) { - - if (event.pubkey !== myPubkey) { - chatPubkeys.add(event.pubkey); - } - - // Look for 'p' tags for recipients other than ourselves - const pTags = event.tags.filter(tag => tag[0] === 'p'); - for (const tag of pTags) { - const pubkey = tag[1]; - if (pubkey !== myPubkey) { - chatPubkeys.add(pubkey); - } - } - - const chatsList: Chat[] = Array.from(chatPubkeys).map(pubkey => ({ - id: pubkey, // Using pubkey as chat ID - pubkey, - unreadCount: 0, - lastMessage: null - })); - - // Sort chats (will be updated with last messages later) - const sortedChats = chatsList.sort((a, b) => { - const aTime = a.lastMessage?.created_at || 0; - const bTime = b.lastMessage?.created_at || 0; - return bTime - aTime; // Most recent first - }); - - this.chats.set(sortedChats); - - // For each chat, fetch the latest message - for (const chat of sortedChats) { - await this.fetchLatestMessageForChat(chat.pubkey, relays); - } - - // this.relayPool?.publish(relays, event); - } - } - }); - - - // Process wrapped events to find unique chat participants - // if (wrappedEvents && wrappedEvents.length > 0) { - // for (const event of wrappedEvents) { - // // Add the sender to our chat list if not us - // if (event.pubkey !== myPubkey) { - // chatPubkeys.add(event.pubkey); - // } - - // // Look for 'p' tags for recipients other than ourselves - // const pTags = event.tags.filter(tag => tag[0] === 'p'); - // for (const tag of pTags) { - // const pubkey = tag[1]; - // if (pubkey !== myPubkey) { - // chatPubkeys.add(pubkey); - // } - // } - // } - // } - - // Also add chats from our outgoing messages - const ourMessages = await this.relayPool?.subscribe(relays, { - kinds: [GIFT_WRAPPED_KIND], - authors: [myPubkey], - limit: 100 - }, { - maxWait: 5000, - label: 'loadChats', - onevent: (event: NostrEvent) => { - debugger; - // Handle incoming wrapped events - if (event.kind === GIFT_WRAPPED_KIND) { - - const pTags = event.tags.filter(tag => tag[0] === 'p'); - for (const tag of pTags) { - const pubkey = tag[1]; - if (pubkey !== myPubkey) { - chatPubkeys.add(pubkey); - } - } - - // Look for 'p' tags for recipients other than ourselves - // const pTags = event.tags.filter(tag => tag[0] === 'p'); - // for (const tag of pTags) { - // const pubkey = tag[1]; - // if (pubkey !== myPubkey) { - // chatPubkeys.add(pubkey); - // } - // } - } - } - }); - - // if (ourMessages && ourMessages.length > 0) { - // for (const event of ourMessages) { - - // } - // } - - // Convert to array of Chat objects - - - this.isLoading.set(false); - } catch (err) { - this.logger.error('Failed to load chats', err); - this.error.set('Failed to load chats. Please try again.'); - this.isLoading.set(false); - } + }, 100); } - /** - * Fetch the latest message for a specific chat - */ - async fetchLatestMessageForChat(pubkey: string, relays: string[]): Promise { - const myPubkey = this.accountState.pubkey(); - if (!myPubkey || !this.relayPool) return; - - try { - // Fetch wrapped messages between us and this pubkey - const wrappedEvents = await this.relayPool.subscribeManyEose(relays, [{ - kinds: [GIFT_WRAPPED_KIND], - authors: [pubkey], - '#p': [myPubkey], - limit: 1 - }, { - kinds: [GIFT_WRAPPED_KIND], - authors: [myPubkey], - '#p': [pubkey], - limit: 1 - }], - { - maxWait: 5000, - label: 'fetchLatestMessageForChat', - onevent: async (event: NostrEvent) => { - debugger; - // Handle incoming wrapped events - if (event.kind === GIFT_WRAPPED_KIND) { - // this.relayPool?.publish(relays, event); - const unwrappedMessage = await this.unwrapMessage(event); - - if (unwrappedMessage) { - // Create a DirectMessage object - const directMessage: DirectMessage = { - id: unwrappedMessage.id, - pubkey: unwrappedMessage.pubkey, - created_at: unwrappedMessage.created_at, - content: unwrappedMessage.content, - isOutgoing: unwrappedMessage.pubkey === myPubkey, - tags: unwrappedMessage.tags, - received: true // Since we've received and decrypted it - }; - - // Update the chat with this latest message - this.chats.update(chats => { - return chats.map(chat => { - if (chat.pubkey === pubkey) { - return { - ...chat, - lastMessage: directMessage - }; - } - return chat; - }); - }); - - // Re-sort chats by latest message - this.chats.update(chats => { - return [...chats].sort((a, b) => { - const aTime = a.lastMessage?.created_at || 0; - const bTime = b.lastMessage?.created_at || 0; - return bTime - aTime; // Most recent first - }); - }); - } - - } - } - }); - - // if (!wrappedEvents || wrappedEvents.length === 0) return; - - // // Sort by created_at to get the most recent - // const latestEvent = wrappedEvents.sort((a, b) => b.created_at - a.created_at)[0]; - - // // Try to unwrap and decrypt the message - // try { - // const unwrappedMessage = await this.unwrapMessage(latestEvent); - - // } catch (err) { - // this.logger.error('Failed to unwrap message for chat preview', err); - // } - } catch (err) { - this.logger.error('Failed to fetch latest message for chat', err); + ngOnDestroy(): void { + // Clean up subscriptions + if (this.messageSubscription) { + this.messageSubscription.close(); } - } - - /** - * Load messages for a specific chat - */ - async loadMessages(pubkey: string): Promise { - debugger; - this.isLoading.set(true); - this.messages.set([]); - - try { - const myPubkey = this.accountState.pubkey(); - if (!myPubkey || !this.relayPool) { - this.error.set('You need to be logged in to view messages'); - this.isLoading.set(false); - return; - } - - // Get relays to fetch from - const relays = this.preferredRelays().length > 0 - ? this.preferredRelays() - : this.getConnectedRelays(); - - if (relays.length === 0) { - this.error.set('No connected relays available.'); - this.isLoading.set(false); - return; - } - - // Fetch wrapped messages between us and this pubkey (in both directions) - const wrappedEvents = await this.relayPool.subscribeManyEose(relays, [{ - kinds: [GIFT_WRAPPED_KIND], - authors: [pubkey], - '#p': [myPubkey], - limit: 50 - }, { - kinds: [GIFT_WRAPPED_KIND], - authors: [myPubkey], - '#p': [pubkey], - limit: 50 - }], { - maxWait: 5000, - label: 'loadMessages', - onevent: async (event: NostrEvent) => { - debugger; - // Handle incoming wrapped events - if (event.kind === GIFT_WRAPPED_KIND) { - // this.relayPool?.publish(relays, event); - const unwrappedMessage = await this.unwrapMessage(event); - - if (unwrappedMessage) { - // Create a DirectMessage object - const directMessage: DirectMessage = { - id: unwrappedMessage.id, - pubkey: unwrappedMessage.pubkey, - created_at: unwrappedMessage.created_at, - content: unwrappedMessage.content, - isOutgoing: unwrappedMessage.pubkey === myPubkey, - tags: unwrappedMessage.tags, - received: true // Since we've received and decrypted it - }; - - // Update the messages list with this message - this.messages.update(msgs => [...msgs, directMessage]); - - - } - } - } - }); - - // if (!wrappedEvents || wrappedEvents.length === 0) { - // this.isLoading.set(false); - // return; - // } - - // // Process each wrapped message - // const decryptedMessages: DirectMessage[] = []; - - // for (const event of wrappedEvents) { - // try { - // const unwrappedMessage = await this.unwrapMessage(event); - // if (unwrappedMessage) { - // decryptedMessages.push({ - // id: unwrappedMessage.id, - // pubkey: unwrappedMessage.pubkey, - // created_at: unwrappedMessage.created_at, - // content: unwrappedMessage.content, - // isOutgoing: unwrappedMessage.pubkey === myPubkey, - // tags: unwrappedMessage.tags, - // received: true, // Since we've received and decrypted it - // read: true // Mark as read since we're viewing it now - // }); - // } - // } catch (err) { - // this.logger.error('Failed to unwrap message', err); - // } - // } - - // Sort messages by timestamp - // const sortedMessages = decryptedMessages.sort((a, b) => a.created_at - b.created_at); - - // // Update the messages signal - // this.messages.set(sortedMessages); - - // // There may be more messages - // this.hasMoreMessages.set(sortedMessages.length >= 50); - // // Send read receipts for these messages - // this.sendReadReceipts(sortedMessages.filter(m => !m.isOutgoing).map(m => m.id)); - - this.isLoading.set(false); - } catch (err) { - this.logger.error('Failed to load messages', err); - this.error.set('Failed to load messages. Please try again.'); - this.isLoading.set(false); + if (this.chatSubscription) { + this.chatSubscription.close(); } + + // Clear the decryption queue + this.clearDecryptionQueue(); } /** - * Unwrap and decrypt a gift-wrapped message + * Clear the decryption queue (useful for cleanup) */ - async unwrapMessage(wrappedEvent: any): Promise { - const myPubkey = this.accountState.pubkey(); - if (!myPubkey) return null; - - // Get our private key (in a real app, this would use a more secure method) - //const privateKey = await this.nostr.getActivePrivateKeySecure(); - const privateKey = await this.accountState.account()?.privkey; - if (!privateKey) return null; - - const privateKeyBytes = hexToBytes(privateKey); - - try { - debugger; - // Parse the wrapped content - const convKey = v2.utils.getConversationKey(privateKeyBytes, wrappedEvent.pubkey); - const decrypted = v2.decrypt(wrappedEvent.content, convKey); - const wrappedContent = JSON.parse(decrypted); - // const wrappedContent = JSON.parse(wrappedEvent.content); - - // Check if this message is for us - const recipient = wrappedEvent.tags.find((t: string[]) => t[0] === 'p')?.[1]; - if (recipient !== myPubkey && wrappedEvent.pubkey !== myPubkey) { - return null; - } - - // Get the sealed message - let sealedEvent; - if (wrappedEvent.pubkey === myPubkey) { - // If we sent it, we can directly use the encryptedMessage - sealedEvent = wrappedContent.encryptedMessage; - } else { - debugger; - const convKey = v2.utils.getConversationKey(privateKeyBytes, wrappedContent.pubkey); - const decrypted = v2.decrypt(wrappedContent.content, convKey); - sealedEvent = JSON.parse(decrypted); - console.log('Decrypted content:', decrypted); - - // Otherwise decrypt the message using NIP-44 - // const sharedKey = getSharedSecret(privateKey, wrappedContent.senderPubkey || wrappedEvent.pubkey); - // sealedEvent = JSON.parse(await nip44.decrypt(sharedKey, wrappedContent.encryptedMessage)); - } - - debugger; - - // Now unseal the actual message content - // const sharedKey = sealedEvent.pubkey === myPubkey - // ? getSharedSecret(privateKey, recipient) - // : getSharedSecret(privateKey, sealedEvent.pubkey); - - // const decryptedContent = await nip44.decrypt(sharedKey, sealedEvent.content); - // Return the final decrypted message - return { - ...sealedEvent - }; - } catch (err) { - this.logger.error('Failed to decrypt message', err); - throw err; - } + private clearDecryptionQueue(): void { + this.messaging.clearDecryptionQueue(); } /** @@ -688,7 +238,7 @@ export class MessagesComponent implements OnInit, OnDestroy { const pubkey = this.selectedChat()?.pubkey; const myPubkey = this.accountState.pubkey(); - if (!pubkey || !myPubkey || !this.relayPool) { + if (!pubkey || !myPubkey) { this.isLoadingMore.set(false); return; } @@ -702,20 +252,15 @@ export class MessagesComponent implements OnInit, OnDestroy { const oldestTimestamp = Math.min(...currentMessages.map(m => m.created_at)); - // Get relays to fetch from - const relays = this.preferredRelays().length > 0 - ? this.preferredRelays() - : this.getConnectedRelays(); - // Fetch older wrapped messages - const wrappedEvents = await this.relayPool.subscribeManyEose(relays, [{ - kinds: [GIFT_WRAPPED_KIND], + const wrappedEvents = await this.relay.getAccountPool().subscribeManyEose(this.relay.getAccountRelayUrls(), [{ + kinds: [kinds.GiftWrap], authors: [pubkey], '#p': [myPubkey], until: oldestTimestamp - 1, limit: 25 }, { - kinds: [GIFT_WRAPPED_KIND], + kinds: [kinds.GiftWrap], authors: [myPubkey], '#p': [pubkey], until: oldestTimestamp - 1, @@ -724,27 +269,26 @@ export class MessagesComponent implements OnInit, OnDestroy { maxWait: 5000, label: 'loadMoreMessages', onevent: async (event: NostrEvent) => { - debugger; // Handle incoming wrapped events - if (event.kind === GIFT_WRAPPED_KIND) { + if (event.kind === kinds.GiftWrap) { // this.relayPool?.publish(relays, event); - const unwrappedMessage = await this.unwrapMessage(event); - - if (unwrappedMessage) { - // Create a DirectMessage object - const directMessage: DirectMessage = { - id: unwrappedMessage.id, - pubkey: unwrappedMessage.pubkey, - created_at: unwrappedMessage.created_at, - content: unwrappedMessage.content, - isOutgoing: unwrappedMessage.pubkey === myPubkey, - tags: unwrappedMessage.tags, - received: true // Since we've received and decrypted it - }; - - // Update the messages list with this message - this.messages.update(msgs => [...msgs, directMessage]); - } + // const unwrappedMessage = await this.messaging.unwrapMessage(event); + + // if (unwrappedMessage) { + // // Create a DirectMessage object + // const directMessage: DirectMessage = { + // id: unwrappedMessage.id, + // pubkey: unwrappedMessage.pubkey, + // created_at: unwrappedMessage.created_at, + // content: unwrappedMessage.content, + // isOutgoing: unwrappedMessage.pubkey === myPubkey, + // tags: unwrappedMessage.tags, + // received: true // Since we've received and decrypted it + // }; + + // // Update the messages list with this message + // this.messages.update(msgs => [...msgs, directMessage]); + // } } } }); @@ -795,14 +339,16 @@ export class MessagesComponent implements OnInit, OnDestroy { this.logger.error('Failed to load more messages', err); this.isLoadingMore.set(false); } - } - - /** + } /** * Select a chat from the list */ selectChat(chat: Chat): void { this.selectedChatId.set(chat.id); - this.showMobileList.set(false); + + // Only hide the chat list on mobile devices + if (this.layout.isHandset()) { + this.showMobileList.set(false); + } // Mark chat as read when selected this.markChatAsRead(chat.id); @@ -813,296 +359,137 @@ export class MessagesComponent implements OnInit, OnDestroy { */ markChatAsRead(chatId: string): void { // Update the chat's unread count - this.chats.update(chats => - chats.map(chat => - chat.id === chatId - ? { ...chat, unreadCount: 0 } - : chat - ) - ); - - // In a real implementation, we would also send read receipts for the messages - const chat = this.chats().find(c => c.id === chatId); - if (chat && this.messages().length > 0) { - // Send read receipts for all messages from this pubkey - const messageIds = this.messages() - .filter(m => !m.isOutgoing && !m.read) - .map(m => m.id); - - if (messageIds.length > 0) { - this.sendReadReceipts(messageIds); - } - } + // this.chats.update(chats => + // chats.map(chat => + // chat.id === chatId + // ? { ...chat, unreadCount: 0 } + // : chat + // ) + // ); + + // // In a real implementation, we would also send read receipts for the messages + // const chat = this.chats().find(c => c.id === chatId); + // if (chat && this.messages().length > 0) { + // // Send read receipts for all messages from this pubkey + // const messageIds = this.messages() + // .filter(m => !m.isOutgoing && !m.read) + // .map(m => m.id); + + // if (messageIds.length > 0) { + // this.sendReadReceipts(messageIds); + // } + // } } /** - * Send read receipts for messages + * Send a direct message using both NIP-04 and NIP-44 */ - async sendReadReceipts(messageIds: string[]): Promise { - if (messageIds.length === 0) return; + async sendMessage(): Promise { + debugger; + const messageText = this.newMessageText().trim(); + if (!messageText || this.isSending()) return; - const myPubkey = this.accountState.pubkey(); - if (!myPubkey) return; + const receiverPubkey = this.selectedChat()?.pubkey; + if (!receiverPubkey) return; - // Convert array of message IDs to an array of [e, ID] tags - const eTags = messageIds.map(id => ['e', id]); + this.isSending.set(true); try { - const receiptEvent = { - kind: RECEIPT_KIND, - pubkey: myPubkey, - created_at: Math.floor(Date.now() / 1000), - tags: eTags, - content: '' // Empty content for receipt events - }; - - // Sign the event - const signedEvent = await this.nostr.signEvent(receiptEvent); - - // Get relays to publish to - const relays = this.preferredRelays().length > 0 - ? this.preferredRelays() - : this.getConnectedRelays(); - - // Publish to relays - if (signedEvent && this.relayPool) { - await this.relayPool.publish(relays, signedEvent); - - // Update message read status in local state - this.messages.update(msgs => - msgs.map(msg => - messageIds.includes(msg.id) - ? { ...msg, read: true } - : msg - ) - ); + const myPubkey = this.accountState.pubkey(); + if (!myPubkey) { + throw new Error('You need to be logged in to send messages'); } - } catch (err) { - this.logger.error('Failed to send read receipts', err); - } - } - - /** - * Send a direct message using NIP-17 - */ - async sendMessage(): Promise { - // const messageText = this.newMessageText().trim(); - // if (!messageText || this.isSending()) return; - - // const receiverPubkey = this.selectedChat()?.pubkey; - // if (!receiverPubkey) return; - - // this.isSending.set(true); - - // try { - // const myPubkey = this.nostr.activeAccount()?.pubkey; - // if (!myPubkey) { - // throw new Error('You need to be logged in to send messages'); - // } - - // // Get relays to publish to - // const relays = this.preferredRelays().length > 0 - // ? this.preferredRelays() - // : this.getConnectedRelays(); - - // if (relays.length === 0) { - // throw new Error('No connected relays available'); - // } - - // // Create a pending message to show immediately in the UI - // const pendingMessage: DirectMessage = { - // id: `pending-${Date.now()}`, - // pubkey: myPubkey, - // created_at: Math.floor(Date.now() / 1000), - // content: messageText, - // isOutgoing: true, - // pending: true, - // tags: [['p', receiverPubkey]] - // }; - - // // Add to the messages immediately so the user sees feedback - // this.messages.update(msgs => [...msgs, pendingMessage]); - - // // Clear the input - // this.newMessageText.set(''); - - // // Step 1: Create the regular direct message (kind 14) - // const directMessage = { - // kind: DIRECT_MESSAGE_KIND, - // pubkey: myPubkey, - // created_at: Math.floor(Date.now() / 1000), - // tags: [['p', receiverPubkey]], - // content: messageText - // }; - - // // Sign the direct message - // const signedDirectMessage = await this.nostr.signEvent(directMessage); - - // if (!signedDirectMessage) { - // throw new Error('Failed to sign direct message'); - // } - - // // Step 2: Seal the message (kind 13) using NIP-44 - // const privateKey = await this.nostr.getActivePrivateKeySecure(); - // if (!privateKey) { - // throw new Error('Could not get private key'); - // } - - // // Create shared secret for encryption - // const sharedSecret = getSharedSecret(privateKey, receiverPubkey); - - // // Encrypt the direct message content - // const encryptedContent = await nip44.encrypt(sharedSecret, JSON.stringify(signedDirectMessage)); - // // Create sealed message - // const sealedMessage = { - // kind: SEALED_MESSAGE_KIND, - // pubkey: myPubkey, - // created_at: Math.floor(Date.now() / 1000), - // tags: [['p', receiverPubkey]], - // content: encryptedContent - // }; + // Get relays to publish to + // TODO: Important, get all relays for the user we are sending DM to and include + // it in this array for publishing the DM!! + // const relays = this.relay.getAccountRelayUrls(); + const userRelay = await this.userRelayFactory.create(receiverPubkey); - // // Sign the sealed message - // const signedSealedMessage = await this.nostr.signEvent(sealedMessage); + // Create a unique ID for the pending message + const pendingId = `pending-${Date.now()}-${Math.random()}`; - // if (!signedSealedMessage) { - // throw new Error('Failed to sign sealed message'); - // } + // Create a pending message to show immediately in the UI + const pendingMessage: DirectMessage = { + id: pendingId, + pubkey: myPubkey, + created_at: Math.floor(Date.now() / 1000), + content: messageText, + isOutgoing: true, + pending: true, + tags: [['p', receiverPubkey]], + received: false, + encryptionType: this.supportsModernEncryption(this.selectedChat()!) ? 'nip44' : 'nip04' + }; - // // Step 3: Gift wrap the sealed message (kind 1059) - // // First, create a gift wrap for the recipient + // Add to the messages immediately so the user sees feedback + this.messages.update(msgs => [...msgs, pendingMessage]); - // // Create another shared secret for the gift wrapping - // const recipientSharedSecret = getSharedSecret(privateKey, receiverPubkey); + // Clear the input + this.newMessageText.set(''); - // // Encrypt the sealed message for the recipient - // const recipientEncryptedMessage = await nip44.encrypt(recipientSharedSecret, JSON.stringify(signedSealedMessage)); + // Determine which encryption to use based on chat and client capabilities + const selectedChat = this.selectedChat()!; + const useModernEncryption = this.supportsModernEncryption(selectedChat); - // // Create recipient gift wrap - // const recipientGiftWrap = { - // kind: GIFT_WRAPPED_KIND, - // pubkey: myPubkey, - // created_at: Math.floor(Date.now() / 1000), - // tags: [['p', receiverPubkey]], - // content: JSON.stringify({ - // recipientPubkey: receiverPubkey, - // encryptedMessage: recipientEncryptedMessage - // }) - // }; + let finalMessage: DirectMessage; - // // Sign the recipient gift wrap - // const signedRecipientGiftWrap = await this.nostr.signEvent(recipientGiftWrap); + if (useModernEncryption) { + // Use NIP-44 encryption + finalMessage = await this.sendNip44Message(messageText, receiverPubkey, myPubkey, userRelay); + } else { + // Use NIP-04 encryption for backwards compatibility + finalMessage = await this.sendNip04Message(messageText, receiverPubkey, myPubkey, userRelay); + } - // if (!signedRecipientGiftWrap) { - // throw new Error('Failed to sign recipient gift wrap'); - // } + // Success: update the message to remove the pending state + this.messages.update(msgs => + msgs.map(msg => + msg.id === pendingId + ? { + ...finalMessage, + pending: false, + received: true + } + : msg + ) + ); - // // Now create a self-addressed gift wrap so we can read our own messages - // // (In reality, we could just store our own messages locally too) - - // // Create self gift wrap (we can just use the sealed message directly) - // const selfGiftWrap = { - // kind: GIFT_WRAPPED_KIND, - // pubkey: myPubkey, - // created_at: Math.floor(Date.now() / 1000), - // tags: [['p', myPubkey]], - // content: JSON.stringify({ - // recipientPubkey: myPubkey, - // encryptedMessage: JSON.stringify(signedSealedMessage) - // }) - // }; - - // // Sign the self gift wrap - // const signedSelfGiftWrap = await this.nostr.signEvent(selfGiftWrap); - - // if (!signedSelfGiftWrap) { - // throw new Error('Failed to sign self gift wrap'); - // } + // Update the last message for this chat in the chat list + // this.updateChatLastMessage(selectedChat.id, finalMessage); - // // Step 4: Publish the gift-wrapped messages to appropriate relays - // if (this.relayPool) { - // await this.relayPool.publish(relays, signedRecipientGiftWrap); - // await this.relayPool.publish(relays, signedSelfGiftWrap); - - // // Success: update the message to remove the pending state - // this.messages.update(msgs => - // msgs.map(msg => - // msg.id === pendingMessage.id - // ? { - // ...msg, - // id: signedDirectMessage.id, - // pending: false - // } - // : msg - // ) - // ); - - // // Update the last message for this chat in the chat list - // this.updateChatLastMessage(this.selectedChat()?.id || '', { - // id: signedDirectMessage.id, - // pubkey: myPubkey, - // created_at: Math.floor(Date.now() / 1000), - // content: messageText, - // isOutgoing: true, - // tags: [['p', receiverPubkey]] - // }); - - // this.isSending.set(false); - - // // Show success notification - // this.snackBar.open('Message sent', 'Close', { - // duration: 3000, - // horizontalPosition: 'center', - // verticalPosition: 'bottom' - // }); - // } + this.isSending.set(false); - // } catch (err) { - // this.logger.error('Failed to send message', err); - - // // Show error state for the message - // this.messages.update(msgs => - // msgs.map(msg => - // msg.id === `pending-${Date.now()}` - // ? { ...msg, pending: false, failed: true } - // : msg - // ) - // ); - - // this.isSending.set(false); - - // this.notifications.addNotification({ - // id: Date.now().toString(), - // type: NotificationType.ERROR, - // title: 'Message Failed', - // message: 'Failed to send message. Please try again.', - // timestamp: Date.now(), - // read: false - // }); - // } - } + // Show success notification + this.snackBar.open('Message sent', 'Close', { + duration: 3000, + horizontalPosition: 'center', + verticalPosition: 'bottom' + }); - /** - * Update the last message for a chat - */ - updateChatLastMessage(chatId: string, message: DirectMessage): void { - this.chats.update(chats => - chats.map(chat => - chat.id === chatId - ? { ...chat, lastMessage: message } - : chat - ) - ); - - // Also re-sort the chats to put the most recent first - this.chats.update(chats => { - return [...chats].sort((a, b) => { - const aTime = a.lastMessage?.created_at || 0; - const bTime = b.lastMessage?.created_at || 0; - return bTime - aTime; + } catch (err) { + this.logger.error('Failed to send message', err); + + // Show error state for the message + this.messages.update(msgs => + msgs.map(msg => + msg.id.startsWith('pending-') + ? { ...msg, pending: false, failed: true } + : msg + ) + ); + + this.isSending.set(false); + + this.notifications.addNotification({ + id: Date.now().toString(), + type: NotificationType.ERROR, + title: 'Message Failed', + message: 'Failed to send message. Please try again.', + timestamp: Date.now(), + read: false }); - }); + } } /** @@ -1130,191 +517,225 @@ export class MessagesComponent implements OnInit, OnDestroy { * Delete a chat */ async deleteChat(chat: Chat, event?: Event): Promise { - if (event) { - event.stopPropagation(); - } + // if (event) { + // event.stopPropagation(); + // } - // Show confirmation dialog - const dialogRef = this.dialog.open(ConfirmDialogComponent, { - width: '400px', - data: { - title: 'Delete Chat', - message: 'Are you sure you want to delete this chat? This will only remove it from your device.', - confirmText: 'Delete', - cancelText: 'Cancel' - } - }); + // // Show confirmation dialog + // const dialogRef = this.dialog.open(ConfirmDialogComponent, { + // width: '400px', + // data: { + // title: 'Delete Chat', + // message: 'Are you sure you want to delete this chat? This will only remove it from your device.', + // confirmText: 'Delete', + // cancelText: 'Cancel' + // } + // }); + + // const result = await dialogRef.afterClosed().toPromise(); + // if (!result) return; + + // // Remove the chat from the list + // this.chats.update(chats => chats.filter(c => c.id !== chat.id)); - const result = await dialogRef.afterClosed().toPromise(); - if (!result) return; + // // If it was the selected chat, clear the selection + // if (this.selectedChatId() === chat.id) { + // this.selectedChatId.set(null); + // this.messages.set([]); + // this.showMobileList.set(true); + // } + + // // In a real implementation, you might also want to delete all related messages from local storage + // this.snackBar.open('Chat deleted', 'Close', { duration: 3000 }); + } + + /** + * Back to list on mobile view + */ + backToList(): void { + this.showMobileList.set(true); + } - // Remove the chat from the list - this.chats.update(chats => chats.filter(c => c.id !== chat.id)); + /** + * View profile of the selected chat + */ + viewProfile(): void { + const pubkey = this.selectedChat()?.pubkey; + if (pubkey) { + this.router.navigate(['/p', pubkey]); + } + } - // If it was the selected chat, clear the selection - if (this.selectedChatId() === chat.id) { - this.selectedChatId.set(null); - this.messages.set([]); - this.showMobileList.set(true); + /** + * Check if a chat supports modern encryption (NIP-44) + * For now, we'll always prefer modern encryption when available + */ + private supportsModernEncryption(chat: Chat): boolean { + // If chat already has an encryption type set, respect it + if (chat.encryptionType) { + return chat.encryptionType === 'nip44'; } - // In a real implementation, you might also want to delete all related messages from local storage - this.snackBar.open('Chat deleted', 'Close', { duration: 3000 }); + // For new chats, prefer modern encryption + // In a more sophisticated implementation, we could check: + // - If the recipient's client supports NIP-44 + // - User preferences + // - Relay capabilities + return true; } /** - * Subscribe to new messages + * Check if we should show encryption warning for a chat */ - subscribeToMessages(): void { - const myPubkey = this.accountState.pubkey(); - if (!myPubkey || !this.relayPool) return; - - // Get relays to subscribe to - const relays = this.preferredRelays().length > 0 - ? this.preferredRelays() - : this.getConnectedRelays(); - - if (relays.length === 0) return; - - // Subscribe to gift-wrapped messages addressed to us - this.messageSubscription = this.relayPool.subscribe(relays, { - kinds: [GIFT_WRAPPED_KIND], - '#p': [myPubkey], - since: Math.floor(Date.now() / 1000) // Only get new messages from now on - }, { - maxWait: 5000, - label: 'subscribeToMessages', - onevent: async (event: NostrEvent) => { - // Handle incoming wrapped events - // Only process if it's not from us - if (event.pubkey === myPubkey) return; - - try { - // Try to unwrap the message - const unwrappedMessage = await this.unwrapMessage(event); - if (!unwrappedMessage) return; - - // Extract the sender pubkey - const senderPubkey = unwrappedMessage.pubkey; - - // Check if we already have a chat with this user - let existingChat = this.chats().find(chat => chat.pubkey === senderPubkey); - let chatId: string; - - if (existingChat) { - chatId = existingChat.id; - - // Update the chat with this new message and increment unread count - this.chats.update(chats => { - return chats.map(chat => { - if (chat.pubkey === senderPubkey) { - return { - ...chat, - lastMessage: { - id: unwrappedMessage.id, - pubkey: senderPubkey, - created_at: unwrappedMessage.created_at, - content: unwrappedMessage.content, - isOutgoing: false, - tags: unwrappedMessage.tags, - }, - unreadCount: this.selectedChatId() === chat.id ? 0 : chat.unreadCount + 1 - }; - } - return chat; - }); - }); - } else { - // Create a new chat for this sender - chatId = senderPubkey; - - const newChat: Chat = { - id: chatId, - pubkey: senderPubkey, - unreadCount: 1, - lastMessage: { - id: unwrappedMessage.id, - pubkey: senderPubkey, - created_at: unwrappedMessage.created_at, - content: unwrappedMessage.content, - isOutgoing: false, - tags: unwrappedMessage.tags - } - }; - - // Add the chat to the list - this.chats.update(chats => [newChat, ...chats]); - } + shouldShowEncryptionWarning(chat: Chat): boolean { + // Show warning for legacy NIP-04 chats + return chat.encryptionType === 'nip04' || chat.isLegacy === true; + } - // Re-sort the chats - this.chats.update(chats => { - return [...chats].sort((a, b) => { - const aTime = a.lastMessage?.created_at || 0; - const bTime = b.lastMessage?.created_at || 0; - return bTime - aTime; - }); - }); - - // If this chat is currently selected, add the message to the view - if (this.selectedChatId() === chatId) { - const newMessage: DirectMessage = { - id: unwrappedMessage.id, - pubkey: senderPubkey, - created_at: unwrappedMessage.created_at, - content: unwrappedMessage.content, - isOutgoing: false, - tags: unwrappedMessage.tags, - received: true - }; + /** + * Get encryption status message for a chat + */ + getEncryptionStatusMessage(chat: Chat): string { + if (chat.encryptionType === 'nip04' || chat.isLegacy === true) { + return 'This chat uses legacy encryption (NIP-04). Consider starting a new chat for better security.'; + } + return 'This chat uses modern encryption (NIP-44) for enhanced security.'; + } - this.messages.update(msgs => [...msgs, newMessage]); + /** + * Send a message using NIP-04 encryption (legacy) + */ + private async sendNip04Message( + messageText: string, + receiverPubkey: string, + myPubkey: string, + userRelay: UserRelayService + ): Promise { + try { + // Encrypt the message using NIP-04 + const encryptedContent = await this.encryption.encryptNip04(messageText, receiverPubkey); - // Send a read receipt - this.sendReadReceipts([newMessage.id]); - } + // Create the event + const event = { + kind: kinds.EncryptedDirectMessage, + pubkey: myPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', receiverPubkey]], + content: encryptedContent + }; - // Show notification for new message if not currently viewing this chat - if (this.selectedChatId() !== chatId) { - this.notifications.addNotification({ - id: `message-${unwrappedMessage.id}`, - type: NotificationType.GENERAL, - title: 'New Message', - message: `New message from ${this.utilities.getTruncatedNpub(senderPubkey)}`, - timestamp: Date.now(), - read: false - }); - } - } catch (err) { - this.logger.error('Failed to process incoming message', err); - } - } - }); + // Sign and finalize the event + const signedEvent = await this.nostr.signEvent(event); - // // Handle incoming events - // this.messageSubscription.on('event', async (event: any) => { + debugger; - // }); + // Publish to relays + await this.publishToRelays(signedEvent, userRelay); - // // Handle subscription closing - // this.messageSubscription.on('eose', () => { - // this.logger.debug('Message subscription EOSE received'); - // }); + // Return the message object + return { + id: signedEvent.id, + pubkey: myPubkey, + created_at: signedEvent.created_at, + content: messageText, // Store decrypted content locally + isOutgoing: true, + tags: signedEvent.tags, + encryptionType: 'nip04' + }; + } catch (error) { + this.logger.error('Failed to send NIP-04 message', error); + throw error; + } } /** - * Back to list on mobile view + * Send a message using NIP-44 encryption (modern) */ - backToList(): void { - this.showMobileList.set(true); + private async sendNip44Message( + messageText: string, + receiverPubkey: string, + myPubkey: string, + userRelay: UserRelayService + ): Promise { + try { + // Create the inner chat message (kind 14) + const chatMessage = { + kind: kinds.PrivateDirectMessage, + pubkey: myPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', receiverPubkey]], + content: messageText + }; + + // Sign the chat message + const signedChatMessage = await this.nostr.signEvent(chatMessage); + + // Create the sealed message (kind 13) - encrypt the chat message + const sealedContent = await this.encryption.encryptNip44( + JSON.stringify(signedChatMessage), + receiverPubkey + ); + + const sealedMessage = { + kind: kinds.Seal, + pubkey: myPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: sealedContent + }; + + // Sign the sealed message + const signedSealedMessage = await this.nostr.signEvent(sealedMessage); + + // Create the gift wrap (kind 1059) - this is what gets published + // Generate a random key for the gift wrap + const randomKey = generateSecretKey(); + const randomPubkey = getPublicKey(randomKey); + + const giftWrapContent = await this.encryption.encryptNip44( + JSON.stringify(signedSealedMessage), + receiverPubkey + ); + + const giftWrap = { + kind: kinds.GiftWrap, + pubkey: randomPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', receiverPubkey]], + content: giftWrapContent + }; + + // Sign the gift wrap with the random key + const signedGiftWrap = finalizeEvent(giftWrap, randomKey); + + // Publish the gift wrap to relays + await this.publishToRelays(signedGiftWrap, userRelay); + + // Return the message object based on the original chat message + return { + id: signedChatMessage.id, + pubkey: myPubkey, + created_at: signedChatMessage.created_at, + content: messageText, + isOutgoing: true, + tags: signedChatMessage.tags, + encryptionType: 'nip44' + }; + } catch (error) { + this.logger.error('Failed to send NIP-44 message', error); + throw error; + } } /** - * View profile of the selected chat + * Publish an event to multiple relays */ - viewProfile(): void { - const pubkey = this.selectedChat()?.pubkey; - if (pubkey) { - this.router.navigate(['/p', pubkey]); - } + private async publishToRelays(event: NostrEvent, userRelay: UserRelayService): Promise { + debugger; + const promisesUser = userRelay.publish(event); + const promisesAccount = this.accountRelayService.publish(event); + + // Wait for all publish attempts to complete + await Promise.allSettled([promisesUser, promisesAccount]); } } \ No newline at end of file diff --git a/src/app/services/account-relay.service.ts b/src/app/services/account-relay.service.ts new file mode 100644 index 0000000..3335a7d --- /dev/null +++ b/src/app/services/account-relay.service.ts @@ -0,0 +1,389 @@ +import { Injectable, inject, signal, computed, effect, untracked } from '@angular/core'; +import { LoggerService } from './logger.service'; +import { StorageService, Nip11Info, NostrEventData, UserMetadata } from './storage.service'; +import { Event, kinds, SimplePool } from 'nostr-tools'; +import { ApplicationStateService } from './application-state.service'; +import { NotificationService } from './notification.service'; +import { LocalStorageService } from './local-storage.service'; +import { NostrService } from './nostr.service'; +import { RelayService } from './relay.service'; + +export interface Relay { + url: string; + status?: 'connected' | 'disconnected' | 'connecting' | 'error'; + lastUsed?: number; + timeout?: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class AccountRelayService { + private logger = inject(LoggerService); + private storage = inject(StorageService); + private nostr = inject(NostrService); + private appState = inject(ApplicationStateService); + private notification = inject(NotificationService); + private localStorage = inject(LocalStorageService); + private relay = inject(RelayService); + userRelaysFound = signal(true); + pool = new SimplePool(); + + // Signal to store the relays for the current user (account relays) + // private relays = signal([]); + // relays: Relay[] = []; + + // relaysChanged = signal([]); + + // /** Holds the metadata event for all accounts in the app. */ + // // accountsMetadata = signal([]); + + // accountRelays = computed(() => { + // return this.relaysChanged(); + // }); + + // accountRelayUrls = computed(() => { + // return this.accountRelays().map((r) => r.url); + // }); + + constructor() { + // // When relays change, sync with storage + // effect(() => { + // if (this.relaysChanged()) { + // this.logger.debug(`Relay effect triggered with ${this.relays.length} relays`); + + // if (this.relays.length > 0) { + // this.syncRelaysToStorage(this.relays); + // } + // } + // }); + } + + // /** + // * Clears all relays (used when logging out) + // */ + // clearRelays(): void { + // this.logger.debug('Clearing all relays'); + // this.relays = []; + // this.relaysChanged.set(this.relays); + // } + + // /** + // * Adds a new relay to the list + // */ + // addRelay(url: string): void { + // this.logger.debug(`Adding new relay: ${url}`); + + // const newRelay: Relay = { + // url, + // status: 'disconnected', + // lastUsed: Date.now() + // }; + + // this.relays.push(newRelay); + // this.relaysChanged.set(this.relays); + + // // this.relays.update(relays => [...relays, newRelay]); + // } + + // /** + // * Sets the list of relays for the current user + // */ + // setRelays(relayUrls: string[]): void { + // this.logger.debug(`Setting ${relayUrls.length} relays for current account`); + + // // Convert simple URLs to Relay objects with default properties + // const relayObjects = relayUrls.map(url => ({ + // url, + // status: 'connecting' as const, + // lastUsed: Date.now() + // })); + + // // Before storing the relays, make sure that they have / at the end + // // if they are missing it. This ensures consistency in the relay URLs with SimplePool. + // relayObjects.forEach(relay => { + // if (!relay.url.endsWith('/')) { + // relay.url += '/'; + // } + // }); + + // this.relays = relayObjects; + // this.logger.debug('Relays updated successfully'); + // this.relaysChanged.set(this.relays); + // } + + // /** + // * Gets the user pool + // */ + // // getUserPool(): SimplePool | null { + // // return this.accountPool; + // // } + + // /** + // * Updates the status of a specific relay + // */ + // updateRelayStatus(url: string, status: Relay['status']): void { + // this.logger.debug(`Updating relay status for ${url} to ${status}`); + + // const relay = this.relays.find(relay => relay.url === url); + // if (relay) { + // relay.status = status; + // relay.lastUsed = Date.now(); + // } + + // this.relaysChanged.set(this.relays); + // } + + // /** + // * Helper method to update the lastUsed timestamp for a relay + // */ + // private updateRelayLastUsed(url: string): void { + // const relay = this.relays.find(relay => relay.url === url); + // if (relay) { + // relay.lastUsed = Date.now(); + // } + + // // this.relays.update(relays => + // // relays.map(relay => + // // relay.url === url + // // ? { ...relay, lastUsed: Date.now() } + // // : relay + // // ) + // // ); + // } + + // /** + // * Removes a relay from the list + // */ + // removeRelay(url: string): void { + // this.logger.debug(`Removing relay: ${url}`); + + // this.relays = this.relays.filter(relay => relay.url !== url); + // // this.relays.update(relays => relays.filter(relay => relay.url !== url)); + + // this.relaysChanged.set(this.relays); + // } + + // /** + // * Saves the current relays to storage for the current user + // */ + // private async syncRelaysToStorage(relays: Relay[]): Promise { + // try { + // // Save each relay to the storage + // for (const relay of relays) { + // await this.storage.saveRelay(relay); + // } + + // this.logger.debug(`Synchronized ${relays.length} relays to storage`); + // } catch (error) { + // this.logger.error('Error syncing relays to storage', error); + // } + // } + + config: any = {}; + // relayUrls: string[] = []; + + // async initialize(pubkey: string, config?: { customConfig?: any, customRelays?: string[] }) { + // let relayUrls = await this.nostr.getRelays(pubkey); + + // // If no relays were found, we will fall back to using the account relays. This is especially + // // important when the current logged-on user opens their own profile page and does NOT have + // // any relay list discovered yet. + // if (relayUrls.length === 0) { + // this.logger.warn(`No relays found for user ${pubkey}, falling back to account relays`); + // relayUrls = this.accountRelayUrls(); + // this.userRelaysFound.set(false); + + // // Log additional info for debugging + // this.logger.debug(`Using ${relayUrls.length} account relays as fallback:`, relayUrls); + // } else { + // this.logger.debug(`Found ${relayUrls.length} relays for user ${pubkey}:`, relayUrls); + // } + + // this.relayUrls = relayUrls; + // } + + /** + * Sets the user pool + */ + // setAccountPool(pool: SimplePool): void { + // this.pool = pool; + + // // After setting the user pool, check the online status of the relays + // this.logger.debug('Account pool set, checking relay status...'); + + // const connectionStatuses = this.pool.listConnectionStatus(); + + // // Update relay statuses using a for...of loop + // for (const [url, status] of connectionStatuses) { + // const userRelay = this.accountRelays().find(r => r.url === url); + + // if (!userRelay) { + // this.logger.warn(`Relay ${url} not found in account relays`); + // continue; + // } + + // userRelay.status = status ? 'connected' : 'disconnected'; + // } + // } + + async getEventByPubkeyAndKindAndTag(pubkey: string, kind: number, tag: { key: string, value: string }): Promise { + const authors = Array.isArray(pubkey) ? pubkey : [pubkey]; + + return this.get({ + authors, + [`#${tag.key}`]: [tag.value], + kinds: [kind] + }); + } + + /** + * Generic function to fetch Nostr events (one-time query) + * @param filter Filter for the query + * @param relayUrls Optional specific relay URLs to use (defaults to user's relays) + * @param options Optional options for the query + * @returns Promise that resolves to an array of events + */ + async get( + filter: { kinds?: number[], authors?: string[], '#e'?: string[], '#p'?: string[], since?: number, until?: number, limit?: number }, + relayUrls?: string[], + options: { timeout?: number } = {} + ): Promise { + this.logger.debug('Getting events with filters:', filter); + + if (!this.pool) { + this.logger.error('Cannot get events: user pool is not initialized'); + return null; + } + + try { + // Default timeout is 5 seconds if not specified + const timeout = options.timeout || 5000; + + // Execute the query + const event = await this.pool.get(this.relay.getAccountRelayUrls(), filter, { maxWait: timeout }) as T; + + this.logger.debug(`Received event from query`, event); + + return event; + } catch (error) { + this.logger.error('Error fetching events', error); + return null; + } + } + + publish(event: Event) { + this.logger.debug('Publishing event:', event); + + if (!this.pool) { + this.logger.error('Cannot publish event: user pool is not initialized'); + return; + } + + try { + // Publish the event to all relays + return this.pool.publish(this.relay.getAccountRelayUrls(), event); + } catch (error) { + this.logger.error('Error publishing event', error); + } + + return; + } + + /** + * Generic function to subscribe to Nostr events + * @param filters Array of filter objects for the subscription + * @param onEvent Callback function that will be called for each event received + * @param onEose Callback function that will be called when EOSE (End Of Stored Events) is received + * @param relayUrls Optional specific relay URLs to use (defaults to user's relays) + * @returns Subscription object with unsubscribe method + */ + subscribe( + filters: { kinds?: number[], authors?: string[], '#e'?: string[], '#p'?: string[], since?: number, until?: number, limit?: number }[], + onEvent: (event: T) => void, + onEose?: () => void, + relayUrls?: string[] + ) { + this.logger.debug('Creating subscription with filters:', filters); + + if (!this.pool) { + this.logger.error('Cannot subscribe: user pool is not initialized'); + return { + unsubscribe: () => { + this.logger.debug('No subscription to unsubscribe from'); + } + }; + } + + // Use provided relay URLs or default to the user's relays + if (this.relay.getAccountRelayUrls().length === 0) { + this.logger.warn('No relays available for subscription'); + return { + unsubscribe: () => { + this.logger.debug('No subscription to unsubscribe from (no relays)'); + } + }; + } + + try { + // Create the subscription + const sub = this.pool.subscribeMany(this.relay.getAccountRelayUrls(), filters, { + onevent: (evt) => { + this.logger.debug(`Received event of kind ${evt.kind}`); + + // Update the lastUsed timestamp for this relay + // this.updateRelayLastUsed(relay); + + // Call the provided event handler + onEvent(evt as T); + + // console.log('Event received', evt); + + // if (evt.kind === kinds.Contacts) { + // const followingList = this.storage.getPTagsValues(evt); + // console.log(followingList); + // this.followingList.set(followingList); + // this.profileState.followingList.set(followingList); + + // this.storage.saveEvent(evt); + + // Now you can use 'this' here + // For example: this.handleContacts(evt); + // } + }, + onclose: (reasons) => { + console.log('Pool closed', reasons); + // Also changed this to an arrow function for consistency + }, + oneose: () => { + if (onEose) { + this.logger.debug('End of stored events reached'); + onEose(); + } + }, + }); + + // Return an object with close method + return { + close: () => { + this.logger.debug('Close from events'); + sub.close(); + } + }; + } catch (error) { + this.logger.error('Error creating subscription', error); + return { + close: () => { + this.logger.debug('Error subscription close called'); + } + }; + } + } + + // async getEventByPubkeyAndKindAndTag(pubkey: string | string[], kind: number, tag: string[]): Promise { + // return this.get({ + // "#d": pubkey, + // kinds: [kind] + // }); + // } +} \ No newline at end of file diff --git a/src/app/services/encryption.service.ts b/src/app/services/encryption.service.ts new file mode 100644 index 0000000..2085b17 --- /dev/null +++ b/src/app/services/encryption.service.ts @@ -0,0 +1,204 @@ +import { Injectable, inject } from '@angular/core'; +import { LoggerService } from './logger.service'; +import { AccountStateService } from './account-state.service'; +import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; +import { Event, nip04, nip44 } from 'nostr-tools'; +import { v2 } from 'nostr-tools/nip44'; +import { UtilitiesService } from './utilities.service'; + +export interface EncryptionResult { + content: string; + algorithm: 'nip04' | 'nip44'; +} + +export interface DecryptionResult { + content: string; + algorithm: 'nip04' | 'nip44'; +} + +@Injectable({ + providedIn: 'root' +}) +export class EncryptionService { + private logger = inject(LoggerService); + private readonly utilities = inject(UtilitiesService); + private accountState = inject(AccountStateService); /** + * Encrypt a message using NIP-04 (legacy, less secure) + * Uses AES-256-CBC encryption + */ + async encryptNip04(plaintext: string, recipientPubkey: string): Promise { + try { + const account = this.accountState.account(); + + // Check if we can use the browser extension + if (account?.source === 'extension' && window.nostr?.nip04) { + return await window.nostr.nip04.encrypt(recipientPubkey, plaintext); + } + + if (!account?.privkey) { + throw new Error('Private key not available for encryption'); + } + + // Use nostr-tools nip04 encryption + const privateKeyBytes = hexToBytes(account.privkey); + return await nip04.encrypt(privateKeyBytes, recipientPubkey, plaintext); + } catch (error) { + this.logger.error('Failed to encrypt with NIP-04', error); + throw new Error('Encryption failed'); + } + } + /** + * Decrypt a message using NIP-04 (legacy, less secure) + */ + async decryptNip04(ciphertext: string, pubkey: string): Promise { + try { + const account = this.accountState.account(); + + // Check if we can use the browser extension + if (account?.source === 'extension' && window.nostr?.nip04) { + const decrypted = await window.nostr.nip04.decrypt(pubkey, ciphertext) + return decrypted; + } + + if (!account?.privkey) { + throw new Error('Private key not available for decryption'); + } + + // Use nostr-tools nip04 decryption + const privateKeyBytes = hexToBytes(account.privkey); + return await nip04.decrypt(privateKeyBytes, pubkey, ciphertext); + } catch (error) { + this.logger.error('Failed to decrypt with NIP-04', error); + throw new Error('Decryption failed'); + } + } + /** + * Encrypt a message using NIP-44 (modern, secure) + */ + async encryptNip44(plaintext: string, recipientPubkey: string): Promise { + try { + const account = this.accountState.account(); + + // Check if we can use the browser extension + if (account?.source === 'extension' && window.nostr?.nip44) { + return await window.nostr.nip44.encrypt(recipientPubkey, plaintext); + } + + if (!account?.privkey) { + throw new Error('Private key not available for encryption'); + } + + // Use nostr-tools nip44 v2 encryption + const privateKeyBytes = hexToBytes(account.privkey); + const conversationKey = v2.utils.getConversationKey(privateKeyBytes, recipientPubkey); + + return v2.encrypt(plaintext, conversationKey); + } catch (error) { + this.logger.error('Failed to encrypt with NIP-44', error); + throw new Error('Encryption failed'); + } + } + /** + * Decrypt a message using NIP-44 (modern, secure) + */ + async decryptNip44(ciphertext: string, senderPubkey: string): Promise { + try { + const account = this.accountState.account(); + + // Check if we can use the browser extension + if (account?.source === 'extension' && window.nostr?.nip44) { + return await window.nostr.nip44.decrypt(senderPubkey, ciphertext); + } + + if (!account?.privkey) { + throw new Error('Private key not available for decryption'); + } + + // Use nostr-tools nip44 v2 decryption + const privateKeyBytes = hexToBytes(account.privkey); + const conversationKey = v2.utils.getConversationKey(privateKeyBytes, senderPubkey); + + return v2.decrypt(ciphertext, conversationKey); + } catch (error) { + this.logger.error('Failed to decrypt with NIP-44', error); + throw new Error('Decryption failed'); + } + } + + /** + * Auto-detect encryption type and decrypt accordingly + */ + async autoDecrypt(ciphertext: string, senderPubkey: string, event: Event): Promise { + if (ciphertext.includes('?iv=')) { + // Fallback to NIP-04 (legacy format with ?iv=) + try { + // TODO: Figure out what this "Echo: " prefix is about. + // Sometimes the ciphertext might have a prefix like "Echo: ". + ciphertext = ciphertext.replace('Echo: ', ''); + + // const pTags = this.utilities.getPTagsValuesFromEvent(event); + // if (pTags && pTags.length > 0) { + // // If we have p-tags, use the first one as the sender's public key + // const receiverPubkey = pTags[0]; + // if (receiverPubkey === "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52") { + // debugger; + // } + // } + + const content = await this.decryptNip04(ciphertext, senderPubkey); + return { content, algorithm: 'nip04' }; + } catch (error) { + this.logger.debug('NIP-04 decryption failed', error); + } + } else { + // Try NIP-44 first (modern format) + try { + const content = await this.decryptNip44(ciphertext, senderPubkey); + return { content, algorithm: 'nip44' }; + } catch (error) { + this.logger.debug('NIP-44 decryption failed, trying NIP-04', error); + } + } + throw new Error('Unable to decrypt message with any supported algorithm'); + } + + /** + * Get preferred encryption algorithm for new messages + * Always prefer NIP-44 for new messages, but support NIP-04 for compatibility + */ + getPreferredEncryption(): 'nip44' | 'nip04' { + return 'nip44'; // Always prefer the more secure option for new messages + } + + /** + * Check if account supports modern encryption + */ + supportsModernEncryption(): boolean { + const account = this.accountState.account(); + if (!account) return false; + + // Extension accounts depend on what the extension supports + if (account.source === 'extension') { + return !!window.nostr?.nip44; + } + + // All other account types support modern encryption + return true; + } + + /** + * Check if account supports legacy encryption + */ + supportsLegacyEncryption(): boolean { + const account = this.accountState.account(); + if (!account) return false; + + // Extension accounts depend on what the extension supports + if (account.source === 'extension') { + return !!window.nostr?.nip04; + } + + // All other account types support legacy encryption + return true; + } +} diff --git a/src/app/services/messaging.service.spec.ts b/src/app/services/messaging.service.spec.ts new file mode 100644 index 0000000..76fb5a1 --- /dev/null +++ b/src/app/services/messaging.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { MessagingService } from './messaging.service'; + +describe('MessagingService', () => { + let service: MessagingService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(MessagingService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/messaging.service.ts b/src/app/services/messaging.service.ts new file mode 100644 index 0000000..d9e04f5 --- /dev/null +++ b/src/app/services/messaging.service.ts @@ -0,0 +1,623 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { NostrService } from './nostr.service'; +import { RelayService } from './relay.service'; +import { LoggerService } from './logger.service'; +import { AccountStateService } from './account-state.service'; +import { Filter, kinds, NostrEvent } from 'nostr-tools'; +import { UtilitiesService } from './utilities.service'; +import { EncryptionService } from './encryption.service'; + +// Define interfaces for our DM data structures +interface Chat { + id: string; + pubkey: string; + unreadCount: number; + lastMessage?: DirectMessage | null; + relays?: string[]; + encryptionType?: 'nip04' | 'nip44'; + isLegacy?: boolean; // true for NIP-04 chats + messages: Map; +} + +interface DirectMessage { + id: string; + pubkey: string; + created_at: number; + content: string; + isOutgoing: boolean; + tags: string[][]; + pending?: boolean; + failed?: boolean; + received?: boolean; + read?: boolean; + encryptionType?: 'nip04' | 'nip44'; +} + +interface DecryptionQueueItem { + id: string; + event: NostrEvent; + type: 'nip04' | 'nip44'; + senderPubkey: string; + resolve: (result: any | null) => void; + reject: (error: Error) => void; +} + +@Injectable({ + providedIn: 'root' +}) +export class MessagingService { + private nostr = inject(NostrService); + private relay = inject(RelayService); + private logger = inject(LoggerService); + private readonly accountState = inject(AccountStateService); + readonly utilities = inject(UtilitiesService); + private readonly encryption = inject(EncryptionService); + isLoading = signal(false); + error = signal(null); + + private chatsMap = signal>(new Map()); + + // chats = computed(() => { + // return this.chatsMap(); + // }); + + getChat(chatId: string): Chat | null { + const chat = this.chatsMap().get(chatId); + return chat || null; + } + + sortedChats = computed(() => { + return Array.from(this.chatsMap().entries()) + .map(([pubkey, chat]) => ({ pubkey, chat })) + .sort((a, b) => { + const aTime = a.chat.lastMessage?.created_at || 0; + const bTime = b.chat.lastMessage?.created_at || 0; + return bTime - aTime; // Most recent first + }); + }); + + // selectedChatId = signal(null); + // selectedChat = computed(() => { + // const chatId = this.selectedChatId(); + // if (!chatId) return null; + // return this.chats().find(chat => chat.id === chatId) || null; + // }); + + private decryptionQueue: DecryptionQueueItem[] = []; + private isProcessingQueue = false; + isDecryptingMessages = signal(false); + decryptionQueueLength = signal(0); + + constructor() { } + + hasMessage(chatId: string, messageId: string): boolean { + const chat = this.chatsMap().get(chatId); + if (!chat) return false; + + return chat.messages.has(messageId); + } + + getChatMessages(chatId: string): DirectMessage[] { + const chat = this.chatsMap().get(chatId); + if (!chat) return []; + + return Array.from(chat.messages.values()) + .sort((a, b) => a.created_at - b.created_at); // Oldest first + } + + // Helper method to add a message to a chat (prevents duplicates and updates sorting) + addMessageToChat(chatId: string, message: DirectMessage): void { + const currentMap = new Map(this.chatsMap()); + + // Individual chats are keyed by pubkey, so we use pubkey as chatId + const chat = currentMap.get(chatId); + + if (!chat) { + // Create new chat if it doesn't exist + const newChat: Chat = { + id: chatId, + pubkey: chatId, + unreadCount: 0, + lastMessage: message, + relays: [], + encryptionType: message.encryptionType || 'nip44', + isLegacy: message.encryptionType === 'nip04', + messages: new Map([[message.id, message]]) + }; + + // The chats map is keyed by pubkey, so we update the chat using the pubkey + currentMap.set(chatId, newChat); + } else { + // Update existing chat + const updatedMessagesMap = new Map(chat.messages); + updatedMessagesMap.set(message.id, message); + + const updatedChat: Chat = { + ...chat, + messages: updatedMessagesMap, + lastMessage: this.getLatestMessage(updatedMessagesMap), + unreadCount: message.isOutgoing ? chat.unreadCount : chat.unreadCount + 1 + }; + + // The chats map is keyed by pubkey, so we update the chat using the pubkey + currentMap.set(chatId, updatedChat); + } + + this.chatsMap.set(currentMap); + } + + // Helper method to get the latest message from a messages map + private getLatestMessage(messagesMap: Map): DirectMessage | null { + if (messagesMap.size === 0) return null; + + return Array.from(messagesMap.values()) + .sort((a, b) => b.created_at - a.created_at)[0]; + } + + async loadChats() { + this.isLoading.set(true); + this.error.set(null); + + try { + const myPubkey = this.accountState.pubkey(); + if (!myPubkey) { + this.error.set('You need to be logged in to view messages'); + this.isLoading.set(false); + return; + } + + const filterReceived: Filter = { + kinds: [kinds.GiftWrap, kinds.EncryptedDirectMessage], + '#p': [myPubkey], + limit: 100 + }; + + const filterSent: Filter = { + kinds: [kinds.GiftWrap, kinds.EncryptedDirectMessage], + authors: [myPubkey], + limit: 100 + }; + + // Store pubkeys of people who've messaged us + const chatPubkeys = new Set(); + + // First, look for existing gift-wrapped messages + const sub = this.relay.subscribe([filterReceived, filterSent], async (event: NostrEvent) => { + // Handle incoming wrapped events + if (event.kind === kinds.GiftWrap) { + // let chats = this.chatsMap(); + // let chat = chats.get(event.pubkey); + + // if (!chat) { + // chat = { + // id: event.pubkey, + // pubkey: event.pubkey, + // unreadCount: 0, + // lastMessage: null, + // relays: [], + // encryptionType: 'nip44', + // isLegacy: false, + // messages: new Map() + // }; + // chats.set(event.pubkey, chat); + // } + + const wrappedevent = await this.unwrapMessageInternal(event); + + if (!wrappedevent) { + this.logger.warn('Failed to unwrap gift-wrapped message', event); + return; + } + + // Create a DirectMessage object from the unwrapped content + const directMessage: DirectMessage = { + id: wrappedevent.id, + pubkey: wrappedevent.pubkey, + created_at: wrappedevent.created_at, + content: wrappedevent.content, + tags: wrappedevent.tags || [], + isOutgoing: event.pubkey === myPubkey, + pending: false, + failed: false, + received: true, + read: false, + encryptionType: 'nip44' // Gift-wrapped messages are NIP-44 + }; + + // Add the message to the chat + this.addMessageToChat(wrappedevent.pubkey, directMessage); + + // Add all pubkeys to the list, including self, we might chat with ourselves for notes, etc. + // chatPubkeys.add(event.pubkey); + + // // Look for 'p' tags for recipients other than ourselves + // const pTags = event.tags.filter(tag => tag[0] === 'p'); + // for (const tag of pTags) { + // const pubkey = tag[1]; + // if (pubkey !== myPubkey) { + // chatPubkeys.add(pubkey); + // } + // } + // this.relayPool?.publish(relays, event); + } else { + // Handle incoming NIP-04 direct messages + if (event.kind === kinds.EncryptedDirectMessage) { + + let targetPubkey = event.pubkey; + + // Target pubkey: + if (targetPubkey === myPubkey) { + // If the event pubkey is our own, we are the sender + // We need to check 'p' tags for recipients + const pTags = this.utilities.getPTagsValuesFromEvent(event); + if (pTags.length > 0) { + // If we have p-tags, use the first one as the recipient + targetPubkey = pTags[0]; + } else { + // No p-tags, we can't unwrap this message + this.logger.warn('NIP-04 message has no recipients, ignoring.', event); + return; + } + } + + if (this.hasMessage(targetPubkey, event.id)) { + return; // Skip if we already have this message + } + + let chats = this.chatsMap(); + let chat = chats.get(targetPubkey); + + if (!chat) { + chat = { + id: targetPubkey, + pubkey: targetPubkey, + unreadCount: 0, + lastMessage: null, + relays: [], + encryptionType: 'nip04', + isLegacy: true, + messages: new Map() + }; + chats.set(targetPubkey, chat); + } + + const unwrappedMessage = await this.unwrapNip04Message(event); + + if (!unwrappedMessage) { + this.logger.warn('Failed to unwrap gift-wrapped message', event); + return; + } + + // Create a DirectMessage object from the unwrapped content + const directMessage: DirectMessage = { + id: unwrappedMessage.id, + pubkey: unwrappedMessage.pubkey, + created_at: unwrappedMessage.created_at, + content: unwrappedMessage.content, + tags: unwrappedMessage.tags || [], + isOutgoing: event.pubkey === myPubkey, + pending: false, + failed: false, + received: true, + read: false, + encryptionType: 'nip04' // Gift-wrapped messages are NIP-04 + }; + + // Add the message to the chat + this.addMessageToChat(targetPubkey, directMessage); + } + } + }, () => { + console.log('End of data for incoming messages.'); + + // Now create chats list from collected pubkeys + // const chatsList: Chat[] = Array.from(chatPubkeys).map(pubkey => ({ + // id: pubkey, // Using pubkey as chat ID + // pubkey, + // unreadCount: 0, + // lastMessage: null + // })); + + // // Sort chats (will be updated with last messages later) + // const sortedChats = chatsList.sort((a, b) => { + // const aTime = a.lastMessage?.created_at || 0; + // const bTime = b.lastMessage?.created_at || 0; + // return bTime - aTime; // Most recent first + // }); + + // this.chats.set(sortedChats); + + // For each chat, fetch the latest message + // for (const chat of sortedChats) { + // this.fetchLatestMessageForChat(chat.pubkey); + // } + + this.isLoading.set(false); + }) + + // Process wrapped events to find unique chat participants + // if (wrappedEvents && wrappedEvents.length > 0) { + // for (const event of wrappedEvents) { + // // Add the sender to our chat list if not us + // if (event.pubkey !== myPubkey) { + // chatPubkeys.add(event.pubkey); + // } + + // // Look for 'p' tags for recipients other than ourselves + // const pTags = event.tags.filter(tag => tag[0] === 'p'); + // for (const tag of pTags) { + // const pubkey = tag[1]; + // if (pubkey !== myPubkey) { + // chatPubkeys.add(pubkey); + // } + // } + // } + // } + + // Also add chats from our outgoing messages + // const ourMessages = await this.relay.getAccountPool()?.subscribe(this.relay.getAccountRelayUrls(), { + // kinds: [kinds.GiftWrap, kinds.EncryptedDirectMessage], + // authors: [myPubkey], + // limit: 100 + // }, { + // maxWait: 5000, + // label: 'loadChats', onevent: async (event: NostrEvent) => { + // if (event.kind == kinds.EncryptedDirectMessage) { + // const unwrappedMessage = await this.unwrapNip04Message(event); + // if (unwrappedMessage) { + // // Look for 'p' tags for recipients other than ourselves + // const pTags = event.tags.filter(tag => tag[0] === 'p'); + // for (const tag of pTags) { + // const pubkey = tag[1]; + // if (pubkey !== myPubkey) { + // chatPubkeys.add(pubkey); + // } + // } + // } + // } + + // // Handle outgoing wrapped events + // if (event.kind === kinds.GiftWrap) { + // const pTags = event.tags.filter(tag => tag[0] === 'p'); + // for (const tag of pTags) { + // const pubkey = tag[1]; + // if (pubkey !== myPubkey) { + // chatPubkeys.add(pubkey); + // } + // } + // } + // }, onclose: () => { + // console.log('End of data for outgoing messages.'); + + // // Final update: create chats list from all collected pubkeys + // const finalChatsList: Chat[] = Array.from(chatPubkeys).map(pubkey => ({ + // id: pubkey, // Using pubkey as chat ID + // pubkey, + // unreadCount: 0, + // lastMessage: null + // })); + + // // Sort chats (will be updated with last messages later) + // const finalSortedChats = finalChatsList.sort((a, b) => { + // const aTime = a.lastMessage?.created_at || 0; + // const bTime = b.lastMessage?.created_at || 0; + // return bTime - aTime; // Most recent first + // }); + + // this.chats.set(finalSortedChats); + + // // For each chat, fetch the latest message + // for (const chat of finalSortedChats) { + // this.fetchLatestMessageForChat(chat.pubkey); + // } + // } + // }); + + // if (ourMessages && ourMessages.length > 0) { + // for (const event of ourMessages) { + + // } + // } + + // Convert to array of Chat objects + + + + } catch (err) { + this.logger.error('Failed to load chats', err); + this.error.set('Failed to load chats. Please try again.'); + this.isLoading.set(false); + } + + } + + /** + * Unwrap and decrypt a NIP-04 direct message (queued version for user-facing calls) + */ + async unwrapNip04Message(event: NostrEvent): Promise { + const senderPubkey = event.pubkey; + return await this.queueMessageForDecryption(event, 'nip04', senderPubkey); + } + + /** + * Add a message to the decryption queue for sequential processing + */ + private async queueMessageForDecryption(event: NostrEvent, type: 'nip04' | 'nip44', senderPubkey: string): Promise { + return new Promise((resolve, reject) => { + const queueItem: DecryptionQueueItem = { + id: `${event.id}-${Date.now()}`, + event, + type, + senderPubkey, + resolve, + reject + }; + + this.decryptionQueue.push(queueItem); + this.decryptionQueueLength.set(this.decryptionQueue.length); + this.logger.debug(`Added message to decryption queue. Queue length: ${this.decryptionQueue.length}`); + + // Start processing if not already processing + if (!this.isProcessingQueue) { + this.processDecryptionQueue(); + } + }); + } + + /** + * Clear the decryption queue (useful for cleanup) + */ + clearDecryptionQueue(): void { + // Reject all pending items + this.decryptionQueue.forEach(item => { + item.reject(new Error('Decryption queue cleared')); + }); + + this.decryptionQueue = []; + this.isProcessingQueue = false; + this.isDecryptingMessages.set(false); + this.decryptionQueueLength.set(0); + this.logger.debug('Decryption queue cleared'); + } + + /** + * Process the decryption queue sequentially + */ + private async processDecryptionQueue(): Promise { + if (this.isProcessingQueue || this.decryptionQueue.length === 0) { + return; + } this.isProcessingQueue = true; + this.isDecryptingMessages.set(true); + this.logger.debug('Starting decryption queue processing'); + + while (this.decryptionQueue.length > 0) { + const item = this.decryptionQueue.shift()!; + this.decryptionQueueLength.set(this.decryptionQueue.length); + + try { + this.logger.debug(`Processing decryption for message ${item.id}`); + + let result: any | null = null; if (item.type === 'nip04') { + result = await this.unwrapNip04MessageInternal(item.event); + } else if (item.type === 'nip44') { + result = await this.unwrapMessageInternal(item.event); + } + + item.resolve(result); + this.logger.debug(`Successfully decrypted message ${item.id}`); + + // Small delay between processing to prevent overwhelming the user with extension prompts + await new Promise(resolve => setTimeout(resolve, 100)); + + } catch (error) { + debugger; + this.logger.error(`Failed to decrypt message ${item.id}:`, error); + item.reject(error as Error); + } + } + + this.isProcessingQueue = false; + this.isDecryptingMessages.set(false); + this.decryptionQueueLength.set(0); + this.logger.debug('Finished processing decryption queue'); + } + + /** + * Internal unwrap and decrypt a NIP-04 direct message (direct processing) + */ + private async unwrapNip04MessageInternal(event: NostrEvent): Promise { + const myPubkey = this.accountState.pubkey(); + if (!myPubkey) return null; + + try { + // For NIP-04 messages, the sender is the event pubkey + const tags = this.utilities.getPTagsValuesFromEvent(event); + + if (tags.length === 0) { + return null; + } + else if (tags.length > 1) { + // NIP-04 only supports one recipient, yet some clients have sent DMs with more. Ignore those. + this.logger.warn('NIP-04 message has multiple recipients, ignoring.', event); + return null; + } + + // If we are the sender, get the pubkey from 'p' tag. + // If we are the receiver, use the event pubkey. + let decryptionPubkey = event.pubkey; + + if (decryptionPubkey === myPubkey) { + + if (tags.length > 0) { + decryptionPubkey = tags[0]; // Use the first 'p' tag as the recipient + } + } + + // Use the EncryptionService to decrypt + const decryptionResult = await this.encryption.autoDecrypt(event.content, decryptionPubkey, event); + + // Return the message with decrypted content + return { + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + content: decryptionResult.content, + tags: event.tags + }; + } catch (err) { + this.logger.error('Failed to decrypt NIP-04 message', err); + return null; + } + } + + /** + * Internal unwrap and decrypt a gift-wrapped message (direct processing) + */ + private async unwrapMessageInternal(wrappedEvent: any): Promise { + const myPubkey = this.accountState.pubkey(); + if (!myPubkey) return null; + + try { + // Check if this message is for us + const recipient = wrappedEvent.tags.find((t: string[]) => t[0] === 'p')?.[1]; + if (recipient !== myPubkey && wrappedEvent.pubkey !== myPubkey) { + return null; + } + + // First decrypt the wrapped content using the EncryptionService + // This will handle both browser extension and direct decryption + let wrappedContent: any; + try { + const decryptionResult = await this.encryption.autoDecrypt(wrappedEvent.content, wrappedEvent.pubkey, wrappedEvent); + wrappedContent = JSON.parse(decryptionResult.content); + } catch (err) { + this.logger.error('Failed to decrypt wrapped content', err); + return null; + } + + // Get the sealed message + let sealedEvent; + if (wrappedEvent.pubkey === myPubkey) { + // If we sent it, we can directly use the encryptedMessage + sealedEvent = wrappedContent.encryptedMessage; + } else { + // Decrypt the sealed content using the EncryptionService + try { + const sealedDecryptionResult = await this.encryption.autoDecrypt(wrappedContent.content, wrappedContent.pubkey, wrappedEvent); + sealedEvent = JSON.parse(sealedDecryptionResult.content); + } catch (err) { + this.logger.error('Failed to decrypt sealed content', err); + return null; + } + } + + // Return the final decrypted message + return { + ...sealedEvent + }; + } catch (err) { + this.logger.error('Failed to unwrap message', err); + throw err; + } + } +} diff --git a/src/app/services/nostr.service.ts b/src/app/services/nostr.service.ts index e534cbf..1e4d654 100644 --- a/src/app/services/nostr.service.ts +++ b/src/app/services/nostr.service.ts @@ -66,23 +66,12 @@ export class NostrService { // accountChanging = signal(null); // accountChanged = signal(null); - /** Holds the metadata event for all accounts in the app. */ - // accountsMetadata = signal([]); - accountsRelays = signal([]); - - accountRelays = computed(() => { - return this.relayService.relaysChanged(); - }); - - accountRelayUrls = computed(() => { - return this.accountRelays().map((r) => r.url); - }); - // These are cache-lookups for the metadata and relays of all users, // to avoid query the database all the time. // These lists will grow // usersMetadata = signal>(new Map()); usersRelays = signal>(new Map()); + accountsRelays = signal([]); hasAccounts = computed(() => { return this.accounts().length > 0; @@ -313,7 +302,6 @@ export class NostrService { // return this.accountsMetadata().find(meta => meta.event.pubkey === pubkey); // } getAccountFromStorage() { - debugger; // Check for pubkey query parameter first (for notification handling) if (typeof window !== 'undefined') { const urlParams = new URLSearchParams(window.location.search); @@ -1852,9 +1840,6 @@ export class NostrService { this.logger.debug('New keypair generated successfully', { pubkey, region }); - this.accountsRelays - - await this.setAccount(newUser); } diff --git a/src/app/services/user-relay.service.ts b/src/app/services/user-relay.service.ts index ac2be37..a390208 100644 --- a/src/app/services/user-relay.service.ts +++ b/src/app/services/user-relay.service.ts @@ -7,6 +7,7 @@ import { NotificationService } from './notification.service'; import { LocalStorageService } from './local-storage.service'; import { NostrService } from './nostr.service'; import { RelayService } from './relay.service'; +import { AccountRelayService } from './account-relay.service'; export interface Relay { url: string; @@ -23,6 +24,7 @@ export class UserRelayService { private appState = inject(ApplicationStateService); private notification = inject(NotificationService); private localStorage = inject(LocalStorageService); + private accountRelayService = inject(AccountRelayService); private relay = inject(RelayService); userRelaysFound = signal(true); pool = new SimplePool(); @@ -41,9 +43,9 @@ export class UserRelayService { // any relay list discovered yet. if (relayUrls.length === 0) { this.logger.warn(`No relays found for user ${pubkey}, falling back to account relays`); - relayUrls = this.nostr.accountRelayUrls(); + relayUrls = this.relay.getAccountRelayUrls(); this.userRelaysFound.set(false); - + // Log additional info for debugging this.logger.debug(`Using ${relayUrls.length} account relays as fallback:`, relayUrls); } else { @@ -64,12 +66,12 @@ export class UserRelayService { } /** - * Generic function to fetch Nostr events (one-time query) - * @param filter Filter for the query - * @param relayUrls Optional specific relay URLs to use (defaults to user's relays) - * @param options Optional options for the query - * @returns Promise that resolves to an array of events - */ + * Generic function to fetch Nostr events (one-time query) + * @param filter Filter for the query + * @param relayUrls Optional specific relay URLs to use (defaults to user's relays) + * @param options Optional options for the query + * @returns Promise that resolves to an array of events + */ async get( filter: { kinds?: number[], authors?: string[], '#e'?: string[], '#p'?: string[], since?: number, until?: number, limit?: number }, relayUrls?: string[], @@ -98,14 +100,32 @@ export class UserRelayService { } } + publish(event: Event) { + this.logger.debug('Publishing event:', event); + + if (!this.pool) { + this.logger.error('Cannot publish event: user pool is not initialized'); + return; + } + + try { + // Publish the event to all relays + return this.pool.publish(this.relayUrls, event); + } catch (error) { + this.logger.error('Error publishing event', error); + } + + return; + } + /** - * Generic function to subscribe to Nostr events - * @param filters Array of filter objects for the subscription - * @param onEvent Callback function that will be called for each event received - * @param onEose Callback function that will be called when EOSE (End Of Stored Events) is received - * @param relayUrls Optional specific relay URLs to use (defaults to user's relays) - * @returns Subscription object with unsubscribe method - */ + * Generic function to subscribe to Nostr events + * @param filters Array of filter objects for the subscription + * @param onEvent Callback function that will be called for each event received + * @param onEose Callback function that will be called when EOSE (End Of Stored Events) is received + * @param relayUrls Optional specific relay URLs to use (defaults to user's relays) + * @returns Subscription object with unsubscribe method + */ subscribe( filters: { kinds?: number[], authors?: string[], '#e'?: string[], '#p'?: string[], since?: number, until?: number, limit?: number }[], onEvent: (event: T) => void, @@ -194,6 +214,4 @@ export class UserRelayService { // kinds: [kind] // }); // } -} - - +} \ No newline at end of file