Add support for editing articles

This commit is contained in:
SondreB 2023-02-22 17:03:13 +01:00
parent 0afa9e973f
commit e37ee0188f
No known key found for this signature in database
GPG Key ID: D6CC44C75005FDBF
10 changed files with 316 additions and 24 deletions

View File

@ -2,12 +2,20 @@
<h1>Write your thoughts</h1> <h1>Write your thoughts</h1>
<div class="note-type"> <div class="note-type">
<mat-button-toggle-group name="fontStyle" [(ngModel)]="eventType" aria-label="Font Style" #group="matButtonToggleGroup"> <mat-button-toggle-group name="fontStyle" (change)="noteTypeChanged()" [(ngModel)]="eventType" aria-label="Font Style" #group="matButtonToggleGroup">
<mat-button-toggle value="text">Note</mat-button-toggle> <mat-button-toggle value="text">Note</mat-button-toggle>
<!-- <mat-button-toggle value="article">Article</mat-button-toggle> --> <mat-button-toggle value="article">Article</mat-button-toggle>
</mat-button-toggle-group> </mat-button-toggle-group>
</div> </div>
<mat-form-field appearance="fill" *ngIf="group.value == 'article'">
<mat-label>Existing articles</mat-label>
<mat-select [(value)]="selectedArticle" (selectionChange)="changedArticle()">
<mat-option></mat-option>
<mat-option [value]="article.slug" *ngFor="let article of articleService.articles">{{ article.title }}</mat-option>
</mat-select>
</mat-form-field>
<form [formGroup]="articleForm" *ngIf="group.value == 'article'" (ngSubmit)="onSubmitArticle()"> <form [formGroup]="articleForm" *ngIf="group.value == 'article'" (ngSubmit)="onSubmitArticle()">
<div mat-dialog-content class="mat-dialog-content"> <div mat-dialog-content class="mat-dialog-content">
<mat-form-field appearance="outline" class="input-full-width"> <mat-form-field appearance="outline" class="input-full-width">
@ -40,6 +48,8 @@
<input matInput #message placeholder="Tech, News, Social" formControlName="tags" /> <input matInput #message placeholder="Tech, News, Social" formControlName="tags" />
</mat-form-field> </mat-form-field>
<input type="hidden" formControlName="published_at" />
<emoji-mart class="picker" *ngIf="isEmojiPickerVisible" emoji="point_up" [isNative]="true" [showPreview]="false" (emojiSelect)="addEmojiArticle($event)" title="Choose your emoji"></emoji-mart> <emoji-mart class="picker" *ngIf="isEmojiPickerVisible" emoji="point_up" [isNative]="true" [showPreview]="false" (emojiSelect)="addEmojiArticle($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" (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-icon class="toolbar-icon margin-right" matTooltip="Upload file">attach_file</mat-icon> -->
@ -55,7 +65,8 @@
</mat-form-field> --> </mat-form-field> -->
</div> </div>
<div mat-dialog-actions class="mat-dialog-actions" align="end"> <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-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]="!articleForm.valid" type="submit" color="primary">Publish Article</button> <button mat-flat-button [disabled]="!articleForm.valid" type="submit" color="primary">Publish Article</button>
</div> </div>
</form> </form>
@ -82,7 +93,8 @@
</mat-form-field> --> </mat-form-field> -->
</div> </div>
<div mat-dialog-actions class="mat-dialog-actions" align="end"> <div mat-dialog-actions class="mat-dialog-actions" align="end">
<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-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 Note</button> <button mat-flat-button [disabled]="!noteForm.valid" type="submit" color="primary">Publish Note</button>
</div> </div>
</form> </form>

View File

