Add relay management

This commit is contained in:
Milad Raeisi
2024-09-16 23:39:46 +04:00
parent 6a86d622c5
commit b9b3a0e4ea
7 changed files with 246 additions and 1759 deletions

View File

@@ -1,99 +1,50 @@
<div class="w-full max-w-3xl">
<!-- Add team member -->
<!-- Add relay -->
<div class="w-full">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>Add team members</mat-label>
<mat-icon
class="icon-size-5"
[svgIcon]="'heroicons_solid:user'"
matPrefix
></mat-icon>
<input matInput [placeholder]="'Email address'" />
<button mat-icon-button matSuffix>
<mat-icon
class="icon-size-5"
[svgIcon]="'heroicons_solid:plus-circle'"
></mat-icon>
<mat-label>Add Relay</mat-label>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:link'" matPrefix></mat-icon>
<input matInput [(ngModel)]="newRelayUrl" placeholder="Relay URL" />
<button mat-icon-button matSuffix (click)="addRelay()">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:plus-circle'"></mat-icon>
</button>
</mat-form-field>
</div>
<!-- Team members -->
<!-- Relays -->
<div class="mt-8 flex flex-col divide-y border-b border-t">
@for (member of members; track trackByFn($index, member)) {
<div class="flex flex-col py-6 sm:flex-row sm:items-center">
<div *ngFor="let relay of relays; trackBy: trackByFn" class="flex flex-col py-6 sm:flex-row sm:items-center">
<div class="flex items-center">
<div
class="flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full"
>
@if (member.avatar) {
<img
class="h-full w-full object-cover"
[src]="member.avatar"
alt="Contact avatar"
onerror="this.src='/images/avatars/avatar-placeholder.png'" [src]="relayFavIcon(relay.url)"
alt="relay avatar"
/>
}
@if (!member.avatar) {
<div
class="flex h-full w-full items-center justify-center rounded-full bg-gray-200 text-lg uppercase text-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
{{ member.name.charAt(0) }}
</div>
}
</div>
<div class="ml-4">
<div class="font-medium">{{ member.name }}</div>
<div class="text-secondary">{{ member.email }}</div>
<div class="font-medium">{{ relay.url }}</div>
<div class="text-sm text-gray-500">Status: {{ getRelayStatus(relay) }}</div>
</div>
</div>
<div class="mt-4 flex items-center sm:ml-auto sm:mt-0">
<div class="order-2 ml-4 sm:order-1 sm:ml-0">
<mat-form-field
class="angor-mat-dense w-32"
[subscriptSizing]="'dynamic'"
>
<mat-select
[panelClass]="
'w-72 min-w-72 max-w-72 h-auto max-h-none'
"
[value]="member.role"
disableOptionCentering
#roleSelect="matSelect"
>
<mat-form-field class="angor-mat-dense w-50" [subscriptSizing]="'dynamic'">
<mat-select [(ngModel)]="relay.accessType" (selectionChange)="updateRelayAccess(relay)">
<mat-select-trigger class="text-md">
<span>Role:</span>
<span class="ml-1 font-medium">{{
roleSelect.value | titlecase
}}</span>
<span class="ml-1 font-medium">{{ relay.accessType | titlecase }}</span>
</mat-select-trigger>
@for (role of roles; track role) {
<mat-option
class="h-auto py-4 leading-none"
[value]="role.value"
>
<div class="font-medium">
{{ role.label }}
</div>
<div
class="text-secondary mt-1.5 whitespace-normal text-sm leading-normal"
>
{{ role.description }}
</div>
<mat-option *ngFor="let option of accessOptions" [value]="option.value">
<div class="font-medium">{{ option.label }}</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="order-1 sm:order-2 sm:ml-3">
<button mat-icon-button>
<mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:trash'"
></mat-icon>
<button mat-icon-button (click)="removeRelay(relay.url)">
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
</button>
</div>
</div>
</div>
}
</div>
</div>

View File

@@ -1,16 +1,21 @@
import { TitleCasePipe } from '@angular/common';
import { CommonModule, TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
ViewEncapsulation,
ChangeDetectorRef,
NgZone,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { RelayService } from 'app/services/relay.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'settings-relay',
@@ -26,105 +31,83 @@ import { MatSelectModule } from '@angular/material/select';
MatSelectModule,
MatOptionModule,
TitleCasePipe,
CommonModule,
FormsModule,
],
})
export class SettingsRoleComponent implements OnInit {
members: any[];
roles: any[];
export class SettingsRelayComponent implements OnInit {
relays: any[] = [];
accessOptions: any[] = [];
newRelayUrl: string = '';
private subscriptions: Subscription = new Subscription();
/**
* Constructor
*/
constructor() {}
constructor(
private relayService: RelayService,
private cdr: ChangeDetectorRef,
private zone: NgZone
) {}
// -----------------------------------------------------------------------------------------------------
// @ Lifecycle hooks
// -----------------------------------------------------------------------------------------------------
/**
* On init
*/
ngOnInit(): void {
// Setup the team members
this.members = [
{
avatar: 'images/avatars/avatar-placeholder.png',
name: 'Dejesus Michael',
email: 'dejesusmichael@mail.org',
role: 'admin',
},
{
avatar: 'images/avatars/avatar-placeholder.png',
name: 'Mclaughlin Steele',
email: 'mclaughlinsteele@mail.me',
role: 'admin',
},
{
avatar: 'images/avatars/avatar-placeholder.png',
name: 'Laverne Dodson',
email: 'lavernedodson@mail.ca',
role: 'write',
},
{
avatar: 'images/avatars/avatar-placeholder.png',
name: 'Trudy Berg',
email: 'trudyberg@mail.us',
role: 'read',
},
{
avatar: 'images/avatars/avatar-placeholder.png',
name: 'Lamb Underwood',
email: 'lambunderwood@mail.me',
role: 'read',
},
{
avatar: 'images/avatars/avatar-placeholder.png',
name: 'Mcleod Wagner',
email: 'mcleodwagner@mail.biz',
role: 'read',
},
{
avatar: 'images/avatars/avatar-placeholder.png',
name: 'Shannon Kennedy',
email: 'shannonkennedy@mail.ca',
role: 'read',
},
];
// Subscribe to relays observable
this.subscriptions.add(
this.relayService.getRelays().subscribe(relays => {
this.zone.run(() => {
this.relays = relays;
this.cdr.markForCheck(); // Mark the component for check
});
})
);
// Setup the roles
this.roles = [
// Setup access roles
this.accessOptions = [
{
label: 'Read',
value: 'read',
description:
'Can read and clone this repository. Can also open and comment on issues and pull requests.',
description: 'Reads only, does not write, unless explicitly specified on publish action.',
},
{
label: 'Write',
value: 'write',
description:
'Can read, clone, and push to this repository. Can also manage issues and pull requests.',
description: 'Writes your events, profile, and other metadata updates. Connects on-demand.',
},
{
label: 'Admin',
value: 'admin',
description:
'Can read, clone, and push to this repository. Can also manage issues, pull requests, and repository settings, including adding collaborators.',
label: 'Read and Write',
value: 'read-write',
description: 'Reads and writes events, profiles, and other metadata. Always connected.',
},
];
}
// -----------------------------------------------------------------------------------------------------
// @ Public methods
// -----------------------------------------------------------------------------------------------------
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
addRelay() {
if (this.newRelayUrl) {
this.relayService.addRelay(this.newRelayUrl);
this.newRelayUrl = '';
}
}
updateRelayAccess(relay: any) {
console.log('Relay Access Updated:', relay.url, relay.accessType);
this.relayService.updateRelayAccessType(relay.url, relay.accessType);
}
removeRelay(url: string) {
this.relayService.removeRelay(url);
}
/**
* Track by function for ngFor loops
*
* @param index
* @param item
*/
trackByFn(index: number, item: any): any {
return item.id || index;
return item.url || index;
}
getRelayStatus(relay: any): string {
return relay.connected ? 'Connected' : 'Disconnected';
}
relayFavIcon(url: string) {
const favUrl = url.replace('wss://', 'https://');
return favUrl + '/favicon.ico';
}
}

View File

@@ -16,7 +16,7 @@ import { Subject, takeUntil } from 'rxjs';
import { SettingsAccountComponent } from './account/account.component';
import { SettingsNotificationsComponent } from './notifications/notifications.component';
import { SettingsSecurityComponent } from './security/security.component';
import { SettingsRoleComponent } from './relay/relay.component';
import { SettingsRelayComponent } from './relay/relay.component';
@Component({
selector: 'settings',
@@ -32,7 +32,7 @@ import { SettingsRoleComponent } from './relay/relay.component';
SettingsAccountComponent,
SettingsSecurityComponent,
SettingsNotificationsComponent,
SettingsRoleComponent,
SettingsRelayComponent,
],
})
export class SettingsComponent implements OnInit, OnDestroy {
@@ -40,7 +40,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
drawerMode: 'over' | 'side' = 'side';
drawerOpened: boolean = true;
panels: any[] = [];
selectedPanel: string = 'account';
selectedPanel: string = 'relay';
private _unsubscribeAll: Subject<any> = new Subject<any>();
/**
@@ -51,16 +51,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
private _angorMediaWatcherService: AngorMediaWatcherService
) {}
// -----------------------------------------------------------------------------------------------------
// @ Lifecycle hooks
// -----------------------------------------------------------------------------------------------------
/**
* On init
*/
ngOnInit(): void {
// Setup available panels
this.panels = [
{
id: 'relay',
icon: 'heroicons_outline:computer-desktop',
title: 'Relay',
description:
'Manage your existing relays and update their access roles/permissions',
},
{
id: 'account',
icon: 'heroicons_outline:user-circle',
@@ -80,14 +83,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
icon: 'heroicons_outline:bell',
title: 'Notifications',
description: "Manage when you'll be notified on which channels",
},
{
id: 'relay',
icon: 'heroicons_outline:computer-desktop',
title: 'Relay',
description:
'Manage your existing relays and change roles/permissions',
},
}
];
// Subscribe to media changes
@@ -117,10 +113,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
this._unsubscribeAll.complete();
}
// -----------------------------------------------------------------------------------------------------
// @ Public methods
// -----------------------------------------------------------------------------------------------------
/**
* Navigate to the panel
*

View File

@@ -1,809 +0,0 @@
import { Injectable } from '@angular/core';
import {
generateSecretKey,
getPublicKey,
finalizeEvent,
verifyEvent,
Event as NostrEvent
} from 'nostr-tools/pure';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { sha256 } from '@noble/hashes/sha256';
import { RelayService } from './relay.service';
import { Filter,kinds } from 'nostr-tools';
import { nip04 } from 'nostr-tools';
import { SecurityService } from './security.service';
import { Observable, of, Subject } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { EncryptedDirectMessage } from 'nostr-tools/kinds';
import { mergeMap, bufferTime } from 'rxjs/operators';
import { IndexedDBService } from './indexed-db.service';
import { MetadataService } from './metadata.service';
interface CustomMessageEvent {
isSentByUser: boolean;
decryptedMessage: string;
createdAt: number;
}
@Injectable({
providedIn: 'root',
})
export class NostrService {
private metadataSubject = new Subject<any>();
private eventSubject = new Subject<NostrEvent>();
private notificationSubject = new Subject<NostrEvent>();
private messageSubject = new Subject<CustomMessageEvent>();
private currentPage = 0;
private messagesPerPage = 50;
private allDecryptedMessages: CustomMessageEvent[] = [];
private isProcessing = false;
private isDecrypting = false;
private messageQueue: NostrEvent[] = [];
private latestMessageTimestamps: { [pubKey: string]: number } = {};
private processedEventIds = new Set<string>();
private chatList: {
pubKey: string;
lastMessage: string;
lastMessageTime: number;
metadata?: any;
}[] = [];
private chatListSubject = new BehaviorSubject<{
pubKey: string;
lastMessage: string;
lastMessageTime: number;
metadata?: any;
}[]>(this.chatList);
nostrPublicKey = '';
nostrSignedEvent = '';
nostrRelays?: string[];
nostrEvent = {
created_at: Date.now(),
kind: 1,
tags: [],
content: 'This is my nostr message',
pubkey: '',
};
// Observable that other parts of the app can subscribe to
public eventUpdates$ = this.eventSubject.asObservable();
constructor(
private relayService: RelayService,
private security: SecurityService,
private indexedDBService: IndexedDBService,
private metadataService: MetadataService
) {}
// Signing events
async signEventWithPassword(
content: string,
encryptedPrivateKey: string,
password: string,
kind: number,
tags: string[][],
pubkey: string
): Promise<NostrEvent> {
const decryptedPrivateKey = await this.security.decryptData(encryptedPrivateKey, password);
const secretKey = hexToBytes(decryptedPrivateKey);
if (!this.isValidHex(bytesToHex(secretKey))) {
console.error('Invalid secret key provided:', bytesToHex(secretKey));
throw new Error('Invalid secret key format');
}
const event = this.createEvent(content, kind, tags, pubkey);
const signedEvent = finalizeEvent(event, secretKey);
if (!this.isValidHex(signedEvent.id)) {
console.error('Invalid signed event ID:', signedEvent.id);
throw new Error('Invalid signed event format');
}
return signedEvent;
}
async signEventWithExtension(content: string, kind: number, tags: string[][], pubkey: string): Promise<NostrEvent> {
const gt = globalThis as any;
if (!gt.nostr || typeof gt.nostr.signEvent !== 'function') {
throw new Error('Nostr extension not available or signEvent method is missing.');
}
const event = this.createEvent(content, kind, tags, pubkey);
try {
const signedEvent = await gt.nostr.signEvent(event);
return signedEvent;
} catch (error) {
console.error('Error signing event with extension:', error);
throw error;
}
}
async signEvent(
eventContent: string,
kind: number,
options: {
encryptedPrivateKey?: string;
password?: string;
useExtension?: boolean;
tags: string[][];
pubkey: string;
}
): Promise<NostrEvent> {
const { encryptedPrivateKey, password, useExtension, tags, pubkey } = options;
if (useExtension) {
return this.signEventWithExtension(eventContent, kind, tags, pubkey);
}
if (encryptedPrivateKey && password) {
return this.signEventWithPassword(eventContent, encryptedPrivateKey, password, kind, tags, pubkey);
}
throw new Error('No valid signing method provided.');
}
private createEvent(content: string, kind: number, tags: string[][], pubkey: string): NostrEvent {
return {
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
pubkey,
id: '',
sig: '',
} as unknown as NostrEvent;
}
async getEventId(event: NostrEvent): Promise<string> {
const eventSerialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content,
]);
return bytesToHex(await sha256(eventSerialized));
}
serializeEvent(event: any): string {
return JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
}
getEventHash(event: any): string {
const utf8Encoder = new TextEncoder();
const eventHash = sha256(utf8Encoder.encode(this.serializeEvent(event)));
return this.bytesToHex(eventHash);
}
verifyEvent(event: NostrEvent): boolean {
return verifyEvent(event);
}
// Messaging (NIP-04)
async decryptMessageWithExtension(encryptedContent: string, senderPubKey: string): Promise<string> {
try {
const gt = globalThis as any;
const decryptedMessage = await gt.nostr.nip04.decrypt(senderPubKey, encryptedContent);
return decryptedMessage;
} catch (error) {
console.error('Error decrypting message with extension:', error);
throw new Error('Failed to decrypt message with Nostr extension.');
}
}
async encryptMessageWithExtension(content: string, pubKey: string): Promise<string> {
const gt = globalThis as any;
const encryptedMessage = await gt.nostr.nip04.encrypt(pubKey, content);
return encryptedMessage;
}
async encryptMessage(privateKey: string, recipientPublicKey: string, message: string): Promise<string> {
console.log(message);
try {
const encryptedMessage = await nip04.encrypt(privateKey, recipientPublicKey, message);
return encryptedMessage;
} catch (error) {
console.error('Error encrypting message:', error);
throw error;
}
}
// NIP-04: Decrypting Direct Messages
async decryptMessage(privateKey: string, senderPublicKey: string, encryptedMessage: string): Promise<string> {
try {
const decryptedMessage = await nip04.decrypt(privateKey, senderPublicKey, encryptedMessage);
return decryptedMessage;
} catch (error) {
console.error('Error decrypting message:', error);
throw error;
}
}
// Profile management
async updateProfile(
name: string | null,
about: string | null,
picture: string | null,
tags: string[][] = [],
pubkey: string | null = null
): Promise<NostrEvent> {
const content = JSON.stringify({ name, about, picture });
const finalPubkey = pubkey ;
const event = this.createEvent(content, 0, tags, finalPubkey);
return this.publishEventToRelays(event);
}
// Social interactions
async getFollowers(pubkey: string): Promise<any[]> {
await this.relayService.ensureConnectedRelays();
const pool = this.relayService.getPool();
const connectedRelays = this.relayService.getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error('No connected relays');
}
const filters: Filter[] = [{ kinds: [3], '#p': [pubkey] }];
const followers: any[] = [];
return new Promise((resolve) => {
const sub = pool.subscribeMany(connectedRelays, filters, {
onevent: (event: NostrEvent) => {
followers.push({ nostrPubKey: event.pubkey });
this.eventSubject.next(event); // Emit the event to subscribers
},
oneose() {
sub.close();
resolve(followers);
},
});
});
}
async getFollowing(pubkey: string): Promise<any[]> {
await this.relayService.ensureConnectedRelays();
const pool = this.relayService.getPool();
const connectedRelays = this.relayService.getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error('No connected relays');
}
const filters: Filter[] = [{ kinds: [3], authors: [pubkey] }];
const following: any[] = [];
return new Promise((resolve) => {
const sub = pool.subscribeMany(connectedRelays, filters, {
onevent: (event: NostrEvent) => {
const tags = event.tags.filter((tag) => tag[0] === 'p');
tags.forEach((tag) => {
following.push({ nostrPubKey: tag[1] });
this.eventSubject.next(event); // Emit the event to subscribers
});
},
oneose() {
sub.close();
resolve(following);
},
});
});
}
async getEventsByAuthor(
pubkey: string,
kinds: number[] = [1]
): Promise<NostrEvent[]> {
await this.relayService.ensureConnectedRelays();
const pool = this.relayService.getPool();
const connectedRelays = this.relayService.getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error('No connected relays');
}
const filters: Filter[] = [{ authors: [pubkey], kinds }];
return new Promise((resolve) => {
const events: NostrEvent[] = [];
const sub = pool.subscribeMany(connectedRelays, filters, {
onevent: (event: NostrEvent) => {
events.push(event);
this.eventSubject.next(event); // Emit the event to subscribers
},
oneose() {
sub.close();
resolve(events);
},
});
});
}
async publishEventToRelays(event: NostrEvent): Promise<NostrEvent> {
await this.relayService.ensureConnectedRelays();
const pool = this.relayService.getPool();
const connectedRelays = this.relayService.getConnectedRelays();
if (connectedRelays.length === 0) {
throw new Error('No connected relays');
}
const publishPromises = connectedRelays.map(async (relayUrl) => {
try {
await pool.publish([relayUrl], event);
console.log(`Event published to relay: ${relayUrl}`);
this.eventSubject.next(event); // Emit the event to subscribers
return event;
} catch (error) {
console.error(`Failed to publish event to relay: ${relayUrl}`, error);
throw error;
}
});
try {
await Promise.any(publishPromises);
return event;
} catch (aggregateError) {
console.error('Failed to publish event: AggregateError', aggregateError);
this.handlePublishFailure(aggregateError);
throw aggregateError;
}
}
private handlePublishFailure(error: unknown): void {
if (error instanceof AggregateError) {
console.error('All relays failed to publish the event. Retrying...');
} else {
console.error('An unexpected error occurred:', error);
}
}
//events ================================================================
subscribeToEvents(pubkey: string): void {
this.relayService.ensureConnectedRelays().then(() => {
const filter: Filter = {
kinds: [1], // Kind 1 represents text notes
authors: [pubkey],
limit: 20
};
this.relayService.subscribeToFilter(filter);
// Sort and process events using RxJS operators
this.relayService.getEventStream()
.pipe(
bufferTime(1000), // Collect events over 1 second intervals
mergeMap(events => this.processAndSortEvents(events)) // Process and sort events
)
.subscribe((sortedEvents: NostrEvent[]) => {
sortedEvents.forEach(event => this.eventSubject.next(event));
});
});
}
// Generic method to process and sort events by `created_at`
private processAndSortEvents(events: NostrEvent[]): Observable<NostrEvent[]> {
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at);
return of(sortedEvents);
}
//notification================================================================
subscribeToNotifications(pubkey: string): void {
this.relayService.ensureConnectedRelays().then(() => {
// Define a filter for notifications
const filter: Filter = {
kinds: [1, 4], // Kind 1 for text notes, kind 4 for encrypted direct messages
'#p': [pubkey], // Events tagged with the user's public key
limit: 50
};
this.relayService.subscribeToFilter(filter);
this.relayService.getEventStream().subscribe((event) => {
if (this.isNotificationEvent(event, pubkey)) {
// Get the UNIX timestamp from event and convert it to readable date
const eventTimestamp = event.created_at * 1000; // Convert seconds to milliseconds
const eventDate = new Date(eventTimestamp);
// Format the date and time (example: 2024-09-07 14:30)
const formattedDate = eventDate.toLocaleString();
// Add message based on the kind of the event
if (event.kind === 4) {
event.content = `Sent a private message at ${formattedDate}.`;
} else if (event.kind === 1) {
event.content = `Mentioned you in an event at ${formattedDate}.`;
}
// Push the updated event to the notificationSubject
this.notificationSubject.next(event);
}
});
});
}
// Check if the event is a notification for the user
private isNotificationEvent(event: NostrEvent, pubkey: string): boolean {
return event.tags.some(tag => tag[0] === 'p' && tag[1] === pubkey);
}
// Get the notification event stream
getNotificationStream() {
return this.notificationSubject.asObservable();
}
getEventStream() {
return this.relayService.getEventStream();
}
//Nostr Extension Interactions=======================================================
async getNostrPublicKeyFromExtension() {
const gt = globalThis as any;
const pubKey = await gt.nostr.getPublicKey();
this.nostrPublicKey = pubKey;
this.nostrEvent.pubkey = this.nostrPublicKey;
}
async getNostrPublicRelaysFromExtension() {
const gt = globalThis as any;
const relays = await gt.nostr.getRelays();
this.nostrRelays = relays;
}
// utility methods ================================================================
isValidHex(hexString: string): boolean {
return /^[0-9a-fA-F]+$/.test(hexString) && hexString.length % 2 === 0;
}
async decryptPrivateKeyWithPassword(encryptedPrivateKey: string, password: string): Promise<string> {
try {
const decryptedPrivateKey = await this.security.decryptData(encryptedPrivateKey, password);
return decryptedPrivateKey;
} catch (error) {
console.error('Error decrypting private key with password:', error);
throw new Error('Failed to decrypt private key with the provided password.');
}
}
bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
//Mwssages============================================================
subscribeToKind4Messages(
pubkey: string,
recipientPublicKey: string,
useExtension: boolean,
decryptedSenderPrivateKey: string
): void {
this.currentPage = 0; // Reset to first page
this.allDecryptedMessages = []; // Reset the list of decrypted messages
this.processedEventIds.clear(); // Reset the set of processed event IDs
this.loadMessages(pubkey, recipientPublicKey, useExtension, decryptedSenderPrivateKey, this.currentPage);
this.subscribeToRealTimeMessages(pubkey, recipientPublicKey, useExtension, decryptedSenderPrivateKey);
}
private loadMessages(
pubkey: string,
recipientPublicKey: string,
useExtension: boolean,
decryptedSenderPrivateKey: string,
page: number
): void {
this.relayService.ensureConnectedRelays().then(() => {
const filters: Filter[] = [
{
kinds: [4],
authors: [pubkey],
'#p': [recipientPublicKey],
limit: this.messagesPerPage,
until: this.getPaginationTime(page),
},
{
kinds: [4],
authors: [recipientPublicKey],
'#p': [pubkey],
limit: this.messagesPerPage,
until: this.getPaginationTime(page),
},
];
this.relayService.getPool().subscribeMany(this.relayService.getConnectedRelays(), filters, {
onevent: (event: NostrEvent) => {
if (!this.processedEventIds.has(event.id) && !this.messageQueue.some(e => e.id === event.id)) {
this.messageQueue.push(event);
this.processQueue(pubkey, useExtension, decryptedSenderPrivateKey, recipientPublicKey);
}
},
oneose: () => {
console.log('Subscription closed');
},
});
});
}
private getPaginationTime(page: number): number {
if (page === 0) {
return Math.floor(Date.now() / 1000);
}
const oldestMessage = this.getOldestMessageTimestamp();
return oldestMessage ? oldestMessage : Math.floor(Date.now() / 1000);
}
loadMoreMessages(pubkey: string, recipientPublicKey: string, useExtension: boolean, decryptedSenderPrivateKey: string): void {
this.currentPage++;
this.loadMessages(pubkey, recipientPublicKey, useExtension, decryptedSenderPrivateKey, this.currentPage);
}
private subscribeToRealTimeMessages(
pubkey: string,
recipientPublicKey: string,
useExtension: boolean,
decryptedSenderPrivateKey: string
): void {
this.relayService.ensureConnectedRelays().then(() => {
const filters: Filter[] = [
{
kinds: [4],
authors: [pubkey],
'#p': [recipientPublicKey],
},
{
kinds: [4],
authors: [recipientPublicKey],
'#p': [pubkey],
},
];
this.relayService.getPool().subscribeMany(this.relayService.getConnectedRelays(), filters, {
onevent: (event: NostrEvent) => {
if (!this.processedEventIds.has(event.id) && !this.messageQueue.some(e => e.id === event.id)) {
this.messageQueue.push(event);
this.processQueue(pubkey, useExtension, decryptedSenderPrivateKey, recipientPublicKey);
}
},
oneose: () => {
console.log('Real-time subscription closed');
},
});
});
}
private async processQueue(
pubkey: string,
useExtension: boolean,
decryptedSenderPrivateKey: string,
recipientPublicKey: string
): Promise<void> {
if (this.isProcessing) {
console.log('Processing is already in progress, waiting for the current batch to finish...');
return;
}
this.isProcessing = true;
try {
while (this.messageQueue.length > 0) {
const event = this.messageQueue.shift();
if (event && !this.processedEventIds.has(event.id)) {
console.log(`Processing event with ID: ${event.id}`);
try {
const decryptedMessage = await this.decryptReceivedMessage(
event,
useExtension,
decryptedSenderPrivateKey,
recipientPublicKey
);
if (decryptedMessage) {
const messageTimestamp = event.created_at * 1000;
const customMessage: CustomMessageEvent = {
isSentByUser: event.pubkey === pubkey,
decryptedMessage,
createdAt: messageTimestamp,
};
this.allDecryptedMessages.push(customMessage);
this.allDecryptedMessages.sort((a, b) => a.createdAt - b.createdAt);
this.messageSubject.next(customMessage);
this.processedEventIds.add(event.id);
} else {
console.warn(`Decrypted message is empty for event ID: ${event.id}`);
}
} catch (decryptError) {
console.error(`Failed to decrypt event with ID: ${event.id}`, decryptError);
}
} else {
console.log(`Event with ID: ${event?.id} has already been processed or is invalid.`);
}
}
} catch (queueError) {
console.error('An error occurred while processing the message queue:', queueError);
} finally {
this.isProcessing = false;
console.log('Finished processing the message queue.');
}
if (this.messageQueue.length > 0) {
console.log('Re-triggering processQueue as there are more messages in the queue...');
this.processQueue(pubkey, useExtension, decryptedSenderPrivateKey, recipientPublicKey);
}
}
private async decryptReceivedMessage(
event: NostrEvent,
useExtension: boolean,
decryptedSenderPrivateKey: string,
recipientPublicKey: string
): Promise<string> {
if (useExtension) {
return await this.decryptMessageWithExtension(event.content, recipientPublicKey);
} else {
return await this.decryptMessage(decryptedSenderPrivateKey, recipientPublicKey, event.content);
}
}
private getOldestMessageTimestamp(): number | null {
if (this.allDecryptedMessages.length === 0) {
return null;
}
return this.allDecryptedMessages.reduce((oldest, message) => {
return message.createdAt < oldest ? message.createdAt : oldest;
}, this.allDecryptedMessages[0].createdAt);
}
getMessageStream(): Observable<CustomMessageEvent> {
return this.messageSubject.asObservable();
}
//Chat list============================================================
subscribeToChatList(
pubkey: string,
useExtension: boolean,
decryptedSenderPrivateKey: string
): void {
this.relayService.ensureConnectedRelays().then(() => {
const filters: Filter[] = [
{
kinds: [EncryptedDirectMessage],
authors: [pubkey],
},
{
kinds: [EncryptedDirectMessage],
'#p': [pubkey]
}
];
this.relayService.getPool().subscribeMany(this.relayService.getConnectedRelays(), filters, {
onevent: async (event: NostrEvent) => {
const otherPartyPubKey = event.pubkey === pubkey
? event.tags.find(tag => tag[0] === 'p')?.[1] || ''
: event.pubkey;
if (!otherPartyPubKey) {
return;
}
const lastTimestamp = this.latestMessageTimestamps[otherPartyPubKey] || 0;
if (event.created_at > lastTimestamp) {
this.latestMessageTimestamps[otherPartyPubKey] = event.created_at;
this.messageQueue.push(event);
this.processNextMessage(pubkey, useExtension, decryptedSenderPrivateKey);
}
},
oneose: () => {
console.log('Subscription closed');
this.chatListSubject.next(this.chatList);
}
});
});
}
private async processNextMessage(pubkey: string, useExtension: boolean, decryptedSenderPrivateKey: string): Promise<void> {
if (this.isDecrypting || this.messageQueue.length === 0) {
return;
}
this.isDecrypting = true;
const event = this.messageQueue.shift();
if (!event) {
this.isDecrypting = false;
return;
}
const isSentByUser = event.pubkey === pubkey;
const otherPartyPubKey = isSentByUser
? event.tags.find(tag => tag[0] === 'p')?.[1] || ''
: event.pubkey;
if (!otherPartyPubKey) {
this.isDecrypting = false;
return;
}
try {
const decryptedMessage = await this.decryptReceivedMessage(
event,
useExtension,
decryptedSenderPrivateKey,
otherPartyPubKey
);
if (decryptedMessage) {
const messageTimestamp = event.created_at * 1000;
this.addOrUpdateChatList(otherPartyPubKey, decryptedMessage, messageTimestamp);
}
} catch (error) {
console.error('Failed to decrypt message:', error);
} finally {
this.isDecrypting = false;
this.processNextMessage(pubkey, useExtension, decryptedSenderPrivateKey);
}
}
private addOrUpdateChatList(pubKey: string, message: string, createdAt: number): void {
const existingItem = this.chatList.find(item => item.pubKey === pubKey);
if (existingItem) {
if (existingItem.lastMessageTime < createdAt) {
existingItem.lastMessage = message;
existingItem.lastMessageTime = createdAt;
}
} else {
this.chatList.push({ pubKey, lastMessage: message, lastMessageTime: createdAt });
this.fetchMetadataForPubKey(pubKey);
}
this.chatList.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
}
private fetchMetadataForPubKey(pubKey: string): void {
this.metadataService.fetchMetadataWithCache(pubKey)
.then(metadata => {
if (metadata) {
const existingItem = this.chatList.find(item => item.pubKey === pubKey);
if (existingItem) {
existingItem.metadata = metadata;
this.chatListSubject.next(this.chatList);
}
}
})
.catch(error => {
console.error(`Failed to fetch metadata for pubKey: ${pubKey}`, error);
});
}
getChatListStream() {
return this.chatListSubject.asObservable();
}
}

View File

@@ -1,643 +0,0 @@
import { Injectable } from '@angular/core';
import { Event, Filter, nip10, UnsignedEvent, getEventHash, finalizeEvent } from 'nostr-tools';
import { User } from '../types/user';
import { Post, Zap } from '../types/post';
import { SignerService } from './signer.service';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { DBUser, dbUserToUser } from '../types/user';
import { SimplePool } from 'nostr-tools'
import { hexToBytes } from '@noble/hashes/utils'
@Injectable({
providedIn: 'root'
})
export class NostrTempService {
constructor(
private signerService: SignerService,
private dbService: NgxIndexedDBService
) { }
// db stuff idk why its in this file -- todo fix
storeUsersInDB(users: User[]) {
this.dbService.bulkAdd('users', users);
}
storeNotificationsInDB(notifications: Zap[]) {
this.dbService.bulkAdd('notifications', notifications);
}
storeUserInLocalStorage(pubkey: string, displayName: string, picture: string) {
// hacky but store the data so its available in other places
localStorage.setItem(pubkey, displayName);
localStorage.setItem(`${pubkey}_img`, picture);
}
getUserFromDB(pubkey: string): User | null {
let user: User | null = null;
this.dbService.getByIndex("users", "pubkey", pubkey)
.subscribe((result: DBUser | any) => {
if (result !== undefined) {
user = dbUserToUser(result)
}
});
return user;
}
// nostr stuff
async relayConnect() {
// Create a new SimplePool instance
const pool = new SimplePool();
// Get the relay URL from the signerService
const relayUrl = this.signerService.getRelay();
// Initialize the relay
const relay = pool.subscribeMany(
[relayUrl],
[],
{
onevent(event) {
console.log(`Event received from ${relayUrl}`, event);
},
oneose() {
console.log(`Connection closed for ${relayUrl}`);
pool.close([relayUrl]);
}
}
);
// Handle connection and errors
relay[0].on('connect', () => {
console.log(`connected to ${relayUrl}`);
});
relay[0].on('error', (error) => {
console.error(`failed to connect to ${relayUrl}`, error);
});
// Return the relay instance
return relay[0];
}
relays(): string[] {
return this.signerService.getRelays();
}
getPool(): SimplePool {
return new SimplePool()
}
async poolList(filters: Filter[]): Promise<Event[]> {
// Create a new SimplePool instance
const pool = new SimplePool();
// Get the relay URLs
const relays = this.relays();
let events: Event[] = [];
// Loop through each filter and query the pool
for (const filter of filters) {
const response = await pool.querySync(relays, filter);
events.push(...response);
}
// Close the connections to the relays after querying
pool.close(relays);
return events;
}
async poolGet(filter: Filter): Promise<Event> {
const pool = this.getPool()
const relays = this.relays()
const response = await pool.get(relays, filter)
pool.close(relays)
return response
}
async getKind0(filter: Filter, followingList: boolean = false): Promise<User[]> {
// user metadata
filter.kinds = [0]; // force this regardless
const response = await this.poolList([filter])
let users: User[] = [];
let content: any; // json parsed
let user: User;
response.forEach(e => {
try {
content = JSON.parse(e.content)
user = new User(content, e.created_at, e.pubkey)
if (followingList) {
user.setFollowing(followingList);
}
users.push(user)
this.storeUserInLocalStorage(e.pubkey, user.displayName, user.picture)
} catch (e) {
console.log(e);
}
});
this.storeUsersInDB(users);
return users;
}
async getUser(pubkey: string): Promise<User | null> {
// user metadata
let user = null;
const filter: Filter = {kinds: [0], authors: [pubkey], limit: 1}
const response = await this.poolGet(filter)
if (!response) {
return null;
}
let kind0 = JSON.parse(response.content)
user = new User(kind0, response.created_at, response.pubkey);
this.storeUsersInDB([user]);
this.storeUserInLocalStorage(user.pubkey, user.displayName, user.picture);
return user;
}
getSince(minutesAgo: number) {
let now = new Date()
return Math.floor(now.setMinutes(now.getMinutes() - minutesAgo) / 1000)
}
getPostFromResponse(response: Event, repostingPubkey: string = "") {
let nip10Result = nip10.parse(response);
return new Post(
response.kind,
response.pubkey,
response.content,
response.id,
response.created_at,
nip10Result,
repostingPubkey
);
}
async getUserPosts(pubkey: string, since: number, until: number): Promise<Post[]> {
let kind1: Filter = {
kinds: [1],
authors: [pubkey],
limit: 100,
since: since,
until: until
}
let kind6: Filter = {
kinds: [6],
authors: [pubkey],
limit: 100,
since: since,
until: until
}
const response = await this.poolList([kind6, kind1])
let posts: Post[] = [];
let repostIds: string[] = [];
response.forEach(e => {
if (e.kind === 1) {
posts.push(this.getPostFromResponse(e));
} else {
repostIds.push(e.tags[0][1]);
}
});
let repostFilter: Filter = {"ids": repostIds}
const r2 = await this.getKind1(repostFilter, pubkey);
posts.push(...r2);
posts.sort((a,b) => a.createdAt - b.createdAt).reverse();
return posts;
}
async getPostReplies() {}
async getFollowingCount(pubkey: string): Promise<number> {
// let filter: Filter = {kinds: [3], authors: [pubkey]}
//
// const response = await relay.count([filter]);
let following = await this.getFollowing(pubkey, 6969)
return following.length
}
async getFollowerCount(pubkey: string): Promise<number> {
// let filter: Filter = {kinds: [3], "#p": [pubkey]}
//
// const response = await relay.count([filter]);
// console.log(response)
// return 10;
let followers = await this.getFollowers(pubkey, 6969)
return followers.length
}
async getFollowing(pubkey: string, limit: number = 100): Promise<string[]> {
let filter: Filter = {kinds: [3], authors: [pubkey], limit: limit}
const response = await this.poolGet(filter);
let following: string[] = []
if (response) {
response.tags.forEach(tag => {
following.push(tag[1]);
});
}
return following
}
async getContactListEvent(pubkey: string) {
let filter: Filter = {kinds: [3], authors: [pubkey], limit: 1}
const response = await this.poolGet(filter);
console.log(response)
return response
}
async getMyLikes(): Promise<string[]> {
let myLikesFilter: Filter = {
kinds: [7], "authors": [this.signerService.getPublicKey()]
}
let myLikes = await this.getKind7(myLikesFilter);
let myLikedNoteIds = [];
myLikes.forEach(like => {
try {
let tag = like.tags[like.tags.length - 2]
if (tag[0] == "e") {
let id = tag[1]
myLikedNoteIds.push(id);
}
} catch {
console.log("err")
}
});
return myLikedNoteIds
}
async getEventLikes(eventPubkeys: string[]): Promise<string[]> {
let myLikesFilter: Filter = {
kinds: [7],
"authors": [this.signerService.getPublicKey()],
"#p": eventPubkeys,
}
let myLikes = await this.getKind7(myLikesFilter);
let myLikedNoteIds = [];
myLikes.forEach(like => {
try {
let tag = like.tags[like.tags.length - 2]
if (tag[0] == "e") {
let id = tag[1]
myLikedNoteIds.push(id);
}
} catch {
console.log("err")
}
});
return myLikedNoteIds
}
// count do count here as well ...
async getFollowers(pubkey: string, limit: number = 100): Promise<string[]> {
let filter: Filter = {kinds: [3], "#p": [pubkey], limit: limit}
const response = await this.poolList([filter]);
let followers: string[] = []
if (response) {
response.forEach(r => {
followers.push(r.pubkey);
});
}
return followers
}
async getPost(id: string): Promise<Post | undefined> {
let filter: Filter = {
kinds: [1],
limit: 1,
ids: [id]
}
const response = await this.poolGet(filter)
if (response) {
return this.getPostFromResponse(response);
}
return undefined;
}
async getKind1(filter: Filter, repostingPubkey: string = ""): Promise<Post[]>{
// text notes
filter.kinds = [1];
const response = await this.poolList([filter])
let posts: Post[] = [];
const muteList: string[] = this.signerService.getMuteList();
response.forEach(e => {
if (!muteList.includes(e.pubkey)) {
posts.push(this.getPostFromResponse(e, repostingPubkey));
} else {
console.log("muted user found not including");
}
});
return posts;
}
async searchUsers(searchTerm: string): Promise<User[]> {
let filter: Filter = {
kinds: [0],
}
const response = await this.poolList([filter])
let users: User[] = [];
let content;
let user;
response.forEach(e => {
try {
content = JSON.parse(e.content);
user = new User(content, e.created_at, e.pubkey)
if (user.displayName.includes(searchTerm)) {
users.push(user)
}
if (user.pubkey.includes(searchTerm)) {
users.push(user)
}
if (user.npub.includes(searchTerm)) {
users.push(user)
}
this.storeUserInLocalStorage(e.pubkey, user.displayName, user.picture);
} catch (e) {
console.log(e);
}
});
this.storeUsersInDB(users);
return users;
}
async getKind1and6(filter: Filter): Promise<Post[]>{
// text notes
filter.kinds = [1, 6];
const response = await this.poolList([filter])
let posts: Post[] = [];
response.forEach(e => {
posts.push(this.getPostFromResponse(e));
});
return posts;
}
async getPostAndReplies(id: string): Promise<Post[]>{
let postFilter: Filter = {
ids: [id], kinds: [1], limit: 1
}
let replyFilter: Filter = {
kinds: [1], "#e": [id]
}
const response = await this.poolList([postFilter, replyFilter])
let posts: Post[] = [];
response.forEach(e => {
posts.push(this.getPostFromResponse(e));
});
return posts;
}
async getReplyCounts(filters: Filter[]): Promise<Post[]>{
const response = await this.poolList(filters)
let posts: Post[] = [];
response.forEach(e => {
posts.push(this.getPostFromResponse(e));
});
return posts;
}
async getFeed(filters: Filter[]): Promise<Post[]>{
// text notes
const response = await this.poolList(filters)
let posts: Post[] = [];
response.forEach(e => {
posts.push(this.getPostFromResponse(e));
});
return posts;
}
async getKind2(filter: Filter) {
// recommend server / relay
filter.kinds = [2];
return await this.poolList([filter]);
}
async getKind3(filter: Filter): Promise<string[]> {
// contact lists
filter.kinds = [3];
const response = await this.poolGet(filter);
let following: string[] = []
if (response) {
response.tags.forEach(tag => {
following.push(tag[1]);
});
}
return following;
}
async getKind4(filter1: Filter, filter2: Filter): Promise<Event[]> {
return await this.poolList([filter1, filter2]);
}
async getKind4MessagesToMe(): Promise<Event[]> {
const filter: Filter = {
kinds: [4],
"#p": [this.signerService.getPublicKey()],
limit: 50
}
const filter2: Filter = {
kinds: [4],
authors: [this.signerService.getPublicKey()],
limit: 50
}
return await this.poolList([filter, filter2]);
}
async getPostLikeCount(filter: Filter): Promise<number> {
let likes = await this.poolList([filter]);
return likes.length
}
async getKind11(limit: number) {
// server meta data (what types of NIPs a server is supporting)
return await this.poolList([{kinds: [11], limit: limit}]);
}
async getKind7(filter: Filter): Promise<Event[]> {
filter.kinds = [7];
return await this.poolList([filter]);
}
async getZaps(filter: Filter) {
const response = await this.poolList([filter])
console.log(response);
let zaps: Zap[] = [];
response.forEach(e => {
zaps.push(new Zap(e.id, e.kind, e.pubkey, e.created_at, e.sig, e.tags));
});
return zaps;
}
async getContactList(pubkey: string) {
let filter: Filter = {kinds: [3], authors: [pubkey], limit: 1}
const response = await this.poolGet(filter);
let following: string[] = []
if (response) {
response.tags.forEach(tag => {
following.push(tag[1]);
});
}
this.signerService.setFollowingList(following);
await this.getContactListUser();
return following
}
async getContactListUser() {
const filter: Filter = {
kinds: [0],
authors: this.signerService.getFollowingList()
}
await this.getKind0(filter, true);
}
async getMuteList(pubkey: string): Promise<void> {
const filter: Filter = {
"authors": [pubkey],
"kinds": [10000],
"limit": 1
}
const response = await this.poolGet(filter)
if (response) {
this.signerService.setMuteListFromTags(response.tags);
} else {
this.signerService.setMuteList([]);
}
}
async addToMuteList(pubkey: string): Promise<void> {
const muteList: string[] = this.signerService.getMuteList();
// Create the tags for the mute list
let tags: string[][] = [["p", pubkey]];
for (let m of muteList) {
if (m) {
tags.push(["p", m]);
}
}
// Create an unsigned event
const unsignedEvent = this.getUnsignedEvent(10000, tags, "");
// Get the private key
const privateKey = this.signerService.getPrivateKey();
// Check if privateKey is available and sign the event
let signedEvent: Event;
if (privateKey !== "") {
// Convert privateKey from hex string to Uint8Array
const privateKeyBytes = hexToBytes(privateKey);
// Finalize the event with signature
signedEvent = finalizeEvent(unsignedEvent, privateKeyBytes);
} else {
// Sign event with extension if no private key available
signedEvent = await this.signerService.signEventWithExtension(unsignedEvent);
}
// Publish the signed event to the relays
this.publishEventToPool(signedEvent);
// Update the mute list with the current public key
await this.getMuteList(this.signerService.getPublicKey());
}
async search(searchTerm: string): Promise<Post[]> {
let tags = searchTerm.split(' ');
// recommend server / relay
// let filter1: Filter = {
// kinds: [1],
// search: searchTerm,
// }
let filter3: Filter = {
kinds: [1],
"#t": tags,
limit: 50
}
const response = await this.poolList([filter3]);
let posts: Post[] = [];
response.forEach(e => {
const post = this.getPostFromResponse(e);
posts.push(post);
});
return posts;
}
getUnsignedEvent(kind: number, tags: string[][], content: string) {
const eventUnsigned: UnsignedEvent = {
kind: kind,
pubkey: this.signerService.getPublicKey(),
tags: tags,
content: content,
created_at: Math.floor(Date.now() / 1000),
}
return eventUnsigned
}
getSignedEvent(eventUnsigned: UnsignedEvent, privateKey: string): Event {
// Convert the private key from hex string to Uint8Array
const privateKeyBytes = hexToBytes(privateKey);
// finalizing and signing the event in one step
const signedEvent: Event = finalizeEvent(eventUnsigned, privateKeyBytes);
return signedEvent;
}
async getNotifications(): Promise<Zap[]> {
// currently only gets zaps
const pubkey = this.signerService.getPublicKey();
// check for replies
// check for zaps
// check for mentions?
// check for new followers
const zapFilter: Filter = {kinds: [9735], "#p": [pubkey]}
const response = await this.poolList([zapFilter])
let notifications: Zap[] = [];
response.forEach(e => {
notifications.push(new Zap(e.id, e.kind, e.pubkey, e.created_at, e.sig, e.tags));
});
notifications.sort((a,b) => a.createdAt - b.createdAt).reverse();
this.storeNotificationsInDB(notifications);
return notifications;
}
async sendEvent(event: Event) {
const relay = await this.relayConnect()
relay.publish(event)
relay.close();
}
async publishEventToPool(event: Event): Promise<void> {
const relays: string[] = this.signerService.getRelays();
const pool = new SimplePool()
let pubs = pool.publish(relays, event)
await Promise.all(pubs)
pool.close(relays)
}
}

View File

@@ -1,76 +1,85 @@
import { Injectable } from "@angular/core";
import { Filter, NostrEvent, SimplePool } from "nostr-tools";
import { Observable, Subject, throttleTime } from "rxjs";
import { BehaviorSubject, Observable } from "rxjs";
import { NostrEvent, SimplePool } from "nostr-tools";
@Injectable({
providedIn: 'root',
})
export class RelayService {
private pool: SimplePool;
private relays: { url: string, connected: boolean, retries: number, retryTimeout: any, ws?: WebSocket }[] = [];
private relays: { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }[] = [];
private maxRetries = 10;
private retryDelay = 15000;
private eventSubject = new Subject<NostrEvent>();
private requestQueue: Set<string> = new Set();
private isProcessingQueue = false;
private maxConcurrentRequests = 2;
private eventSubject = new BehaviorSubject<NostrEvent | null>(null); // For publishing events to the UI
private relaysSubject = new BehaviorSubject<{ url: string, connected: boolean, accessType: string, ws?: WebSocket }[]>([]); // For tracking relay changes
constructor() {
this.pool = new SimplePool();
this.relays = this.loadRelaysFromLocalStorage();
this.connectToRelays();
this.connectToRelays(); // Initiates connection to all relays on service creation
this.setupVisibilityChangeHandling();
this.relaysSubject.next(this.relays); // Emit the initial relay state
}
private loadRelaysFromLocalStorage() {
/**
* Load relays from localStorage and return them with their initial state.
*/
private loadRelaysFromLocalStorage(): { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }[] {
const storedRelays = JSON.parse(localStorage.getItem('nostrRelays') || '[]');
const defaultRelays = [
{ url: 'wss://relay.angor.io', connected: false, retries: 0, retryTimeout: null, ws: undefined },
{ url: 'wss://relay2.angor.io', connected: false, retries: 0, retryTimeout: null, ws: undefined },
{ url: 'wss://relay.angor.io', connected: false, retries: 0, retryTimeout: null, accessType: 'read-write', ws: undefined },
{ url: 'wss://relay2.angor.io', connected: false, retries: 0, retryTimeout: null, accessType: 'read-write', ws: undefined },
];
const storedRelays = JSON.parse(localStorage.getItem('nostrRelays') || '[]').map((relay: any) => ({
return storedRelays.length > 0 ? storedRelays.map(relay => ({
...relay,
connected: false,
connected: false, // The connection will be re-established
retries: 0,
retryTimeout: null,
ws: undefined,
}));
return [...defaultRelays, ...storedRelays];
ws: undefined, // WebSocket will be reinitialized
})) : defaultRelays;
}
private connectToRelay(relay: { url: string, connected: boolean, retries: number, retryTimeout: any, ws?: WebSocket }) {
/**
* Save the current relays to localStorage, omitting WebSocket references.
*/
private saveRelaysToLocalStorage(): void {
const relaysToSave = this.relays.map(relay => ({
url: relay.url,
accessType: relay.accessType,
connected: relay.connected,
retries: relay.retries,
retryTimeout: relay.retryTimeout,
}));
localStorage.setItem('nostrRelays', JSON.stringify(relaysToSave));
this.relaysSubject.next(this.relays); // Notify subscribers of relay changes
}
/**
* Connect to a single relay using WebSocket.
*/
private connectToRelay(relay: { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }): void {
if (relay.connected) {
return;
return; // Already connected
}
relay.ws = new WebSocket(relay.url);
relay.ws.onopen = () => {
try {
relay.connected = true;
relay.retries = 0;
clearTimeout(relay.retryTimeout);
this.saveRelaysToLocalStorage();
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
console.log(`Connected to relay: ${relay.url}`);
};
relay.ws.onerror = (error) => {
try {
relay.ws.onerror = () => {
this.handleRelayError(relay);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
relay.ws.onclose = () => {
try {
relay.connected = false;
this.handleRelayError(relay);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
relay.ws.onmessage = (message) => {
@@ -84,7 +93,10 @@ export class RelayService {
};
}
private handleRelayError(relay: { url: string, connected: boolean, retries: number, retryTimeout: any, ws?: WebSocket }) {
/**
* Handle errors for a relay connection and attempt reconnection.
*/
private handleRelayError(relay: { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }): void {
if (relay.retries >= this.maxRetries) {
console.error(`Max retries reached for relay: ${relay.url}. No further attempts will be made.`);
return;
@@ -99,7 +111,10 @@ export class RelayService {
}, retryInterval);
}
public connectToRelays() {
/**
* Connect to all relays in the list.
*/
public connectToRelays(): void {
this.relays.forEach((relay) => {
if (!relay.connected) {
this.connectToRelay(relay);
@@ -107,6 +122,9 @@ export class RelayService {
});
}
/**
* Ensure that at least one relay is connected before continuing.
*/
public async ensureConnectedRelays(): Promise<void> {
this.connectToRelays();
@@ -115,15 +133,17 @@ export class RelayService {
if (this.getConnectedRelays().length > 0) {
resolve();
} else {
setTimeout(checkConnection, 1000);
setTimeout(checkConnection, 1000); // Retry after 1 second
}
};
checkConnection();
});
}
private setupVisibilityChangeHandling() {
/**
* Handle browser visibility changes to reconnect relays when the user returns to the page.
*/
private setupVisibilityChangeHandling(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.connectToRelays();
@@ -139,78 +159,82 @@ export class RelayService {
});
}
/**
* Return the URLs of all connected relays.
*/
public getConnectedRelays(): string[] {
return this.relays.filter((relay) => relay.connected).map((relay) => relay.url);
}
public saveRelaysToLocalStorage(): void {
const customRelays = this.relays.filter(
(relay) => !['wss://relay.angor.io', 'wss://relay2.angor.io'].includes(relay.url)
);
localStorage.setItem('nostrRelays', JSON.stringify(customRelays));
/**
* Return an observable of relay changes for the UI to subscribe to.
*/
public getRelays(): Observable<{ url: string, connected: boolean, accessType: string, ws?: WebSocket }[]> {
return this.relaysSubject.asObservable();
}
public getEventStream(): Observable<NostrEvent> {
return this.eventSubject.asObservable();
/**
* Publish an event to all relays with write or read-write access.
*/
public publishEventToWriteRelays(event: NostrEvent): void {
const writeRelays = this.relays.filter(relay => relay.accessType === 'write' || relay.accessType === 'read-write');
writeRelays.forEach((relay) => {
if (relay.connected && relay.ws?.readyState === WebSocket.OPEN) {
relay.ws.send(JSON.stringify(event));
console.log(`Event published to ${relay.url}`);
}
});
}
public addRelay(url: string): void {
/**
* Add a new relay to the list and attempt to connect to it.
*/
public addRelay(url: string, accessType: string = 'read-write'): void {
if (!this.relays.some(relay => relay.url === url)) {
const newRelay = { url, connected: false, retries: 0, retryTimeout: null, ws: undefined };
const newRelay = { url, connected: false, retries: 0, retryTimeout: null, accessType, ws: undefined };
this.relays.push(newRelay);
this.connectToRelay(newRelay);
this.saveRelaysToLocalStorage();
} else {
console.log(`Relay with URL ${url} already exists.`);
}
}
/**
* Remove a relay from the list by its URL.
*/
public removeRelay(url: string): void {
this.relays = this.relays.filter(relay => relay.url !== url);
this.saveRelaysToLocalStorage();
}
/**
* Remove all custom relays, leaving only the default ones.
*/
public removeAllCustomRelays(): void {
const defaultRelays = ['wss://relay.angor.io', 'wss://relay2.angor.io'];
this.relays = this.relays.filter(relay => defaultRelays.includes(relay.url));
this.saveRelaysToLocalStorage();
}
private async processRequestQueue(): Promise<void> {
if (this.isProcessingQueue) {
return;
}
this.isProcessingQueue = true;
while (this.requestQueue.size > 0) {
const batch = Array.from(this.requestQueue).slice(0, this.maxConcurrentRequests);
this.requestQueue = new Set(Array.from(this.requestQueue).slice(this.maxConcurrentRequests));
await Promise.all(batch.map(async (filterId) => {
console.log(`Processing request for filter: ${filterId}`);
}));
await new Promise(resolve => setTimeout(resolve, this.retryDelay));
}
this.isProcessingQueue = false;
}
public async subscribeToFilter(filter: Filter): Promise<void> {
try {
this.requestQueue.add(JSON.stringify(filter));
this.processRequestQueue();
} catch (error) {
console.error('Failed to subscribe to filter:', error);
/**
* Update the access type of a specific relay.
*/
public updateRelayAccessType(url: string, accessType: string): void {
const relay = this.relays.find(relay => relay.url === url);
if (relay) {
relay.accessType = accessType;
this.saveRelaysToLocalStorage();
}
}
public getPool(): SimplePool {
return this.pool;
}
public getRelays(): { url: string, connected: boolean, ws?: WebSocket }[] {
return this.relays;
/**
* Return the event stream as an observable for other parts of the app to subscribe to.
*/
public getEventStream(): Observable<NostrEvent | null> {
return this.eventSubject.asObservable();
}
}

View File

@@ -1,33 +1,46 @@
import { Injectable } from '@angular/core';
import { NostrService } from './nostr.service';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class StateService {
private projects: any[] = [];
private metadataCache: Map<string, any> = new Map();
private projectsSubject = new BehaviorSubject<any[]>([]); // Stream for projects updates
constructor(private nostrService: NostrService) {}
async setProjects(projects: any[]): Promise<void> {
this.projects = projects;
this.updateMetadataInBackground();
/**
* Returns an observable for project updates.
*/
getProjectsObservable() {
return this.projectsSubject.asObservable();
}
/**
* Sets the projects and emits the updated list.
*/
setProjects(projects: any[]): void {
this.projects = projects;
this.projectsSubject.next(this.projects); // Emit updated projects
}
/**
* Returns the current list of projects.
*/
getProjects(): any[] {
return this.projects;
}
/**
* Returns a boolean indicating whether there are projects.
*/
hasProjects(): boolean {
return this.projects.length > 0;
}
async updateProjectActivity(project: any): Promise<void> {
/**
* Updates or adds a project based on its nostrPubKey.
*/
updateProject(project: any): void {
const index = this.projects.findIndex(p => p.nostrPubKey === project.nostrPubKey);
if (index > -1) {
@@ -36,37 +49,13 @@ export class StateService {
this.projects.push(project);
}
this.projects.sort((a, b) => b.lastActivity - a.lastActivity);
await this.updateMetadataForProject(project); // Ensure metadata is updated for the project
this.projectsSubject.next(this.projects); // Emit updated projects
}
private updateMetadataInBackground(): void {
const batchSize = 5;
for (let i = 0; i < this.projects.length; i += batchSize) {
const batch = this.projects.slice(i, i + batchSize);
batch.forEach(project => this.updateMetadataForProject(project));
/**
* Returns a specific project by its nostrPubKey.
*/
getProjectByPubKey(nostrPubKey: string): any | undefined {
return this.projects.find(p => p.nostrPubKey === nostrPubKey);
}
}
private async updateMetadataForProject(project: any): Promise<void> {
if (this.metadataCache.has(project.nostrPubKey)) {
this.applyMetadata(project, this.metadataCache.get(project.nostrPubKey));
return;
}
}
private applyMetadata(project: any, metadata: any): void {
if (metadata && typeof metadata === 'object') {
project.displayName = metadata.name || project.displayName;
project.picture = metadata.picture || project.picture;
} else {
console.warn(`Metadata for project ${project.nostrPubKey} is invalid or null.`);
}
}
}