Add ability to publish articles

- Ensures that the relay supports NIP-33.
This commit is contained in:
SondreB 2023-02-20 22:48:41 +01:00
parent 0d969586c0
commit e16e29b28b
No known key found for this signature in database
GPG Key ID: D6CC44C75005FDBF
9 changed files with 318 additions and 72 deletions

View File

@ -164,6 +164,14 @@ const routes: Routes = [
data: LoadingResolverService,
},
},
{
path: 'a/:id',
component: NoteComponent,
canActivate: [AuthGuard],
resolve: {
data: LoadingResolverService,
},
},
{
path: 'followers/:id',
component: FollowersComponent,

View File

@ -121,6 +121,7 @@ import { PasswordDialog } from './shared/password-dialog/password-dialog';
import { UsernamePipe } from './shared/username';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { EditorComponent } from './editor/editor';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
@NgModule({
declarations: [
@ -195,7 +196,7 @@ import { EditorComponent } from './editor/editor';
ConnectKeyComponent,
PasswordDialog,
UsernamePipe,
EditorComponent
EditorComponent,
],
imports: [
AboutModule,
@ -239,6 +240,7 @@ import { EditorComponent } from './editor/editor';
MatProgressBarModule,
MatDialogModule,
MatDatepickerModule,
MatButtonToggleModule,
ScrollingModule,
PhotoGalleryModule,
NgxMatDatetimePickerModule,

View File

@ -20,19 +20,6 @@ h1 {
color: #9c27b0;
}
.maximize-button {
cursor: pointer;
margin-right: auto;
color: #d87fe7;
}
.maximize-button:hover {
color: #9c27b0;
}
.note-input {
}
.margin-right {
margin-right: 5px;
}
@ -42,3 +29,8 @@ h1 {
position: fixed;
z-index: 3;
}
.note-type {
text-align: right;
margin-bottom: 1em;
}

View File

@ -1,17 +1,67 @@
<div class="page">
<!-- <mat-card class="home-card">
<mat-card-header>
<mat-card-title>Create Note</mat-card-title>
</mat-card-header>
<mat-card-content> -->
<h1>Write your thoughts</h1>
<form [formGroup]="formGroup">
<div mat-dialog-content class="mat-dialog-content">
<!-- <div class="toolbar">
<mat-icon class="toolbar-icon margin-right" (click)="isEmojiPickerVisible = !isEmojiPickerVisible;" matTooltip="Insert emoji">sentiment_satisfied</mat-icon>
</div> -->
<div class="note-type">
<mat-button-toggle-group name="fontStyle" [(ngModel)]="eventType" aria-label="Font Style" #group="matButtonToggleGroup">
<mat-button-toggle value="text">Text</mat-button-toggle>
<mat-button-toggle value="blog">Blog</mat-button-toggle>
</mat-button-toggle-group>
</div>
<form [formGroup]="blogForm" *ngIf="group.value == 'blog'" (ngSubmit)="onSubmitBlog()">
<div mat-dialog-content class="mat-dialog-content">
<mat-form-field appearance="outline" class="input-full-width">
<mat-label>Title</mat-label>
<input matInput #message formControlName="title" placeholder="Ex. My favorite food..." />
</mat-form-field>
<mat-form-field appearance="outline" class="input-full-width">
<mat-label>URL (slug)</mat-label>
<input matInput #message placeholder="Can only contain - and lower case text" formControlName="slug" (blur)="formatSlug()" />
</mat-form-field>
<mat-form-field appearance="outline" class="input-full-width">
<mat-label>Long form text that supports markdown formatting</mat-label>
<textarea class="note-input" matInput type="text" [(ngModel)]="note" formControlName="content" rows="7"></textarea>
</mat-form-field>
<mat-form-field appearance="outline" class="input-full-width">
<mat-label>Summary (optional)</mat-label>
<textarea class="note-input" matInput type="text" [(ngModel)]="summary" formControlName="summary" rows="2"></textarea>
</mat-form-field>
<mat-form-field appearance="outline" class="input-full-width">
<mat-label>Banner image (optional)</mat-label>
<input matInput #message placeholder="Blog post banner image" formControlName="image" />
</mat-form-field>
<mat-form-field appearance="outline" class="input-full-width">
<mat-label>Tags (optional, comma separated)</mat-label>
<input matInput #message placeholder="Tech, News, Social" formControlName="tags" />
</mat-form-field>
<emoji-mart class="picker" *ngIf="isEmojiPickerVisible" emoji="point_up" [isNative]="true" [showPreview]="false" (emojiSelect)="addEmoji($event)" title="Choose your emoji"></emoji-mart>
<mat-icon class="toolbar-icon margin-right" (click)="isEmojiPickerVisible = !isEmojiPickerVisible;" matTooltip="Insert emoji">sentiment_satisfied</mat-icon>
<!-- <mat-icon class="toolbar-icon margin-right" matTooltip="Upload file">attach_file</mat-icon> -->
<!-- <mat-form-field>
<input matInput [ngxMatDatetimePicker]="picker" placeholder="Choose a timeout" [formControl]="dateControl" [min]="minDate" />
<mat-datepicker-toggle matSuffix [for]="$any(picker)"></mat-datepicker-toggle>
<ngx-mat-datetime-picker #picker [showSpinners]="true" [showSeconds]="false" [stepHour]="1" [stepMinute]="1" [stepSecond]="1" [touchUi]="false" [enableMeridian]="false" [disableMinute]="false" [hideTime]="false">
<ng-template>
<span>Set timeout</span>
</ng-template>
</ngx-mat-datetime-picker>
</mat-form-field> -->
</div>
<div mat-dialog-actions class="mat-dialog-actions" align="end">
<button mat-stroked-button type="button" (click)="onCancel()">Cancel</button>&nbsp; <button type="button" mat-stroked-button disabled="disabled">Save Draft</button>&nbsp;
<button mat-flat-button [disabled]="!blogForm.valid" type="submit" color="primary">Publish</button>
</div>
</form>
<form [formGroup]="noteForm" *ngIf="group.value == 'text'" (ngSubmit)="onSubmitNote()">
<div mat-dialog-content class="mat-dialog-content">
<mat-form-field appearance="outline" class="input-full-width">
<mat-label>What's on your mind?</mat-label>
<textarea class="note-input" matInput type="text" [(ngModel)]="note" formControlName="note" rows="7"></textarea>
@ -32,8 +82,8 @@
</mat-form-field> -->
</div>
<div mat-dialog-actions class="mat-dialog-actions" align="end">
<button mat-stroked-button (click)="onCancel()">Cancel</button>&nbsp; <button mat-stroked-button disabled="disabled">Save Draft</button>&nbsp;
<button mat-flat-button [disabled]="note == ''" (click)="postNote()" color="primary" cdkFocusInitial>Publish</button>
<button mat-stroked-button type="button" (click)="onCancel()">Cancel</button>&nbsp; <button mat-stroked-button type="button" disabled="disabled">Save Draft</button>&nbsp;
<button mat-flat-button [disabled]="!noteForm.valid" type="submit" color="primary">Publish</button>
</div>
</form>
<!-- </mat-card-content>

View File

@ -1,8 +1,12 @@
import { Component, ViewChild } from '@angular/core';
import { FormControl, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { FormBuilder, FormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { NavigationService } from '../services/navigation';
import { Location } from '@angular/common';
import { ApplicationState } from '../services/applicationstate';
import { BlogEvent } from '../services/interfaces';
import { Event } from 'nostr-tools';
import { Subscription } from 'rxjs';
import { Utilities } from '../services/utilities';
export interface NoteDialogData {
note: string;
@ -18,15 +22,38 @@ export class EditorComponent {
isEmojiPickerVisible: boolean | undefined;
formGroup!: UntypedFormGroup;
noteForm = this.fb.group({
note: ['', Validators.required],
expiration: [''],
dateControl: [],
});
blogForm = this.fb.group({
content: ['', Validators.required],
title: ['', Validators.required],
summary: [''],
image: [''],
slug: [''],
tags: [''],
});
note: string = '';
blog?: BlogEvent = { title: '', content: '', tags: '' };
title = '';
summary = '';
minDate?: number;
eventType: string = 'text';
public dateControl = new FormControl(null);
constructor(private appState: ApplicationState, private location: Location, private formBuilder: UntypedFormBuilder, public navigation: NavigationService) {}
subscriptions: Subscription[] = [];
constructor(private utilities: Utilities, private appState: ApplicationState, private location: Location, private fb: FormBuilder, public navigation: NavigationService) {}
ngOnInit() {
this.appState.updateTitle(`Write a note`);
@ -36,11 +63,28 @@ export class EditorComponent {
this.minDate = Date.now();
this.formGroup = this.formBuilder.group({
note: ['', Validators.required],
expiration: [''],
dateControl: [],
});
this.subscriptions.push(
this.blogForm.controls.title.valueChanges.subscribe((text) => {
if (text) {
this.blogForm.controls.slug.setValue(this.createSlug(text));
}
})
);
}
ngOnDestroy() {
this.utilities.unsubscribe(this.subscriptions);
}
createSlug(input: string) {
// convert input to lowercase
input = input.toLowerCase();
// replace spaces and punctuation with hyphens
input = input.replace(/[\s\W]+/g, '-');
// remove duplicate or trailing hyphens
input = input.replace(/^-+|-+$/g, '');
// return the slug
return input;
}
public addEmoji(event: { emoji: { native: any } }) {
@ -49,6 +93,49 @@ export class EditorComponent {
this.isEmojiPickerVisible = false;
}
postBlog() {
// this.formGroupBlog.controls.
// this.profileForm.value
console.log('BLOG:', this.blog);
// this.navigation.saveNote(this.note);
}
formatSlug() {
this.blogForm.controls.slug.setValue(this.createSlug(this.blogForm.controls.slug.value!));
}
onSubmitBlog() {
const controls = this.blogForm.controls;
const blog: BlogEvent = {
content: controls.content.value!,
title: controls.title.value!,
summary: controls.summary.value!,
image: controls.image.value!,
slug: controls.slug.value!,
tags: controls.tags.value!,
};
this.navigation.saveBlog(blog);
// const entry: Event = {
// kind: 30023,
// id: '',
// sig: '',
// content: '',
// tags: [],
// created_at:
// };
console.log('SUBMIT BLOG!!');
}
onSubmitNote() {
console.log('SUBMIT NOTE!');
}
postNote() {
this.navigation.saveNote(this.note);
}

View File

@ -3,7 +3,7 @@ import { NostrEvent, NostrEventDocument, NostrProfileDocument, NostrRelay, Nostr
import { ProfileService } from './profile';
import { EventService } from './event';
import { RelayService } from './relay';
import { Filter, Relay, Event, getEventHash, validateEvent, verifySignature, Kind } from 'nostr-tools';
import { Filter, Relay, Event, getEventHash, validateEvent, verifySignature, Kind, UnsignedEvent } from 'nostr-tools';
import { DataValidation } from './data-validation';
import { ApplicationState } from './applicationstate';
import { timeout, map, merge, Observable, delay, Observer, race, take, switchMap, mergeMap, tap, finalize, concatMap, mergeAll, exhaustMap, catchError, of, combineAll, combineLatestAll, filter, from } from 'rxjs';
@ -64,9 +64,7 @@ export class DataService {
async publishRelays() {
const mappedRelays = this.getArrayFomattedRelayList();
let originalEvent: Event = {
id: '',
sig: '',
let originalEvent: UnsignedEvent = {
kind: 10002, // NIP-65: https://github.com/nostr-protocol/nips/blob/master/65.md
created_at: Math.floor(Date.now() / 1000),
content: '',
@ -115,24 +113,29 @@ export class DataService {
return mappedRelays;
}
private async createAndSignEvent(originalEvent: Event) {
originalEvent.id = getEventHash(originalEvent);
private async createAndSignEvent(originalEvent: UnsignedEvent) {
let signedEvent = originalEvent as Event;
signedEvent.id = getEventHash(originalEvent);
// Use nostr directly on global, similar to how most Nostr app will interact with the provider.
const signedEvent = await this.nostr.sign(originalEvent);
originalEvent = signedEvent;
signedEvent = await this.nostr.sign(originalEvent);
// 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);
const event = this.eventService.processEvent(signedEvent as NostrEventDocument);
let ok = validateEvent(originalEvent);
if (!event) {
throw new Error('The event is not valid. Cannot publish.');
}
let ok = validateEvent(signedEvent);
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.
let veryOk = await verifySignature(event 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?');
@ -182,9 +185,7 @@ export class DataService {
const mappedRelays = this.getJsonFormattedRelayList();
let originalEvent: Event = {
id: '',
sig: '',
let originalEvent: UnsignedEvent = {
kind: Kind.Contacts,
created_at: Math.floor(Date.now() / 1000),
content: JSON.stringify(mappedRelays),
@ -724,10 +725,8 @@ export class DataService {
}
/** Creates an event ready for modification, signing and publish. */
createEvent(kind: Kind | number, content: any): Event {
let event: Event = {
id: '',
sig: '',
createEvent(kind: Kind | number, content: any): UnsignedEvent {
let event: UnsignedEvent = {
kind: kind,
created_at: Math.floor(Date.now() / 1000),
content: content,
@ -738,19 +737,21 @@ export class DataService {
return event;
}
/** Request an event to be signed. This method will calculate the content id automatically. */
async signEvent(event: Event) {
if (!event.id) {
event.id = getEventHash(event);
}
/** Request an article to be signed. This method does not add id. */
async signArticle(event: UnsignedEvent) {
let signedEvent = event as Event;
// Use nostr directly on global, similar to how most Nostr app will interact with the provider.
const signedEvent = await this.nostr.sign(event);
signedEvent = await this.nostr.sign(event);
// 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 verifiedEvent = this.eventService.processEvent(signedEvent as NostrEventDocument);
if (!verifiedEvent) {
throw new Error('The event is not valid. Cannot publish.');
}
let ok = validateEvent(signedEvent);
if (!ok) {
@ -766,6 +767,38 @@ export class DataService {
return signedEvent;
}
/** Request an event to be signed. This method will calculate the content id automatically. */
async signEvent(event: UnsignedEvent) {
let signedEvent = event as Event;
if (!signedEvent.id) {
signedEvent.id = getEventHash(event);
}
return this.signArticle(signedEvent);
// // Use nostr directly on global, similar to how most Nostr app will interact with the provider.
// signedEvent = await this.nostr.sign(event);
// // 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 verifiedEvent = this.eventService.processEvent(signedEvent as NostrEventDocument);
// let ok = validateEvent(signedEvent);
// if (!ok) {
// throw new Error('The event is not valid. Cannot publish.');
// }
// let veryOk = await verifySignature(signedEvent 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?');
// }
// return signedEvent;
}
async publishEvent(event: Event) {
this.relayService.publish(event);
}
@ -775,9 +808,7 @@ export class DataService {
return ['p', c];
});
let originalEvent: Event = {
id: '',
sig: '',
let originalEvent: UnsignedEvent = {
kind: 3,
created_at: Math.floor(Date.now() / 1000),
content: '',
@ -785,23 +816,23 @@ export class DataService {
tags: mappedContacts,
};
originalEvent.id = getEventHash(originalEvent);
let signedEvent = originalEvent as Event;
signedEvent.id = getEventHash(originalEvent);
// Use nostr directly on global, similar to how most Nostr app will interact with the provider.
const signedEvent = await this.nostr.sign(originalEvent);
originalEvent = signedEvent;
signedEvent = await this.nostr.sign(originalEvent);
// 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);
const event = this.eventService.processEvent(signedEvent as NostrEventDocument);
let ok = validateEvent(originalEvent);
let ok = validateEvent(signedEvent);
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.
let veryOk = await verifySignature(signedEvent 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?');
@ -811,7 +842,7 @@ export class DataService {
return;
}
console.log('PUBLISH EVENT:', originalEvent);
console.log('PUBLISH EVENT:', signedEvent);
// First we persist our own event like would normally happen if we receive this event.
// await this.#persist(event);

View File

@ -61,9 +61,9 @@ export interface NostrDocument<T> {
}
export interface NostrRelay extends Relay {
// nip11: any;
nip11: any;
// error: string;
metadata: NostrRelayDocument;
// metadata: NostrRelayDocument;
// subscriptions: Sub[];
}
@ -289,3 +289,19 @@ export interface NotificationModel {
kind: number;
}
export interface BlogEvent {
title: string;
content: string;
summary?: string;
image?: string;
slug?: string;
tags: string;
published_at?: number;
}

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Circle, NostrEvent, NostrEventDocument, NostrProfileDocument, NostrThreadEventDocument } from './interfaces';
import { BlogEvent, Circle, NostrEvent, NostrEventDocument, NostrProfileDocument, NostrThreadEventDocument } from './interfaces';
import { tap, delay, timer, takeUntil, timeout, Observable, of, BehaviorSubject, map, combineLatest, single, Subject, Observer, concat, concatMap, switchMap, catchError, race } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { NoteDialog } from '../shared/create-note-dialog/create-note-dialog';
@ -102,6 +102,51 @@ export class NavigationService {
this.router.navigate(['/e', signedEvent.id]);
}
/** Saves a new note and navigates to it. */
async saveBlog(blog: BlogEvent) {
console.log('save blog data:', blog);
let note = blog.content;
if (typeof note !== 'string') {
note = JSON.stringify(note);
}
let event = this.dataService.createEvent(Kind.Article, note);
if (blog.slug) {
event.tags.push(['d', blog.slug]);
}
if (blog.summary) {
event.tags.push(['summary', blog.summary]);
}
if (blog.title) {
event.tags.push(['title', blog.title]);
}
if (blog.image) {
event.tags.push(['image', blog.image]);
}
event.tags.push(['published_at', event.created_at.toString()]);
const tags = blog.tags.split(',').filter((t) => t);
for (let index = 0; index < tags.length; index++) {
const tag = tags[index];
event.tags.push(['t', tag]);
}
// TODO: We should likely save this event locally to ensure user don't loose their posts
// if all of the network is down.
const signedEvent = await this.dataService.signArticle(event);
await this.dataService.publishEvent(signedEvent);
this.router.navigate(['/a', signedEvent.id]);
}
createNote(): void {
this.router.navigateByUrl('/editor');

View File

@ -1,4 +1,4 @@
import { Event, Filter, relayInit } from 'nostr-tools';
import { Event, Filter, Kind, relayInit } from 'nostr-tools';
import { NostrRelay, NostrRelaySubscription, NostrSub, QueryJob } from '../services/interfaces';
import { RelayResponse } from '../services/messages';
import { Queue } from '../services/queue';
@ -18,6 +18,19 @@ export class RelayWorker {
}
async publish(event: Event) {
if (event.kind == Kind.Article) {
// If we don't have metadata from the relay, don't publish articles.
if (!this.relay.nip11) {
console.log(`${this.relay.url}: This relay does not return NIP-11 metadata. Article will not be published here.`);
return;
} else if (!this.relay.nip11.supported_nips.includes(33)) {
console.log(`${this.relay.url}: This relay does not NIP-23. Article will not be published here.`);
return;
} else {
console.log(`${this.relay.url}: This relay supports NIP-23. Publishing article on this relay.`);
}
}
let pub = this.relay.publish(event);
pub.on('ok', () => {
console.log(`${this.relay.url} has accepted our event`);
@ -493,6 +506,8 @@ export class RelayWorker {
});
if (rawResponse.status === 200) {
const content = await rawResponse.json();
// Keep a local reference to the NIP11 info on the relay instance.
this.relay.nip11 = content;
postMessage({ type: 'nip11', data: content, url: this.url } as RelayResponse);
} else {
postMessage({ type: 'nip11', data: { error: `Unable to get NIP-11 data. Status: ${rawResponse.statusText}` }, url: this.url } as RelayResponse);