From 24cec0c35e02d940b041ce0dde13e73ce1a07636 Mon Sep 17 00:00:00 2001 From: SondreB Date: Sun, 1 Jan 2023 04:14:41 +0100 Subject: [PATCH] Add contact list import - WIP: Get the lists, doesn't do import yet. - Lists number of following on the Circles --- src/app/app.module.ts | 4 +- src/app/circles/circles.component.css | 12 +- src/app/circles/circles.component.html | 11 +- src/app/circles/circles.component.ts | 60 +++++++++- .../import-follow-dialog.html | 14 +++ .../import-follow-dialog.scss | 11 ++ .../import-follow-dialog.ts | 22 ++++ src/app/feed-public/feed-public.component.ts | 2 +- src/app/services/applicationstate.service.ts | 7 +- src/app/services/data-validation.service.ts | 37 ++++++ src/app/services/event.service.ts | 25 +++- src/app/services/feed.service.ts | 107 +++++++++++++++++- src/app/services/interfaces.ts | 6 + 13 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 src/app/circles/import-follow-dialog/import-follow-dialog.html create mode 100644 src/app/circles/import-follow-dialog/import-follow-dialog.scss create mode 100644 src/app/circles/import-follow-dialog/import-follow-dialog.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0d57911..5761d60 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -66,6 +66,7 @@ import { CircleStylePipe } from './shared/circle-style'; import { ReplyListComponent } from './shared/reply-list/reply-list.component'; import { ContentComponent } from './shared/content/content.component'; import { ScrollDirective } from './shared/scroll.directive'; +import { ImportFollowDialog } from './circles/import-follow-dialog/import-follow-dialog'; @NgModule({ declarations: [ @@ -96,7 +97,8 @@ import { ScrollDirective } from './shared/scroll.directive'; NoteComponent, ReplyListComponent, ContentComponent, - ScrollDirective + ScrollDirective, + ImportFollowDialog, ], imports: [ BrowserModule, diff --git a/src/app/circles/circles.component.css b/src/app/circles/circles.component.css index 7772ec6..59cf9f4 100644 --- a/src/app/circles/circles.component.css +++ b/src/app/circles/circles.component.css @@ -15,7 +15,6 @@ } .circle-button { - } .circle-button-icon { @@ -41,16 +40,21 @@ order: 0; flex: 0 1 auto; align-self: auto; - } +} .circle-item:nth-child(2) { order: 0; flex: 1 1 auto; align-self: auto; - } +} .circle-item:nth-child(3) { order: 0; flex: 0 1 auto; align-self: auto; - } \ No newline at end of file +} + +.circle-actions button { + margin-right: 0.4em; + margin-bottom: 0.4em; +} diff --git a/src/app/circles/circles.component.html b/src/app/circles/circles.component.html index 880ad9f..4693f22 100644 --- a/src/app/circles/circles.component.html +++ b/src/app/circles/circles.component.html @@ -5,7 +5,10 @@
trip_origin
- {{ circle.name }}
PublicPrivate - {{ circle.style | circlestyle }} - Created: {{ circle.created | ago }} + {{ circle.name }}
+ Count: {{ countMembers(circle) }}
+ Public + Private - {{ circle.style | circlestyle }} - Created: {{ circle.created | ago }}
-

Circles is how you organize people you follow. Different circles can have different rules applied and circles is an important way to make the experience more enjoyable.

+

+ + +

+

Circles is how you organize people you follow. Different circles can have different rules applied and circles is an important way to make the experience more enjoyable.

