Add contact list import

- WIP: Get the lists, doesn't do import yet.
- Lists number of following on the Circles
This commit is contained in:
SondreB 2023-01-01 04:14:41 +01:00
parent a8cf0af8c3
commit 24cec0c35e
No known key found for this signature in database
GPG Key ID: D6CC44C75005FDBF
13 changed files with 306 additions and 12 deletions

View File

@ -66,6 +66,7 @@ import { CircleStylePipe } from './shared/circle-style';
import { ReplyListComponent } from './shared/reply-list/reply-list.component'; import { ReplyListComponent } from './shared/reply-list/reply-list.component';
import { ContentComponent } from './shared/content/content.component'; import { ContentComponent } from './shared/content/content.component';
import { ScrollDirective } from './shared/scroll.directive'; import { ScrollDirective } from './shared/scroll.directive';
import { ImportFollowDialog } from './circles/import-follow-dialog/import-follow-dialog';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -96,7 +97,8 @@ import { ScrollDirective } from './shared/scroll.directive';
NoteComponent, NoteComponent,
ReplyListComponent, ReplyListComponent,
ContentComponent, ContentComponent,
ScrollDirective ScrollDirective,
ImportFollowDialog,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -15,7 +15,6 @@
} }
.circle-button { .circle-button {
} }
.circle-button-icon { .circle-button-icon {
@ -41,16 +40,21 @@
order: 0; order: 0;
flex: 0 1 auto; flex: 0 1 auto;
align-self: auto; align-self: auto;
} }
.circle-item:nth-child(2) { .circle-item:nth-child(2) {
order: 0; order: 0;
flex: 1 1 auto; flex: 1 1 auto;
align-self: auto; align-self: auto;
} }
.circle-item:nth-child(3) { .circle-item:nth-child(3) {
order: 0; order: 0;
flex: 0 1 auto; flex: 0 1 auto;
align-self: auto; align-self: auto;
} }
.circle-actions button {
margin-right: 0.4em;
margin-bottom: 0.4em;
}

View File

@ -5,7 +5,10 @@
<div class="circle-container" *ngFor="let circle of circles"> <div class="circle-container" *ngFor="let circle of circles">
<div class="circle-item"><mat-icon matListItemIcon [style.color]="circle.color">trip_origin</mat-icon></div> <div class="circle-item"><mat-icon matListItemIcon [style.color]="circle.color">trip_origin</mat-icon></div>
<div class="circle-item"> <div class="circle-item">
{{ circle.name }}<br /><span class="dimmed"><span *ngIf="circle.public">Public</span><span *ngIf="!circle.public">Private</span> - {{ circle.style | circlestyle }} - Created: {{ circle.created | ago }}</span> {{ circle.name }}<br />
<span class="dimmed"><span *ngIf="circle.public">Count: {{ countMembers(circle) }}</span></span><br />
<span class="dimmed"><span *ngIf="circle.public">Public</span>
<span *ngIf="!circle.public">Private</span> - {{ circle.style | circlestyle }} - Created: {{ circle.created | ago }}</span>
</div> </div>
<div class="circle-item"> <div class="circle-item">
<button (click)="deleteCircle(circle.id)" *ngIf="circle.id" class="circle-button" mat-icon-button> <button (click)="deleteCircle(circle.id)" *ngIf="circle.id" class="circle-button" mat-icon-button>
@ -14,6 +17,10 @@
</div> </div>
</div> </div>
<p class="dimmed">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.</p> <p class="circle-actions">
<button mat-stroked-button (click)="importFollowList()">Import Follow List</button>
<button mat-stroked-button (click)="publishFollowList()">Publish Follow List</button>
</p>
<p class="dimmed">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.</p>
</div> </div>

View File

