diff --git a/package-lock.json b/package-lock.json index c38a5ad1..544e2a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@twogate/ngx-photo-gallery": "^1.4.0", "@types/sharedworker": "^0.0.91", "angularx-qrcode": "^15.0.1", + "animiq-nip76-tools": "^1.0.5", "dexie": "^3.2.3", "html5-qrcode": "^2.3.7", "idb": "^7.1.1", @@ -4743,6 +4744,31 @@ "@angular/core": "^15.0.0" } }, + "node_modules/animiq-nip76-tools": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/animiq-nip76-tools/-/animiq-nip76-tools-1.0.5.tgz", + "integrity": "sha512-JumeH/1uTgTMEST5LOzeirprY7L0fQbFCien3UrhZN8Z31npSh8tK66gj+p9nG1+yCX5aYXN9LIxVadzndVDyA==", + "dependencies": { + "@noble/hashes": "^1.2.0" + }, + "peerDependencies": { + "@noble/secp256k1": "^1.7", + "@scure/base": "^1.1", + "nostr-tools": "^1.7.1", + "rxjs": "^7.0" + } + }, + "node_modules/animiq-nip76-tools/node_modules/@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -16448,6 +16474,21 @@ "tslib": "^2.3.0" } }, + "animiq-nip76-tools": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/animiq-nip76-tools/-/animiq-nip76-tools-1.0.5.tgz", + "integrity": "sha512-JumeH/1uTgTMEST5LOzeirprY7L0fQbFCien3UrhZN8Z31npSh8tK66gj+p9nG1+yCX5aYXN9LIxVadzndVDyA==", + "requires": { + "@noble/hashes": "^1.2.0" + }, + "dependencies": { + "@noble/hashes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.0.tgz", + "integrity": "sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==" + } + } + }, "ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", diff --git a/package.json b/package.json index fc9b14eb..d8ab08dc 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@twogate/ngx-photo-gallery": "^1.4.0", "@types/sharedworker": "^0.0.91", "angularx-qrcode": "^15.0.1", + "animiq-nip76-tools": "file:../../animiq-nip76-tools/dist", "dexie": "^3.2.3", "html5-qrcode": "^2.3.7", "idb": "^7.1.1", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 2942a28a..127c5638 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -33,6 +33,9 @@ import { LoginComponent } from './connect/login/login'; import { CreateProfileComponent } from './connect/create/create'; import { EditorBadgesComponent } from './editor-badges/editor'; import { BadgeComponent } from './badge/badge'; +import { Nip76MainComponent } from './nip76/nip76-main/nip76-main.component'; +import { Nip76DemoService } from './nip76/demo-only/nip76-demo.service'; +import { Nip76DemoStarterComponent } from './nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component'; const routes: Routes = [ { @@ -139,6 +142,32 @@ const routes: Routes = [ data: LoadingResolverService, }, }, + { + path: 'private-channels', + component: Nip76MainComponent, + canActivate: [AuthGuard], + resolve: { + data: LoadingResolverService, + }, + }, + { + path: 'private-channels/sent-rsvps', + component: Nip76MainComponent, + canActivate: [AuthGuard], + data: { tabIndex: 1 }, + resolve: { + data: LoadingResolverService, + }, + }, + { + path: 'private-channels/:channelPubKey/notes', + component: Nip76MainComponent, + canActivate: [AuthGuard], + data: { tabIndex: 3 }, + resolve: { + data: LoadingResolverService, + }, + }, { path: 'badges/:id', component: BadgesComponent, diff --git a/src/app/app.css b/src/app/app.css index 3936a639..22906b4a 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -254,6 +254,9 @@ header { overscroll-behavior-y: contain; overflow-x: hidden; overflow-y: overlay !important; + + flex-direction: column; + display: flex; } .app-mediaplayer { diff --git a/src/app/app.html b/src/app/app.html index fb53fba8..d0a6da48 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -175,6 +175,10 @@

@{{ profile?.name }}

badge Badges + + key + Private Channels + settings Settings diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9367d342..6c99c32f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -153,6 +153,19 @@ import { BadgeComponent } from './badge/badge'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { DragScrollModule } from 'ngx-drag-scroll'; import { ZappersListDialogComponent } from './shared/zappers-list-dialog/zappers-list-dialog.component'; +import { Nip76MainComponent } from './nip76/nip76-main/nip76-main.component'; +import { AddChannelDialog } from './nip76/nip76-add-channel-dialog/add-channel-dialog.component'; +import { Nip76EventButtonsComponent } from './nip76/nip76-event-buttons/nip76-event-buttons.component'; +import { Nip76EventThreadComponent } from './nip76/nip76-event-thread/nip76-event-thread.component'; +import { Nip76ContentComponent } from './nip76/nip76-content/nip76-event-content.component'; +import { Nip76AddInvitationComponent } from './nip76/nip76-add-invitation/nip76-add-invitation.component'; +import { Nip76DiagnosticsComponent } from './nip76/nip76-diagnostics/nip76-diagnostics.component'; +import { Nip76ChannelHeaderComponent } from './nip76/nip76-channel/nip76-channel.component'; +import { Nip76ChannelNotesComponent } from './nip76/nip76-channel-notes/nip76-channel-notes.component'; +import { Nip76RsvpsSentComponent } from './nip76/nip76-rsvps-sent/nip76-rsvps-sent.component'; +import { Nip76DemoService } from './nip76/demo-only/nip76-demo.service'; +import { Nip76DemoStarterComponent, Nip76DemoCreateComponent, Nip76DemoKeyComponent } from './nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component'; + @NgModule({ declarations: [ AppComponent, @@ -244,6 +257,19 @@ import { ZappersListDialogComponent } from './shared/zappers-list-dialog/zappers TagsComponent, BadgeComponent, ZappersListDialogComponent, + Nip76MainComponent, + AddChannelDialog, + Nip76EventButtonsComponent, + Nip76EventThreadComponent, + Nip76ContentComponent, + Nip76AddInvitationComponent, + Nip76DiagnosticsComponent, + Nip76ChannelHeaderComponent, + Nip76ChannelNotesComponent, + Nip76RsvpsSentComponent, + Nip76DemoKeyComponent, + Nip76DemoCreateComponent, + Nip76DemoStarterComponent ], imports: [ HttpClientModule, @@ -322,7 +348,7 @@ import { ZappersListDialogComponent } from './shared/zappers-list-dialog/zappers }), ], exports: [], - providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } }, AuthGuardService, AppUpdateService, CheckForUpdateService, ChatService, UserService], + providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } }, AuthGuardService, AppUpdateService, CheckForUpdateService, ChatService, UserService, Nip76DemoService], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.html b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.html new file mode 100644 index 00000000..67c1a3bb --- /dev/null +++ b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.html @@ -0,0 +1,18 @@ + + Thanks for coming to checkout the Nip76 Private Channels Demo. +

+ You may use your own private nostr key, but we understand why many users would be hesitant + to do this. If so, please feel free to use a demo profile like + Alice + or Bob, + or create an entirely new user. +