diff --git a/src/app/circles/circles.component.ts b/src/app/circles/circles.component.ts index 4cc42a5..5362994 100644 --- a/src/app/circles/circles.component.ts +++ b/src/app/circles/circles.component.ts @@ -10,6 +10,10 @@ import { CirclesService } from '../services/circles.service'; import { CircleDialog } from '../shared/create-circle-dialog/create-circle-dialog'; import { MatDialog } from '@angular/material/dialog'; import { v4 as uuidv4 } from 'uuid'; +import { ImportFollowDialog, ImportFollowDialogData } from './import-follow-dialog/import-follow-dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AuthenticationService } from '../services/authentication.service'; +import { FeedService } from '../services/feed.service'; @Component({ selector: 'app-circles', @@ -26,9 +30,12 @@ export class CirclesComponent { private storage: StorageService, private profile: ProfileService, public dialog: MatDialog, + private feedService: FeedService, private validator: DataValidation, private utilities: Utilities, - private router: Router + private authService: AuthenticationService, + private router: Router, + private snackBar: MatSnackBar ) {} // public trackByFn(index: number, item: NostrProfileDocument) { @@ -92,6 +99,53 @@ export class CirclesComponent { await this.load(); } + countMembers(circle: Circle) { + if (circle.id == null || circle.id == '') { + return this.following.filter((f) => f.circle == null).length; + } else { + return this.following.filter((f) => f.circle == circle.id).length; + } + } + + async importFollowList() { + const dialogRef = this.dialog.open(ImportFollowDialog, { + data: { pubkey: this.appState.getPublicKey() }, + maxWidth: '100vw', + panelClass: 'full-width-dialog', + }); + + dialogRef.afterClosed().subscribe(async (result: ImportFollowDialogData) => { + if (!result) { + return; + } + + this.snackBar.open('Importing followers process has started', 'Hide', { + horizontalPosition: 'center', + verticalPosition: 'bottom', + }); + + let pubkey = result.pubkey; + + console.log('GET FOLLOWING LIST FOR:', pubkey); + + // TODO: Add ability to slowly query one after one relay, we don't want to receive multiple + // follow lists and having to process everything multiple times. Just query one by one until + // we find the list. Until then, we simply grab the first relay only. + this.feedService.downloadContacts(pubkey).subscribe((contacts) => { + console.log('DOWNLOAD COMPLETE!', contacts); + }); + + // if (pubkey.startsWith('npub')) { + // pubkey = this.utilities.arrayToHex(this.utilities.convertFromBech32(pubkey)); + // } + + // await this.profileService.follow(pubkey); + // await this.feedService.downloadRecent([pubkey]); + }); + } + + publishFollowList() {} + createCircle(): void { const dialogRef = this.dialog.open(CircleDialog, { data: { name: '', style: '1', public: true }, @@ -113,7 +167,11 @@ export class CirclesComponent { }); } + following: NostrProfileDocument[] = []; + async ngOnInit() { + this.following = await this.profile.followList(); + this.appState.title = 'Circles'; this.appState.showBackButton = true; this.appState.actions = [ diff --git a/src/app/circles/import-follow-dialog/import-follow-dialog.html b/src/app/circles/import-follow-dialog/import-follow-dialog.html new file mode 100644 index 0000000..a1b5567 --- /dev/null +++ b/src/app/circles/import-follow-dialog/import-follow-dialog.html @@ -0,0 +1,14 @@ +

Import complete following list

+
+

The existing value is your own public key, use this to import your own public following list.