@ -7,6 +7,9 @@ import { BlogEvent } from '../services/interfaces';
import { Event } from 'nostr-tools'; import { Event } from 'nostr-tools';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { Utilities } from '../services/utilities'; import { Utilities } from '../services/utilities';
import { QueueService } from '../services/queue.service';
import { ArticleService } from '../services/article';
import { MatSnackBar } from '@angular/material/snack-bar';
export interface NoteDialogData { export interface NoteDialogData {
note: string; note: string;
@ -37,6 +40,7 @@ export class EditorComponent {
image: [''], image: [''],
slug: [''], slug: [''],
tags: [''], tags: [''],
published_at: [''],
}); });
note: string = ''; note: string = '';
@ -55,7 +59,16 @@ export class EditorComponent {
subscriptions: Subscription[] = []; subscriptions: Subscription[] = [];
constructor(private utilities: Utilities, private appState: ApplicationState, private location: Location, private fb: FormBuilder, public navigation: NavigationService) {} constructor(
private snackBar: MatSnackBar,
public articleService: ArticleService,
private queueService: QueueService,
private utilities: Utilities,
private appState: ApplicationState,
private location: Location,
private fb: FormBuilder,
public navigation: NavigationService
) {}
ngOnInit() { ngOnInit() {
this.appState.updateTitle(`Write a note`); this.appState.updateTitle(`Write a note`);
@ -74,10 +87,51 @@ export class EditorComponent {
); );
} }
selectedArticle: string = '';
changedArticle() {
const article = this.articleService.get(this.selectedArticle!);
if (!article) {
this.articleForm.reset();
return;
}
if (article.summary == null) {
article.summary = '';
}
if (article.image == null) {
article.image = '';
}
if (article.title == null) {
article.title = '';
}
this.articleForm.setValue({
content: article.content,
title: article.title,
summary: article.summary,
image: article.image,
slug: article.slug ? article.slug : '',
tags: article.metatags ? article.metatags.toString() : '',
published_at: article.published_at ? article.published_at.toString() : '',
});
}
ngOnDestroy() { ngOnDestroy() {
this.utilities.unsubscribe(this.subscriptions); this.utilities.unsubscribe(this.subscriptions);
} }
noteTypeChanged() {
// Load all articles for the user when toggling.
if (this.eventType == 'article') {
this.queueService.enque(this.appState.getPublicKey(), 'Article');
}
}
createSlug(input: string) { createSlug(input: string) {
// convert input to lowercase // convert input to lowercase
input = input.toLowerCase(); input = input.toLowerCase();
@ -111,20 +165,11 @@ export class EditorComponent {
(<any>this.articleContent).nativeElement.focus(); (<any>this.articleContent).nativeElement.focus();
} }
postBlog() {
// this.formGroupBlog.controls.
// this.profileForm.value
console.log('BLOG:', this.blog);
// this.navigation.saveNote(this.note);
}
formatSlug() { formatSlug() {
this.articleForm.controls.slug.setValue(this.createSlug(this.articleForm.controls.slug.value!)); this.articleForm.controls.slug.setValue(this.createSlug(this.articleForm.controls.slug.value!));
} }
onSubmitArticle() { async onSubmitArticle() {
const controls = this.articleForm.controls; const controls = this.articleForm.controls;
const blog: BlogEvent = { const blog: BlogEvent = {
@ -136,7 +181,17 @@ export class EditorComponent {
tags: controls.tags.value!, tags: controls.tags.value!,
}; };
this.navigation.saveArticle(blog); if (controls.published_at.value) {
blog.published_at = Number(controls.published_at.value);
}
await this.navigation.saveArticle(blog);
this.snackBar.open(`Article was published. Notes does not support viewing articles yet.`, 'Hide', {
duration: 2000,
horizontalPosition: 'center',
verticalPosition: 'bottom',
});
} }
onSubmitNote() { onSubmitNote() {

View File

@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
import { ApplicationState } from './applicationstate';
import { EventService } from './event';
import { NostrArticle, NostrEvent } from './interfaces';
import { Utilities } from './utilities';
@Injectable({
providedIn: 'root',
})
export class ArticleService {
articles: NostrArticle[] = [];
constructor(private appState: ApplicationState, private utilities: Utilities, private eventService: EventService) {}
get(slug: string) {
return this.articles.find((a) => a.slug == slug);
}
put(event: NostrEvent) {
const article = event as NostrArticle;
article.slug = this.eventService.lastDTag(event);
article.title = this.eventService.lastTagOfType(event, 'title');
article.summary = this.eventService.lastTagOfType(event, 'summary');
article.image = this.eventService.lastTagOfType(event, 'image');
article.metatags = this.eventService.tagsOfTypeValues(event, 't');
article.metatags = this.eventService.tagsOfTypeValues(event, 't');
const publishedAt = this.eventService.lastTagOfType(event, 'published_at');
if (publishedAt) {
article.published_at = Number(publishedAt);
}
if (article.pubkey == this.appState.getPublicKey()) {
const index = this.articles.findIndex((a) => a.slug == article.slug);
if (index > -1) {
const existing = this.articles[index];
// If the existing is newer, ignore this article.
if (existing.created_at > article.created_at) {
return;
}
// Replace when newer.
this.articles[index] = article;
} else {
this.articles.push(article);
}
} else {
// TODO: We currently don't support lookup of others articles, when the time comes, update this.
debugger;
}
}
}

View File

@ -67,6 +67,54 @@ export class EventService {
return tags[tags.length - 1][1]; return tags[tags.length - 1][1];
} }
titleTag(event: NostrEventDocument | null) {
const tags = this.tagsOfType(event, 'title');
if (tags.length == 0) {
return undefined;
}
return tags[tags.length - 1][1];
}
lastDTag(event: NostrEventDocument | null) {
const tags = this.tagsOfType(event, 'd');
if (tags.length == 0) {
return undefined;
}
return tags[tags.length - 1][1];
}
lastTagOfType(event: NostrEventDocument | null, type: string) {
const tags = this.tagsOfType(event, type);
if (tags.length == 0) {
return undefined;
}
return tags[tags.length - 1][1];
}
tagsOfType(event: NostrEventDocument | null, type: string) {
if (!event) {
return [];
}
const tags = event.tags.filter((t) => t[0] === type);
return tags;
}
tagsOfTypeValues(event: NostrEventDocument | null, type: string) {
if (!event) {
return [];
}
const tags = event.tags.filter((t) => t[0] === type);
return tags.map((t) => t[1]);
}
eTags(event: NostrEventDocument | null) { eTags(event: NostrEventDocument | null) {
if (!event) { if (!event) {
return []; return [];

View File

@ -25,7 +25,7 @@ export interface Contact {
} }
export interface QueryJob { export interface QueryJob {
type: 'Profile' | 'Event' | 'Contacts'; type: 'Profile' | 'Event' | 'Contacts' | 'Article';
identifier: string; identifier: string;
// callback?: any; // callback?: any;
// limit?: number; // limit?: number;
@ -102,6 +102,15 @@ export interface NostrEvent extends Event {
tagsCut: boolean; tagsCut: boolean;
} }
export interface NostrArticle extends NostrEvent {
slug?: string;
title?: string;
summary?: string;
image?: string;
published_at: number;
metatags: string[];
}
export interface NostrSub extends Sub { export interface NostrSub extends Sub {
// id: string; // id: string;
} }

View File

@ -129,7 +129,11 @@ export class NavigationService {
event.tags.push(['image', blog.image]); event.tags.push(['image', blog.image]);
} }
event.tags.push(['published_at', event.created_at.toString()]); if (!blog.published_at) {
event.tags.push(['published_at', event.created_at.toString()]);
} else {
event.tags.push(['published_at', blog.published_at.toString()]);
}
const tags = blog.tags.split(',').filter((t) => t); const tags = blog.tags.split(',').filter((t) => t);
@ -144,7 +148,7 @@ export class NavigationService {
await this.dataService.publishEvent(signedEvent); await this.dataService.publishEvent(signedEvent);
this.router.navigate(['/a', signedEvent.id]); // this.router.navigate(['/a', signedEvent.id]);
} }
createNote(): void { createNote(): void {

View File

@ -21,17 +21,23 @@ export class QueueService {
this.#queuesChanged.next({ identifier: identifier, type: 'Event' }); this.#queuesChanged.next({ identifier: identifier, type: 'Event' });
} }
enqueArticle(identifier: string) {
this.#queuesChanged.next({ identifier: identifier, type: 'Article' });
}
enqueContacts(identifier: string) { enqueContacts(identifier: string) {
this.#queuesChanged.next({ identifier: identifier, type: 'Contacts' }); this.#queuesChanged.next({ identifier: identifier, type: 'Contacts' });
} }
enque(identifier: string, type: 'Profile' | 'Event' | 'Contacts') { enque(identifier: string, type: 'Profile' | 'Event' | 'Contacts' | 'Article') {
if (type === 'Profile') { if (type === 'Profile') {
this.enqueProfile(identifier); this.enqueProfile(identifier);
} else if (type === 'Event') { } else if (type === 'Event') {
this.enqueEvent(identifier); this.enqueEvent(identifier);
} else if (type === 'Contacts') { } else if (type === 'Contacts') {
this.enqueContacts(identifier); this.enqueContacts(identifier);
} else if (type === 'Article') {
this.enqueArticle(identifier);
} }
} }
} }

View File

@ -32,6 +32,10 @@ export class Queue {
active: false, active: false,
jobs: [] as QueryJob[], jobs: [] as QueryJob[],
}, },
article: {
active: false,
jobs: [] as QueryJob[],
},
contacts: { contacts: {
active: false, active: false,
jobs: [] as QueryJob[], jobs: [] as QueryJob[],

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { LoadMoreOptions, NostrEventDocument, NostrRelay, NostrRelayDocument, NostrRelaySubscription, ProfileStatus, QueryJob } from './interfaces'; import { LoadMoreOptions, NostrArticle, NostrEventDocument, NostrRelay, NostrRelayDocument, NostrRelaySubscription, ProfileStatus, QueryJob } from './interfaces';
import { Observable, BehaviorSubject, from, merge, timeout, catchError, of, finalize, tap } from 'rxjs'; import { Observable, BehaviorSubject, from, merge, timeout, catchError, of, finalize, tap } from 'rxjs';
import { Filter, Kind, Relay, relayInit, Sub } from 'nostr-tools'; import { Filter, Kind, Relay, relayInit, Sub } from 'nostr-tools';
import { EventService } from './event'; import { EventService } from './event';
@ -16,6 +16,7 @@ import { ImportSheet } from '../shared/import-sheet/import-sheet';
import { QueueService } from './queue.service'; import { QueueService } from './queue.service';
import { UIService } from './ui'; import { UIService } from './ui';
import { NostrService } from './nostr'; import { NostrService } from './nostr';
import { ArticleService } from './article';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -55,6 +56,7 @@ export class RelayService {
} }
constructor( constructor(
private articleService: ArticleService,
private nostr: NostrService, private nostr: NostrService,
private ui: UIService, private ui: UIService,
private queue: QueueService, private queue: QueueService,
@ -473,7 +475,9 @@ export class RelayService {
} }
} }
if (event.kind == Kind.Metadata) { if (event.kind == Kind.Article) {
this.articleService.put(event);
} else if (event.kind == Kind.Metadata) {
// This is a profile event, store it. // This is a profile event, store it.
const nostrProfileDocument = this.utilities.mapProfileEvent(event); const nostrProfileDocument = this.utilities.mapProfileEvent(event);

View File

@ -52,6 +52,8 @@ export class RelayWorker {
this.queue.queues.contacts.jobs.push(job); this.queue.queues.contacts.jobs.push(job);
} else if (job.type == 'Event') { } else if (job.type == 'Event') {
this.queue.queues.event.jobs.push(job); this.queue.queues.event.jobs.push(job);
} else if (job.type == 'Article') {
this.queue.queues.article.jobs.push(job);
} else { } else {
throw Error(`This type of job (${job.type}) is currently not supported.`); throw Error(`This type of job (${job.type}) is currently not supported.`);
} }
@ -61,10 +63,11 @@ export class RelayWorker {
// We always delay the processing in case we receive more. // We always delay the processing in case we receive more.
setTimeout(() => { setTimeout(() => {
this.process(); this.process();
}, 150); }, 500);
} }
process() { process() {
this.processArticle();
this.processProfiles(); this.processProfiles();
this.processContacts(); this.processContacts();
this.processEvents(); this.processEvents();
@ -135,7 +138,7 @@ export class RelayWorker {
processEvents() { processEvents() {
if (!this.relay || this.relay.status != 1 || this.queue.queues.event.active) { if (!this.relay || this.relay.status != 1 || this.queue.queues.event.active) {
console.log(`${this.url}: processProfiles: Relay not ready or currently active: ${this.queue.queues.event.active}.`, this.relay); console.log(`${this.url}: processEvents: Relay not ready or currently active: ${this.queue.queues.event.active}.`, this.relay);
return; return;
} }
@ -159,6 +162,32 @@ export class RelayWorker {
this.downloadEvent(eventsToDownload, eventsToDownload.length * 3); this.downloadEvent(eventsToDownload, eventsToDownload.length * 3);
} }
processArticle() {
if (!this.relay || this.relay.status != 1 || this.queue.queues.article.active) {
console.log(`${this.url}: processArticle: Relay not ready or currently active: ${this.queue.queues.article.active}.`, this.relay);
return;
}
console.log(`${this.url}: processArticle: Processing with downloading... Count: ` + this.queue.queues.article.jobs.length);
if (this.queue.queues.article.jobs.length == 0) {
this.queue.queues.article.active = false;
return;
}
this.queue.queues.article.active = true;
console.log(this.relay);
const eventsToDownload = this.queue.queues.article.jobs
.splice(0, 500)
.map((j) => j.identifier)
.filter((v, i, a) => a.indexOf(v) === i); // Unique, it can happen that multiple of same is added.
console.log('articleToDownload:', eventsToDownload);
this.downloadArticle(eventsToDownload, eventsToDownload.length * 3);
}
/** Provide event to publish and terminate immediately. */ /** Provide event to publish and terminate immediately. */
async connect(event?: any) { async connect(event?: any) {
// const relay = relayInit('wss://relay.nostr.info'); // const relay = relayInit('wss://relay.nostr.info');
@ -250,6 +279,9 @@ export class RelayWorker {
eventSub?: NostrSub; eventSub?: NostrSub;
eventTimer?: any; eventTimer?: any;
articleSub?: NostrSub;
articleTimer?: any;
clearProfileSub() { clearProfileSub() {
this.profileSub?.unsub(); this.profileSub?.unsub();
this.profileSub = undefined; this.profileSub = undefined;
@ -265,6 +297,11 @@ export class RelayWorker {
this.eventSub = undefined; this.eventSub = undefined;
} }
clearArticleSub() {
this.articleSub?.unsub();
this.articleTimer = undefined;
}
downloadProfile(pubkeys: string[], timeoutSeconds: number = 12) { downloadProfile(pubkeys: string[], timeoutSeconds: number = 12) {
console.log('DOWNLOAD PROFILE....'); console.log('DOWNLOAD PROFILE....');
let finalizedCalled = false; let finalizedCalled = false;
@ -387,6 +424,64 @@ export class RelayWorker {
}, timeoutSeconds * 1000); }, timeoutSeconds * 1000);
} }
downloadArticle(ids: string[], timeoutSeconds: number = 12) {
console.log('DOWNLOAD ARTICLE....');
let finalizedCalled = false;
if (!this.relay) {
debugger;
console.warn('This relay does not have active connection and download cannot be executed at this time.');
return;
}
// If the profilesub already exists, unsub and remove.
if (this.articleSub) {
console.log('Article sub already existed, unsub before continue.');
this.clearArticleSub();
}
// Skip if the subscription is already added.
// if (this.subscriptions.findIndex((s) => s.id == id) > -1) {
// debugger;
// console.log('This subscription is already added!');
// return;
// }
const filter = { kinds: [Kind.Article], authors: ids };
const sub = this.relay.sub([filter]) as NostrSub;
this.articleSub = sub;
sub.on('event', (originalEvent: any) => {
console.log('POST MESSAGE BACK TO MAIN');
postMessage({ url: this.url, type: 'event', data: originalEvent } as RelayResponse);
console.log('FINISHED POST MESSAGE BACK TO MAIN');
});
sub.on('eose', () => {
console.log('eose on event.');
clearTimeout(this.articleTimer);
this.clearArticleSub();
this.queue.queues.article.active = false;
this.processArticle();
});
console.log('REGISTER TIMEOUT!!', timeoutSeconds * 1000);
this.articleTimer = setTimeout(() => {
console.warn(`${this.url}: Event download timeout reached.`);
this.clearArticleSub();
this.queue.queues.article.active = false;
this.processArticle();
postMessage({ url: this.url, type: 'timeout', data: { type: 'Event', identifier: ids } } as RelayResponse);
// if (!finalizedCalled) {
// finalizedCalled = true;
// finalized();
// }
}, timeoutSeconds * 1000);
}
downloadEvent(ids: string[], timeoutSeconds: number = 12) { downloadEvent(ids: string[], timeoutSeconds: number = 12) {
console.log('DOWNLOAD EVENT....'); console.log('DOWNLOAD EVENT....');
let finalizedCalled = false; let finalizedCalled = false;