@ -10,6 +10,10 @@ import { CirclesService } from '../services/circles.service';
import { CircleDialog } from '../shared/create-circle-dialog/create-circle-dialog'; import { CircleDialog } from '../shared/create-circle-dialog/create-circle-dialog';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { v4 as uuidv4 } from 'uuid'; 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({ @Component({
selector: 'app-circles', selector: 'app-circles',
@ -26,9 +30,12 @@ export class CirclesComponent {
private storage: StorageService, private storage: StorageService,
private profile: ProfileService, private profile: ProfileService,
public dialog: MatDialog, public dialog: MatDialog,
private feedService: FeedService,
private validator: DataValidation, private validator: DataValidation,
private utilities: Utilities, private utilities: Utilities,
private router: Router private authService: AuthenticationService,
private router: Router,
private snackBar: MatSnackBar
) {} ) {}
// public trackByFn(index: number, item: NostrProfileDocument) { // public trackByFn(index: number, item: NostrProfileDocument) {
@ -92,6 +99,53 @@ export class CirclesComponent {
await this.load(); 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 { createCircle(): void {
const dialogRef = this.dialog.open(CircleDialog, { const dialogRef = this.dialog.open(CircleDialog, {
data: { name: '', style: '1', public: true }, data: { name: '', style: '1', public: true },
@ -113,7 +167,11 @@ export class CirclesComponent {
}); });
} }
following: NostrProfileDocument[] = [];
async ngOnInit() { async ngOnInit() {
this.following = await this.profile.followList();
this.appState.title = 'Circles'; this.appState.title = 'Circles';
this.appState.showBackButton = true; this.appState.showBackButton = true;
this.appState.actions = [ this.appState.actions = [

View File

@ -0,0 +1,14 @@
<h1 mat-dialog-title>Import complete following list</h1>
<div mat-dialog-content class="mat-dialog-content">
<p>The existing value is your own public key, use this to import your own public following list.</p>
<mat-form-field appearance="fill" class="input-full-width">
<mat-icon class="circle" matPrefix>person_add</mat-icon>
<mat-label>Public Key</mat-label>
<input matInput type="text" autocomplete="off" [(ngModel)]="data.pubkey" />
</mat-form-field>
</div>
<div mat-dialog-actions class="mat-dialog-actions" align="end">
<button mat-stroked-button (click)="onNoClick()">Cancel</button>
<button mat-flat-button [mat-dialog-close]="data" color="primary" cdkFocusInitial>Import</button>
</div>

View File

@ -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;
}

View File

@ -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<ImportFollowDialogData>, @Inject(MAT_DIALOG_DATA) public data: ImportFollowDialogData) {
}
onNoClick(): void {
this.data.pubkey = '';
this.dialogRef.close();
}
}

View File

@ -147,7 +147,7 @@ export class FeedPublicComponent {
} }
event = this.validator.sanitizeEvent(event); event = this.validator.sanitizeEvent(event);
event = this.validator.filterEvent(event); // event = this.validator.filterEvent(event);
if (!event) { if (!event) {
return null; return null;

View File

@ -1,13 +1,14 @@
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject, Subject, map, shareReplay, Observable } from 'rxjs'; import { BehaviorSubject, Subject, map, shareReplay, Observable } from 'rxjs';
import { AuthenticationService } from './authentication.service';
import { Action } from './interfaces'; import { Action } from './interfaces';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ApplicationState { export class ApplicationState {
constructor(private breakpointObserver: BreakpointObserver) { constructor(private breakpointObserver: BreakpointObserver, private authService: AuthenticationService) {
this.isSmallScreen$ = this.breakpointObserver.observe('(max-width: 599px)').pipe( this.isSmallScreen$ = this.breakpointObserver.observe('(max-width: 599px)').pipe(
map((result) => result.matches), map((result) => result.matches),
shareReplay() shareReplay()
@ -19,6 +20,10 @@ export class ApplicationState {
); );
} }
getPublicKey() {
return this.authService.authInfo$.getValue().publicKeyHex;
}
title = 'Blockcore Notes'; title = 'Blockcore Notes';
goBack = false; goBack = false;

View File

@ -14,6 +14,8 @@ export class DataValidation {
profileLimit = 2048; profileLimit = 2048;
profileTagsLimit = 10; profileTagsLimit = 10;
contactsContentLimit = 2048;
constructor(private options: OptionsService) {} constructor(private options: OptionsService) {}
sanitizeEvent(event: NostrEvent) { sanitizeEvent(event: NostrEvent) {
@ -107,6 +109,41 @@ export class DataValidation {
return event; 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) { escapeNewLineChars(valueToEscape: string) {
if (valueToEscape != null && valueToEscape != '') { if (valueToEscape != null && valueToEscape != '') {
return valueToEscape.replace(/\n/g, ' '); return valueToEscape.replace(/\n/g, ' ');

View File

@ -19,7 +19,7 @@ export class EventService {
} }
event = this.validator.sanitizeEvent(event); event = this.validator.sanitizeEvent(event);
event = this.validator.filterEvent(event); // event = this.validator.filterEvent(event);
if (!event) { if (!event) {
return null; return null;
@ -32,6 +32,29 @@ export class EventService {
return event; 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. */ /** Returns the root event, first looks for "root" attribute on the e tag element or picks first in array. */
eTags(event: NostrEventDocument | null) { eTags(event: NostrEventDocument | null) {
if (!event) { if (!event) {

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; 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 * as sanitizeHtml from 'sanitize-html';
import { SettingsService } from './settings.service'; 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'; 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 { OptionsService } from './options.service';
import { RelayService } from './relay.service'; import { RelayService } from './relay.service';
import { RelayStorageService } from './relay.storage.service'; import { RelayStorageService } from './relay.storage.service';
import { AuthenticationService } from './authentication.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -132,6 +133,7 @@ export class FeedService {
private relayService: RelayService, private relayService: RelayService,
private eventService: EventService, private eventService: EventService,
private validator: DataValidation, private validator: DataValidation,
private authService: AuthenticationService,
private storage: StorageService, private storage: StorageService,
private profileService: ProfileService, private profileService: ProfileService,
private circlesService: CirclesService 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<NostrEventDocument>((observer: Observer<NostrEventDocument>) => {
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() { async initialize() {
// Whenever the profile service needs to get a profile from the network, this event is triggered. // Whenever the profile service needs to get a profile from the network, this event is triggered.
// this.profileService.profileRequested$.subscribe(async (pubkey) => { // this.profileService.profileRequested$.subscribe(async (pubkey) => {

View File

@ -10,6 +10,12 @@ export interface Circle {
public: boolean; public: boolean;
} }
export interface Contact {
pubkey: string;
relay?: string;
name?: string;
}
export interface Action { export interface Action {
tooltip: string; tooltip: string;
icon: string; icon: string;