+ + + + \ No newline at end of file diff --git a/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.scss b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.scss new file mode 100644 index 00000000..78c1490c --- /dev/null +++ b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.scss @@ -0,0 +1,28 @@ +.intro { + position: absolute; + top: 86px; + padding: 8px; + left: 198px; + z-index: 1000; + width: 66%; + background-color: floralwhite; + border-radius: 20px; +} + +.menubar { + display: flex; + flex-direction: row; + justify-content: center; +} + +.menubar-button { + display: flex; +} + +.action-link { + cursor: pointer; +} + +p { + margin: 4px 0px; +} \ No newline at end of file diff --git a/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.spec.ts b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.spec.ts new file mode 100644 index 00000000..abb8fc91 --- /dev/null +++ b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76DemoStarterComponent } from './nip76-demo-starter.component'; + +describe('LoginOrCreateNewComponent', () => { + let component: Nip76DemoStarterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76DemoStarterComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76DemoStarterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.ts b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.ts new file mode 100644 index 00000000..b338c792 --- /dev/null +++ b/src/app/nip76/demo-only/nip76-demo-starter/nip76-demo-starter.component.ts @@ -0,0 +1,103 @@ +import { Component } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { filter } from 'rxjs'; +import { CreateProfileComponent } from 'src/app/connect/create/create'; +import { ConnectKeyComponent } from 'src/app/connect/key/key'; +import { AuthenticationService } from 'src/app/services/authentication'; +import { DataService } from 'src/app/services/data'; +import { ProfileService } from 'src/app/services/profile'; +import { SecurityService } from 'src/app/services/security'; +import { ThemeService } from 'src/app/services/theme'; +import { Utilities } from 'src/app/services/utilities'; +import { defaultSnackBarOpts } from '../../nip76.service'; +import { Nip76DemoService } from '../nip76-demo.service'; + + + +@Component({ + selector: 'app-nip76-demo-starter', + templateUrl: './nip76-demo-starter.component.html', + styleUrls: ['./nip76-demo-starter.component.scss'] +}) +export class Nip76DemoStarterComponent { + demoUserType: 'existing' | 'new' = 'existing'; + constructor( + private snackBar: MatSnackBar, + private profileService: ProfileService, + private router: Router + ) { } + + ngOnInit() { + this.profileService.profile$.pipe(filter(x => !!x)).subscribe(x => { + this.router.navigateByUrl('/private-channels'); + }); + } + + copyKey(name: 'Alice' | 'Bob') { + const key = { + 'Alice': 'nsec1y72ekupwshrl6zca2kx439uz23x4fqppc6gg9y9e5up5es06qqxqlcw698', + 'Bob': 'nsec12l6c5g8e7gt9twyctk0t073trlrf2zzs88240k3d2dmqlyh2hwhq9s2wl3' + }[name]; + navigator.clipboard.writeText(key); + this.snackBar.open(`${name}'s key is now in your clipboard.`, 'Hide', defaultSnackBarOpts); + } +} + +@Component({ + selector: 'nip76-demo-key', + templateUrl: '../../../connect/key/key.html', + styleUrls: ['../../../connect/key/key.css', '../../../connect/connect.css'] +}) +export class Nip76DemoKeyComponent extends ConnectKeyComponent { + + constructor( + dialog: MatDialog, + theme: ThemeService, + private router1: Router, + security: SecurityService, + nip76DemoService: Nip76DemoService + + ) { + super(dialog, theme, router1, security); + this.step = 3; + } + + override async persistKey() { + super.persistKey(); + setTimeout(() => { + this.router1.navigateByUrl('/private-channels'); + }, 200) + } + +} + +@Component({ + selector: 'nip76-demo-create', + templateUrl: '../../../connect/create/create.html', + styleUrls: ['../../../connect/create/create.css', '../../../connect/connect.css'] +}) +export class Nip76DemoCreateComponent extends CreateProfileComponent { + + constructor( + utilities: Utilities, + dataService: DataService, + profileService: ProfileService, + authService: AuthenticationService, + theme: ThemeService, + private router1: Router, + security: SecurityService, + nip76DemoService: Nip76DemoService + ) { + super(utilities, dataService, profileService, authService, theme, router1, security) + } + + override async persistKey() { + super.persistKey(); + setTimeout(() => { + this.router1.navigateByUrl('/private-channels'); + }, 200) + } +} + diff --git a/src/app/nip76/demo-only/nip76-demo.service.ts b/src/app/nip76/demo-only/nip76-demo.service.ts new file mode 100644 index 00000000..05f25e71 --- /dev/null +++ b/src/app/nip76/demo-only/nip76-demo.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { Router, CanActivate } from '@angular/router'; +import { Nip76WebWalletStorage } from 'animiq-nip76-tools'; +import { ApplicationState } from '../../services/applicationstate'; +import { AuthenticationService, UserInfo } from '../../services/authentication'; +import { Nip76Service } from '../nip76.service'; + +@Injectable() +export class Nip76DemoService implements CanActivate { + constructor( + public appState: ApplicationState, + private authService: AuthenticationService, + public router: Router, + private nip76Service: Nip76Service + ) { + localStorage.setItem('blockcore:notes:nostr:consent', 'true'); + } + canActivate() { + if (this.authService.authInfo$.getValue().authenticated()) { + return true; + } + return this.authService.getAuthInfo().then((authInfo: UserInfo) => { + if (authInfo.authenticated()) { + return true; + } else { + this.router.navigateByUrl('/private-channels/demo-setup'); + return false; + } + }); + } + + async createNip76Wallet(publicKey: string, privateKey: string) { + + // these Alice and Bob accounts are used for nip76 demostration only. they would not be needed in a final build + if (publicKey === '6ea813a435667275c736d722261dc2516c14452c421342a1e6a42046d849c8b3') { //Alice + localStorage.setItem(Nip76WebWalletStorage.backupKey, 'cec4LbCMhRCugybUHTqsZh97hzl6+eABGghHTWs/xO1Xyrx8KnDHQOc8yclbA35/Uz8TBqb5UohPRXZIeFRJj0AxxUw3Fpv2LcHA2gvyBIKpiwGdMIcq8fCzbFdEN7tb'); + } else if (publicKey === 'c94f40831616c246675a134f457e2a18db19570159e920dd62f91b66635982e1') { //Bob + localStorage.setItem(Nip76WebWalletStorage.backupKey, 'wJAM+65IkkjXd3EyvITdSwv+o8NvAgB6NP+lM3JE5qoWoYjkod08XpTu4q5oRIZfYz4FQj4QEJo2Q6zKcOVPWbmtVAxsoQh6Jbj4KxV3+C9fClqY1RhDAYGJyocWiKYW'); + } + + this.nip76Service.wallet = await Nip76WebWalletStorage.fromStorage({ publicKey, privateKey }); + this.nip76Service.wallet.saveWallet(privateKey); + } +} \ No newline at end of file diff --git a/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.html b/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.html new file mode 100644 index 00000000..af2ea7d6 --- /dev/null +++ b/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.html @@ -0,0 +1,21 @@ +
+

Read Channel Invitation

+
+ + key + nprivatechan + + + + password + Password + + +
+ +
+ + +
+
\ No newline at end of file diff --git a/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.scss b/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.spec.ts b/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.spec.ts new file mode 100644 index 00000000..fb2bd440 --- /dev/null +++ b/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddChannelDialog } from './add-channel-dialog.component'; + +describe('AddChannelDialogComponent', () => { + let component: AddChannelDialog; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AddChannelDialog ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AddChannelDialog); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.ts b/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.ts new file mode 100644 index 00000000..a2dc372d --- /dev/null +++ b/src/app/nip76/nip76-add-channel-dialog/add-channel-dialog.component.ts @@ -0,0 +1,36 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { bech32 } from '@scure/base'; +import { nip19Extension } from 'animiq-nip76-tools'; + +export interface AddChannelDialogData { + channelPointer: string; + password?: string; +} + +@Component({ + selector: 'nip76-add-channel-dialog', + templateUrl: './add-channel-dialog.component.html', + styleUrls: ['./add-channel-dialog.component.scss'] +}) +export class AddChannelDialog { + + requirePassword = false; + + constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: AddChannelDialogData) {} + + onNoClick(): void { + this.data.channelPointer = ''; + this.dialogRef.close(); + } + + onChannelPointerChange(){ + try{ + const words = bech32.decode(this.data.channelPointer, 5000).words; + const pointerType = Uint8Array.from(bech32.fromWords(words))[0] as nip19Extension.PointerType; + this.requirePassword = (pointerType & nip19Extension.PointerType.Password) == nip19Extension.PointerType.Password; + } catch (error) { + this.requirePassword = false; + } + } +} diff --git a/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.html b/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.html new file mode 100644 index 00000000..311b3c22 --- /dev/null +++ b/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.html @@ -0,0 +1,45 @@ +
+

Make a Channel Invitation

+
+
+ + + By Public Key + By Password + + +
+ + person_add + Public Key + + + +
+
+ + password + Password + + + + password + Confirm Password + + +
+
+

Error: {{ error }}

+
+ +
+ + + +
+
\ No newline at end of file diff --git a/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.scss b/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.spec.ts b/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.spec.ts new file mode 100644 index 00000000..43aca4c0 --- /dev/null +++ b/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76AddInvitationComponent } from './nip76-add-invitation.component'; + +describe('Nip76AddInvitationComponent', () => { + let component: Nip76AddInvitationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76AddInvitationComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76AddInvitationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.ts b/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.ts new file mode 100644 index 00000000..1d7e1c15 --- /dev/null +++ b/src/app/nip76/nip76-add-invitation/nip76-add-invitation.component.ts @@ -0,0 +1,107 @@ +import { Component, Inject } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Invitation, nip19Extension, PrivateChannel } from 'animiq-nip76-tools'; +import { nip19 } from 'nostr-tools'; +import { defaultSnackBarOpts, Nip76Service } from '../nip76.service'; + +export interface AddInvitationDialogData { + channel: PrivateChannel; + invitationType: 'pubkey' | 'password'; + pubkey?: string; + validPubkey?: string; + password?: string; + password2?: string; + +} + +@Component({ + selector: 'app-nip76-add-invitation', + templateUrl: './nip76-add-invitation.component.html', + styleUrls: ['./nip76-add-invitation.component.scss'] +}) +export class Nip76AddInvitationComponent { + error!: string; + valid = false; + constructor( + private snackBar: MatSnackBar, + private nip76Service: Nip76Service, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AddInvitationDialogData, + ) { + data.invitationType = 'pubkey'; + } + + onNoClick(): void { + this.dialogRef.close(); + } + + updatePubkey() { + this.error = ''; + this.data.validPubkey = ''; + try { + if (this.data.pubkey!.startsWith('npub')) { + this.data.pubkey = this.data.validPubkey = nip19.decode(this.data.pubkey!).data as any; + this.valid = true; + } else if (this.data.pubkey!.match(/^[0-9a-f]{64}$/i)) { + this.data.validPubkey = nip19.decode(nip19.npubEncode(this.data.pubkey!)).data as any; + this.valid = true; + } else { + this.error = 'This does not appear to be a valid public key.' + this.valid = false; + } + } catch (err: any) { + this.error = err.message; + this.valid = false; + } + } + + updatePassword() { + this.error = ''; + if (this.data.password!.length < 4) { + this.error = 'Password must be at least 4 characters.' + this.valid = false; + } else if (this.data.password !== this.data.password2) { + this.error = 'Password was not entered the same.' + this.valid = false; + } else { + this.valid = true; + } + } + + async copyInviteWithoutSave() { + if (this.valid) { + let pointer: string; + const threadPointer: nip19Extension.PrivateChannelPointer = { + type: 0, + docIndex: -1, + signingKey: this.data.channel.dkxPost.signingParent!.publicKey, + signingChain: this.data.channel.dkxPost.signingParent!.chainCode, + cryptoKey: this.data.channel.dkxPost.encryptParent.publicKey, + cryptoChain: this.data.channel.dkxPost.encryptParent.chainCode, + }; + if (this.data.invitationType === 'password') { + pointer = await nip19Extension.nprivateChannelEncode(threadPointer, this.data.password!); + } else { + if (this.nip76Service.extensionProvider) { + pointer = await this.nip76Service.extensionProvider.createInvitation(threadPointer, this.data.validPubkey!); + } else { + const privateKey = await this.nip76Service.passwordDialog('Save RSVP'); + pointer = await nip19Extension.nprivateChannelEncode(threadPointer, privateKey, this.data.validPubkey); + } + } + navigator.clipboard.writeText(pointer); + this.snackBar.open(`The invitation is now in your clipboard.`, 'Hide', defaultSnackBarOpts); + } + } + + clearForm() { + this.error = ''; + this.valid = false; + this.data = { + invitationType: this.data.invitationType, + channel: this.data.channel + }; + } +} + diff --git a/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.html b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.html new file mode 100644 index 00000000..3341dc32 --- /dev/null +++ b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.html @@ -0,0 +1,41 @@ +
+ + +
+
+
+ + What's on your mind? + + + + sentiment_satisfied +
+
+   + +
+
+
+ +
+ + {{ + post.nostrEvent.created_at | ago }} + + verified_user + + +
+ +
+ {{ item.key }} + {{ item.value }} +
+ + +
+
diff --git a/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.scss b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.scss new file mode 100644 index 00000000..c966a45b --- /dev/null +++ b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.scss @@ -0,0 +1,38 @@ + +:host { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +// .top-menu { +// display: flex; +// flex-direction: row; +// flex-wrap: nowrap; +// justify-content: flex-start; +// } +// .top-menu-left { +// display: flex; +// flex-grow: 1; +// } + +.top-menu-right { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-end; +} + +.notes-wrapper { + height: 200px; + flex-grow: 1; + overflow-y: scroll; + // border: 1px solid red; +} + +.note-card { + margin-top: 0; + padding: 1em; + margin-bottom: 1em; + border-radius: 10px !important; +} \ No newline at end of file diff --git a/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.spec.ts b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.spec.ts new file mode 100644 index 00000000..5740a288 --- /dev/null +++ b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76ChannelNotesComponent } from './nip76-channel-notes.component'; + +describe('Nip76ChannelNotesComponent', () => { + let component: Nip76ChannelNotesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76ChannelNotesComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76ChannelNotesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.ts b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.ts new file mode 100644 index 00000000..b194f093 --- /dev/null +++ b/src/app/nip76/nip76-channel-notes/nip76-channel-notes.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Invitation, Nip76Wallet, PostDocument, PrivateChannel, Rsvp } from 'animiq-nip76-tools'; +import { CircleService } from 'src/app/services/circle'; +import { Circle, NostrProfileDocument } from 'src/app/services/interfaces'; +import { ProfileService } from 'src/app/services/profile'; +import { defaultSnackBarOpts, Nip76Service } from '../nip76.service'; + +@Component({ + selector: 'app-nip76-channel-notes', + templateUrl: './nip76-channel-notes.component.html', + styleUrls: ['./nip76-channel-notes.component.scss'] +}) +export class Nip76ChannelNotesComponent { + showNoteForm = false; + isEmojiPickerVisible = false; + @ViewChild('picker') picker: unknown; + @ViewChild('noteContent') noteContent?: FormControl; + noteForm = this.fb.group({ + content: ['', Validators.required], + expiration: [''], + dateControl: [], + }); + + @Input() + channel!: PrivateChannel; + + constructor( + private profiles: ProfileService, + private circleService: CircleService, + private snackBar: MatSnackBar, + private fb: FormBuilder, + public nip76Service: Nip76Service + ) { } + + get wallet(): Nip76Wallet { + return this.nip76Service.wallet; + } + + public trackByFn(index: number, item: PostDocument) { + return item.nostrEvent.id; + } + + addEmojiNote(event: { emoji: { native: any } }) { + let startPos = (this.noteContent).nativeElement.selectionStart; + let value = this.noteForm.controls.content.value; + + let parsedValue = value?.substring(0, startPos) + event.emoji.native + value?.substring(startPos, value.length); + this.noteForm.controls.content.setValue(parsedValue); + this.isEmojiPickerVisible = false; + + (this.noteContent).nativeElement.focus(); + } + + async saveNote() { + if (await this.nip76Service.saveNote(this.channel!, this.noteForm.controls.content.value!)) { + this.noteForm.reset(); + this.showNoteForm = false; + } + } + + shouldRSVP(channel: PrivateChannel) { + if (channel.dkxPost.signingParent.privateKey) return false; + if (channel.invitation?.pointer?.docIndex !== undefined) { + const huh = this.wallet.rsvps.filter(x => x.content.pointerDocIndex === channel.invitation?.pointer.docIndex); + return huh.length === 0; + } + return false; + } + + async rsvp(channel: PrivateChannel) { + this.nip76Service.saveRSVP(channel) + this.snackBar.open(`Thank you for your RSVP to this channel.`, 'Hide', defaultSnackBarOpts); + } +} diff --git a/src/app/nip76/nip76-channel/nip76-channel.component.html b/src/app/nip76/nip76-channel/nip76-channel.component.html new file mode 100644 index 00000000..8f317af9 --- /dev/null +++ b/src/app/nip76/nip76-channel/nip76-channel.component.html @@ -0,0 +1,172 @@ + +
+
+ + + +
+ +
+ +
+
+
+
+ + + + + +
+
+ +
+
+
Name
+
{{channel.content.name}}
+
About
+
{{channel.content.about}} 
+
+
+ + Name + + + + About + + + + Picture + + + + +
+
+
+
+ +
+
+ + + + Password Protected {{ !invite.content.encryptParent ? " (Suspended)" : ""}} + + + + + (Suspended) + + +
+ + + + Copy + + + Suspend + + + Reinstate + + + Delete + + + +
+
+
+
+ + + + +
+
+
+
+
+
+
+
+
+
No RSVPs Received yet.
+
+ +
+ + + + Delete + + + +
+
+
+
+
+
+ +
+
+ \ No newline at end of file diff --git a/src/app/nip76/nip76-channel/nip76-channel.component.scss b/src/app/nip76/nip76-channel/nip76-channel.component.scss new file mode 100644 index 00000000..259c4f96 --- /dev/null +++ b/src/app/nip76/nip76-channel/nip76-channel.component.scss @@ -0,0 +1,121 @@ +:host { + flex-grow: 2; + display: flex; + flex-direction: column; +} + +.flex { + display: flex; +} + +.icon-large .profile-image { + width: 256px; + height: 256px; +} + +.icon-medium .profile-image { + object-fit: cover; + width: 128px; + height: 128px; + border-radius: 50%; +} + +.icon-thumbnail .profile-image { + object-fit: cover; + width: 64px; + height: 64px; + border-radius: 50%; + border-width: 2px; + margin-left: 2px; + margin-right: 2px; + margin-bottom: 2px; +} + +.icon-small .profile-image { + object-fit: cover; + width: 45px; + height: 45px; + border-radius: 50%; + border-width: 2px; + margin-left: 4px; + margin-right: 4px; + margin-bottom: 2px; +} + +.content-items div { + word-wrap: break-word; + line-break: anywhere; +} + +.byline { + font-size: 12px; + + .about { + font-style: italic; + } +} + +.byline .profile-image { + object-fit: cover; + width: 24px; + height: 24px; + border-radius: 50%; + border-width: 1px; + margin-left: 2px; + margin-right: 2px; + margin-bottom: 1px; + position: relative; + top: 8px; +} + +.rounded-button { + border-radius: 16px; + margin-top: 0.24em; + margin-right: 0.5em; + margin-bottom: 12px; +} + +.channel-items-list { + height: 240px; + overflow-y: scroll; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start +} + +.channel-items-card { + margin: 0px 10px 10px 0px; + border-radius: 10px !important; + width: 400px; + display: flex; +} + +.main { + min-height: 140px; + // flex-grow: 1; +} + +.field-label { + font-weight: bold; + margin-top: 4px; +} + +.label-only-anchor { + position: relative; + left: 66px; + top: 22px; + width: 300px; +} + +.action-icon { + margin-right: 4px; + cursor: pointer; + font-size: 14px; + display: inline; +} + +.action-link { + cursor: pointer; + margin: 2px 8px 2px 0px; +} \ No newline at end of file diff --git a/src/app/nip76/nip76-channel/nip76-channel.component.spec.ts b/src/app/nip76/nip76-channel/nip76-channel.component.spec.ts new file mode 100644 index 00000000..73088e30 --- /dev/null +++ b/src/app/nip76/nip76-channel/nip76-channel.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76ChannelHeaderComponent } from './nip76-channel.component'; + +describe('Nip76ChannelHeaderComponent', () => { + let component: Nip76ChannelHeaderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76ChannelHeaderComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76ChannelHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-channel/nip76-channel.component.ts b/src/app/nip76/nip76-channel/nip76-channel.component.ts new file mode 100644 index 00000000..53f134bc --- /dev/null +++ b/src/app/nip76/nip76-channel/nip76-channel.component.ts @@ -0,0 +1,151 @@ +import { Component, Input } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { ContentDocument, Invitation, Nip76Wallet, PostDocument, PrivateChannel, Rsvp } from 'animiq-nip76-tools'; +import { CircleService } from 'src/app/services/circle'; +import { Circle, NostrProfileDocument } from 'src/app/services/interfaces'; +import { ProfileService } from 'src/app/services/profile'; +import { defaultSnackBarOpts, Nip76Service } from '../nip76.service'; + +enum DisplayType { + Summary = 'Summary', + Edit = 'Edit', + RSVPs = 'RSVPs', + Invitations = 'Invitations', + Notes = 'Notes', +} + +@Component({ + selector: 'app-nip76-channel', + templateUrl: './nip76-channel.component.html', + styleUrls: ['./nip76-channel.component.scss'] +}) +export class Nip76ChannelHeaderComponent { + private _channel?: PrivateChannel; + private _displayType = DisplayType.Summary; + DisplayType = DisplayType; + imagePath = '/assets/profile.png'; + profileName = ''; + profile?: NostrProfileDocument; + circle?: Circle; + + @Input() displayName = true; + @Input() showDisplayOptions = true; + @Input() listType = 'list'; + @Input() iconSize = 'small'; + + constructor( + private profiles: ProfileService, + private circleService: CircleService, + private snackBar: MatSnackBar, + public nip76Service: Nip76Service, + private router: Router + ) { } + + @Input() + set displayType(val: 'Summary' | 'Edit' | 'RSVPs' | 'Invitations' | 'Notes') { + this._displayType = DisplayType[val] + } + + get displayType(): DisplayType { + return this._displayType!; + } + + @Input() + set channel(val: PrivateChannel) { + this._channel = val; + if (val.editing) { + this._displayType = DisplayType.Edit; + } + this.profiles.getProfile(val.ownerPubKey).then(async (profile) => { + this.profile = profile; + await this.updateProfileDetails(); + }); + } + + get channel(): PrivateChannel { + return this._channel!; + } + + get wallet(): Nip76Wallet { + return this.nip76Service.wallet; + } + + get pubkey(): string { + return this.channel.dkxPost.signingParent.nostrPubKey; + } + + get isOwner(): boolean { + return !!this.channel.dkxInvite; + } + + async updateProfileDetails() { + this.imagePath = this.channel.content.picture || this.profile?.picture || this.imagePath; + if (this.profile) { + this.profileName = this.profile.display_name || this.profile.name || this.profileName; + this.circle = await this.circleService.get(this.profile.circle); + } + } + + viewNotes() { + this.router.navigate(['/private-channels', this.pubkey, 'notes']) + } + + async saveChannel() { + const success = await this.nip76Service.saveChannel(this.channel!); + } + + cancelAdd() { + const index = this.wallet.documentsIndex!.documents.findIndex(x => this.channel); + this.wallet.documentsIndex!.documents.splice(index, 1); + } + + async copyKeys(invite: Invitation) { + let invitation: string; + if (this.nip76Service.extensionProvider && invite.content.for) { + const keyset = invite.dkxParent.getDocumentKeyset(invite.docIndex); + invitation = await this.nip76Service.extensionProvider.createInvitation({ + type: 0, + docIndex: invite.docIndex, + signingKey: keyset.signingKey!.publicKey, + cryptoKey: keyset.encryptKey!.publicKey, + }, invite.content.for); + } else { + invitation = await invite.getPointer(); + } + navigator.clipboard.writeText(invitation); + this.snackBar.open(`The invitation is now in your clipboard.`, 'Hide', defaultSnackBarOpts); + } + + async deleteRSVP(rsvp: Rsvp) { + const privateKeyRequired = !this.nip76Service.extensionProvider; + const privateKey = privateKeyRequired ? await this.nip76Service.passwordDialog('Delete RSVP') : undefined; + if (privateKey || !privateKeyRequired) { + const walletRsvp = this.wallet.rsvps.find(x => x.content.pubkey === this.wallet.ownerPubKey && x.content.pointerDocIndex === rsvp.content.pointerDocIndex); + if (await this.nip76Service.deleteDocument(rsvp, privateKey)) { + rsvp.dkxParent.documents.splice(rsvp.dkxParent.documents.indexOf(rsvp), 1); + if (walletRsvp) { + if (await this.nip76Service.deleteDocument(walletRsvp, privateKey)) { + walletRsvp.dkxParent.documents.splice(walletRsvp.dkxParent.documents.indexOf(walletRsvp), 1); + } + } + } + } + } + + async deleteInvitation(invite: Invitation) { + if (await this.nip76Service.deleteDocument(invite)) { + invite.dkxParent.documents.splice(invite.dkxParent.documents.indexOf(invite), 1); + } + } + + async deleteChannelRSVP(channel: PrivateChannel) { + if (channel.invitation?.pointer?.docIndex) { + const rsvp = this.wallet.rsvps.find(x => x.channel === channel + && x.content.pointerDocIndex === channel.invitation.pointer.docIndex) as Rsvp; + await this.deleteRSVP(rsvp); + } + + } + +} diff --git a/src/app/nip76/nip76-content/nip76-event-content.component.ts b/src/app/nip76/nip76-content/nip76-event-content.component.ts new file mode 100644 index 00000000..72eb802e --- /dev/null +++ b/src/app/nip76/nip76-content/nip76-event-content.component.ts @@ -0,0 +1,35 @@ +import * as nostrTools from 'nostr-tools'; +import { Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core'; +// import { MatDialog } from '@angular/material/dialog'; +// import { Kind } from 'nostr-tools'; +// import { DataService } from 'src/app/services/data'; +// import { EventService } from 'src/app/services/event'; +// import { NostrEventDocument } from 'src/app/services/interfaces'; +// import { OptionsService } from 'src/app/services/options'; +// import { ProfileService } from 'src/app/services/profile'; +// import { Utilities } from 'src/app/services/utilities'; +import { PostDocument } from 'animiq-nip76-tools'; +import { ContentComponent } from '../../shared/content/content'; +import { Nip76Service } from '../nip76.service'; +@Component({ + selector: 'app-nip76-content', + templateUrl: '../../shared/content/content.html', + styleUrls: ['../../shared/content/content.css'] +}) +export class Nip76ContentComponent extends ContentComponent { + @Input() + post!: PostDocument; + + override async ngOnInit() { + this.event = this.post.nostrEvent; + if (!this.event) { + return; + } + + this.dynamicText = this.toDynamicText({ + content: this.post.content.text, + tags: this.post.content.tags!, + } as any); + this.isFollowing = true; //this.profileService.isFollowing(this.event.pubkey); + } +} \ No newline at end of file diff --git a/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.html b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.html new file mode 100644 index 00000000..0bd62d71 --- /dev/null +++ b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.html @@ -0,0 +1,19 @@ + + +
+ content_copy + + + + +
+
+
+
\ No newline at end of file diff --git a/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.scss b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.scss new file mode 100644 index 00000000..e702c51a --- /dev/null +++ b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.scss @@ -0,0 +1,45 @@ +:host { + height: 0px; +} + +.diagnostics { + position: fixed; + z-index: 500; + width: 600px; + display: none; + overflow: hidden; + background-color: white; + padding: 8px; + box-shadow: 0 4px 1px -1px rgba(0, 0, 0, .5), + 0 1px 1px 0 rgba(0, 0, 0, .5), + 0 1px 6px 0 rgba(0, 0, 0, .5); +} + +.action-button-icon.active { + color: blueviolet; +} + +.json-content { + max-height: 300px; + overflow-y: scroll; + background-color: #efefef; + border: 1px solid gray; + border-radius: 4px; + padding: 4px; + overflow-wrap: break-word; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + white-space: pre-wrap; +} + +::ng-deep .json-content b { + color: blue; +} + +.copy-json-button { + display: inline; + position: relative; + left: 556px; + top: 42px; + cursor: pointer; +} \ No newline at end of file diff --git a/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.spec.ts b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.spec.ts new file mode 100644 index 00000000..84842f9f --- /dev/null +++ b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76DiagnosticsComponent } from './nip76-diagnostics.component'; + +describe('Nip76DiagnosticsComponent', () => { + let component: Nip76DiagnosticsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76DiagnosticsComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76DiagnosticsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.ts b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.ts new file mode 100644 index 00000000..ca29bf6c --- /dev/null +++ b/src/app/nip76/nip76-diagnostics/nip76-diagnostics.component.ts @@ -0,0 +1,125 @@ +import { Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { ContentDocument } from 'animiq-nip76-tools'; +import { defaultSnackBarOpts } from '../nip76.service'; + +enum DiagType { + Event = 'Event', + Payload = 'Payload', + Content = 'Content', + Document = 'Document', +} + +@Component({ + selector: 'app-nip76-diagnostics', + templateUrl: './nip76-diagnostics.component.html', + styleUrls: ['./nip76-diagnostics.component.scss'] +}) +export class Nip76DiagnosticsComponent { + diagType = DiagType.Event; + DiagTypeEnum = DiagType; + delay = 190; + includePrivateData = false; + keepOpen = false; + timer!: NodeJS.Timeout | undefined; + @Input() + doc!: ContentDocument + @ViewChild('diagButton', { read: ElementRef }) + diagButton!: ElementRef; + @ViewChild('diagCard', { read: ElementRef }) + diagCard!: ElementRef; + + constructor( + private sanitizer: DomSanitizer, + private snackBar: MatSnackBar, + ) { } + + ngOnInit() { + this.diagType = DiagType.Event; + } + + @HostListener('mouseenter') + onMouseEnter() { + this.timer = setTimeout(() => { + + let x = this.diagButton.nativeElement.getBoundingClientRect().left + this.diagButton.nativeElement.offsetWidth / 2; + let y = this.diagButton.nativeElement.getBoundingClientRect().top + this.diagButton.nativeElement.offsetHeight / 2; + + this.diagCard.nativeElement.style.display = 'block'; + this.diagCard.nativeElement.style.top = y + 'px'; + this.diagCard.nativeElement.style.left = (x - 600) + 'px'; + + const diagHeight = 400; + if (y + diagHeight > document.scrollingElement!.scrollHeight) { + y = document.scrollingElement!.scrollHeight - diagHeight; + this.diagCard.nativeElement.style.top = y + 'px'; + } + const sidNavWidth = parseInt((document.getElementsByClassName('mat-sidenav-content')[0] as any).style.marginLeft); + const diagWidth = 600; + if (x - diagWidth < sidNavWidth) { + x = sidNavWidth; + this.diagCard.nativeElement.style.left = x + 'px'; + } + + }, this.delay) + } + + @HostListener('mouseleave') + onMouseLeave() { + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + if (!this.keepOpen) + this.diagCard.nativeElement.style.display = 'none'; + } + + @HostListener('window:keydown', ['$event']) + @HostListener('window:keyup', ['$event']) + keyEventDown(event: KeyboardEvent) { + if (event.ctrlKey) { + this.includePrivateData = !this.includePrivateData; + } + if (event.shiftKey) { + this.keepOpen = !this.keepOpen; + } + } + + prettierJson(forCopy = false): SafeHtml | string { + const keys: string[] = []; + const ignoreKeys = ['channelSubscription', 'documents']; + const privateDataKeys = ['xpriv', "xpub", "wordset", "password", "signingKey", "encryptKey", "signingParent", "encryptParent"]; + const replacer = (k: string, v: any) => { + if (this.diagType === DiagType.Document && ignoreKeys.includes(k)) { + return undefined; + } + if (keys.indexOf(k) === -1) { + keys.push(k); + } + if (v && !this.includePrivateData && privateDataKeys.includes(k)) { + return '**MASKED**'; + } + return v; + }; + const obj = { + 'Event': this.doc.nostrEvent, + 'Payload': this.doc.payload, + 'Content': this.doc.content, + 'Document': this.doc, + }[this.diagType] || {}; + let json = JSON.stringify(obj, replacer, 2); + if (forCopy) { + return json; + } else { + keys.forEach(k => { + const regex = new RegExp(`"${k}":`, 'g'); + json = json.replace(regex, `"${k}":`); + }); + return this.sanitizer.bypassSecurityTrustHtml(json); + } + } + + copyJson() { + navigator.clipboard.writeText(this.prettierJson(true) as string); + this.snackBar.open(`The JSON is copied into your clipboard.`, 'Hide', defaultSnackBarOpts); + } +} diff --git a/src/app/nip76/nip76-event-buttons/nip76-event-buttons.component.ts b/src/app/nip76/nip76-event-buttons/nip76-event-buttons.component.ts new file mode 100644 index 00000000..da3a271c --- /dev/null +++ b/src/app/nip76/nip76-event-buttons/nip76-event-buttons.component.ts @@ -0,0 +1,52 @@ +import * as nostrTools from 'nostr-tools'; +import { Component, ElementRef, HostListener, Input, ViewChild } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Kind } from 'nostr-tools'; +import { DataService } from 'src/app/services/data'; +import { EventService } from 'src/app/services/event'; +import { NostrEventDocument } from 'src/app/services/interfaces'; +import { OptionsService } from 'src/app/services/options'; +import { ProfileService } from 'src/app/services/profile'; +import { Utilities } from 'src/app/services/utilities'; +import { PostDocument } from 'animiq-nip76-tools'; +import { EventButtonsComponent } from '../../shared/event-buttons/event-buttons'; +import { Nip76Service } from '../nip76.service'; +import { NotesService } from 'src/app/services/notes'; +@Component({ + selector: 'app-nip76-event-buttons', + templateUrl: '../../shared/event-buttons/event-buttons.html', + styleUrls: ['../../shared/event-buttons/event-buttons.css'] +}) +export class Nip76EventButtonsComponent extends EventButtonsComponent { + + private _doc!: PostDocument; + @Input() + set doc(doc: PostDocument) { + this._doc = doc; + this.event = doc.nostrEvent as NostrEventDocument; + } + get doc(): PostDocument { return this._doc; } + + constructor( + private nip76Service: Nip76Service, + eventService: EventService, + notesService: NotesService, + dataService: DataService, + optionsService: OptionsService, + profileService: ProfileService, + utilities: Utilities, + dialog: MatDialog) { + super(eventService, notesService, dataService, optionsService, profileService, utilities, dialog); + } + + override async addEmoji(e: { emoji: { native: any } }) { + this.isEmojiPickerVisible = false; + const reactionDoc = await this.nip76Service.saveReaction(this.doc, e.emoji.native, nostrTools.Kind.Reaction); + } + + override async addReply() { + this.isEmojiPickerVisible = false; + const reactionDoc = await this.nip76Service.saveReaction(this.doc, this.note!, nostrTools.Kind.Text); + this.hideReply(); + } +} \ No newline at end of file diff --git a/src/app/nip76/nip76-event-thread/nip76-event-thread.component.html b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.html new file mode 100644 index 00000000..7056349b --- /dev/null +++ b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.html @@ -0,0 +1,18 @@ +
+ +
+ + + + verified_user + + +
+ +
+ {{ item.key }} {{ item.value }} +
+ + +
+
\ No newline at end of file diff --git a/src/app/nip76/nip76-event-thread/nip76-event-thread.component.scss b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.scss new file mode 100644 index 00000000..cc2fe2d8 --- /dev/null +++ b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.scss @@ -0,0 +1,13 @@ +.thread-event { + margin-left: 27px; + /* border-left: 2px solid rgba(255, 255, 255, 0.15); */ + padding-top: 0em; + padding-left: 1em; +} + +.thread-content { + margin-left: 27px; + padding-left: 1em; + /* border-left: 2px solid rgba(255, 255, 255, 0.15) !important; */ + display: block; +} \ No newline at end of file diff --git a/src/app/nip76/nip76-event-thread/nip76-event-thread.component.spec.ts b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.spec.ts new file mode 100644 index 00000000..2d850a46 --- /dev/null +++ b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76EventThreadComponent } from './nip76-event-thread.component'; + +describe('Nip76EventThreadComponent', () => { + let component: Nip76EventThreadComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76EventThreadComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76EventThreadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-event-thread/nip76-event-thread.component.ts b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.ts new file mode 100644 index 00000000..1e460e75 --- /dev/null +++ b/src/app/nip76/nip76-event-thread/nip76-event-thread.component.ts @@ -0,0 +1,13 @@ +import { Component, Input } from '@angular/core'; +import { PostDocument } from 'animiq-nip76-tools'; + +@Component({ + selector: 'app-nip76-event-thread', + templateUrl: './nip76-event-thread.component.html', + styleUrls: ['./nip76-event-thread.component.scss'] +}) +export class Nip76EventThreadComponent { + @Input() + doc!: PostDocument; + +} diff --git a/src/app/nip76/nip76-main/nip76-main.component.html b/src/app/nip76/nip76-main/nip76-main.component.html new file mode 100644 index 00000000..9b5b2eb4 --- /dev/null +++ b/src/app/nip76/nip76-main/nip76-main.component.html @@ -0,0 +1,72 @@ +
+
+ + + + +
+
+ + + +
+
+
+
+
+

NIP 76 Private Channels are not initialized for this profile yet.

+
+
+
+ + Thanks for coming to checkout the Nip76 Private Channels Demo. +

+ You have no channels yet. You can create one of your own and invite others + or read an invitation to another's channel. +

+

+ Here is an invitation + from a Demo user named Alice. + The password is "test". +

+
+ + +
+ + +
+
+
+
+
+
+ + +
+
+ +
+
+
\ No newline at end of file diff --git a/src/app/nip76/nip76-main/nip76-main.component.scss b/src/app/nip76/nip76-main/nip76-main.component.scss new file mode 100644 index 00000000..05d94948 --- /dev/null +++ b/src/app/nip76/nip76-main/nip76-main.component.scss @@ -0,0 +1,106 @@ +:host { + padding: 1em; + display: flex; + flex-direction: column; + flex-grow: 1; +} +.flex-height { + display: flex; + flex-direction: column; + flex-grow: 1; +} +.menu-link { + display: inline-block; + padding-right: 16px; + padding-bottom: 8px; + cursor: pointer; +} + +.action-link { + display: inline-block; + cursor: pointer; +} + +li.instruction { + padding-bottom: 16px; +} + +.rounded-button { + border-radius: 16px; + margin-top: 0.24em; + margin-right: 0.5em; + margin-bottom: 12px; +} + +.settings-card { + margin: 16px 32px 16px 0px; +} + +.settings-card.edit { + background-color: aliceblue; +} + +.input-full-width { + width: 100%; + background-color: white; +} + +.new-post-button { + // width: 92px; + // height: 92px; + // position: fixed; + // bottom: 2em; + // left: 2.9em; + cursor: pointer; + transition: opacity 250ms ease; +} + +.new-post-button:hover { + opacity: 0.6; +} + +.field-label { + font-weight: bold; + margin-top: 4px; +} + +.label-only-anchor { + position: relative; + left: 66px; + top: 22px; + width: 300px; +} + +.action-icon { + margin-right: 4px; + cursor: pointer; + font-size: 14px; + display: inline; +} +.top-menu { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; +} +.top-menu-left { + display: flex; + flex-grow: 1; +} + +.top-menu-right { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-end; +} + +.intro { + padding: 8px; + background-color: floralwhite; + border-radius: 20px; +} + +p { + margin: 4px 0px; +} \ No newline at end of file diff --git a/src/app/nip76/nip76-main/nip76-main.component.spec.ts b/src/app/nip76/nip76-main/nip76-main.component.spec.ts new file mode 100644 index 00000000..fa65f180 --- /dev/null +++ b/src/app/nip76/nip76-main/nip76-main.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76MainComponent } from './nip76-main.component'; + +describe('PrivateThreadsComponent', () => { + let component: Nip76MainComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76MainComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76MainComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-main/nip76-main.component.ts b/src/app/nip76/nip76-main/nip76-main.component.ts new file mode 100644 index 00000000..a2747a8a --- /dev/null +++ b/src/app/nip76/nip76-main/nip76-main.component.ts @@ -0,0 +1,112 @@ +import { Component } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Nip76Wallet, Nip76WebWalletStorage, PrivateChannel } from 'animiq-nip76-tools'; +import { ApplicationState } from '../../services/applicationstate'; +import { NavigationService } from '../../services/navigation'; +import { UIService } from '../../services/ui'; +import { defaultSnackBarOpts, Nip76Service } from '../nip76.service'; + +enum DisplayType { + GuestUser = 'GuestUser', + ChannelList = 'ChannelList', + SingleChannel = 'SingleChannel', + SentRSVPs = 'SentRSVPs', +} +@Component({ + selector: 'app-nip76', + templateUrl: './nip76-main.component.html', + styleUrls: ['./nip76-main.component.scss'] +}) +export class Nip76MainComponent { + + DisplayType = DisplayType; + activeChannelId!: string | null; + showHelp = false; + constructor( + private router: Router, + private activatedRoute: ActivatedRoute, + private snackBar: MatSnackBar, + public navigation: NavigationService, + public appState: ApplicationState, + public ui: UIService, + public nip76Service: Nip76Service, + ) { } + + async ngOnInit() { + this.activatedRoute.paramMap.subscribe(async (params) => { + this.activeChannelId = params.get('channelPubKey'); + }); + setTimeout(() => { + this.showHelp = this.wallet?.isReady && this.wallet?.channels?.length === 0; + }, 3000); + } + + get displayType(): DisplayType { + if (!this.wallet || this.wallet.isGuest) { + return DisplayType.GuestUser; + } else { + if (this.activeChannelId) { + const channel = this.nip76Service.findChannel(this.activeChannelId); + if (channel) { + return DisplayType.SingleChannel; + } + } if (this.router.url.endsWith('/sent-rsvps')) { + return DisplayType.SentRSVPs; + } else { + return DisplayType.ChannelList; + } + } + } + + get wallet(): Nip76Wallet { + return this.nip76Service.wallet; + } + + get activeChannel(): PrivateChannel | undefined { + if (this.activeChannelId) { + if (!this.wallet.isGuest && this.wallet.isReady) { + const channel = this.nip76Service.findChannel(this.activeChannelId); + if (channel) { + return channel; + } + } + this.listChannels(); + } + return undefined; + } + + async initPrivateChannels() { + await this.nip76Service.loadWallet(); + } + + copyDemoInvitation(name: 'Alice') { + const invitation = { + 'Alice': 'nprivatechan1z5ay69wdt282c54z0m5rnmvqmr5elgsadczlkn5j50d7r2mtll8klfcxm76rzg90eqjdep70c88wur2sgvw0qt90vt3jw9lfser66hkcwywjxqudzfws20zyex2pktzmfjk0hpdehu9d4swanmcsckayfxrr0wgyvzm0j6' + }[name]; + navigator.clipboard.writeText(invitation); + this.snackBar.open(`The invitation is now in your clipboard. Click Read Invitation and paste it there.`, 'Hide', defaultSnackBarOpts); + } + + async readInvitation() { + const channel = await this.nip76Service.readInvitationDialog(); + if (channel) { + this.activeChannelId = channel.dkxPost.signingParent.nostrPubKey; + this.router.navigate(['/private-channels', this.activeChannelId, 'notes']); + } + } + + createChannel() { + let newChannel = this.wallet.createChannel(); + newChannel.ready = newChannel.editing = true; + this.router.navigate(['/private-channels']); + } + + listChannels() { + this.router.navigate(['/private-channels']); + } + + sentRvps() { + this.router.navigate(['/private-channels', 'sent-rsvps']); + } +} diff --git a/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.html b/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.html new file mode 100644 index 00000000..570f4dee --- /dev/null +++ b/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.html @@ -0,0 +1,20 @@ +
No RSVPs Sent yet.
+
+ + + + Suspended + + +
+ + delete + + + + +
+
+
\ No newline at end of file diff --git a/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.scss b/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.spec.ts b/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.spec.ts new file mode 100644 index 00000000..25e8111d --- /dev/null +++ b/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Nip76RsvpsSentComponent } from './nip76-rsvps-sent.component'; + +describe('Nip76RsvpsSentComponent', () => { + let component: Nip76RsvpsSentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ Nip76RsvpsSentComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Nip76RsvpsSentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.ts b/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.ts new file mode 100644 index 00000000..be5eb561 --- /dev/null +++ b/src/app/nip76/nip76-rsvps-sent/nip76-rsvps-sent.component.ts @@ -0,0 +1,44 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Invitation, Nip76Wallet, PostDocument, PrivateChannel, Rsvp } from 'animiq-nip76-tools'; +import { CircleService } from 'src/app/services/circle'; +import { Circle, NostrProfileDocument } from 'src/app/services/interfaces'; +import { ProfileService } from 'src/app/services/profile'; +import { defaultSnackBarOpts, Nip76Service } from '../nip76.service'; + +@Component({ + selector: 'app-nip76-rsvps-sent', + templateUrl: './nip76-rsvps-sent.component.html', + styleUrls: ['./nip76-rsvps-sent.component.scss'] +}) +export class Nip76RsvpsSentComponent { + + constructor( + private profiles: ProfileService, + private circleService: CircleService, + private snackBar: MatSnackBar, + private fb: FormBuilder, + public nip76Service: Nip76Service + ) { } + + get wallet(): Nip76Wallet { + return this.nip76Service.wallet; + } + + async deleteRSVP(rsvp: Rsvp) { + const privateKeyRequired = !this.nip76Service.extensionProvider; + const privateKey = privateKeyRequired ? await this.nip76Service.passwordDialog('Delete RSVP') : undefined; + if (privateKey) { + const channelRsvp = rsvp.channel?.rsvps.find(x => x.content.pubkey === this.wallet.ownerPubKey && x.content.pointerDocIndex === rsvp.content.pointerDocIndex); + if (await this.nip76Service.deleteDocument(rsvp, privateKey)) { + rsvp.dkxParent.documents.splice(rsvp.dkxParent.documents.indexOf(rsvp), 1); + if (channelRsvp) { + if (await this.nip76Service.deleteDocument(channelRsvp, privateKey)) { + channelRsvp.dkxParent.documents.splice(channelRsvp.dkxParent.documents.indexOf(channelRsvp), 1); + } + } + } + } + } +} diff --git a/src/app/nip76/nip76.service.spec.ts b/src/app/nip76/nip76.service.spec.ts new file mode 100644 index 00000000..9a524647 --- /dev/null +++ b/src/app/nip76/nip76.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Nip76Service } from './nip76.service'; + +describe('Nip76Service', () => { + let service: Nip76Service; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Nip76Service); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/nip76/nip76.service.ts b/src/app/nip76/nip76.service.ts new file mode 100644 index 00000000..4aaa883f --- /dev/null +++ b/src/app/nip76/nip76.service.ts @@ -0,0 +1,507 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; +import { bech32 } from '@scure/base'; +import { + ContentDocument, getNowSeconds, HDKey, HDKIndex, HDKIndexType, Invitation, nip19Extension, Nip76Wallet, INostrNip76Provider, + Nip76WebWalletStorage, NostrEventDocument, NostrKinds, PostDocument, PrivateChannel, Rsvp, Versions, walletRsvpDocumentsOffset +} from 'animiq-nip76-tools'; +import * as nostrTools from 'nostr-tools'; +import { filter, firstValueFrom, Subject, take } from 'rxjs'; +import { DataService } from '../services/data'; +import { NostrEvent, NostrProfileDocument, NostrRelaySubscription } from '../services/interfaces'; +import { ProfileService } from '../services/profile'; +import { RelayService } from '../services/relay'; +import { SecurityService } from '../services/security'; +import { UIService } from '../services/ui'; +import { PasswordDialog, PasswordDialogData } from '../shared/password-dialog/password-dialog'; +import { AddChannelDialog, AddChannelDialogData } from './nip76-add-channel-dialog/add-channel-dialog.component'; +import { AddInvitationDialogData, Nip76AddInvitationComponent } from './nip76-add-invitation/nip76-add-invitation.component'; + +const nostrPrivKeyAddress = 'blockcore:notes:nostr:prvkey'; + +interface PrivateChannelWithRelaySub extends PrivateChannel { + channelSubscription?: NostrRelaySubscription; +} + +export const defaultSnackBarOpts: MatSnackBarConfig = { + duration: 3000, + horizontalPosition: 'center', + verticalPosition: 'bottom', +}; + +@Injectable({ + providedIn: 'root' +}) +export class Nip76Service { + + wallet!: Nip76Wallet; + documentsSubscription?: NostrRelaySubscription; + profile!: NostrProfileDocument; + + constructor( + private dialog: MatDialog, + private snackBar: MatSnackBar, + private security: SecurityService, + private profileService: ProfileService, + private relayService: RelayService, + private dataService: DataService, + private ui: UIService + ) { + this.profileService.profile$.pipe(filter(x => !!x), take(1)).subscribe(profile => { + this.profile = profile!; + this.loadWallet(); + }); + } + + get extensionProvider(): INostrNip76Provider { + return (globalThis as any).nostr?.nip76; + } + + async loadWallet() { + const publicKey = this.profile.pubkey; + if (this.extensionProvider) { + const documentsIndex: HDKIndex = await this.extensionProvider.getIndex(); + this.wallet = new Nip76Wallet({ publicKey, documentsIndex }); + setTimeout(() => { this.loadDocuments(); }, 500); + } else if (localStorage.getItem(nostrPrivKeyAddress)) { + if (localStorage.getItem(Nip76WebWalletStorage.backupKey)) { + Nip76WebWalletStorage.fromStorage({ publicKey }).then(wallet => { + this.wallet = wallet; + if (this.wallet.isReady) { + setTimeout(() => { this.loadDocuments(); }, 500); + } else if (!this.wallet.isGuest) { + this.login(); + } + }); + } else { + const privateKey = await this.passwordDialog('Create an HD Wallet'); + this.wallet = await Nip76WebWalletStorage.fromStorage({ publicKey, privateKey }); + this.wallet.saveWallet(privateKey); + location.reload(); + } + } + } + + async passwordDialog(actionPrompt: string): Promise { + return new Promise((resolve, reject) => { + const dialogRef = this.dialog.open(PasswordDialog, { + data: { action: actionPrompt, password: '' }, + maxWidth: '100vw', + panelClass: 'full-width-dialog', + }); + dialogRef.afterClosed().subscribe(async (result: PasswordDialogData) => { + if (result) { + const prvkeyEncrypted = localStorage.getItem(nostrPrivKeyAddress); + const prvkey = await this.security.decryptData(prvkeyEncrypted!, result.password); + if (prvkey) { + resolve(prvkey); + } else { + this.snackBar.open(`Unable to access user private key. Probably wrong password. Try again.`, 'Hide', defaultSnackBarOpts); + reject(); + } + } else { + reject('Unable to access user private key.'); + } + }); + }); + } + + async saveWallet(): Promise { + const privateKey = await this.passwordDialog('Save Private Channel Keys'); + await this.wallet.saveWallet(privateKey); + return true; + } + + async login(): Promise { + const privateKey = await this.passwordDialog('Load Private Channel Keys'); + this.wallet = await Nip76WebWalletStorage.fromStorage({ privateKey }); + if (this.wallet.isReady) { + this.loadDocuments(); + } + return this.wallet.isReady; + } + + async logout() { + this.wallet.channels.forEach((channel: PrivateChannelWithRelaySub) => { + if (channel.channelSubscription) { + this.relayService.unsubscribe(channel.channelSubscription.id); + } + }); + this.wallet.clearSession(); + this.wallet = await Nip76WebWalletStorage.fromStorage({ publicKey: this.wallet.ownerPubKey }); + } + + loadDocuments(start = 0) { + const channelPubkeys = this.wallet.documentsIndex!.getSequentialKeyset(0, 0); + const invitePubkeys = this.wallet.documentsIndex!.getSequentialKeyset(walletRsvpDocumentsOffset, 0); + if (this.documentsSubscription) { + this.relayService.unsubscribe(this.documentsSubscription.id); + } + const privateDoc$ = new Subject(); + privateDoc$.subscribe(async nostrEvent => { + let docIndex = channelPubkeys.keys.findIndex(x => x.signingKey?.nostrPubKey === nostrEvent.pubkey) + start; + if (docIndex > -1) { + const doc = await this.wallet.documentsIndex!.readEvent(nostrEvent, docIndex) as PrivateChannel; + if (this.extensionProvider) { + doc.dkxInvite = await this.extensionProvider.getIndex(docIndex); + } + doc.dkxInvite.parentDocument = doc; + if (doc) { + this.loadChannel(doc); + } + } else { + docIndex = invitePubkeys.keys.findIndex(x => x.signingKey?.nostrPubKey === nostrEvent.pubkey) + start + walletRsvpDocumentsOffset; + const doc = await this.wallet.documentsIndex!.readEvent(nostrEvent, docIndex) as Rsvp; + if (doc) { + const pointer: nip19Extension.PrivateChannelPointer = { + type: doc.content.type, + docIndex: doc.content.pointerDocIndex, + signingKey: doc.content.signingKey, + cryptoKey: doc.content.cryptoKey + }; + doc.pointer = pointer; + const rsvpIndex = HDKIndex.fromChannelPointer(pointer); + const channel = await this.readChannelIndex(rsvpIndex, pointer); + } + } + }); + const filters = [ + { authors: channelPubkeys.keys.map(x => x.signingKey?.nostrPubKey!), kinds: [17761], limit: channelPubkeys.keys.length }, + { authors: invitePubkeys.keys.map(x => x.signingKey?.nostrPubKey!), kinds: [17761], limit: invitePubkeys.keys.length } + ]; + this.documentsSubscription = this.relayService.subscribe(filters, + `nip76Service.loadDocuments.${start}-${length}`, 'Replaceable', privateDoc$); + } + + loadChannel(channel: PrivateChannelWithRelaySub, start = 0) { + const invitePubs = channel.dkxInvite?.getSequentialKeyset(0, 0); + if (channel.channelSubscription) { + this.relayService.unsubscribe(channel.channelSubscription.id); + } + const privateChannel$ = new Subject(); + privateChannel$.subscribe(async nostrEvent => { + if (channel.dkxPost.eventTag === nostrEvent.tags[0][1]) { + const post = await channel.dkxPost.readEvent(nostrEvent); + } else if (channel.dkxRsvp.eventTag === nostrEvent.tags[0][1]) { + const rsvp = await channel.dkxRsvp.readEvent(nostrEvent); + } else if (invitePubs) { + const docIndex = invitePubs.keys.findIndex(x => x.signingKey?.nostrPubKey === nostrEvent.pubkey) + start; + const invite = await channel.dkxInvite.readEvent(nostrEvent, docIndex); + } + }); + const filters: nostrTools.Filter[] = [ + { '#e': [channel.dkxPost.eventTag], kinds: [17761], limit: 100 }, + { '#e': [channel.dkxRsvp.eventTag], kinds: [17761], limit: 100 }, + ]; + if (invitePubs) { + filters.push({ authors: invitePubs.keys.map(x => x.signingKey?.nostrPubKey!), kinds: [17761], limit: invitePubs.keys.length }) + } + channel.channelSubscription = this.relayService.subscribe( + filters, + `nip76Service.loadChannel.${channel.dkxPost.eventTag}`, + 'Replaceable', + privateChannel$ + ); + } + + findChannel(pubkey: string): PrivateChannelWithRelaySub | undefined { + return this.wallet?.channels.find(x => pubkey === x.dkxPost.signingParent.nostrPubKey); + } + + async readInvitationDialog(): Promise { + return new Promise((resolve, reject) => { + const dialogRef = this.dialog.open(AddChannelDialog, { + data: { channelPointer: '' }, + maxWidth: '200vw', + panelClass: 'full-width-dialog', + }); + dialogRef.afterClosed().subscribe(async (result: AddChannelDialogData) => { + if (result?.channelPointer) { + const channel = await this.readChannelPointer(result.channelPointer, result.password!); + if (channel) { + resolve(channel); + } else { + reject(); + } + } else { + reject(); + } + }); + }); + } + + async addInvitation(channel: PrivateChannel): Promise { + return new Promise((resolve, reject) => { + const dialogRef = this.dialog.open(Nip76AddInvitationComponent, { + data: { channelPointer: '', channel }, + maxWidth: '200vw', + panelClass: 'full-width-dialog', + }); + dialogRef.afterClosed().subscribe(async (result: AddInvitationDialogData) => { + if (result) { + const invitation = await this.saveInvitation(channel, result); + if (invitation) { + resolve(invitation); + } else { + reject(); + } + } else { + reject(); + } + }); + }); + } + + async readInvitation(invite: Invitation): Promise { + if (!invite.content.signingParent && !invite.content.encryptParent) { + this.snackBar.open(`Encountered a suspended invitation from ${invite.ownerPubKey}`, 'Hide', defaultSnackBarOpts); + return undefined; + } + const channelIndex = new HDKIndex(HDKIndexType.Singleton, invite.content.signingParent!, invite.content.encryptParent!); + const channelIndex$ = new Subject(); + const channelIndexSub = this.relayService.subscribe( + [{ authors: [channelIndex.signingParent.nostrPubKey], kinds: [17761], limit: 1 }], + `nip76Service.readInvitation.${channelIndex.signingParent.nostrPubKey}`, 'Replaceable', channelIndex$ + ); + const nostrEvent = await firstValueFrom(channelIndex$); + this.relayService.unsubscribe(channelIndexSub.id); + if (nostrEvent) { + const channel = await channelIndex.readEvent(nostrEvent) as PrivateChannel; + if (channel) { + channel.invitation = invite; + const exisitng = this.wallet.documentsIndex!.documents.find(x => x.nostrEvent.id === nostrEvent.id) as PrivateChannel; + if (exisitng) { + channel.dkxPost.documents = exisitng.dkxPost.documents; + channel.dkxRsvp.documents = exisitng.dkxRsvp.documents; + channel.dkxInvite = exisitng.dkxInvite; + const index = this.wallet.documentsIndex!.documents.findIndex(x => x.nostrEvent.id === nostrEvent.id); + this.wallet.documentsIndex!.documents[index] = channel; + } else { + this.wallet.documentsIndex!.documents.push(channel); + } + this.loadChannel(channel); + return channel; + } else { + this.snackBar.open(`Unable to read contents the channel pointer keyset.`, 'Hide', defaultSnackBarOpts); + } + } else { + this.snackBar.open(`Unable to locate the channel pointer keyset.`, 'Hide', defaultSnackBarOpts); + } + return undefined; + } + + async readChannelPointer(channelPointer: string, secret?: string): Promise { + try { + const words = bech32.decode(channelPointer, 5000).words; + const pointerType = Uint8Array.from(bech32.fromWords(words))[0] as nip19Extension.PointerType; + let pointer: nip19Extension.PrivateChannelPointer; + + if ((pointerType & nip19Extension.PointerType.SharedSecret) == nip19Extension.PointerType.SharedSecret) { + if (this.extensionProvider) { + pointer = await this.extensionProvider.readInvitation(channelPointer); + } else { + secret = await this.passwordDialog('Preview Private Invitation'); + const p = await nip19Extension.decode(channelPointer, secret!); + pointer = p.data as nip19Extension.PrivateChannelPointer; + } + } else { + const p = await nip19Extension.decode(channelPointer, secret!); + pointer = p.data as nip19Extension.PrivateChannelPointer; + } + + if (pointer) { + if ((pointer.type & nip19Extension.PointerType.FullKeySet) === nip19Extension.PointerType.FullKeySet) { + const signingParent = new HDKey({ publicKey: pointer.signingKey, chainCode: pointer.signingChain, version: Versions.nip76API1 }); + const cryptoParent = new HDKey({ publicKey: pointer.cryptoKey, chainCode: pointer.cryptoChain, version: Versions.nip76API1 }); + const invite = new Invitation(); + pointer.docIndex = -1; + invite.pointer = pointer; + invite.content = { + kind: NostrKinds.PrivateChannelInvitation, + pubkey: signingParent.nostrPubKey, + docIndex: pointer.docIndex, + signingParent, + encryptParent: cryptoParent + }; + invite.ready = true; + return this.readInvitation(invite); + } else { + const inviteIndex = HDKIndex.fromChannelPointer(pointer); + return this.readChannelIndex(inviteIndex, pointer); + } + } else { + this.snackBar.open(`Unable to decode channel pointer string.`, 'Hide', defaultSnackBarOpts); + } + } catch (error) { + this.snackBar.open(`${error}`, 'Hide', defaultSnackBarOpts); + } + return undefined; + } + + async readChannelIndex(inviteIndex: HDKIndex, pointer: nip19Extension.PrivateChannelPointer): Promise { + const inviteIndex$ = new Subject(); + const inviteIndexSub = this.relayService.subscribe( + [{ authors: [inviteIndex.signingParent.nostrPubKey], kinds: [17761], limit: 1 }], + `nip76Service.readChannelIndex.${inviteIndex.signingParent.nostrPubKey}`, 'Replaceable', inviteIndex$ + ); + const nostrEvent = await firstValueFrom(inviteIndex$); + this.relayService.unsubscribe(inviteIndexSub.id); + if (nostrEvent) { + const invite = await inviteIndex.readEvent(nostrEvent) as Invitation; + if (invite) { + invite.pointer = pointer; + return await this.readInvitation(invite); + } else { + this.snackBar.open(`Unable to read contents the channel pointer record.`, 'Hide', defaultSnackBarOpts); + } + } else { + this.snackBar.open(`Unable to locate channel pointer record.`, 'Hide', defaultSnackBarOpts); + } + return undefined; + } + + async saveChannel(channel: PrivateChannel, privateKey?: string) { + channel.dkxParent = this.wallet.documentsIndex!; + channel.content.created_at = channel.content.created_at || channel?.nostrEvent.created_at || getNowSeconds(); + let event: NostrEventDocument; + if (this.extensionProvider) { + event = await this.extensionProvider.createEvent(channel); + } else { + privateKey = privateKey || await this.passwordDialog('Save Channel Details'); + event = await this.wallet.documentsIndex!.createEvent(channel, privateKey); + } + await this.dataService.publishEvent(event); + return true; + } + + async saveNote(channel: PrivateChannel, text: string) { + const postDocument = new PostDocument(); + postDocument.dkxParent = channel.dkxPost; + postDocument.content = { + text, + pubkey: this.wallet.ownerPubKey, + kind: nostrTools.Kind.Text + } + let event: NostrEventDocument; + if (this.extensionProvider) { + event = await this.extensionProvider.createEvent(postDocument); + } else { + const privateKey = await this.passwordDialog('Save Note'); + event = await channel.dkxPost.createEvent(postDocument, privateKey); + } + await this.dataService.publishEvent(event); + return true; + } + + async saveReaction(post: PostDocument, text: string, kind: nostrTools.Kind): Promise { + const postDocument = new PostDocument(); + postDocument.dkxParent = post.dkxParent; + postDocument.content = { + kind, + pubkey: this.wallet.ownerPubKey, + text, + tags: [['e', post.nostrEvent.id]] + }; + let event: NostrEventDocument; + if (this.extensionProvider) { + event = await this.extensionProvider.createEvent(postDocument); + } else { + const privateKey = await this.passwordDialog('Save ' + (kind === nostrTools.Kind.Reaction ? 'Reaction' : 'Reply')); + event = await post.dkxParent.createEvent(postDocument, privateKey); + } + await this.dataService.publishEvent(event); + return postDocument; + } + + async saveInvitation(channel: PrivateChannel, invitation: AddInvitationDialogData): Promise { + const invite = new Invitation(); + invite.dkxParent = channel.dkxInvite; + invite.docIndex = channel.dkxInvite.documents.length + 1; + invite.content = { + kind: NostrKinds.PrivateChannelInvitation, + docIndex: invite.docIndex, + for: invitation.invitationType === 'pubkey' ? invitation.validPubkey : undefined, + password: invitation.invitationType === 'password' ? invitation.password : undefined, + pubkey: channel.dkxPost.signingParent.nostrPubKey, + signingParent: channel.dkxPost.signingParent, + encryptParent: channel.dkxPost.encryptParent, + }; + let event: NostrEventDocument; + if (this.extensionProvider) { + event = await this.extensionProvider.createEvent(invite); + } else { + const privateKey = await this.passwordDialog('Save Invitation'); + event = await channel.dkxInvite.createEvent(invite, privateKey); + } + await this.dataService.publishEvent(event); + return invite; + } + + async resaveInvitation(channel: PrivateChannel, invite: Invitation, withKeys: boolean): Promise { + invite.dkxParent = channel.dkxInvite; + invite.content.signingParent = withKeys ? channel.dkxPost.signingParent : undefined; + invite.content.encryptParent = withKeys ? channel.dkxPost.encryptParent : undefined; + let event: NostrEventDocument; + if (this.extensionProvider) { + event = await this.extensionProvider.createEvent(invite); + } else { + const privateKey = await this.passwordDialog((withKeys ? 'Reinstate' : 'Suspend') + ' Invitation'); + event = await channel.dkxInvite.createEvent(invite, privateKey); + } + await this.dataService.publishEvent(event); + return invite; + } + + async saveRSVP(channel: PrivateChannel) { + const rsvp = new Rsvp(); + rsvp.dkxParent = channel.dkxRsvp; + rsvp.content = { + kind: NostrKinds.PrivateChannelRSVP, + pubkey: this.wallet.ownerPubKey, + pointerDocIndex: channel.invitation.pointer.docIndex, + type: channel.invitation.pointer.type, + }; + let event1: NostrEventDocument; + let privateKey: string; + if (this.extensionProvider) { + event1 = await this.extensionProvider.createEvent(rsvp); + } else { + privateKey = await this.passwordDialog('Save RSVP'); + event1 = await channel.dkxRsvp.createEvent(rsvp, privateKey); + } + await this.dataService.publishEvent(event1); + + rsvp.dkxParent = this.wallet.documentsIndex!; + rsvp.docIndex = channel.invitation.docIndex || (this.wallet.rsvps.length + 1 + walletRsvpDocumentsOffset); + rsvp.content.signingKey = channel.invitation.pointer.signingKey; + rsvp.content.cryptoKey = channel.invitation.pointer.cryptoKey; + let event2: NostrEventDocument; + if (this.extensionProvider) { + event2 = await this.extensionProvider.createEvent(rsvp); + } else { + event2 = await this.wallet.documentsIndex!.createEvent(rsvp, privateKey!); + } + await this.dataService.publishEvent(event2); + + return true; + } + + async deleteDocument(doc: ContentDocument, privateKey?: string) { + let event: NostrEventDocument; + if (this.extensionProvider) { + event = await this.extensionProvider.createDeleteEvent(doc); + } else { + privateKey = privateKey || await this.passwordDialog('Delete Document'); + event = await doc.dkxParent.createDeleteEvent(doc, privateKey); + } + if (doc.nostrEvent.pubkey !== event.pubkey) { + this.snackBar.open(`Cannot delete another user's document.`, 'Hide', defaultSnackBarOpts); + return false; + } + await this.dataService.publishEvent(event); + return true; + } +} + + diff --git a/src/app/services/interfaces.ts b/src/app/services/interfaces.ts index 74458b62..233f84ba 100644 --- a/src/app/services/interfaces.ts +++ b/src/app/services/interfaces.ts @@ -1,4 +1,5 @@ import { Event, Filter, Relay, Sub } from 'nostr-tools'; +import { SubjectLike } from 'rxjs'; export interface Circle { id?: number; @@ -82,6 +83,7 @@ export interface NostrRelaySubscription { events: Event[]; // events: Map; // events$: any; + observable?: SubjectLike | undefined; type: string | 'Profile' | 'Event' | 'Contacts' | 'Article' | 'BadgeDefinition'; } diff --git a/src/app/services/relay.ts b/src/app/services/relay.ts index 6473cb76..8ab7e791 100644 --- a/src/app/services/relay.ts +++ b/src/app/services/relay.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { LoadMoreOptions, NostrRelay, NostrRelayDocument, NostrRelaySubscription, QueryJob } from './interfaces'; -import { Observable, BehaviorSubject } from 'rxjs'; +import { Observable, BehaviorSubject, SubjectLike } from 'rxjs'; import { Event, Filter, Kind } from 'nostr-tools'; import { EventService } from './event'; import { OptionsService } from './options'; @@ -435,6 +435,9 @@ export class RelayService { if (index === -1) { sub.events.push(event); + if(sub.observable) { + sub.observable.next(event); + } } } else if (sub.type == 'Profile') { const index = sub.events.findIndex((e) => e.pubkey == event.pubkey); @@ -462,10 +465,22 @@ export class RelayService { if (index > -1) { if (event.created_at > sub.events[index].created_at) { sub.events[index] = event; + if(sub.observable) { + sub.observable.next(event); + if((event.kind as number) === 17761) { + return; + } + } await this.badgeService.putDefinition(event); } } else { sub.events.push(event); + if(sub.observable) { + sub.observable.next(event); + if((event.kind as number) === 17761) { + return; + } + } await this.badgeService.putDefinition(event); } @@ -841,12 +856,12 @@ export class RelayService { return id; } - subscribe(filters: Filter[], id?: string, type: string = 'Event') { + subscribe(filters: Filter[], id?: string, type: string = 'Event', observable: SubjectLike | undefined = undefined) { if (!id) { id = uuidv4(); } - const sub = { id: id, filters: filters, events: [], type: type }; + const sub = { id: id, filters: filters, events: [], type: type, observable }; // this.action('subscribe', { filters, id }); this.subs.set(id, sub); @@ -908,7 +923,10 @@ export class RelayService { const worker = this.workers[index]; worker.unsubscribe(id); } - + const sub = this.subs.get(id); + if(sub && sub.observable) { + sub.observable.complete(); + } this.subs.delete(id); }