mirror of
https://github.com/block-core/angor-hub-old.git
synced 2026-01-06 11:24:21 +01:00
Add relay management
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user