+ + person_add + Public Key + + +
+ +
+ + +
diff --git a/src/app/circles/import-follow-dialog/import-follow-dialog.scss b/src/app/circles/import-follow-dialog/import-follow-dialog.scss new file mode 100644 index 0000000..64d47e7 --- /dev/null +++ b/src/app/circles/import-follow-dialog/import-follow-dialog.scss @@ -0,0 +1,11 @@ +.input-full-width { + width: 100% !important; +} + +.mat-dialog-content { + padding: 20px 24px 0px 24px !important; +} + +.mat-dialog-actions { + padding: 10px 24px 24px 24px; +} diff --git a/src/app/circles/import-follow-dialog/import-follow-dialog.ts b/src/app/circles/import-follow-dialog/import-follow-dialog.ts new file mode 100644 index 0000000..a1136c5 --- /dev/null +++ b/src/app/circles/import-follow-dialog/import-follow-dialog.ts @@ -0,0 +1,22 @@ +import { Component, Inject } from '@angular/core'; +import { MatDialog, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +export interface ImportFollowDialogData { + pubkey: string; +} + +@Component({ + selector: 'import-follow-dialog', + templateUrl: 'import-follow-dialog.html', + styleUrls: ['import-follow-dialog.scss'], +}) +export class ImportFollowDialog { + constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: ImportFollowDialogData) { + + } + + onNoClick(): void { + this.data.pubkey = ''; + this.dialogRef.close(); + } +} diff --git a/src/app/feed-public/feed-public.component.ts b/src/app/feed-public/feed-public.component.ts index 63786d2..3a1c88b 100644 --- a/src/app/feed-public/feed-public.component.ts +++ b/src/app/feed-public/feed-public.component.ts @@ -147,7 +147,7 @@ export class FeedPublicComponent { } event = this.validator.sanitizeEvent(event); - event = this.validator.filterEvent(event); + // event = this.validator.filterEvent(event); if (!event) { return null; diff --git a/src/app/services/applicationstate.service.ts b/src/app/services/applicationstate.service.ts index dc7d7fb..0fe0253 100644 --- a/src/app/services/applicationstate.service.ts +++ b/src/app/services/applicationstate.service.ts @@ -1,13 +1,14 @@ import { BreakpointObserver } from '@angular/cdk/layout'; import { Injectable } from '@angular/core'; import { BehaviorSubject, Subject, map, shareReplay, Observable } from 'rxjs'; +import { AuthenticationService } from './authentication.service'; import { Action } from './interfaces'; @Injectable({ providedIn: 'root', }) export class ApplicationState { - constructor(private breakpointObserver: BreakpointObserver) { + constructor(private breakpointObserver: BreakpointObserver, private authService: AuthenticationService) { this.isSmallScreen$ = this.breakpointObserver.observe('(max-width: 599px)').pipe( map((result) => result.matches), shareReplay() @@ -19,6 +20,10 @@ export class ApplicationState { ); } + getPublicKey() { + return this.authService.authInfo$.getValue().publicKeyHex; + } + title = 'Blockcore Notes'; goBack = false; diff --git a/src/app/services/data-validation.service.ts b/src/app/services/data-validation.service.ts index 3165643..d5886e2 100644 --- a/src/app/services/data-validation.service.ts +++ b/src/app/services/data-validation.service.ts @@ -14,6 +14,8 @@ export class DataValidation { profileLimit = 2048; profileTagsLimit = 10; + contactsContentLimit = 2048; + constructor(private options: OptionsService) {} sanitizeEvent(event: NostrEvent) { @@ -107,6 +109,41 @@ export class DataValidation { return event; } + /** Returns true if valid, false if not valid. Does not throw error for optimization purposes. */ + validateContacts(event: NostrEvent) { + if (event.pubkey.length < 60 || event.pubkey.length > 70) { + return null; + } + + if (!event.sig || !event.id) { + return null; + } + + if (event.sig.length < 100 || event.pubkey.length > 150) { + return null; + } + + if (event.id.length !== 64) { + return null; + } + + if (typeof event.kind !== 'number' || typeof event.created_at !== 'number') { + return null; + } + + if (event.kind !== 3) { + return null; + } + + // Reduce the content length to reduce system resource usage and improve UI experience. + if (event.content.length > this.contactsContentLimit) { + event.content = event.content.substring(0, this.contactsContentLimit); + event.contentCut = true; + } + + return event; + } + escapeNewLineChars(valueToEscape: string) { if (valueToEscape != null && valueToEscape != '') { return valueToEscape.replace(/\n/g, ' '); diff --git a/src/app/services/event.service.ts b/src/app/services/event.service.ts index bfc3162..b91e0c1 100644 --- a/src/app/services/event.service.ts +++ b/src/app/services/event.service.ts @@ -19,7 +19,7 @@ export class EventService { } event = this.validator.sanitizeEvent(event); - event = this.validator.filterEvent(event); + // event = this.validator.filterEvent(event); if (!event) { return null; @@ -32,6 +32,29 @@ export class EventService { return event; } + processContacts(originalEvent: NostrEvent): NostrEvent | null { + // Validate the event: + let event = this.validator.validateContacts(originalEvent); + + if (!event) { + debugger; + console.log('INVALID CONTACT EVENT!'); + return null; + } + + // event = this.validator.filterEvent(event); + + // if (!event) { + // return null; + // } + + // TODO: Store the raw event. + // const nostrEvent = event as NostrEventDocument; + // nostrEvent.raw = originalEvent; + + return event; + } + /** Returns the root event, first looks for "root" attribute on the e tag element or picks first in array. */ eTags(event: NostrEventDocument | null) { if (!event) { diff --git a/src/app/services/feed.service.ts b/src/app/services/feed.service.ts index e27d4e4..6cbcd85 100644 --- a/src/app/services/feed.service.ts +++ b/src/app/services/feed.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { NostrEvent, NostrProfile, NostrEventDocument, NostrProfileDocument, Circle, Person, NostrSubscription, NostrRelay } from './interfaces'; +import { NostrEvent, NostrProfile, NostrEventDocument, NostrProfileDocument, Circle, Person, NostrSubscription, NostrRelay, Contact } from './interfaces'; import * as sanitizeHtml from 'sanitize-html'; import { SettingsService } from './settings.service'; import { tap, delay, timer, takeUntil, timeout, Observable, of, BehaviorSubject, map, combineLatest, single, Subject, Observer, concat, concatMap, switchMap, catchError, race } from 'rxjs'; @@ -14,6 +14,7 @@ import { DataValidation } from './data-validation.service'; import { OptionsService } from './options.service'; import { RelayService } from './relay.service'; import { RelayStorageService } from './relay.storage.service'; +import { AuthenticationService } from './authentication.service'; @Injectable({ providedIn: 'root', @@ -132,6 +133,7 @@ export class FeedService { private relayService: RelayService, private eventService: EventService, private validator: DataValidation, + private authService: AuthenticationService, private storage: StorageService, private profileService: ProfileService, private circlesService: CirclesService @@ -715,6 +717,109 @@ export class FeedService { } } + downloadContacts(pubkey: string) { + //const relay = this.relayService.relays[index]; + //console.log('downloadFromRelayIndex:', id, index, relayCount); + const relay = this.relayService.relays[0]; + let finished = false; + + const observable = new Observable((observer: Observer) => { + const sub = relay.sub([{ kinds: [3], authors: [pubkey] }], {}) as NostrSubscription; + + sub.on('event', (originalEvent: any) => { + debugger; + // console.log('downloadFromRelayIndex: event', id); + const event = this.eventService.processContacts(originalEvent); + // console.log('downloadFromRelayIndex: event', event); + + if (!event) { + return; + } + + finished = true; + + observer.next(event); + observer.complete(); + // sub.unsub(); + }); + + sub.on('eose', () => { + debugger; + // If we did not find the data, we must finish. + if (!finished) { + observer.complete(); + } + // console.log('downloadFromRelayIndex: eose', id); + // observer.complete(); + sub.unsub(); + }); + }); + + return observable; + } + + async publishContacts(contacts: Contact[]) { + const mappedContacts = contacts.map((c) => { + return ['p', c.pubkey]; + }); + + let originalEvent: Event = { + kind: 3, + created_at: Math.floor(Date.now() / 1000), + content: '', + pubkey: this.authService.authInfo$.getValue().publicKeyHex!, + tags: mappedContacts, + }; + + originalEvent.id = getEventHash(originalEvent); + + const gt = globalThis as any; + + // Use nostr directly on global, similar to how most Nostr app will interact with the provider. + const signedEvent = await gt.nostr.signEvent(originalEvent); + originalEvent = signedEvent; + + // We force validation upon user so we make sure they don't create content that we won't be able to parse back later. + // We must do this before we run nostr-tools validate and signature validation. + const event = this.eventService.processEvent(originalEvent as NostrEventDocument); + + let ok = validateEvent(originalEvent); + + if (!ok) { + throw new Error('The event is not valid. Cannot publish.'); + } + + let veryOk = await verifySignature(originalEvent as any); // Required .id and .sig, which we know has been added at this stage. + + if (!veryOk) { + throw new Error('The event signature not valid. Maybe you choose a different account than the one specified?'); + } + + if (!event) { + return; + } + + console.log('PUBLISH EVENT:', originalEvent); + + // First we persist our own event like would normally happen if we receive this event. + // await this.#persist(event); + + // for (let i = 0; i < this.relayService.relays.length; i++) { + // const relay = this.relayService.relays[i]; + + // let pub = relay.publish(event); + // pub.on('ok', () => { + // console.log(`${relay.url} has accepted our event`); + // }); + // pub.on('seen', () => { + // console.log(`we saw the event on ${relay.url}`); + // }); + // pub.on('failed', (reason: any) => { + // console.log(`failed to publish to ${relay.url}: ${reason}`); + // }); + // } + } + async initialize() { // Whenever the profile service needs to get a profile from the network, this event is triggered. // this.profileService.profileRequested$.subscribe(async (pubkey) => { diff --git a/src/app/services/interfaces.ts b/src/app/services/interfaces.ts index 2293ad5..ee2496f 100644 --- a/src/app/services/interfaces.ts +++ b/src/app/services/interfaces.ts @@ -10,6 +10,12 @@ export interface Circle { public: boolean; } +export interface Contact { + pubkey: string; + relay?: string; + name?: string; +} + export interface Action { tooltip: string; icon: string;