Add Events support (#41)

* Update profile.component.html

* Prepare event box and comment box for receiving and sending events and comments.

* Add like, share and event preview UI to events

* Add event box

* Update user.ts

* Update post.ts

* Update notification.service.ts

* Update metadata.service.ts

* Update event.service.ts

* Update notifications.component.ts

* Update profile.component.ts

* Update profile.component.html

* Load user events

* Fix UI

* Add new service to get events

* Update event service

* Update event service

* Update event service

* Update publishEventToWriteRelays

* Update event service

* Clean and format code

* Change events UI

* Update event service

* Update event service

* Update event UI

* Update profile.component.html

* Add event list component and update profile
This commit is contained in:
Milad Raeisi
2024-10-12 02:15:17 +04:00
committed by GitHub
parent e980c5a072
commit 36146b3f28
172 changed files with 7817 additions and 12466 deletions

View File

@@ -31,7 +31,12 @@
"quill-delta", "quill-delta",
"buffer", "buffer",
"localforage", "localforage",
"moment" "moment",
"bech32",
"bn.js",
"qrcode",
"dayjs",
"dayjs/plugin/relativeTime"
], ],
"assets": [ "assets": [
{ {

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "angor-hub", "name": "angor-hub",
"version": "0.0.7", "version": "0.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "angor-hub", "name": "angor-hub",
"version": "0.0.7", "version": "0.0.8",
"dependencies": { "dependencies": {
"@angular-builders/custom-webpack": "^18.0.0", "@angular-builders/custom-webpack": "^18.0.0",
"@angular/animations": "18.2.6", "@angular/animations": "18.2.6",
@@ -91,7 +91,7 @@
"karma-jasmine-html-reporter": "2.1.0", "karma-jasmine-html-reporter": "2.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"postcss": "8.4.47", "postcss": "8.4.47",
"prettier": "3.3.3", "prettier": "^3.3.3",
"prettier-plugin-organize-imports": "4.1.0", "prettier-plugin-organize-imports": "4.1.0",
"prettier-plugin-tailwindcss": "0.6.8", "prettier-plugin-tailwindcss": "0.6.8",
"tailwindcss": "3.4.13", "tailwindcss": "3.4.13",

View File

@@ -12,7 +12,8 @@
"test": "ng test", "test": "ng test",
"deploy": "ng deploy", "deploy": "ng deploy",
"version": "node -p \"require('./package.json').version\"", "version": "node -p \"require('./package.json').version\"",
"changelog": "conventional-changelog -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md" "changelog": "conventional-changelog -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md",
"format": "prettier --write \"src/**/*.{ts,html,css,scss,json,js}\""
}, },
"dependencies": { "dependencies": {
"@angular-builders/custom-webpack": "^18.0.0", "@angular-builders/custom-webpack": "^18.0.0",
@@ -98,7 +99,7 @@
"karma-jasmine-html-reporter": "2.1.0", "karma-jasmine-html-reporter": "2.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"postcss": "8.4.47", "postcss": "8.4.47",
"prettier": "3.3.3", "prettier": "^3.3.3",
"prettier-plugin-organize-imports": "4.1.0", "prettier-plugin-organize-imports": "4.1.0",
"prettier-plugin-tailwindcss": "0.6.8", "prettier-plugin-tailwindcss": "0.6.8",
"tailwindcss": "3.4.13", "tailwindcss": "3.4.13",

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,3 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
APP_INITIALIZER,
ENVIRONMENT_INITIALIZER,
EnvironmentProviders,
Provider,
importProvidersFrom,
inject,
} from '@angular/core';
import { MATERIAL_SANITY_CHECKS } from '@angular/material/core';
import { MatDialogModule } from '@angular/material/dialog';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { import {
ANGOR_MOCK_API_DEFAULT_DELAY, ANGOR_MOCK_API_DEFAULT_DELAY,
mockApiInterceptor, mockApiInterceptor,
@@ -25,6 +13,18 @@ import { AngorMediaWatcherService } from '@angor/services/media-watcher';
import { AngorPlatformService } from '@angor/services/platform'; import { AngorPlatformService } from '@angor/services/platform';
import { AngorSplashScreenService } from '@angor/services/splash-screen'; import { AngorSplashScreenService } from '@angor/services/splash-screen';
import { AngorUtilsService } from '@angor/services/utils'; import { AngorUtilsService } from '@angor/services/utils';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
APP_INITIALIZER,
ENVIRONMENT_INITIALIZER,
EnvironmentProviders,
Provider,
importProvidersFrom,
inject,
} from '@angular/core';
import { MATERIAL_SANITY_CHECKS } from '@angular/material/core';
import { MatDialogModule } from '@angular/material/dialog';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
export type AngorProviderConfig = { export type AngorProviderConfig = {
mockApi?: { mockApi?: {
@@ -40,10 +40,8 @@ export type AngorProviderConfig = {
export const provideAngor = ( export const provideAngor = (
config: AngorProviderConfig config: AngorProviderConfig
): Array<Provider | EnvironmentProviders> => { ): Array<Provider | EnvironmentProviders> => {
const providers: Array<Provider | EnvironmentProviders> = [ const providers: Array<Provider | EnvironmentProviders> = [
{ {
provide: MATERIAL_SANITY_CHECKS, provide: MATERIAL_SANITY_CHECKS,
useValue: { useValue: {
doctype: true, doctype: true,
@@ -52,7 +50,6 @@ export const provideAngor = (
}, },
}, },
{ {
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: { useValue: {
appearance: 'fill', appearance: 'fill',
@@ -103,7 +100,6 @@ export const provideAngor = (
}, },
]; ];
if (config?.mockApi?.services) { if (config?.mockApi?.services) {
providers.push( providers.push(
provideHttpClient(withInterceptors([mockApiInterceptor])), provideHttpClient(withInterceptors([mockApiInterceptor])),
@@ -116,6 +112,5 @@ export const provideAngor = (
); );
} }
return providers; return providers;
}; };

View File

@@ -2,17 +2,17 @@
* Defines animation curves for Angor. * Defines animation curves for Angor.
*/ */
export class AngorAnimationCurves { export class AngorAnimationCurves {
static standard = 'cubic-bezier(0.4, 0.0, 0.2, 1)'; // Standard animation curve static standard = 'cubic-bezier(0.4, 0.0, 0.2, 1)'; // Standard animation curve
static deceleration = 'cubic-bezier(0.0, 0.0, 0.2, 1)'; // Deceleration curve static deceleration = 'cubic-bezier(0.0, 0.0, 0.2, 1)'; // Deceleration curve
static acceleration = 'cubic-bezier(0.4, 0.0, 1, 1)'; // Acceleration curve static acceleration = 'cubic-bezier(0.4, 0.0, 1, 1)'; // Acceleration curve
static sharp = 'cubic-bezier(0.4, 0.0, 0.6, 1)'; // Sharp curve static sharp = 'cubic-bezier(0.4, 0.0, 0.6, 1)'; // Sharp curve
} }
/** /**
* Defines animation durations for Angor. * Defines animation durations for Angor.
*/ */
export class AngorAnimationDurations { export class AngorAnimationDurations {
static complex = '375ms'; // Duration for complex animations static complex = '375ms'; // Duration for complex animations
static entering = '225ms'; // Duration for entering animations static entering = '225ms'; // Duration for entering animations
static exiting = '195ms'; // Duration for exiting animations static exiting = '195ms'; // Duration for exiting animations
} }

View File

@@ -1,3 +1,7 @@
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
import { import {
animate, animate,
state, state,
@@ -5,10 +9,6 @@ import {
transition, transition,
trigger, trigger,
} from '@angular/animations'; } from '@angular/animations';
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
/** /**
* Animation trigger for expand/collapse transitions * Animation trigger for expand/collapse transitions

View File

@@ -1,3 +1,7 @@
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
import { import {
animate, animate,
state, state,
@@ -5,10 +9,6 @@ import {
transition, transition,
trigger, trigger,
} from '@angular/animations'; } from '@angular/animations';
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
/** /**
* Fade in animation trigger * Fade in animation trigger

View File

@@ -21,15 +21,42 @@ const shake = trigger('shake', [
'{{timings}}', '{{timings}}',
keyframes([ keyframes([
style({ transform: 'translate3d(0, 0, 0)', offset: 0 }), style({ transform: 'translate3d(0, 0, 0)', offset: 0 }),
style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.1 }), style({
style({ transform: 'translate3d(10px, 0, 0)', offset: 0.2 }), transform: 'translate3d(-10px, 0, 0)',
style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.3 }), offset: 0.1,
style({ transform: 'translate3d(10px, 0, 0)', offset: 0.4 }), }),
style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.5 }), style({
style({ transform: 'translate3d(10px, 0, 0)', offset: 0.6 }), transform: 'translate3d(10px, 0, 0)',
style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.7 }), offset: 0.2,
style({ transform: 'translate3d(10px, 0, 0)', offset: 0.8 }), }),
style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.9 }), style({
transform: 'translate3d(-10px, 0, 0)',
offset: 0.3,
}),
style({
transform: 'translate3d(10px, 0, 0)',
offset: 0.4,
}),
style({
transform: 'translate3d(-10px, 0, 0)',
offset: 0.5,
}),
style({
transform: 'translate3d(10px, 0, 0)',
offset: 0.6,
}),
style({
transform: 'translate3d(-10px, 0, 0)',
offset: 0.7,
}),
style({
transform: 'translate3d(10px, 0, 0)',
offset: 0.8,
}),
style({
transform: 'translate3d(-10px, 0, 0)',
offset: 0.9,
}),
style({ transform: 'translate3d(0, 0, 0)', offset: 1 }), style({ transform: 'translate3d(0, 0, 0)', offset: 1 }),
]) ])
), ),

View File

@@ -1,3 +1,7 @@
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
import { import {
animate, animate,
state, state,
@@ -5,10 +9,6 @@ import {
transition, transition,
trigger, trigger,
} from '@angular/animations'; } from '@angular/animations';
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
/** /**
* Slide in from top animation trigger * Slide in from top animation trigger

View File

@@ -1,3 +1,7 @@
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
import { import {
animate, animate,
state, state,
@@ -5,10 +9,6 @@ import {
transition, transition,
trigger, trigger,
} from '@angular/animations'; } from '@angular/animations';
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
/** /**
* Creates a reusable animation trigger with configurable parameters. * Creates a reusable animation trigger with configurable parameters.

View File

@@ -1,3 +1,10 @@
import { angorAnimations } from '@angor/animations';
import { AngorAlertService } from '@angor/components/alert/alert.service';
import {
AngorAlertAppearance,
AngorAlertType,
} from '@angor/components/alert/alert.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -16,13 +23,6 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { angorAnimations } from '@angor/animations';
import { AngorAlertService } from '@angor/components/alert/alert.service';
import {
AngorAlertAppearance,
AngorAlertType,
} from '@angor/components/alert/alert.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { Subject, filter, takeUntil } from 'rxjs'; import { Subject, filter, takeUntil } from 'rxjs';
@Component({ @Component({
@@ -86,16 +86,22 @@ export class AngorAlertComponent implements OnChanges, OnInit, OnDestroy {
*/ */
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if ('dismissed' in changes) { if ('dismissed' in changes) {
this.dismissed = coerceBooleanProperty(changes.dismissed.currentValue); this.dismissed = coerceBooleanProperty(
changes.dismissed.currentValue
);
this._toggleDismiss(this.dismissed); this._toggleDismiss(this.dismissed);
} }
if ('dismissible' in changes) { if ('dismissible' in changes) {
this.dismissible = coerceBooleanProperty(changes.dismissible.currentValue); this.dismissible = coerceBooleanProperty(
changes.dismissible.currentValue
);
} }
if ('showIcon' in changes) { if ('showIcon' in changes) {
this.showIcon = coerceBooleanProperty(changes.showIcon.currentValue); this.showIcon = coerceBooleanProperty(
changes.showIcon.currentValue
);
} }
} }

View File

@@ -1,3 +1,5 @@
import { angorAnimations } from '@angor/animations';
import { AngorCardFace } from '@angor/components/card/card.types';
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import { import {
Component, Component,
@@ -7,8 +9,6 @@ import {
SimpleChanges, SimpleChanges,
ViewEncapsulation, ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { angorAnimations } from '@angor/animations';
import { AngorCardFace } from '@angor/components/card/card.types';
@Component({ @Component({
selector: 'angor-card', selector: 'angor-card',
@@ -47,11 +47,15 @@ export class AngorCardComponent implements OnChanges {
*/ */
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if ('expanded' in changes) { if ('expanded' in changes) {
this.expanded = coerceBooleanProperty(changes.expanded.currentValue); this.expanded = coerceBooleanProperty(
changes.expanded.currentValue
);
} }
if ('flippable' in changes) { if ('flippable' in changes) {
this.flippable = coerceBooleanProperty(changes.flippable.currentValue); this.flippable = coerceBooleanProperty(
changes.flippable.currentValue
);
} }
} }
} }

View File

@@ -1,3 +1,9 @@
import { AngorDrawerService } from '@angor/components/drawer/drawer.service';
import {
AngorDrawerMode,
AngorDrawerPosition,
} from '@angor/components/drawer/drawer.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { import {
animate, animate,
AnimationBuilder, AnimationBuilder,
@@ -11,6 +17,7 @@ import {
EventEmitter, EventEmitter,
HostBinding, HostBinding,
HostListener, HostListener,
inject,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
@@ -19,14 +26,7 @@ import {
Renderer2, Renderer2,
SimpleChanges, SimpleChanges,
ViewEncapsulation, ViewEncapsulation,
inject,
} from '@angular/core'; } from '@angular/core';
import { AngorDrawerService } from '@angor/components/drawer/drawer.service';
import {
AngorDrawerMode,
AngorDrawerPosition,
} from '@angor/components/drawer/drawer.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
@Component({ @Component({
selector: 'angor-drawer', selector: 'angor-drawer',
@@ -56,7 +56,8 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy {
@Output() readonly fixedChanged = new EventEmitter<boolean>(); @Output() readonly fixedChanged = new EventEmitter<boolean>();
@Output() readonly modeChanged = new EventEmitter<AngorDrawerMode>(); @Output() readonly modeChanged = new EventEmitter<AngorDrawerMode>();
@Output() readonly openedChanged = new EventEmitter<boolean>(); @Output() readonly openedChanged = new EventEmitter<boolean>();
@Output() readonly positionChanged = new EventEmitter<AngorDrawerPosition>(); @Output() readonly positionChanged =
new EventEmitter<AngorDrawerPosition>();
private _animationsEnabled: boolean = false; private _animationsEnabled: boolean = false;
private _hovered: boolean = false; private _hovered: boolean = false;
@@ -107,7 +108,11 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy {
this._hideOverlay(); this._hideOverlay();
} }
if (previousMode === 'side' && currentMode === 'over' && this.opened) { if (
previousMode === 'side' &&
currentMode === 'over' &&
this.opened
) {
this._showOverlay(); this._showOverlay();
} }
@@ -125,7 +130,9 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy {
} }
if ('transparentOverlay' in changes) { if ('transparentOverlay' in changes) {
this.transparentOverlay = coerceBooleanProperty(changes.transparentOverlay.currentValue); this.transparentOverlay = coerceBooleanProperty(
changes.transparentOverlay.currentValue
);
} }
} }

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { AngorDrawerComponent } from '@angor/components/drawer/drawer.component'; import { AngorDrawerComponent } from '@angor/components/drawer/drawer.component';
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AngorDrawerService { export class AngorDrawerService {

View File

@@ -1,3 +1,4 @@
import { AngorHighlightService } from '@angor/components/highlight/highlight.service';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
@@ -16,7 +17,6 @@ import {
ViewEncapsulation, ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { AngorHighlightService } from '@angor/components/highlight/highlight.service';
@Component({ @Component({
selector: 'textarea[angor-highlight]', selector: 'textarea[angor-highlight]',

View File

@@ -3,7 +3,6 @@ import hljs from 'highlight.js';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AngorHighlightService { export class AngorHighlightService {
/** /**
* Highlights the provided code using the specified language. * Highlights the provided code using the specified language.
*/ */
@@ -28,15 +27,17 @@ export class AngorHighlightService {
} }
// Determine the smallest indentation // Determine the smallest indentation
lines.filter(line => line.length).forEach((line, index) => { lines
if (index === 0) { .filter((line) => line.length)
indentation = line.search(/\S|$/); .forEach((line, index) => {
} else { if (index === 0) {
indentation = Math.min(line.search(/\S|$/), indentation); indentation = line.search(/\S|$/);
} } else {
}); indentation = Math.min(line.search(/\S|$/), indentation);
}
});
// Remove extra indentation and return formatted code // Remove extra indentation and return formatted code
return lines.map(line => line.substring(indentation)).join('\n'); return lines.map((line) => line.substring(indentation)).join('\n');
} }
} }

View File

@@ -1,7 +1,16 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Component, inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewEncapsulation } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { AngorLoadingService } from '@angor/services/loading'; import { AngorLoadingService } from '@angor/services/loading';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
Component,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
ViewEncapsulation,
} from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,4 @@
import { angorAnimations } from '@angor/animations';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
@@ -8,7 +9,6 @@ import {
TemplateRef, TemplateRef,
ViewEncapsulation, ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { angorAnimations } from '@angor/animations';
@Component({ @Component({
selector: 'angor-masonry', selector: 'angor-masonry',

View File

@@ -1,3 +1,7 @@
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { NgClass, NgTemplateOutlet } from '@angular/common'; import { NgClass, NgTemplateOutlet } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -16,10 +20,6 @@ import {
RouterLink, RouterLink,
RouterLinkActive, RouterLinkActive,
} from '@angular/router'; } from '@angular/router';
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
@@ -69,7 +69,7 @@ export class AngorHorizontalNavigationBasicItemComponent
// "isActiveMatchOptions" or the equivalent form of // "isActiveMatchOptions" or the equivalent form of
// item's "exactMatch" option // item's "exactMatch" option
this.isActiveMatchOptions = this.isActiveMatchOptions =
this.item.isActiveMatchOptions ?? this.item.exactMatch (this.item.isActiveMatchOptions ?? this.item.exactMatch)
? this._angorUtilsService.exactMatchOptions ? this._angorUtilsService.exactMatchOptions
: this._angorUtilsService.subsetMatchOptions; : this._angorUtilsService.subsetMatchOptions;

View File

@@ -66,7 +66,10 @@
<!-- Divider --> <!-- Divider -->
@if (item.type === 'divider') { @if (item.type === 'divider') {
<div class="angor-horizontal-navigation-menu-item" mat-menu-item> <div
class="angor-horizontal-navigation-menu-item"
mat-menu-item
>
<angor-horizontal-navigation-divider-item <angor-horizontal-navigation-divider-item
[item]="item" [item]="item"
[name]="name" [name]="name"

View File

@@ -1,3 +1,8 @@
import { AngorHorizontalNavigationBasicItemComponent } from '@angor/components/navigation/horizontal/components/basic/basic.component';
import { AngorHorizontalNavigationDividerItemComponent } from '@angor/components/navigation/horizontal/components/divider/divider.component';
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { BooleanInput } from '@angular/cdk/coercion'; import { BooleanInput } from '@angular/cdk/coercion';
import { NgClass, NgTemplateOutlet } from '@angular/common'; import { NgClass, NgTemplateOutlet } from '@angular/common';
import { import {
@@ -14,11 +19,6 @@ import {
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuModule } from '@angular/material/menu'; import { MatMenu, MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { AngorHorizontalNavigationBasicItemComponent } from '@angor/components/navigation/horizontal/components/basic/basic.component';
import { AngorHorizontalNavigationDividerItemComponent } from '@angor/components/navigation/horizontal/components/divider/divider.component';
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,6 @@
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,6 @@
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,7 @@
import { angorAnimations } from '@angor/animations';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@@ -10,10 +14,6 @@ import {
ViewEncapsulation, ViewEncapsulation,
inject, inject,
} from '@angular/core'; } from '@angular/core';
import { angorAnimations } from '@angor/animations';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { ReplaySubject, Subject } from 'rxjs'; import { ReplaySubject, Subject } from 'rxjs';
import { AngorHorizontalNavigationBasicItemComponent } from './components/basic/basic.component'; import { AngorHorizontalNavigationBasicItemComponent } from './components/basic/basic.component';
import { AngorHorizontalNavigationBranchItemComponent } from './components/branch/branch.component'; import { AngorHorizontalNavigationBranchItemComponent } from './components/branch/branch.component';

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types'; import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AngorNavigationService { export class AngorNavigationService {
@@ -93,7 +93,10 @@ export class AngorNavigationService {
* @param id * @param id
* @param navigation * @param navigation
*/ */
getItem(id: string, navigation: AngorNavigationItem[]): AngorNavigationItem | null { getItem(
id: string,
navigation: AngorNavigationItem[]
): AngorNavigationItem | null {
for (const item of navigation) { for (const item of navigation) {
if (item.id === id) return item; if (item.id === id) return item;
if (item.children) { if (item.children) {

View File

@@ -38,6 +38,10 @@ export interface AngorNavigationItem {
meta?: any; meta?: any;
} }
export type AngorVerticalNavigationAppearance = 'default' | 'compact' | 'dense' | 'thin'; export type AngorVerticalNavigationAppearance =
| 'default'
| 'compact'
| 'dense'
| 'thin';
export type AngorVerticalNavigationMode = 'over' | 'side'; export type AngorVerticalNavigationMode = 'over' | 'side';
export type AngorVerticalNavigationPosition = 'left' | 'right'; export type AngorVerticalNavigationPosition = 'left' | 'right';

View File

@@ -1,3 +1,11 @@
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { BooleanInput } from '@angular/cdk/coercion'; import { BooleanInput } from '@angular/cdk/coercion';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
@@ -14,14 +22,6 @@ import {
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { Subject, filter, takeUntil } from 'rxjs'; import { Subject, filter, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,7 @@
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { NgClass, NgTemplateOutlet } from '@angular/common'; import { NgClass, NgTemplateOutlet } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -15,10 +19,6 @@ import {
RouterLink, RouterLink,
RouterLinkActive, RouterLinkActive,
} from '@angular/router'; } from '@angular/router';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
@@ -67,7 +67,7 @@ export class AngorVerticalNavigationBasicItemComponent
// "isActiveMatchOptions" or the equivalent form of // "isActiveMatchOptions" or the equivalent form of
// item's "exactMatch" option // item's "exactMatch" option
this.isActiveMatchOptions = this.isActiveMatchOptions =
this.item.isActiveMatchOptions ?? this.item.exactMatch (this.item.isActiveMatchOptions ?? this.item.exactMatch)
? this._angorUtilsService.exactMatchOptions ? this._angorUtilsService.exactMatchOptions
: this._angorUtilsService.subsetMatchOptions; : this._angorUtilsService.subsetMatchOptions;

View File

@@ -1,3 +1,11 @@
import { angorAnimations } from '@angor/animations';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { BooleanInput } from '@angular/cdk/coercion'; import { BooleanInput } from '@angular/cdk/coercion';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
@@ -14,14 +22,6 @@ import {
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { angorAnimations } from '@angor/animations';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { Subject, filter, takeUntil } from 'rxjs'; import { Subject, filter, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,6 @@
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,10 @@
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { BooleanInput } from '@angular/cdk/coercion'; import { BooleanInput } from '@angular/cdk/coercion';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
@@ -11,13 +18,6 @@ import {
inject, inject,
} from '@angular/core'; } from '@angular/core';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -1,3 +1,6 @@
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
} from '@angular/core'; } from '@angular/core';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import { AngorNavigationItem } from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationComponent } from '@angor/components/navigation/vertical/vertical.component';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({

View File

@@ -80,8 +80,6 @@
} }
} }
} }
</div> </div>
<!-- Footer --> <!-- Footer -->

View File

@@ -1,3 +1,19 @@
import { angorAnimations } from '@angor/animations';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import {
AngorNavigationItem,
AngorVerticalNavigationAppearance,
AngorVerticalNavigationMode,
AngorVerticalNavigationPosition,
} from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationAsideItemComponent } from '@angor/components/navigation/vertical/components/aside/aside.component';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorScrollbarDirective } from '@angor/directives/scrollbar/scrollbar.directive';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { import {
animate, animate,
AnimationBuilder, AnimationBuilder,
@@ -30,22 +46,6 @@ import {
ViewEncapsulation, ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { angorAnimations } from '@angor/animations';
import { AngorNavigationService } from '@angor/components/navigation/navigation.service';
import {
AngorNavigationItem,
AngorVerticalNavigationAppearance,
AngorVerticalNavigationMode,
AngorVerticalNavigationPosition,
} from '@angor/components/navigation/navigation.types';
import { AngorVerticalNavigationAsideItemComponent } from '@angor/components/navigation/vertical/components/aside/aside.component';
import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component';
import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component';
import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component';
import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component';
import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component';
import { AngorScrollbarDirective } from '@angor/directives/scrollbar/scrollbar.directive';
import { AngorUtilsService } from '@angor/services/utils/utils.service';
import { import {
delay, delay,
filter, filter,

View File

@@ -21,7 +21,7 @@ export class AngorScrollResetDirective implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this._router.events this._router.events
.pipe( .pipe(
filter(event => event instanceof NavigationEnd), filter((event) => event instanceof NavigationEnd),
takeUntil(this._unsubscribeAll) takeUntil(this._unsubscribeAll)
) )
.subscribe(() => { .subscribe(() => {

View File

@@ -1,3 +1,7 @@
import {
ScrollbarGeometry,
ScrollbarPosition,
} from '@angor/directives/scrollbar/scrollbar.types';
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { import {
@@ -10,10 +14,6 @@ import {
SimpleChanges, SimpleChanges,
inject, inject,
} from '@angular/core'; } from '@angular/core';
import {
ScrollbarGeometry,
ScrollbarPosition,
} from '@angor/directives/scrollbar/scrollbar.types';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import PerfectScrollbar from 'perfect-scrollbar'; import PerfectScrollbar from 'perfect-scrollbar';
import { Subject, debounceTime, fromEvent, takeUntil } from 'rxjs'; import { Subject, debounceTime, fromEvent, takeUntil } from 'rxjs';
@@ -47,12 +47,20 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if ('angorScrollbar' in changes) { if ('angorScrollbar' in changes) {
this.angorScrollbar = coerceBooleanProperty(changes.angorScrollbar.currentValue); this.angorScrollbar = coerceBooleanProperty(
this.angorScrollbar ? this._initScrollbar() : this._destroyScrollbar(); changes.angorScrollbar.currentValue
);
this.angorScrollbar
? this._initScrollbar()
: this._destroyScrollbar();
} }
if ('angorScrollbarOptions' in changes) { if ('angorScrollbarOptions' in changes) {
this._options = merge({}, this._options, changes.angorScrollbarOptions.currentValue); this._options = merge(
{},
this._options,
changes.angorScrollbarOptions.currentValue
);
this._reinitializeScrollbar(); this._reinitializeScrollbar();
} }
} }
@@ -92,7 +100,10 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
position(absolute: boolean = false): ScrollbarPosition { position(absolute: boolean = false): ScrollbarPosition {
if (!absolute && this._ps) { if (!absolute && this._ps) {
return new ScrollbarPosition(this._ps.reach.x || 0, this._ps.reach.y || 0); return new ScrollbarPosition(
this._ps.reach.x || 0,
this._ps.reach.y || 0
);
} else { } else {
return new ScrollbarPosition( return new ScrollbarPosition(
this._elementRef.nativeElement.scrollLeft, this._elementRef.nativeElement.scrollLeft,
@@ -115,7 +126,6 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
} }
} }
scrollToX(x: number, speed?: number): void { scrollToX(x: number, speed?: number): void {
this.animateScrolling('scrollLeft', x, speed); this.animateScrolling('scrollLeft', x, speed);
} }
@@ -129,7 +139,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
} }
scrollToBottom(offset: number = 0, speed?: number): void { scrollToBottom(offset: number = 0, speed?: number): void {
const top = this._elementRef.nativeElement.scrollHeight - this._elementRef.nativeElement.clientHeight; const top =
this._elementRef.nativeElement.scrollHeight -
this._elementRef.nativeElement.clientHeight;
this.animateScrolling('scrollTop', top - offset, speed); this.animateScrolling('scrollTop', top - offset, speed);
} }
@@ -138,7 +150,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
} }
scrollToRight(offset: number = 0, speed?: number): void { scrollToRight(offset: number = 0, speed?: number): void {
const left = this._elementRef.nativeElement.scrollWidth - this._elementRef.nativeElement.clientWidth; const left =
this._elementRef.nativeElement.scrollWidth -
this._elementRef.nativeElement.clientWidth;
this.animateScrolling('scrollLeft', left - offset, speed); this.animateScrolling('scrollLeft', left - offset, speed);
} }
@@ -152,14 +166,29 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
if (!element) return; if (!element) return;
const elementPos = element.getBoundingClientRect(); const elementPos = element.getBoundingClientRect();
const scrollerPos = this._elementRef.nativeElement.getBoundingClientRect(); const scrollerPos =
this._elementRef.nativeElement.getBoundingClientRect();
if (this._elementRef.nativeElement.classList.contains('ps--active-x')) { if (this._elementRef.nativeElement.classList.contains('ps--active-x')) {
this._scrollToInAxis(elementPos.left, scrollerPos.left, 'scrollLeft', offset, ignoreVisible, speed); this._scrollToInAxis(
elementPos.left,
scrollerPos.left,
'scrollLeft',
offset,
ignoreVisible,
speed
);
} }
if (this._elementRef.nativeElement.classList.contains('ps--active-y')) { if (this._elementRef.nativeElement.classList.contains('ps--active-y')) {
this._scrollToInAxis(elementPos.top, scrollerPos.top, 'scrollTop', offset, ignoreVisible, speed); this._scrollToInAxis(
elementPos.top,
scrollerPos.top,
'scrollTop',
offset,
ignoreVisible,
speed
);
} }
} }
@@ -176,8 +205,16 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
} }
private _initScrollbar(): void { private _initScrollbar(): void {
if (this._ps || this._platform.ANDROID || this._platform.IOS || !this._platform.isBrowser) return; if (
this._ps = new PerfectScrollbar(this._elementRef.nativeElement, { ...this._options }); this._ps ||
this._platform.ANDROID ||
this._platform.IOS ||
!this._platform.isBrowser
)
return;
this._ps = new PerfectScrollbar(this._elementRef.nativeElement, {
...this._options,
});
} }
private _destroyScrollbar(): void { private _destroyScrollbar(): void {
@@ -198,7 +235,8 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
ignoreVisible: boolean, ignoreVisible: boolean,
speed?: number speed?: number
): void { ): void {
if (ignoreVisible && elementPos <= scrollerPos - Math.abs(offset)) return; if (ignoreVisible && elementPos <= scrollerPos - Math.abs(offset))
return;
const currentPos = this._elementRef.nativeElement[target]; const currentPos = this._elementRef.nativeElement[target];
const position = elementPos - scrollerPos + currentPos; const position = elementPos - scrollerPos + currentPos;
@@ -213,7 +251,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
const step = (newTimestamp: number) => { const step = (newTimestamp: number) => {
scrollCount += Math.PI / (speed / (newTimestamp - oldTimestamp)); scrollCount += Math.PI / (speed / (newTimestamp - oldTimestamp));
const newValue = Math.round(value + cosParameter + cosParameter * Math.cos(scrollCount)); const newValue = Math.round(
value + cosParameter + cosParameter * Math.cos(scrollCount)
);
if (this._elementRef.nativeElement[target] === oldValue) { if (this._elementRef.nativeElement[target] === oldValue) {
if (scrollCount >= Math.PI) { if (scrollCount >= Math.PI) {

View File

@@ -1,3 +1,5 @@
import { ANGOR_MOCK_API_DEFAULT_DELAY } from '@angor/lib/mock-api/mock-api.constants';
import { AngorMockApiService } from '@angor/lib/mock-api/mock-api.service';
import { import {
HttpErrorResponse, HttpErrorResponse,
HttpEvent, HttpEvent,
@@ -6,8 +8,6 @@ import {
HttpResponse, HttpResponse,
} from '@angular/common/http'; } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { ANGOR_MOCK_API_DEFAULT_DELAY } from '@angor/lib/mock-api/mock-api.constants';
import { AngorMockApiService } from '@angor/lib/mock-api/mock-api.service';
import { Observable, delay, of, switchMap, throwError } from 'rxjs'; import { Observable, delay, of, switchMap, throwError } from 'rxjs';
import { AngorMockApiMethods } from './mock-api.types'; import { AngorMockApiMethods } from './mock-api.types';
@@ -43,11 +43,14 @@ export const mockApiInterceptor = (
switchMap((response) => { switchMap((response) => {
// If no response is returned, generate a 404 error // If no response is returned, generate a 404 error
if (!response) { if (!response) {
return throwError(() => new HttpErrorResponse({ return throwError(
error: 'NOT FOUND', () =>
status: 404, new HttpErrorResponse({
statusText: 'NOT FOUND', error: 'NOT FOUND',
})); status: 404,
statusText: 'NOT FOUND',
})
);
} }
// Parse the response data (status and body) // Parse the response data (status and body)
@@ -58,19 +61,24 @@ export const mockApiInterceptor = (
// If status code is between 200 and 300, return a successful response // If status code is between 200 and 300, return a successful response
if (data.status >= 200 && data.status < 300) { if (data.status >= 200 && data.status < 300) {
return of(new HttpResponse({ return of(
body: data.body, new HttpResponse({
status: data.status, body: data.body,
statusText: 'OK', status: data.status,
})); statusText: 'OK',
})
);
} }
// For other status codes, throw an error response // For other status codes, throw an error response
return throwError(() => new HttpErrorResponse({ return throwError(
error: data.body?.error, () =>
status: data.status, new HttpErrorResponse({
statusText: 'ERROR', error: data.body?.error,
})); status: data.status,
statusText: 'ERROR',
})
);
}) })
); );
}; };

View File

@@ -1,5 +1,5 @@
import { HttpRequest } from '@angular/common/http';
import { AngorMockApiReplyCallback } from '@angor/lib/mock-api/mock-api.types'; import { AngorMockApiReplyCallback } from '@angor/lib/mock-api/mock-api.types';
import { HttpRequest } from '@angular/common/http';
import { Observable, of, take, throwError } from 'rxjs'; import { Observable, of, take, throwError } from 'rxjs';
export class AngorMockApiHandler { export class AngorMockApiHandler {
@@ -17,7 +17,10 @@ export class AngorMockApiHandler {
* @param url - The URL for the mock API handler * @param url - The URL for the mock API handler
* @param delay - Optional delay for the response * @param delay - Optional delay for the response
*/ */
constructor(public url: string, public delay?: number) {} constructor(
public url: string,
public delay?: number
) {}
/** /**
* Getter for the response observable. * Getter for the response observable.
@@ -27,12 +30,16 @@ export class AngorMockApiHandler {
get response(): Observable<any> { get response(): Observable<any> {
// Check if the execution limit has been reached // Check if the execution limit has been reached
if (this._replyCount > 0 && this._replyCount <= this._replied) { if (this._replyCount > 0 && this._replyCount <= this._replied) {
return throwError(() => new Error('Execution limit has been reached!')); return throwError(
() => new Error('Execution limit has been reached!')
);
} }
// Ensure the response callback exists // Ensure the response callback exists
if (!this._reply) { if (!this._reply) {
return throwError(() => new Error('Response callback function does not exist!')); return throwError(
() => new Error('Response callback function does not exist!')
);
} }
// Ensure the request exists // Ensure the request exists

View File

@@ -1,11 +1,14 @@
import { Injectable } from '@angular/core';
import { AngorMockApiHandler } from '@angor/lib/mock-api/mock-api.request-handler'; import { AngorMockApiHandler } from '@angor/lib/mock-api/mock-api.request-handler';
import { AngorMockApiMethods } from '@angor/lib/mock-api/mock-api.types'; import { AngorMockApiMethods } from '@angor/lib/mock-api/mock-api.types';
import { compact, fromPairs } from 'lodash-es'; import { Injectable } from '@angular/core';
import { fromPairs } from 'lodash-es';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AngorMockApiService { export class AngorMockApiService {
private readonly _handlers: Record<AngorMockApiMethods, Map<string, AngorMockApiHandler>> = { private readonly _handlers: Record<
AngorMockApiMethods,
Map<string, AngorMockApiHandler>
> = {
get: new Map<string, AngorMockApiHandler>(), get: new Map<string, AngorMockApiHandler>(),
post: new Map<string, AngorMockApiHandler>(), post: new Map<string, AngorMockApiHandler>(),
patch: new Map<string, AngorMockApiHandler>(), patch: new Map<string, AngorMockApiHandler>(),
@@ -26,24 +29,36 @@ export class AngorMockApiService {
findHandler( findHandler(
method: AngorMockApiMethods, method: AngorMockApiMethods,
url: string url: string
): { handler: AngorMockApiHandler | undefined; urlParams: Record<string, string> } { ): {
const matchingHandler = { handler: undefined, urlParams: {} as Record<string, string> }; handler: AngorMockApiHandler | undefined;
urlParams: Record<string, string>;
} {
const matchingHandler = {
handler: undefined,
urlParams: {} as Record<string, string>,
};
const urlParts = url.split('/'); const urlParts = url.split('/');
const handlers = this._handlers[method.toLowerCase() as AngorMockApiMethods]; const handlers =
this._handlers[method.toLowerCase() as AngorMockApiMethods];
for (const [handlerUrl, handler] of handlers) { for (const [handlerUrl, handler] of handlers) {
const handlerUrlParts = handlerUrl.split('/'); const handlerUrlParts = handlerUrl.split('/');
if (urlParts.length === handlerUrlParts.length) { if (urlParts.length === handlerUrlParts.length) {
const matches = handlerUrlParts.every((part, index) => const matches = handlerUrlParts.every(
part.startsWith(':') || part === urlParts[index] (part, index) =>
part.startsWith(':') || part === urlParts[index]
); );
if (matches) { if (matches) {
matchingHandler.handler = handler; matchingHandler.handler = handler;
matchingHandler.urlParams = fromPairs( matchingHandler.urlParams = fromPairs(
handlerUrlParts handlerUrlParts
.map((part, index) => (part.startsWith(':') ? [part.substring(1), urlParts[index]] : undefined)) .map((part, index) =>
part.startsWith(':')
? [part.substring(1), urlParts[index]]
: undefined
)
.filter(Boolean) .filter(Boolean)
); );
break; break;
@@ -150,7 +165,11 @@ export class AngorMockApiService {
* @param delay - (Optional) Delay for the response in milliseconds * @param delay - (Optional) Delay for the response in milliseconds
* @returns An instance of AngorMockApiHandler * @returns An instance of AngorMockApiHandler
*/ */
private _registerHandler(method: AngorMockApiMethods, url: string, delay?: number): AngorMockApiHandler { private _registerHandler(
method: AngorMockApiMethods,
url: string,
delay?: number
): AngorMockApiHandler {
const handler = new AngorMockApiHandler(url, delay); const handler = new AngorMockApiHandler(url, delay);
this._handlers[method].set(url, handler); this._handlers[method].set(url, handler);
return handler; return handler;

View File

@@ -18,7 +18,11 @@ export class AngorFindByKeyPipe implements PipeTransform {
* @param source The array of objects to search within. * @param source The array of objects to search within.
* @returns A single object if `value` is a string, or an array of objects if `value` is an array. * @returns A single object if `value` is a string, or an array of objects if `value` is an array.
*/ */
transform(value: string | string[], key: string, source: any[]): any | any[] { transform(
value: string | string[],
key: string,
source: any[]
): any | any[] {
// If value is an array of strings, map each to its corresponding object in the source. // If value is an array of strings, map each to its corresponding object in the source.
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map((item) => return value.map((item) =>

View File

@@ -1,12 +1,14 @@
import { inject, Injectable } from '@angular/core';
import { ANGOR_CONFIG } from '@angor/services/config/config.constants'; import { ANGOR_CONFIG } from '@angor/services/config/config.constants';
import { inject, Injectable } from '@angular/core';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AngorConfigService { export class AngorConfigService {
private readonly _defaultConfig = inject(ANGOR_CONFIG); private readonly _defaultConfig = inject(ANGOR_CONFIG);
private readonly _configSubject = new BehaviorSubject<any>(this._defaultConfig); private readonly _configSubject = new BehaviorSubject<any>(
this._defaultConfig
);
/** /**
* Getter for config as an Observable. * Getter for config as an Observable.

View File

@@ -9,9 +9,9 @@ export type Themes = Array<{ id: string; name: string }>;
* This ensures consistency when defining or updating app settings. * This ensures consistency when defining or updating app settings.
*/ */
export interface AngorConfig { export interface AngorConfig {
layout: string; // Layout type (e.g., 'vertical', 'horizontal') layout: string; // Layout type (e.g., 'vertical', 'horizontal')
scheme: Scheme; // Color scheme: 'auto', 'dark', or 'light' scheme: Scheme; // Color scheme: 'auto', 'dark', or 'light'
screens: Screens; // Screen breakpoints, e.g., { 'xs': '600px', ... } screens: Screens; // Screen breakpoints, e.g., { 'xs': '600px', ... }
theme: Theme; // Active theme identifier, e.g., 'theme-default' theme: Theme; // Active theme identifier, e.g., 'theme-default'
themes: Themes; // List of available themes, each with an id and name themes: Themes; // List of available themes, each with an id and name
} }

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types'; import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types';
import { AngorConfirmationDialogComponent } from '@angor/services/confirmation/dialog/dialog.component'; import { AngorConfirmationDialogComponent } from '@angor/services/confirmation/dialog/dialog.component';
import { inject, Injectable } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { merge } from 'lodash-es'; import { merge } from 'lodash-es';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -1,9 +1,9 @@
import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types';
import { NgClass } from '@angular/common'; import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation, inject } from '@angular/core'; import { Component, ViewEncapsulation, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types';
@Component({ @Component({
selector: 'angor-confirmation-dialog', selector: 'angor-confirmation-dialog',

View File

@@ -1,6 +1,6 @@
import { AngorLoadingService } from '@angor/services/loading/loading.service';
import { HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http'; import { HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { AngorLoadingService } from '@angor/services/loading/loading.service';
import { Observable, finalize, take } from 'rxjs'; import { Observable, finalize, take } from 'rxjs';
export const angorLoadingInterceptor = ( export const angorLoadingInterceptor = (

View File

@@ -1,6 +1,6 @@
import { AngorConfigService } from '@angor/services/config';
import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { AngorConfigService } from '@angor/services/config';
import { fromPairs } from 'lodash-es'; import { fromPairs } from 'lodash-es';
import { Observable, ReplaySubject, map, switchMap } from 'rxjs'; import { Observable, ReplaySubject, map, switchMap } from 'rxjs';

View File

@@ -1,6 +1,3 @@
/* ----------------------------------------------------------------------------------------------------- */
/* @ Example viewer
/* ----------------------------------------------------------------------------------------------------- */
.example-viewer { .example-viewer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -80,7 +80,6 @@ $dark-base: (
), ),
); );
/* Include the core Angular Material styles */ /* Include the core Angular Material styles */
@include mat.core(); @include mat.core();

View File

@@ -13,17 +13,6 @@ const jsonToSassMap = require(
path.resolve(__dirname, '../utils/json-to-sass-map') path.resolve(__dirname, '../utils/json-to-sass-map')
); );
// -----------------------------------------------------------------------------------------------------
// @ Utilities
// -----------------------------------------------------------------------------------------------------
/**
* Normalizes the provided theme by omitting empty values and values that
* start with "on" from each palette. Also sets the correct DEFAULT value
* of each palette.
*
* @param theme
*/
const normalizeTheme = (theme) => { const normalizeTheme = (theme) => {
return _.fromPairs( return _.fromPairs(
_.map( _.map(
@@ -43,17 +32,10 @@ const normalizeTheme = (theme) => {
); );
}; };
// -----------------------------------------------------------------------------------------------------
// @ ANGOR TailwindCSS Main Plugin
// -----------------------------------------------------------------------------------------------------
const theming = plugin.withOptions( const theming = plugin.withOptions(
(options) => (options) =>
({ addComponents, e, theme }) => { ({ addComponents, e, theme }) => {
/**
* Create user themes object by going through the provided themes and
* merging them with the provided "default" so, we can have a complete
* set of color palettes for each user theme.
*/
const userThemes = _.fromPairs( const userThemes = _.fromPairs(
_.map(options.themes, (theme, themeName) => [ _.map(options.themes, (theme, themeName) => [
themeName, themeName,
@@ -61,10 +43,6 @@ const theming = plugin.withOptions(
]) ])
); );
/**
* Normalize the themes and assign it to the themes object. This will
* be the final object that we create a SASS map from
*/
let themes = _.fromPairs( let themes = _.fromPairs(
_.map(userThemes, (theme, themeName) => [ _.map(userThemes, (theme, themeName) => [
themeName, themeName,
@@ -72,10 +50,6 @@ const theming = plugin.withOptions(
]) ])
); );
/**
* Go through the themes to generate the contrasts and filter the
* palettes to only have "primary", "accent" and "warn" objects.
*/
themes = _.fromPairs( themes = _.fromPairs(
_.map(themes, (theme, themeName) => [ _.map(themes, (theme, themeName) => [
themeName, themeName,
@@ -105,10 +79,6 @@ const theming = plugin.withOptions(
]) ])
); );
/**
* Go through the themes and attach appropriate class selectors so,
* we can use them to encapsulate each theme.
*/
themes = _.fromPairs( themes = _.fromPairs(
_.map(themes, (theme, themeName) => [ _.map(themes, (theme, themeName) => [
themeName, themeName,
@@ -119,18 +89,15 @@ const theming = plugin.withOptions(
]) ])
); );
/* Generate the SASS map using the themes object */
const sassMap = jsonToSassMap( const sassMap = jsonToSassMap(
JSON.stringify({ 'user-themes': themes }) JSON.stringify({ 'user-themes': themes })
); );
/* Get the file path */
const filename = path.resolve( const filename = path.resolve(
__dirname, __dirname,
'../../styles/user-themes.scss' '../../styles/user-themes.scss'
); );
/* Read the file and get its data */
let data; let data;
try { try {
data = fs.readFileSync(filename, { encoding: 'utf8' }); data = fs.readFileSync(filename, { encoding: 'utf8' });
@@ -138,7 +105,6 @@ const theming = plugin.withOptions(
console.error(err); console.error(err);
} }
/* Write the file if the map has been changed */
if (data !== sassMap) { if (data !== sassMap) {
try { try {
fs.writeFileSync(filename, sassMap, { encoding: 'utf8' }); fs.writeFileSync(filename, sassMap, { encoding: 'utf8' });
@@ -147,11 +113,6 @@ const theming = plugin.withOptions(
} }
} }
/**
* Iterate through the user's themes and build Tailwind components containing
* CSS Custom Properties using the colors from them. This allows switching
* themes by simply replacing a class name as well as nesting them.
*/
addComponents( addComponents(
_.fromPairs( _.fromPairs(
_.map(options.themes, (theme, themeName) => [ _.map(options.themes, (theme, themeName) => [
@@ -214,9 +175,7 @@ const theming = plugin.withOptions(
) )
); );
/**
* Generate scheme based css custom properties and utility classes
*/
const schemeCustomProps = _.map( const schemeCustomProps = _.map(
['light', 'dark'], ['light', 'dark'],
(colorScheme) => { (colorScheme) => {
@@ -234,31 +193,9 @@ const theming = plugin.withOptions(
return { return {
[isDark ? darkSchemeSelectors : lightSchemeSelectors]: { [isDark ? darkSchemeSelectors : lightSchemeSelectors]: {
/**
* If a custom property is not available, browsers will use
* the fallback value. In this case, we want to use '--is-dark'
* as the indicator of a dark theme so, we can use it like this:
* background-color: var(--is-dark, red);
*
* If we set '--is-dark' as "true" on dark themes, the above rule
* won't work because of the said "fallback value" logic. Therefore,
* we set the '--is-dark' to "false" on light themes and not set it
* at all on dark themes so that the fallback value can be used on
* dark themes.
*
* On light themes, since '--is-dark' exists, the above rule will be
* interpolated as:
* "background-color: false"
*
* On dark themes, since '--is-dark' doesn't exist, the fallback value
* will be used ('red' in this case) and the rule will be interpolated as:
* "background-color: red"
*
* It's easier to understand and remember like this.
*/
...(!isDark ? { '--is-dark': 'false' } : {}), ...(!isDark ? { '--is-dark': 'false' } : {}),
/* Generate custom properties from customProps */
..._.fromPairs( ..._.fromPairs(
_.flatten( _.flatten(
_.map(background, (value, key) => [ _.map(background, (value, key) => [
@@ -287,7 +224,6 @@ const theming = plugin.withOptions(
); );
const schemeUtilities = (() => { const schemeUtilities = (() => {
/* Generate general styles & utilities */
return {}; return {};
})(); })();
@@ -298,11 +234,6 @@ const theming = plugin.withOptions(
return { return {
theme: { theme: {
extend: { extend: {
/**
* Add 'Primary', 'Accent' and 'Warn' palettes as colors so all color utilities
* are generated for them; "bg-primary", "text-on-primary", "bg-accent-600" etc.
* This will also allow using arbitrary values with them such as opacity and such.
*/
colors: _.fromPairs( colors: _.fromPairs(
_.flatten( _.flatten(
_.map( _.map(
@@ -398,7 +329,6 @@ const theming = plugin.withOptions(
}, },
}, },
}, },
}, },
}; };
} }

View File

@@ -1,11 +1,6 @@
const plugin = require('tailwindcss/plugin'); const plugin = require('tailwindcss/plugin');
module.exports = plugin(({ addComponents }) => { module.exports = plugin(({ addComponents }) => {
/*
* Add base components. These are very important for everything to look
* correct. We are adding these to the 'components' layer because they must
* be defined before pretty much everything else.
*/
addComponents({ addComponents({
'.mat-icon': { '.mat-icon': {
'--tw-text-opacity': '1', '--tw-text-opacity': '1',

View File

@@ -1,6 +1,6 @@
:host { :host {
display: flex; display: flex;
flex: 1 1 auto; // Flex-grow, flex-shrink, and basis set to auto flex: 1 1 auto; // Flex-grow, flex-shrink, and basis set to auto
width: 100%; // Full width of the container width: 100%; // Full width of the container
height: 100%; // Full height of the container height: 100%; // Full height of the container
} }

View File

@@ -1,5 +1,11 @@
import { provideAngor } from '@angor';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { APP_INITIALIZER, ApplicationConfig, inject, isDevMode } from '@angular/core'; import {
APP_INITIALIZER,
ApplicationConfig,
inject,
isDevMode,
} from '@angular/core';
import { LuxonDateAdapter } from '@angular/material-luxon-adapter'; import { LuxonDateAdapter } from '@angular/material-luxon-adapter';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { provideAnimations } from '@angular/platform-browser/animations'; import { provideAnimations } from '@angular/platform-browser/animations';
@@ -9,20 +15,18 @@ import {
withInMemoryScrolling, withInMemoryScrolling,
withPreloading, withPreloading,
} from '@angular/router'; } from '@angular/router';
import { provideAngor } from '@angor'; import { provideServiceWorker } from '@angular/service-worker';
import { TranslocoService, provideTransloco } from '@ngneat/transloco'; import { TranslocoService, provideTransloco } from '@ngneat/transloco';
import { WebLNProvider } from '@webbtc/webln-types';
import { appRoutes } from 'app/app.routes'; import { appRoutes } from 'app/app.routes';
import { provideIcons } from 'app/core/icons/icons.provider'; import { provideIcons } from 'app/core/icons/icons.provider';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { TranslocoHttpLoader } from './core/transloco/transloco.http-loader'; import { TranslocoHttpLoader } from './core/transloco/transloco.http-loader';
import { provideServiceWorker } from '@angular/service-worker';
import { HashService } from './services/hash.service';
import { navigationServices } from './layout/navigation/navigation.services'; import { navigationServices } from './layout/navigation/navigation.services';
import { WebLNProvider } from '@webbtc/webln-types'; import { HashService } from './services/hash.service';
import { NostrWindow } from './types/nostr'; import { NostrWindow } from './types/nostr';
export function initializeApp(hashService: HashService) { export function initializeApp(hashService: HashService) {
console.log('initializeApp. Getting hashService.load.');
return (): Promise<void> => hashService.load(); return (): Promise<void> => hashService.load();
} }
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
@@ -31,7 +35,7 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(), provideHttpClient(),
provideServiceWorker('ngsw-worker.js', { provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000' registrationStrategy: 'registerWhenStable:30000',
}), }),
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
@@ -54,12 +58,12 @@ export const appConfig: ApplicationConfig = {
provide: MAT_DATE_FORMATS, provide: MAT_DATE_FORMATS,
useValue: { useValue: {
parse: { parse: {
dateInput: 'D', // Date format for parsing dateInput: 'D', // Date format for parsing
}, },
display: { display: {
dateInput: 'DDD', // Date format for input display dateInput: 'DDD', // Date format for input display
monthYearLabel: 'LLL yyyy', // Format for month-year labels monthYearLabel: 'LLL yyyy', // Format for month-year labels
dateA11yLabel: 'DD', // Accessible format for dates dateA11yLabel: 'DD', // Accessible format for dates
monthYearA11yLabel: 'LLLL yyyy', // Accessible format for month-year monthYearA11yLabel: 'LLLL yyyy', // Accessible format for month-year
}, },
}, },
@@ -72,7 +76,7 @@ export const appConfig: ApplicationConfig = {
{ {
id: 'en', id: 'en',
label: 'English', label: 'English',
} },
], ],
defaultLang: 'en', defaultLang: 'en',
fallbackLang: 'en', fallbackLang: 'en',
@@ -121,13 +125,11 @@ export const appConfig: ApplicationConfig = {
], ],
}, },
}), }),
], ],
}; };
declare global { declare global {
interface Window { interface Window {
webln?: WebLNProvider; webln?: WebLNProvider;
nostr?: NostrWindow; nostr?: NostrWindow;
} }
} }

View File

@@ -9,11 +9,11 @@ import { forkJoin } from 'rxjs';
*/ */
export const initialDataResolver = () => { export const initialDataResolver = () => {
const navigationService = inject(NavigationService); const navigationService = inject(NavigationService);
const quickChatService = inject(QuickChatService); const quickChatService = inject(QuickChatService);
// Combine API calls into a single observable // Combine API calls into a single observable
return forkJoin([ return forkJoin([
navigationService.get(), // Fetch navigation data navigationService.get(), // Fetch navigation data
// quickChatService.getChats(), // Fetch chat data // quickChatService.getChats(), // Fetch chat data
]); ]);
}; };

View File

@@ -1,31 +1,30 @@
import { Route } from '@angular/router'; import { Route } from '@angular/router';
import { initialDataResolver } from 'app/app.resolvers'; import { initialDataResolver } from 'app/app.resolvers';
import { LayoutComponent } from 'app/layout/layout.component'; import { LayoutComponent } from 'app/layout/layout.component';
import { authGuard } from './core/auth/auth.guard'; import { authGuard } from './core/auth/auth.guard';
/** /**
* Application routes configuration * Application routes configuration
*/ */
export const appRoutes: Route[] = [ export const appRoutes: Route[] = [
// Redirect root path to '/explore' // Redirect root path to '/explore'
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'home' redirectTo: 'home',
}, },
{ {
path: 'project/:pubkey', path: 'project/:pubkey',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'explore' redirectTo: 'explore',
}, },
// Redirect login user to '/explore' // Redirect login user to '/explore'
{ {
path: 'login-redirect', path: 'login-redirect',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'explore' redirectTo: 'explore',
}, },
// Routes for guests // Routes for guests
@@ -36,13 +35,15 @@ export const appRoutes: Route[] = [
children: [ children: [
{ {
path: 'login', path: 'login',
loadChildren: () => import('app/components/auth/login/login.routes') loadChildren: () =>
import('app/components/auth/login/login.routes'),
}, },
{ {
path: 'register', path: 'register',
loadChildren: () => import('app/components/auth/register/register.routes') loadChildren: () =>
} import('app/components/auth/register/register.routes'),
] },
],
}, },
// Routes for authenticated users // Routes for authenticated users
@@ -55,13 +56,12 @@ export const appRoutes: Route[] = [
children: [ children: [
{ {
path: 'logout', path: 'logout',
loadChildren: () => import('app/components/auth/logout/logout.routes') loadChildren: () =>
} import('app/components/auth/logout/logout.routes'),
] },
],
}, },
// Authenticated routes for Angor // Authenticated routes for Angor
{ {
path: '', path: '',
@@ -72,41 +72,49 @@ export const appRoutes: Route[] = [
children: [ children: [
{ {
path: 'home', path: 'home',
loadChildren: () => import('app/components/home/home.routes') loadChildren: () => import('app/components/home/home.routes'),
}, },
{ {
path: 'explore', path: 'explore',
loadChildren: () => import('app/components/explore/explore.routes') loadChildren: () =>
import('app/components/explore/explore.routes'),
}, },
{ {
path: 'profile', path: 'profile',
loadChildren: () => import('app/components/profile/profile.routes') loadChildren: () =>
import('app/components/profile/profile.routes'),
}, },
{ {
path: 'profile/:pubkey', path: 'profile/:pubkey',
loadChildren: () => import('app/components/profile/profile.routes') loadChildren: () =>
import('app/components/profile/profile.routes'),
}, },
{ {
path: 'settings', path: 'settings',
loadChildren: () => import('app/components/settings/settings.routes') loadChildren: () =>
import('app/components/settings/settings.routes'),
}, },
{ {
path: 'settings/:id', path: 'settings/:id',
loadChildren: () => import('app/components/settings/settings.routes') loadChildren: () =>
import('app/components/settings/settings.routes'),
}, },
{ {
path: 'chat', path: 'chat',
loadChildren: () => import('app/components/chat/chat.routes') loadChildren: () => import('app/components/chat/chat.routes'),
}, },
{ {
path: '404-not-found', path: '404-not-found',
pathMatch: 'full', pathMatch: 'full',
loadChildren: () => import('app/components/pages/error/error-404/error-404.routes') loadChildren: () =>
import(
'app/components/pages/error/error-404/error-404.routes'
),
}, },
{ {
path: '**', path: '**',
redirectTo: '404-not-found' redirectTo: '404-not-found',
} },
] ],
} },
]; ];

View File

@@ -1,18 +1,33 @@
<div class="flex min-w-0 flex-auto flex-col items-center sm:flex-row sm:justify-center md:items-start md:justify-start"> <div
class="flex min-w-0 flex-auto flex-col items-center sm:flex-row sm:justify-center md:items-start md:justify-start"
>
<div <div
class="w-full px-4 py-8 sm:bg-card sm:w-auto sm:rounded-2xl sm:p-12 sm:shadow md:flex md:h-full md:w-1/2 md:items-center md:justify-end md:rounded-none md:p-16 md:shadow-none"> class="w-full px-4 py-8 sm:bg-card sm:w-auto sm:rounded-2xl sm:p-12 sm:shadow md:flex md:h-full md:w-1/2 md:items-center md:justify-end md:rounded-none md:p-16 md:shadow-none"
>
<div class="mx-auto w-full max-w-80 sm:mx-0 sm:w-80"> <div class="mx-auto w-full max-w-80 sm:mx-0 sm:w-80">
<!-- Title --> <!-- Title -->
<div class="mt-8 text-4xl font-extrabold leading-tight tracking-tight"> <div
class="mt-8 text-4xl font-extrabold leading-tight tracking-tight"
>
Login Login
</div> </div>
<div class="mt-0.5 flex items-baseline font-medium"> <div class="mt-0.5 flex items-baseline font-medium">
<div>Don't have an account?</div> <div>Don't have an account?</div>
<a class="ml-1 text-primary-500 hover:underline" [routerLink]="['/register']">Register</a> <a
class="ml-1 text-primary-500 hover:underline"
[routerLink]="['/register']"
>Register</a
>
</div> </div>
<angor-alert *ngIf="showSecAlert" class="mt-8" [appearance]="'outline'" [showIcon]="false" [type]="secAlert.type" <angor-alert
[@shake]="secAlert.type === 'error'"> *ngIf="showSecAlert"
class="mt-8"
[appearance]="'outline'"
[showIcon]="false"
[type]="secAlert.type"
[@shake]="secAlert.type === 'error'"
>
{{ secAlert.message }} {{ secAlert.message }}
</angor-alert> </angor-alert>
@@ -26,8 +41,16 @@
<!-- extension login buttons --> <!-- extension login buttons -->
<div class="mt-8 flex items-center space-x-4"> <div class="mt-8 flex items-center space-x-4">
<button class="flex-auto space-x-2" type="button" mat-stroked-button (click)="loginWithNostrExtension()"> <button
<mat-icon class="icon-size-5" [svgIcon]="'feather:zap'"></mat-icon> class="flex-auto space-x-2"
type="button"
mat-stroked-button
(click)="loginWithNostrExtension()"
>
<mat-icon
class="icon-size-5"
[svgIcon]="'feather:zap'"
></mat-icon>
<span>Login with Nostr Extension</span> <span>Login with Nostr Extension</span>
</button> </button>
</div> </div>
@@ -40,7 +63,11 @@
</div> </div>
<!-- Login form with Secret Key --> <!-- Login form with Secret Key -->
<form class="mt-8" [formGroup]="SecretKeyLoginForm" (ngSubmit)="loginWithSecretKey()"> <form
class="mt-8"
[formGroup]="SecretKeyLoginForm"
(ngSubmit)="loginWithSecretKey()"
>
<!-- secret key field --> <!-- secret key field -->
<div class="mt-8 flex items-center"> <div class="mt-8 flex items-center">
<div class="mt-px flex-auto border-t"></div> <div class="mt-px flex-auto border-t"></div>
@@ -49,8 +76,14 @@
</div> </div>
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Secret Key</mat-label> <mat-label>Secret Key</mat-label>
<input matInput formControlName="secretKey" autocomplete="secretKey" /> <input
@if (SecretKeyLoginForm.get('secretKey').hasError('required')) { matInput
formControlName="secretKey"
autocomplete="secretKey"
/>
@if (
SecretKeyLoginForm.get('secretKey').hasError('required')
) {
<mat-error> Secret key is required </mat-error> <mat-error> Secret key is required </mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -58,47 +91,94 @@
<!-- Password field --> <!-- Password field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Password</mat-label> <mat-label>Password</mat-label>
<input matInput type="password" [formControlName]="'password'" autocomplete="current-password-seckey" #secretPasswordField /> <input
<button mat-icon-button type="button" (click)=" matInput
secretPasswordField.type === 'password' type="password"
? (secretPasswordField.type = 'text') [formControlName]="'password'"
: (secretPasswordField.type = 'password') autocomplete="current-password-seckey"
" matSuffix> #secretPasswordField
<mat-icon *ngIf="secretPasswordField.type === 'password'" class="icon-size-5" />
[svgIcon]="'heroicons_solid:eye'"></mat-icon> <button
<mat-icon *ngIf="secretPasswordField.type === 'text'" class="icon-size-5" mat-icon-button
[svgIcon]="'heroicons_solid:eye-slash'"></mat-icon> type="button"
(click)="
secretPasswordField.type === 'password'
? (secretPasswordField.type = 'text')
: (secretPasswordField.type = 'password')
"
matSuffix
>
<mat-icon
*ngIf="secretPasswordField.type === 'password'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye'"
></mat-icon>
<mat-icon
*ngIf="secretPasswordField.type === 'text'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye-slash'"
></mat-icon>
</button> </button>
<mat-error *ngIf="SecretKeyLoginForm.get('password').hasError('required')"> Password is required <mat-error
*ngIf="
SecretKeyLoginForm.get('password').hasError(
'required'
)
"
>
Password is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<!-- Submit button --> <!-- Submit button -->
<button class="angor-mat-button-large mt-6 w-full" mat-flat-button color="primary" <button
[disabled]="SecretKeyLoginForm.invalid"> class="angor-mat-button-large mt-6 w-full"
mat-flat-button
color="primary"
[disabled]="SecretKeyLoginForm.invalid"
>
<span *ngIf="!loading">Login</span> <span *ngIf="!loading">Login</span>
<mat-progress-spinner *ngIf="loading" diameter="24" mode="indeterminate"></mat-progress-spinner> <mat-progress-spinner
*ngIf="loading"
diameter="24"
mode="indeterminate"
></mat-progress-spinner>
</button> </button>
</form> </form>
<div class="mt-8 flex items-center"> <div class="mt-8 flex items-center">
<div class="mt-px flex-auto border-t"></div> <div class="mt-px flex-auto border-t"></div>
<div class="text-secondary mx-2">Or enter menemonic</div> <div class="text-secondary mx-2">Or enter menemonic</div>
<div class="mt-px flex-auto border-t"></div> <div class="mt-px flex-auto border-t"></div>
</div> </div>
<angor-alert *ngIf="showMenemonicAlert" class="mt-8" [appearance]="'outline'" [showIcon]="false" [type]="menemonicAlert.type" <angor-alert
[@shake]="menemonicAlert.type === 'error'"> *ngIf="showMenemonicAlert"
class="mt-8"
[appearance]="'outline'"
[showIcon]="false"
[type]="menemonicAlert.type"
[@shake]="menemonicAlert.type === 'error'"
>
{{ menemonicAlert.message }} {{ menemonicAlert.message }}
</angor-alert> </angor-alert>
<!-- Login form with Menemonic --> <!-- Login form with Menemonic -->
<form class="mt-8" [formGroup]="MenemonicLoginForm" (ngSubmit)="loginWithMenemonic()"> <form
class="mt-8"
[formGroup]="MenemonicLoginForm"
(ngSubmit)="loginWithMenemonic()"
>
<!-- Menemonic field --> <!-- Menemonic field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Menemonic</mat-label> <mat-label>Menemonic</mat-label>
<input matInput formControlName="menemonic" autocomplete="menemonic" /> <input
@if (MenemonicLoginForm.get('menemonic').hasError('required')) { matInput
formControlName="menemonic"
autocomplete="menemonic"
/>
@if (
MenemonicLoginForm.get('menemonic').hasError('required')
) {
<mat-error> Menemonic is required </mat-error> <mat-error> Menemonic is required </mat-error>
} }
</mat-form-field> </mat-form-field>
@@ -106,66 +186,156 @@
<!-- Passphrase field --> <!-- Passphrase field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Passphrase (Optional)</mat-label> <mat-label>Passphrase (Optional)</mat-label>
<input matInput type="password" [formControlName]="'passphrase'" autocomplete="current-passphrase-menemonic" <input
#passphraseField /> matInput
<button mat-icon-button type="button" (click)=" type="password"
passphraseField.type === 'password' [formControlName]="'passphrase'"
? (passphraseField.type = 'text') autocomplete="current-passphrase-menemonic"
: (passphraseField.type = 'password') #passphraseField
" matSuffix> />
<mat-icon *ngIf="passphraseField.type === 'password'" class="icon-size-5" [svgIcon]="'heroicons_solid:eye'"></mat-icon> <button
<mat-icon *ngIf="passphraseField.type === 'text'" class="icon-size-5" [svgIcon]="'heroicons_solid:eye-slash'"></mat-icon> mat-icon-button
type="button"
(click)="
passphraseField.type === 'password'
? (passphraseField.type = 'text')
: (passphraseField.type = 'password')
"
matSuffix
>
<mat-icon
*ngIf="passphraseField.type === 'password'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye'"
></mat-icon>
<mat-icon
*ngIf="passphraseField.type === 'text'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye-slash'"
></mat-icon>
</button> </button>
<mat-error *ngIf="MenemonicLoginForm.get('passphrase').hasError('required')"> Passphrase is required <mat-error
*ngIf="
MenemonicLoginForm.get('passphrase').hasError(
'required'
)
"
>
Passphrase is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<!-- Password field --> <!-- Password field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Password</mat-label> <mat-label>Password</mat-label>
<input matInput type="password" [formControlName]="'password'" autocomplete="current-password-menemonic" <input
#menemonicPasswordField /> matInput
<button mat-icon-button type="button" (click)=" type="password"
menemonicPasswordField.type === 'password' [formControlName]="'password'"
? (menemonicPasswordField.type = 'text') autocomplete="current-password-menemonic"
: (menemonicPasswordField.type = 'password') #menemonicPasswordField
" matSuffix> />
<mat-icon *ngIf="menemonicPasswordField.type === 'password'" class="icon-size-5" [svgIcon]="'heroicons_solid:eye'"></mat-icon> <button
<mat-icon *ngIf="menemonicPasswordField.type === 'text'" class="icon-size-5" [svgIcon]="'heroicons_solid:eye-slash'"></mat-icon> mat-icon-button
type="button"
(click)="
menemonicPasswordField.type === 'password'
? (menemonicPasswordField.type = 'text')
: (menemonicPasswordField.type = 'password')
"
matSuffix
>
<mat-icon
*ngIf="menemonicPasswordField.type === 'password'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye'"
></mat-icon>
<mat-icon
*ngIf="menemonicPasswordField.type === 'text'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye-slash'"
></mat-icon>
</button> </button>
<mat-error *ngIf="MenemonicLoginForm.get('password').hasError('required')"> Password is required <mat-error
*ngIf="
MenemonicLoginForm.get('password').hasError(
'required'
)
"
>
Password is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<!-- Submit button --> <!-- Submit button -->
<button class="angor-mat-button-large mt-6 w-full" mat-flat-button color="primary" <button
[disabled]="MenemonicLoginForm.invalid"> class="angor-mat-button-large mt-6 w-full"
mat-flat-button
color="primary"
[disabled]="MenemonicLoginForm.invalid"
>
<span *ngIf="!loading">Login</span> <span *ngIf="!loading">Login</span>
<mat-progress-spinner *ngIf="loading" diameter="24" mode="indeterminate"></mat-progress-spinner> <mat-progress-spinner
*ngIf="loading"
diameter="24"
mode="indeterminate"
></mat-progress-spinner>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<div <div
class="relative hidden h-full w-1/2 flex-auto items-center justify-center overflow-hidden bg-gray-800 p-16 dark:border-l md:flex lg:px-28"> class="relative hidden h-full w-1/2 flex-auto items-center justify-center overflow-hidden bg-gray-800 p-16 dark:border-l md:flex lg:px-28"
>
<svg class="absolute inset-0 pointer-events-none" viewBox="0 0 960 540" width="100%" height="100%" <svg
preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg"> class="pointer-events-none absolute inset-0"
<g class="text-gray-700 opacity-25" fill="none" stroke="currentColor" stroke-width="100"> viewBox="0 0 960 540"
width="100%"
height="100%"
preserveAspectRatio="xMidYMax slice"
xmlns="http://www.w3.org/2000/svg"
>
<g
class="text-gray-700 opacity-25"
fill="none"
stroke="currentColor"
stroke-width="100"
>
<circle r="234" cx="196" cy="23"></circle> <circle r="234" cx="196" cy="23"></circle>
<circle r="234" cx="790" cy="491"></circle> <circle r="234" cx="790" cy="491"></circle>
</g> </g>
</svg> </svg>
<svg class="absolute -top-16 -right-16 text-gray-700" viewBox="0 0 220 192" width="220" height="192" <svg
fill="none"> class="absolute -right-16 -top-16 text-gray-700"
viewBox="0 0 220 192"
width="220"
height="192"
fill="none"
>
<defs> <defs>
<pattern id="837c3e70-6c3a-44e6-8854-cc48c737b659" x="0" y="0" width="20" height="20" <pattern
patternUnits="userSpaceOnUse"> id="837c3e70-6c3a-44e6-8854-cc48c737b659"
<rect x="0" y="0" width="4" height="4" fill="currentColor"></rect> x="0"
y="0"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width="4"
height="4"
fill="currentColor"
></rect>
</pattern> </pattern>
</defs> </defs>
<rect width="220" height="192" fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"></rect> <rect
width="220"
height="192"
fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"
></rect>
</svg> </svg>
<!-- Background and Content --> <!-- Background and Content -->
<div class="relative z-10 w-full max-w-2xl"> <div class="relative z-10 w-full max-w-2xl">

View File

@@ -1,7 +1,13 @@
import { AngorAlertComponent } from '@angor/components/alert'; import { AngorAlertComponent } from '@angor/components/alert';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,7 +32,7 @@ import { SignerService } from 'app/services/signer.service';
MatIconModule, MatIconModule,
MatCheckboxModule, MatCheckboxModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
CommonModule CommonModule,
], ],
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
@@ -41,15 +47,15 @@ export class LoginComponent implements OnInit {
loading = false; loading = false;
isInstalledExtension = false; isInstalledExtension = false;
privateKey: Uint8Array = new Uint8Array(); privateKey: Uint8Array = new Uint8Array();
publicKey: string = ""; publicKey: string = '';
npub: string = ""; npub: string = '';
nsec: string = ""; nsec: string = '';
constructor( constructor(
private _formBuilder: FormBuilder, private _formBuilder: FormBuilder,
private _router: Router, private _router: Router,
private _signerService: SignerService private _signerService: SignerService
) { } ) {}
ngOnInit(): void { ngOnInit(): void {
this.initializeForms(); this.initializeForms();
@@ -59,20 +65,25 @@ export class LoginComponent implements OnInit {
private initializeForms(): void { private initializeForms(): void {
this.SecretKeyLoginForm = this._formBuilder.group({ this.SecretKeyLoginForm = this._formBuilder.group({
secretKey: ['', [Validators.required, Validators.minLength(3)]], secretKey: ['', [Validators.required, Validators.minLength(3)]],
password: ['', Validators.required] password: ['', Validators.required],
}); });
this.MenemonicLoginForm = this._formBuilder.group({ this.MenemonicLoginForm = this._formBuilder.group({
menemonic: ['', [Validators.required, Validators.minLength(3)]], menemonic: ['', [Validators.required, Validators.minLength(3)]],
passphrase: [''], // Passphrase is optional passphrase: [''], // Passphrase is optional
password: ['', Validators.required] password: ['', Validators.required],
}); });
} }
private checkNostrExtensionAvailability(): void { private checkNostrExtensionAvailability(): void {
const globalContext = globalThis as unknown as { nostr?: { signEvent?: Function } }; const globalContext = globalThis as unknown as {
nostr?: { signEvent?: Function };
};
if (globalContext.nostr && typeof globalContext.nostr.signEvent === 'function') { if (
globalContext.nostr &&
typeof globalContext.nostr.signEvent === 'function'
) {
this.isInstalledExtension = true; this.isInstalledExtension = true;
} else { } else {
this.isInstalledExtension = false; this.isInstalledExtension = false;
@@ -91,7 +102,10 @@ export class LoginComponent implements OnInit {
this.showSecAlert = false; this.showSecAlert = false;
try { try {
const success = this._signerService.handleLoginWithKey(secretKey,password); // Updated method to handle both nsec and hex const success = this._signerService.handleLoginWithKey(
secretKey,
password
); // Updated method to handle both nsec and hex
if (success) { if (success) {
// Successful login // Successful login
@@ -102,26 +116,33 @@ export class LoginComponent implements OnInit {
} catch (error) { } catch (error) {
// Handle login failure // Handle login failure
this.loading = false; this.loading = false;
this.secAlert.message = (error instanceof Error) ? error.message : 'An unexpected error occurred.'; this.secAlert.message =
error instanceof Error
? error.message
: 'An unexpected error occurred.';
this.showSecAlert = true; this.showSecAlert = true;
console.error("Login error: ", error); console.error('Login error: ', error);
} }
} }
loginWithMenemonic(): void { loginWithMenemonic(): void {
if (this.MenemonicLoginForm.invalid) { if (this.MenemonicLoginForm.invalid) {
return; return;
} }
const menemonic = this.MenemonicLoginForm.get('menemonic')?.value; const menemonic = this.MenemonicLoginForm.get('menemonic')?.value;
const passphrase = this.MenemonicLoginForm.get('passphrase')?.value || ''; // Optional passphrase const passphrase =
this.MenemonicLoginForm.get('passphrase')?.value || ''; // Optional passphrase
const password = this.MenemonicLoginForm.get('password')?.value; const password = this.MenemonicLoginForm.get('password')?.value;
this.loading = true; this.loading = true;
this.showMenemonicAlert = false; this.showMenemonicAlert = false;
const success = this._signerService.handleLoginWithMenemonic(menemonic, passphrase,password); const success = this._signerService.handleLoginWithMenemonic(
menemonic,
passphrase,
password
);
if (success) { if (success) {
this._router.navigateByUrl('/home'); this._router.navigateByUrl('/home');
@@ -140,5 +161,4 @@ export class LoginComponent implements OnInit {
console.error('Failed to log in using Nostr extension'); console.error('Failed to log in using Nostr extension');
} }
} }
} }

View File

@@ -27,9 +27,8 @@
<a <a
class="ml-1 text-primary-500 hover:underline" class="ml-1 text-primary-500 hover:underline"
[routerLink]="['/login']" [routerLink]="['/login']"
> >
<span class="text-2xl font-extrabold">Go to login</span> <span class="text-2xl font-extrabold">Go to login</span>
</a> </a>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,10 @@ export class LogoutComponent implements OnInit, OnDestroy {
}; };
private _unsubscribeAll: Subject<any> = new Subject<any>(); private _unsubscribeAll: Subject<any> = new Subject<any>();
constructor(private _router: Router, private _signerService: SignerService) {} constructor(
private _router: Router,
private _signerService: SignerService
) {}
ngOnInit(): void { ngOnInit(): void {
timer(1000, 1000) timer(1000, 1000)
@@ -35,7 +38,6 @@ export class LogoutComponent implements OnInit, OnDestroy {
.subscribe(); .subscribe();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this._unsubscribeAll.next(null); this._unsubscribeAll.next(null);
this._unsubscribeAll.complete(); this._unsubscribeAll.complete();
@@ -44,6 +46,6 @@ export class LogoutComponent implements OnInit, OnDestroy {
logout(): void { logout(): void {
this._signerService.clearPassword(); this._signerService.clearPassword();
this._signerService.logout(); this._signerService.logout();
console.log("User logged out and keys removed from localStorage."); console.log('User logged out and keys removed from localStorage.');
} }
} }

View File

@@ -1,107 +1,231 @@
<div class="flex min-w-0 flex-auto flex-col items-center sm:flex-row sm:justify-center md:items-start md:justify-start"> <div
class="flex min-w-0 flex-auto flex-col items-center sm:flex-row sm:justify-center md:items-start md:justify-start"
>
<div <div
class="w-full px-4 py-8 sm:bg-card sm:w-auto sm:rounded-2xl sm:p-12 sm:shadow md:flex md:h-full md:w-1/2 md:items-center md:justify-end md:rounded-none md:p-16 md:shadow-none"> class="w-full px-4 py-8 sm:bg-card sm:w-auto sm:rounded-2xl sm:p-12 sm:shadow md:flex md:h-full md:w-1/2 md:items-center md:justify-end md:rounded-none md:p-16 md:shadow-none"
>
<div class="mx-auto w-full max-w-80 sm:mx-0 sm:w-80"> <div class="mx-auto w-full max-w-80 sm:mx-0 sm:w-80">
<!-- Title --> <!-- Title -->
<div class="mt-8 text-4xl font-extrabold leading-tight tracking-tight"> <div
class="mt-8 text-4xl font-extrabold leading-tight tracking-tight"
>
Register Register
</div> </div>
<div class="mt-0.5 flex items-baseline font-medium"> <div class="mt-0.5 flex items-baseline font-medium">
<div>Already have an account?</div> <div>Already have an account?</div>
<a class="ml-1 text-primary-500 hover:underline" [routerLink]="['/login']">Login <a
class="ml-1 text-primary-500 hover:underline"
[routerLink]="['/login']"
>Login
</a> </a>
</div> </div>
<!-- Alert --> <!-- Alert -->
@if (showAlert) { @if (showAlert) {
<angor-alert class="mt-8" [appearance]="'outline'" [showIcon]="false" [type]="alert.type" <angor-alert
[@shake]="alert.type === 'error'"> class="mt-8"
{{ alert.message }} [appearance]="'outline'"
</angor-alert> [showIcon]="false"
[type]="alert.type"
[@shake]="alert.type === 'error'"
>
{{ alert.message }}
</angor-alert>
} }
<!-- Register form --> <!-- Register form -->
<form class="mt-8" [formGroup]="registerForm" #registerNgForm="ngForm"> <form
class="mt-8"
[formGroup]="registerForm"
#registerNgForm="ngForm"
>
<!-- Name field (secretKey) --> <!-- Name field (secretKey) -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Full name</mat-label> <mat-label>Full name</mat-label>
<input id="name" matInput [formControlName]="'name'" autocomplete="name"/> <input
<mat-error *ngIf="registerForm.get('name').hasError('required')"> Full name is required </mat-error> id="name"
matInput
[formControlName]="'name'"
autocomplete="name"
/>
<mat-error
*ngIf="registerForm.get('name').hasError('required')"
>
Full name is required
</mat-error>
</mat-form-field> </mat-form-field>
<!-- Username field --> <!-- Username field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Username</mat-label> <mat-label>Username</mat-label>
<input id="username" matInput [formControlName]="'username'" autocomplete="username"/> <input
<mat-error *ngIf="registerForm.get('username').hasError('required')"> Username is required </mat-error> id="username"
matInput
[formControlName]="'username'"
autocomplete="username"
/>
<mat-error
*ngIf="
registerForm.get('username').hasError('required')
"
>
Username is required
</mat-error>
</mat-form-field> </mat-form-field>
<!-- About field --> <!-- About field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>About</mat-label> <mat-label>About</mat-label>
<textarea id="about" matInput [formControlName]="'about'"></textarea> <textarea
id="about"
matInput
[formControlName]="'about'"
></textarea>
</mat-form-field> </mat-form-field>
<!-- Avatar URL field --> <!-- Avatar URL field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Avatar URL</mat-label> <mat-label>Avatar URL</mat-label>
<input id="avatarUrl" matInput [formControlName]="'avatarUrl'" autocomplete="avatarUrl" /> <input
id="avatarUrl"
matInput
[formControlName]="'avatarUrl'"
autocomplete="avatarUrl"
/>
</mat-form-field> </mat-form-field>
<!-- Password field --> <!-- Password field -->
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Password</mat-label> <mat-label>Password</mat-label>
<input id="password" matInput type="password" [formControlName]="'password'" autocomplete="password" #passwordField /> <input
<button mat-icon-button type="button" (click)=" id="password"
matInput
type="password"
[formControlName]="'password'"
autocomplete="password"
#passwordField
/>
<button
mat-icon-button
type="button"
(click)="
passwordField.type === 'password' passwordField.type === 'password'
? (passwordField.type = 'text') ? (passwordField.type = 'text')
: (passwordField.type = 'password') : (passwordField.type = 'password')
" matSuffix> "
<mat-icon *ngIf="passwordField.type === 'password'" class="icon-size-5" [svgIcon]="'heroicons_solid:eye'"></mat-icon> matSuffix
<mat-icon *ngIf="passwordField.type === 'text'" class="icon-size-5" [svgIcon]="'heroicons_solid:eye-slash'"></mat-icon> >
<mat-icon
*ngIf="passwordField.type === 'password'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye'"
></mat-icon>
<mat-icon
*ngIf="passwordField.type === 'text'"
class="icon-size-5"
[svgIcon]="'heroicons_solid:eye-slash'"
></mat-icon>
</button> </button>
<mat-error *ngIf="registerForm.get('password').hasError('required')"> Password is required </mat-error> <mat-error
*ngIf="
registerForm.get('password').hasError('required')
"
>
Password is required
</mat-error>
</mat-form-field> </mat-form-field>
<!-- ToS and PP --> <!-- ToS and PP -->
<div class="mt-1.5 inline-flex w-full items-end"> <div class="mt-1.5 inline-flex w-full items-end">
<mat-checkbox class="-ml-2" [color]="'primary'" [formControlName]="'agreements'"> <mat-checkbox
class="-ml-2"
[color]="'primary'"
[formControlName]="'agreements'"
>
<span>I agree with</span> <span>I agree with</span>
<a class="ml-1 text-primary-500 hover:underline" [routerLink]="['./']">Terms</a> <a
class="ml-1 text-primary-500 hover:underline"
[routerLink]="['./']"
>Terms</a
>
<span>and</span> <span>and</span>
<a class="ml-1 text-primary-500 hover:underline" [routerLink]="['./']">Privacy Policy</a> <a
class="ml-1 text-primary-500 hover:underline"
[routerLink]="['./']"
>Privacy Policy</a
>
</mat-checkbox> </mat-checkbox>
</div> </div>
<!-- Submit button --> <!-- Submit button -->
<button class="angor-mat-button-large mt-6 w-full" mat-flat-button [color]="'primary'" <button
[disabled]="registerForm.invalid" (click)="register()"> class="angor-mat-button-large mt-6 w-full"
mat-flat-button
[color]="'primary'"
[disabled]="registerForm.invalid"
(click)="register()"
>
<span>Create your account</span> <span>Create your account</span>
<mat-progress-spinner *ngIf="registerForm.disabled" [diameter]="24" [mode]="'indeterminate'"></mat-progress-spinner> <mat-progress-spinner
*ngIf="registerForm.disabled"
[diameter]="24"
[mode]="'indeterminate'"
></mat-progress-spinner>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<div <div
class="relative hidden h-full w-1/2 flex-auto items-center justify-center overflow-hidden bg-gray-800 p-16 dark:border-l md:flex lg:px-28"> class="relative hidden h-full w-1/2 flex-auto items-center justify-center overflow-hidden bg-gray-800 p-16 dark:border-l md:flex lg:px-28"
>
<svg class="absolute inset-0 pointer-events-none" viewBox="0 0 960 540" width="100%" height="100%" <svg
preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg"> class="pointer-events-none absolute inset-0"
<g class="text-gray-700 opacity-25" fill="none" stroke="currentColor" stroke-width="100"> viewBox="0 0 960 540"
width="100%"
height="100%"
preserveAspectRatio="xMidYMax slice"
xmlns="http://www.w3.org/2000/svg"
>
<g
class="text-gray-700 opacity-25"
fill="none"
stroke="currentColor"
stroke-width="100"
>
<circle r="234" cx="196" cy="23"></circle> <circle r="234" cx="196" cy="23"></circle>
<circle r="234" cx="790" cy="491"></circle> <circle r="234" cx="790" cy="491"></circle>
</g> </g>
</svg> </svg>
<svg class="absolute -top-16 -right-16 text-gray-700" viewBox="0 0 220 192" width="220" height="192" <svg
fill="none"> class="absolute -right-16 -top-16 text-gray-700"
viewBox="0 0 220 192"
width="220"
height="192"
fill="none"
>
<defs> <defs>
<pattern id="837c3e70-6c3a-44e6-8854-cc48c737b659" x="0" y="0" width="20" height="20" <pattern
patternUnits="userSpaceOnUse"> id="837c3e70-6c3a-44e6-8854-cc48c737b659"
<rect x="0" y="0" width="4" height="4" fill="currentColor"></rect> x="0"
y="0"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width="4"
height="4"
fill="currentColor"
></rect>
</pattern> </pattern>
</defs> </defs>
<rect width="220" height="192" fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"></rect> <rect
width="220"
height="192"
fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"
></rect>
</svg> </svg>
<!-- Content --> <!-- Content -->
<div class="relative z-10 w-full max-w-2xl"> <div class="relative z-10 w-full max-w-2xl">
@@ -110,8 +234,8 @@
<div>Angor Hub</div> <div>Angor Hub</div>
</div> </div>
<div class="mt-6 text-lg leading-6 tracking-tight text-gray-400"> <div class="mt-6 text-lg leading-6 tracking-tight text-gray-400">
Angor Hub is a Nostr client that is customized around the Angor protocol, a decentralized crowdfunding Angor Hub is a Nostr client that is customized around the Angor
platform. protocol, a decentralized crowdfunding platform.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,6 @@
import { angorAnimations } from '@angor/animations';
import { AngorAlertComponent, AngorAlertType } from '@angor/components/alert';
import { CommonModule } from '@angular/common';
import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { import {
FormsModule, FormsModule,
@@ -14,11 +17,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { angorAnimations } from '@angor/animations';
import { AngorAlertComponent, AngorAlertType } from '@angor/components/alert';
import { SecurityService } from 'app/services/security.service';
import { SignerService } from 'app/services/signer.service'; import { SignerService } from 'app/services/signer.service';
import { CommonModule } from '@angular/common';
@Component({ @Component({
selector: 'auth-register', selector: 'auth-register',
@@ -37,7 +36,7 @@ import { CommonModule } from '@angular/common';
MatIconModule, MatIconModule,
MatCheckboxModule, MatCheckboxModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
CommonModule CommonModule,
], ],
}) })
export class RegisterComponent implements OnInit { export class RegisterComponent implements OnInit {
@@ -89,7 +88,10 @@ export class RegisterComponent implements OnInit {
if (!keys) { if (!keys) {
// If key generation failed, enable the form and show an error // If key generation failed, enable the form and show an error
this.registerForm.enable(); this.registerForm.enable();
this.alert = { type: 'error', message: 'Error generating keys. Please try again.' }; this.alert = {
type: 'error',
message: 'Error generating keys. Please try again.',
};
this.showAlert = true; this.showAlert = true;
return; return;
} }
@@ -112,11 +114,13 @@ export class RegisterComponent implements OnInit {
console.log('User Metadata:', userMetadata); console.log('User Metadata:', userMetadata);
// Display success alert // Display success alert
this.alert = { type: 'success', message: 'Account created successfully!' }; this.alert = {
type: 'success',
message: 'Account created successfully!',
};
this.showAlert = true; this.showAlert = true;
// Redirect to home // Redirect to home
this._router.navigateByUrl('/home'); this._router.navigateByUrl('/home');
} }
} }

View File

@@ -25,7 +25,8 @@ const conversationResolver = (
const chatService = inject(ChatService); const chatService = inject(ChatService);
const router = inject(Router); const router = inject(Router);
let chatId = route.paramMap.get('id') || localStorage.getItem('currentChatId'); let chatId =
route.paramMap.get('id') || localStorage.getItem('currentChatId');
if (!chatId) { if (!chatId) {
const parentUrl = state.url.split('/').slice(0, -1).join('/'); const parentUrl = state.url.split('/').slice(0, -1).join('/');
@@ -45,7 +46,6 @@ const conversationResolver = (
); );
}; };
export default [ export default [
{ {
path: '', path: '',

View File

@@ -1,14 +1,28 @@
import { Injectable, OnDestroy } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Observable, Subject, throwError, of, Subscriber, from } from 'rxjs';
import { catchError, filter, map, switchMap, take, takeUntil, tap } from 'rxjs/operators';
import { Chat, Contact, Profile } from 'app/components/chat/chat.types'; import { Chat, Contact, Profile } from 'app/components/chat/chat.types';
import { IndexedDBService } from 'app/services/indexed-db.service'; import { IndexedDBService } from 'app/services/indexed-db.service';
import { MetadataService } from 'app/services/metadata.service'; import { MetadataService } from 'app/services/metadata.service';
import { SignerService } from 'app/services/signer.service';
import { Filter, NostrEvent } from 'nostr-tools';
import { RelayService } from 'app/services/relay.service'; import { RelayService } from 'app/services/relay.service';
import { SignerService } from 'app/services/signer.service';
import { Filter, getEventHash, NostrEvent } from 'nostr-tools';
import { EncryptedDirectMessage } from 'nostr-tools/kinds'; import { EncryptedDirectMessage } from 'nostr-tools/kinds';
import { getEventHash } from 'nostr-tools'; import {
BehaviorSubject,
from,
Observable,
of,
Subject,
throwError,
} from 'rxjs';
import {
catchError,
filter,
map,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChatService implements OnDestroy { export class ChatService implements OnDestroy {
@@ -18,22 +32,26 @@ export class ChatService implements OnDestroy {
private isDecrypting = false; private isDecrypting = false;
private recipientPublicKey: string; private recipientPublicKey: string;
private message: string; private message: string;
private decryptedPrivateKey: string = ""; private decryptedPrivateKey: string = '';
private _chat: BehaviorSubject<Chat | null> = new BehaviorSubject(null); private _chat: BehaviorSubject<Chat | null> = new BehaviorSubject(null);
private _chats: BehaviorSubject<Chat[] | null> = new BehaviorSubject(null); private _chats: BehaviorSubject<Chat[] | null> = new BehaviorSubject(null);
private _contact: BehaviorSubject<Contact | null> = new BehaviorSubject(null); private _contact: BehaviorSubject<Contact | null> = new BehaviorSubject(
private _contacts: BehaviorSubject<Contact[] | null> = new BehaviorSubject(null); null
private _profile: BehaviorSubject<Profile | null> = new BehaviorSubject(null); );
private _contacts: BehaviorSubject<Contact[] | null> = new BehaviorSubject(
null
);
private _profile: BehaviorSubject<Profile | null> = new BehaviorSubject(
null
);
private _unsubscribeAll: Subject<void> = new Subject<void>(); private _unsubscribeAll: Subject<void> = new Subject<void>();
constructor( constructor(
private _metadataService: MetadataService, private _metadataService: MetadataService,
private _signerService: SignerService, private _signerService: SignerService,
private _indexedDBService: IndexedDBService, private _indexedDBService: IndexedDBService,
private _relayService: RelayService, private _relayService: RelayService
) {}
) { }
get profile$(): Observable<Profile | null> { get profile$(): Observable<Profile | null> {
return this._profile.asObservable(); return this._profile.asObservable();
} }
@@ -54,46 +72,50 @@ export class ChatService implements OnDestroy {
return this._contacts.asObservable(); return this._contacts.asObservable();
} }
checkCurrentChatOnPageRefresh(chatIdFromURL: string): void { checkCurrentChatOnPageRefresh(chatIdFromURL: string): void {
if (chatIdFromURL) { if (chatIdFromURL) {
const currentChat = this._chat.value; const currentChat = this._chat.value;
this.getChatById(chatIdFromURL).subscribe(chat => { this.getChatById(chatIdFromURL).subscribe((chat) => {
if (chat) { if (chat) {
this._chat.next(chat); this._chat.next(chat);
this.loadChatHistory(chatIdFromURL); this.loadChatHistory(chatIdFromURL);
} }
}); });
} }
} }
async getContact(pubkey: string): Promise<void> { async getContact(pubkey: string): Promise<void> {
try { try {
if (!pubkey) { if (!pubkey) {
return; return;
} }
const metadata = await this._metadataService.fetchMetadataWithCache(pubkey); const metadata =
await this._metadataService.fetchMetadataWithCache(pubkey);
if (metadata) { if (metadata) {
const contact: Contact = { const contact: Contact = {
pubKey: pubkey, pubKey: pubkey,
displayName: metadata.name ? metadata.name : 'Unknown', displayName: metadata.name ? metadata.name : 'Unknown',
picture: metadata.picture, picture: metadata.picture,
about: metadata.about about: metadata.about,
}; };
this._contact.next(contact); this._contact.next(contact);
this._indexedDBService.getMetadataStream() this._indexedDBService
.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => { .subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === pubkey) { if (
updatedMetadata &&
updatedMetadata.pubkey === pubkey
) {
const updatedContact: Contact = { const updatedContact: Contact = {
pubKey: pubkey, pubKey: pubkey,
displayName: updatedMetadata.metadata.name ? updatedMetadata.metadata.name : 'Unknown', displayName: updatedMetadata.metadata.name
? updatedMetadata.metadata.name
: 'Unknown',
picture: updatedMetadata.metadata.picture, picture: updatedMetadata.metadata.picture,
about: updatedMetadata.metadata.about about: updatedMetadata.metadata.about,
}; };
this._contact.next(updatedContact); this._contact.next(updatedContact);
} }
@@ -104,18 +126,23 @@ export class ChatService implements OnDestroy {
} }
} }
getContacts(): Observable<Contact[]> { getContacts(): Observable<Contact[]> {
return new Observable<Contact[]>((observer) => { return new Observable<Contact[]>((observer) => {
this._indexedDBService.getAllUsers() this._indexedDBService
.getAllUsers()
.then((cachedContacts: Contact[]) => { .then((cachedContacts: Contact[]) => {
if (cachedContacts && cachedContacts.length > 0) { if (cachedContacts && cachedContacts.length > 0) {
const validatedContacts = cachedContacts.map(contact => { const validatedContacts = cachedContacts.map(
if (!contact.pubKey) { (contact) => {
console.error('Contact is missing pubKey:', contact); if (!contact.pubKey) {
console.error(
'Contact is missing pubKey:',
contact
);
}
return contact;
} }
return contact; );
});
this._contacts.next(validatedContacts); this._contacts.next(validatedContacts);
observer.next(validatedContacts); observer.next(validatedContacts);
@@ -125,33 +152,49 @@ export class ChatService implements OnDestroy {
observer.complete(); observer.complete();
}) })
.catch((error) => { .catch((error) => {
console.error('Error loading cached contacts from IndexedDB:', error); console.error(
'Error loading cached contacts from IndexedDB:',
error
);
observer.next([]); observer.next([]);
observer.complete(); observer.complete();
}); });
return { unsubscribe() { } }; return { unsubscribe() {} };
}); });
} }
async updateChatListMetadata(): Promise<void> { async updateChatListMetadata(): Promise<void> {
const pubKeys = this.chatList.map(chat => chat.contact?.pubKey).filter(pubKey => pubKey); const pubKeys = this.chatList
.map((chat) => chat.contact?.pubKey)
.filter((pubKey) => pubKey);
if (pubKeys.length > 0) { if (pubKeys.length > 0) {
const metadataList = await this._metadataService.fetchMetadataForMultipleKeys(pubKeys); const metadataList =
await this._metadataService.fetchMetadataForMultipleKeys(
pubKeys
);
metadataList.forEach(metadata => { metadataList.forEach((metadata) => {
const contact = this._contacts.value?.find(contact => contact.pubKey === metadata.pubkey); const contact = this._contacts.value?.find(
(contact) => contact.pubKey === metadata.pubkey
);
if (contact) { if (contact) {
contact.displayName = metadata.metadata.name || 'Unknown'; contact.displayName = metadata.metadata.name || 'Unknown';
contact.picture = metadata.metadata.picture || contact.picture; contact.picture =
metadata.metadata.picture || contact.picture;
contact.about = metadata.metadata.about || contact.about; contact.about = metadata.metadata.about || contact.about;
} }
const chat = this.chatList.find(chat => chat.contact?.pubKey === metadata.pubkey); const chat = this.chatList.find(
(chat) => chat.contact?.pubKey === metadata.pubkey
);
if (chat) { if (chat) {
chat.contact.displayName = metadata.metadata.name || 'Unknown'; chat.contact.displayName =
chat.contact.picture = metadata.metadata.picture || chat.contact.picture; metadata.metadata.name || 'Unknown';
chat.contact.about = metadata.metadata.about || chat.contact.about; chat.contact.picture =
metadata.metadata.picture || chat.contact.picture;
chat.contact.about =
metadata.metadata.about || chat.contact.about;
} }
}); });
@@ -161,40 +204,60 @@ export class ChatService implements OnDestroy {
} }
private subscribeToRealTimeMetadataUpdates(pubKey: string): void { private subscribeToRealTimeMetadataUpdates(pubKey: string): void {
this._metadataService.getMetadataStream() this._metadataService
.pipe(filter(updatedMetadata => updatedMetadata && updatedMetadata.pubkey === pubKey)) .getMetadataStream()
.subscribe(updatedMetadata => { .pipe(
const chat = this.chatList.find(chat => chat.contact?.pubKey === pubKey); filter(
(updatedMetadata) =>
updatedMetadata && updatedMetadata.pubkey === pubKey
)
)
.subscribe((updatedMetadata) => {
const chat = this.chatList.find(
(chat) => chat.contact?.pubKey === pubKey
);
if (chat) { if (chat) {
chat.contact.displayName = updatedMetadata.metadata.name || 'Unknown'; chat.contact.displayName =
chat.contact.picture = updatedMetadata.metadata.picture || chat.contact.picture; updatedMetadata.metadata.name || 'Unknown';
chat.contact.about = updatedMetadata.metadata.about || chat.contact.about; chat.contact.picture =
updatedMetadata.metadata.picture ||
chat.contact.picture;
chat.contact.about =
updatedMetadata.metadata.about || chat.contact.about;
this._chats.next(this.chatList); this._chats.next(this.chatList);
} }
const contact = this._contacts.value?.find(contact => contact.pubKey === pubKey); const contact = this._contacts.value?.find(
(contact) => contact.pubKey === pubKey
);
if (contact) { if (contact) {
contact.displayName = updatedMetadata.metadata.name || 'Unknown'; contact.displayName =
contact.picture = updatedMetadata.metadata.picture || contact.picture; updatedMetadata.metadata.name || 'Unknown';
contact.about = updatedMetadata.metadata.about || contact.about; contact.picture =
updatedMetadata.metadata.picture || contact.picture;
contact.about =
updatedMetadata.metadata.about || contact.about;
this._contacts.next(this._contacts.value || []); this._contacts.next(this._contacts.value || []);
} }
}); });
} }
async getProfile(): Promise<void> { async getProfile(): Promise<void> {
try { try {
const publicKey = this._signerService.getPublicKey(); const publicKey = this._signerService.getPublicKey();
const metadata = await this._metadataService.fetchMetadataWithCache(publicKey); const metadata =
await this._metadataService.fetchMetadataWithCache(publicKey);
if (metadata) { if (metadata) {
this._profile.next(metadata); this._profile.next(metadata);
this._indexedDBService
this._indexedDBService.getMetadataStream() .getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => { .subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === publicKey) { if (
updatedMetadata &&
updatedMetadata.pubkey === publicKey
) {
this._profile.next(updatedMetadata.metadata); this._profile.next(updatedMetadata.metadata);
} }
}); });
@@ -206,14 +269,14 @@ export class ChatService implements OnDestroy {
async getChats(): Promise<Observable<Chat[]>> { async getChats(): Promise<Observable<Chat[]>> {
return this.getChatListStream().pipe( return this.getChatListStream().pipe(
tap(chats => { tap((chats) => {
if (chats && chats.length === 0) { if (chats && chats.length === 0) {
return; return;
} }
const pubKeys = chats const pubKeys = chats
.filter(chat => chat.contact?.pubKey) .filter((chat) => chat.contact?.pubKey)
.map(chat => chat.contact!.pubKey); .map((chat) => chat.contact!.pubKey);
// Subscribe to all metadata updates in parallel // Subscribe to all metadata updates in parallel
this.subscribeToRealTimeMetadataUpdatesBatch(pubKeys); this.subscribeToRealTimeMetadataUpdatesBatch(pubKeys);
@@ -226,12 +289,19 @@ export class ChatService implements OnDestroy {
const useExtension = await this._signerService.isUsingExtension(); const useExtension = await this._signerService.isUsingExtension();
const useSecretKey = await this._signerService.isUsingSecretKey(); const useSecretKey = await this._signerService.isUsingSecretKey();
this.decryptedPrivateKey = useSecretKey ? await this._signerService.getDecryptedSecretKey() : ''; this.decryptedPrivateKey = useSecretKey
? await this._signerService.getDecryptedSecretKey()
: '';
// Perform metadata and chat loading in parallel for speed // Perform metadata and chat loading in parallel for speed
await Promise.all([ await Promise.all([
this.updateChatListMetadata(), this.updateChatListMetadata(),
this.subscribeToChatList(pubkey, useExtension, useSecretKey, this.decryptedPrivateKey) this.subscribeToChatList(
pubkey,
useExtension,
useSecretKey,
this.decryptedPrivateKey
),
]); ]);
return this.getChatListStream(); return this.getChatListStream();
@@ -239,46 +309,80 @@ export class ChatService implements OnDestroy {
private subscribeToRealTimeMetadataUpdatesBatch(pubKeys: string[]): void { private subscribeToRealTimeMetadataUpdatesBatch(pubKeys: string[]): void {
// Batch subscribe to all pubKeys metadata updates for efficiency // Batch subscribe to all pubKeys metadata updates for efficiency
pubKeys.forEach(pubKey => { pubKeys.forEach((pubKey) => {
this.subscribeToRealTimeMetadataUpdates(pubKey); this.subscribeToRealTimeMetadataUpdates(pubKey);
}); });
} }
subscribeToChatList(pubkey: string, useExtension: boolean, useSecretKey: boolean, decryptedSenderPrivateKey: string): Observable<Chat[]> { subscribeToChatList(
pubkey: string,
useExtension: boolean,
useSecretKey: boolean,
decryptedSenderPrivateKey: string
): Observable<Chat[]> {
this._relayService.ensureConnectedRelays().then(async () => { this._relayService.ensureConnectedRelays().then(async () => {
const filters: Filter[] = [ const filters: Filter[] = [
{ kinds: [EncryptedDirectMessage], authors: [pubkey] , limit:1500}, {
{ kinds: [EncryptedDirectMessage], '#p': [pubkey] , limit:1500} kinds: [EncryptedDirectMessage],
authors: [pubkey],
limit: 1500,
},
{
kinds: [EncryptedDirectMessage],
'#p': [pubkey],
limit: 1500,
},
]; ];
this._relayService.getPool().subscribeMany(this._relayService.getConnectedRelays(), filters, { this._relayService
onevent: async (event: NostrEvent) => { .getPool()
const otherPartyPubKey = event.pubkey === pubkey .subscribeMany(
? event.tags.find(tag => tag[0] === 'p')?.[1] || '' this._relayService.getConnectedRelays(),
: event.pubkey; filters,
{
onevent: async (event: NostrEvent) => {
const otherPartyPubKey =
event.pubkey === pubkey
? event.tags.find(
(tag) => tag[0] === 'p'
)?.[1] || ''
: event.pubkey;
if (!otherPartyPubKey) return; if (!otherPartyPubKey) return;
const lastTimestamp = this.latestMessageTimestamps[otherPartyPubKey] || 0; const lastTimestamp =
if (event.created_at > lastTimestamp) { this.latestMessageTimestamps[
this.messageQueue.push(event); otherPartyPubKey
this.processNextMessage(pubkey, useExtension, useSecretKey, decryptedSenderPrivateKey); ] || 0;
if (event.created_at > lastTimestamp) {
this.messageQueue.push(event);
this.processNextMessage(
pubkey,
useExtension,
useSecretKey,
decryptedSenderPrivateKey
);
}
},
oneose: () => {
const currentChats = this.chatList || [];
if (currentChats.length > 0) {
this._chats.next(this.chatList);
}
},
} }
}, );
oneose: () => {
const currentChats = this.chatList || [];
if (currentChats.length > 0) {
this._chats.next(this.chatList);
}
}
});
}); });
return this.getChatListStream(); return this.getChatListStream();
} }
private async processNextMessage(pubkey: string, useExtension: boolean, useSecretKey: boolean, decryptedSenderPrivateKey: string): Promise<void> { private async processNextMessage(
pubkey: string,
useExtension: boolean,
useSecretKey: boolean,
decryptedSenderPrivateKey: string
): Promise<void> {
if (this.isDecrypting || this.messageQueue.length === 0) return; if (this.isDecrypting || this.messageQueue.length === 0) return;
this.isDecrypting = true; this.isDecrypting = true;
@@ -290,7 +394,7 @@ export class ChatService implements OnDestroy {
const isSentByUser = event.pubkey === pubkey; const isSentByUser = event.pubkey === pubkey;
const otherPartyPubKey = isSentByUser const otherPartyPubKey = isSentByUser
? event.tags.find(tag => tag[0] === 'p')?.[1] || '' ? event.tags.find((tag) => tag[0] === 'p')?.[1] || ''
: event.pubkey; : event.pubkey;
if (!otherPartyPubKey) continue; if (!otherPartyPubKey) continue;
@@ -304,7 +408,12 @@ export class ChatService implements OnDestroy {
); );
if (decryptedMessage) { if (decryptedMessage) {
this.addOrUpdateChatList(otherPartyPubKey, decryptedMessage, event.created_at, isSentByUser); this.addOrUpdateChatList(
otherPartyPubKey,
decryptedMessage,
event.created_at,
isSentByUser
);
} }
} }
} catch (error) { } catch (error) {
@@ -314,8 +423,15 @@ export class ChatService implements OnDestroy {
} }
} }
private addOrUpdateChatList(pubKey: string, message: string, createdAt: number, isMine: boolean): void { private addOrUpdateChatList(
const existingChat = this.chatList.find(chat => chat.contact?.pubKey === pubKey); pubKey: string,
message: string,
createdAt: number,
isMine: boolean
): void {
const existingChat = this.chatList.find(
(chat) => chat.contact?.pubKey === pubKey
);
const newMessage = { const newMessage = {
id: `${pubKey}-${createdAt}`, id: `${pubKey}-${createdAt}`,
@@ -327,11 +443,19 @@ export class ChatService implements OnDestroy {
}; };
if (existingChat) { if (existingChat) {
const messageExists = existingChat.messages?.some(m => m.id === newMessage.id); const messageExists = existingChat.messages?.some(
(m) => m.id === newMessage.id
);
if (!messageExists) { if (!messageExists) {
existingChat.messages = [...(existingChat.messages || []), newMessage] existingChat.messages = [
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); ...(existingChat.messages || []),
newMessage,
].sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime()
);
if (Number(existingChat.lastMessageAt) < createdAt) { if (Number(existingChat.lastMessageAt) < createdAt) {
existingChat.lastMessage = message; existingChat.lastMessage = message;
@@ -339,25 +463,34 @@ export class ChatService implements OnDestroy {
} }
} }
} else { } else {
const contactInfo = this._contacts.value?.find(contact => contact.pubKey === pubKey) || { pubKey }; const contactInfo = this._contacts.value?.find(
(contact) => contact.pubKey === pubKey
) || { pubKey };
const newChat: Chat = { const newChat: Chat = {
id: pubKey, id: pubKey,
contact: { contact: {
pubKey: contactInfo.pubKey, pubKey: contactInfo.pubKey,
name: contactInfo.name || "Unknown", name: contactInfo.name || 'Unknown',
picture: contactInfo.picture || "/images/avatars/avatar-placeholder.png", picture:
about: contactInfo.about || "", contactInfo.picture ||
displayName: contactInfo.displayName || contactInfo.name || "Unknown" '/images/avatars/avatar-placeholder.png',
about: contactInfo.about || '',
displayName:
contactInfo.displayName ||
contactInfo.name ||
'Unknown',
}, },
lastMessage: message, lastMessage: message,
lastMessageAt: createdAt.toString(), lastMessageAt: createdAt.toString(),
messages: [newMessage] messages: [newMessage],
}; };
this.chatList.push(newChat); this.chatList.push(newChat);
} }
this.chatList.sort((a, b) => Number(b.lastMessageAt!) - Number(a.lastMessageAt!)); this.chatList.sort(
(a, b) => Number(b.lastMessageAt!) - Number(a.lastMessageAt!)
);
this._chats.next(this.chatList); this._chats.next(this.chatList);
} }
@@ -373,9 +506,16 @@ export class ChatService implements OnDestroy {
recipientPublicKey: string recipientPublicKey: string
): Promise<string> { ): Promise<string> {
if (useExtension && !useSecretKey) { if (useExtension && !useSecretKey) {
return await this._signerService.decryptMessageWithExtension(recipientPublicKey, event.content); return await this._signerService.decryptMessageWithExtension(
recipientPublicKey,
event.content
);
} else if (useSecretKey && !useExtension) { } else if (useSecretKey && !useExtension) {
return await this._signerService.decryptMessage(decryptedSenderPrivateKey, recipientPublicKey, event.content); return await this._signerService.decryptMessage(
decryptedSenderPrivateKey,
recipientPublicKey,
event.content
);
} }
} }
@@ -383,83 +523,143 @@ export class ChatService implements OnDestroy {
const myPubKey = this._signerService.getPublicKey(); const myPubKey = this._signerService.getPublicKey();
const historyFilter: Filter[] = [ const historyFilter: Filter[] = [
{ kinds: [EncryptedDirectMessage], authors: [myPubKey], '#p': [pubKey], limit: 10 }, {
{ kinds: [EncryptedDirectMessage], authors: [pubKey], '#p': [myPubKey], limit: 10 } kinds: [EncryptedDirectMessage],
authors: [myPubKey],
'#p': [pubKey],
limit: 10,
},
{
kinds: [EncryptedDirectMessage],
authors: [pubKey],
'#p': [myPubKey],
limit: 10,
},
]; ];
this._relayService.getPool().subscribeMany(this._relayService.getConnectedRelays(), historyFilter, { this._relayService
onevent: async (event: NostrEvent) => { .getPool()
const isSentByMe = event.pubkey === myPubKey; .subscribeMany(
const senderOrRecipientPubKey = isSentByMe ? pubKey : event.pubkey; this._relayService.getConnectedRelays(),
const useExtension = await this._signerService.isUsingExtension(); historyFilter,
const useSecretKey = await this._signerService.isUsingSecretKey(); {
const decryptedMessage = await this.decryptReceivedMessage( onevent: async (event: NostrEvent) => {
event, const isSentByMe = event.pubkey === myPubKey;
useExtension, const senderOrRecipientPubKey = isSentByMe
useSecretKey, ? pubKey
this.decryptedPrivateKey, : event.pubkey;
senderOrRecipientPubKey const useExtension =
); await this._signerService.isUsingExtension();
const useSecretKey =
await this._signerService.isUsingSecretKey();
const decryptedMessage =
await this.decryptReceivedMessage(
event,
useExtension,
useSecretKey,
this.decryptedPrivateKey,
senderOrRecipientPubKey
);
if (decryptedMessage) { if (decryptedMessage) {
const messageTimestamp = Math.floor(event.created_at); const messageTimestamp = Math.floor(
event.created_at
);
this.addOrUpdateChatList(pubKey, decryptedMessage, messageTimestamp, isSentByMe); this.addOrUpdateChatList(
this._chat.next(this.chatList.find(chat => chat.id === pubKey)); pubKey,
decryptedMessage,
messageTimestamp,
isSentByMe
);
this._chat.next(
this.chatList.find((chat) => chat.id === pubKey)
);
}
},
oneose: () => {},
} }
}, );
oneose: () => {
}
});
} }
private async fetchChatHistory(pubKey: string): Promise<any[]> { private async fetchChatHistory(pubKey: string): Promise<any[]> {
const myPubKey = this._signerService.getPublicKey(); const myPubKey = this._signerService.getPublicKey();
const historyFilter: Filter[] = [ const historyFilter: Filter[] = [
{ kinds: [EncryptedDirectMessage], authors: [myPubKey], '#p': [pubKey], limit: 10 }, {
{ kinds: [EncryptedDirectMessage], authors: [pubKey], '#p': [myPubKey], limit: 10 } kinds: [EncryptedDirectMessage],
authors: [myPubKey],
'#p': [pubKey],
limit: 10,
},
{
kinds: [EncryptedDirectMessage],
authors: [pubKey],
'#p': [myPubKey],
limit: 10,
},
]; ];
const messages: any[] = []; const messages: any[] = [];
this._relayService.getPool().subscribeMany(this._relayService.getConnectedRelays(), historyFilter, { this._relayService
onevent: async (event: NostrEvent) => { .getPool()
const isSentByMe = event.pubkey === myPubKey; .subscribeMany(
const senderOrRecipientPubKey = isSentByMe ? pubKey : event.pubkey; this._relayService.getConnectedRelays(),
const useExtension = await this._signerService.isUsingExtension(); historyFilter,
const useSecretKey = await this._signerService.isUsingSecretKey(); {
const decryptedMessage = await this.decryptReceivedMessage( onevent: async (event: NostrEvent) => {
event, const isSentByMe = event.pubkey === myPubKey;
useExtension, const senderOrRecipientPubKey = isSentByMe
useSecretKey, ? pubKey
this.decryptedPrivateKey, : event.pubkey;
senderOrRecipientPubKey const useExtension =
); await this._signerService.isUsingExtension();
const useSecretKey =
await this._signerService.isUsingSecretKey();
const decryptedMessage =
await this.decryptReceivedMessage(
event,
useExtension,
useSecretKey,
this.decryptedPrivateKey,
senderOrRecipientPubKey
);
if (decryptedMessage) { if (decryptedMessage) {
const messageTimestamp = Math.floor(event.created_at); const messageTimestamp = Math.floor(
event.created_at
);
const message = { const message = {
id: event.id, id: event.id,
chatId: pubKey, chatId: pubKey,
contactId: senderOrRecipientPubKey, contactId: senderOrRecipientPubKey,
isMine: isSentByMe, isMine: isSentByMe,
value: decryptedMessage, value: decryptedMessage,
createdAt: new Date(messageTimestamp * 1000).toISOString(), createdAt: new Date(
}; messageTimestamp * 1000
).toISOString(),
};
messages.push(message); messages.push(message);
this.addOrUpdateChatList(pubKey, decryptedMessage, messageTimestamp, isSentByMe); this.addOrUpdateChatList(
this._chat.next(this.chatList.find(chat => chat.id === pubKey)); pubKey,
decryptedMessage,
messageTimestamp,
isSentByMe
);
this._chat.next(
this.chatList.find((chat) => chat.id === pubKey)
);
}
},
oneose: () => {},
} }
}, );
oneose: () => {
}
});
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
return messages; return messages;
} }
@@ -484,10 +684,14 @@ export class ChatService implements OnDestroy {
event.id = getEventHash(event); event.id = getEventHash(event);
return from(this._relayService.publishEventToRelays(event)).pipe( return from(
this._relayService.publishEventToRelays(event)
).pipe(
map(() => { map(() => {
if (chats) { if (chats) {
const index = chats.findIndex((item) => item.id === id); const index = chats.findIndex(
(item) => item.id === id
);
if (index !== -1) { if (index !== -1) {
chats[index] = chat; chats[index] = chat;
this._chats.next(chats); this._chats.next(chats);
@@ -496,7 +700,10 @@ export class ChatService implements OnDestroy {
return chat; return chat;
}), }),
catchError((error) => { catchError((error) => {
console.error('Failed to update chat via Nostr:', error); console.error(
'Failed to update chat via Nostr:',
error
);
return throwError(error); return throwError(error);
}) })
); );
@@ -516,7 +723,7 @@ export class ChatService implements OnDestroy {
return this.createNewChat(id, contact); return this.createNewChat(id, contact);
} }
const cachedChat = chats.find(chat => chat.id === id); const cachedChat = chats.find((chat) => chat.id === id);
if (cachedChat) { if (cachedChat) {
this._chat.next(cachedChat); this._chat.next(cachedChat);
this.loadChatHistory(this.recipientPublicKey); this.loadChatHistory(this.recipientPublicKey);
@@ -534,8 +741,6 @@ export class ChatService implements OnDestroy {
); );
} }
createNewChat(id: string, contact: Contact = null): Observable<Chat> { createNewChat(id: string, contact: Contact = null): Observable<Chat> {
// const existingChat = this._chats.value?.find(chat => chat.id === id); // const existingChat = this._chats.value?.find(chat => chat.id === id);
@@ -545,11 +750,21 @@ export class ChatService implements OnDestroy {
const newChat: Chat = { const newChat: Chat = {
id: id || '', id: id || '',
contact: contact contact: contact
? { pubKey: contact.pubKey || id, name: contact.name || 'Unknown', picture: contact.picture || '/images/avatars/avatar-placeholder.png' } ? {
: { pubKey: id, name: 'Unknown', picture: '/images/avatars/avatar-placeholder.png' }, pubKey: contact.pubKey || id,
name: contact.name || 'Unknown',
picture:
contact.picture ||
'/images/avatars/avatar-placeholder.png',
}
: {
pubKey: id,
name: 'Unknown',
picture: '/images/avatars/avatar-placeholder.png',
},
lastMessage: 'new chat...', lastMessage: 'new chat...',
lastMessageAt: Math.floor(Date.now() / 1000).toString() || '0', lastMessageAt: Math.floor(Date.now() / 1000).toString() || '0',
messages: [] messages: [],
}; };
// const updatedChats = this._chats.value ? [...this._chats.value, newChat] : [newChat]; // const updatedChats = this._chats.value ? [...this._chats.value, newChat] : [newChat];
@@ -559,10 +774,13 @@ export class ChatService implements OnDestroy {
map((metadata: any) => { map((metadata: any) => {
newChat.contact = { newChat.contact = {
pubKey: id, pubKey: id,
name: metadata?.name || "Unknown", name: metadata?.name || 'Unknown',
picture: metadata?.picture || "/images/avatars/avatar-placeholder.png", picture:
about: metadata?.about || "", metadata?.picture ||
displayName: metadata?.displayName || metadata?.name || "Unknown" '/images/avatars/avatar-placeholder.png',
about: metadata?.about || '',
displayName:
metadata?.displayName || metadata?.name || 'Unknown',
}; };
return newChat; return newChat;
@@ -576,8 +794,10 @@ export class ChatService implements OnDestroy {
chatId: id, chatId: id,
contactId: id, contactId: id,
isMine: true, isMine: true,
value: "new chat...", value: 'new chat...',
createdAt: Math.floor(Date.now() / 1000).toString(), createdAt: Math.floor(
Date.now() / 1000
).toString(),
}; };
messages.push(testMessage); messages.push(testMessage);
@@ -591,7 +811,9 @@ export class ChatService implements OnDestroy {
newChat.lastMessageAt = lastMessage.createdAt; newChat.lastMessageAt = lastMessage.createdAt;
} }
const updatedChatsWithMessages = this._chats.value ? [...this._chats.value, newChat] : [newChat]; const updatedChatsWithMessages = this._chats.value
? [...this._chats.value, newChat]
: [newChat];
this._chats.next(updatedChatsWithMessages); this._chats.next(updatedChatsWithMessages);
this._chat.next(newChat); this._chat.next(newChat);
@@ -602,14 +824,10 @@ export class ChatService implements OnDestroy {
); );
} }
resetChat(): void { resetChat(): void {
this._chat.next(null); this._chat.next(null);
} }
public async sendPrivateMessage(message: string): Promise<void> { public async sendPrivateMessage(message: string): Promise<void> {
try { try {
this.message = message; this.message = message;
@@ -620,20 +838,31 @@ export class ChatService implements OnDestroy {
await this.handleMessageSendingWithExtension(); await this.handleMessageSendingWithExtension();
} else if (!useExtension && useSecretKey) { } else if (!useExtension && useSecretKey) {
if (!this.isValidMessageSetup()) { if (!this.isValidMessageSetup()) {
console.error('Message, sender private key, or recipient public key is not properly set.'); console.error(
'Message, sender private key, or recipient public key is not properly set.'
);
return; return;
} }
const encryptedMessage = await this._signerService.encryptMessage( const encryptedMessage =
this.decryptedPrivateKey, await this._signerService.encryptMessage(
this.recipientPublicKey, this.decryptedPrivateKey,
this.message this.recipientPublicKey,
this.message
);
const messageEvent = this._signerService.getUnsignedEvent(
4,
[['p', this.recipientPublicKey]],
encryptedMessage
); );
const messageEvent = this._signerService.getUnsignedEvent(4, [['p', this.recipientPublicKey]], encryptedMessage); const signedEvent = this._signerService.getSignedEvent(
messageEvent,
this.decryptedPrivateKey
);
const signedEvent = this._signerService.getSignedEvent(messageEvent, this.decryptedPrivateKey); const published =
await this._relayService.publishEventToRelays(signedEvent);
const published = await this._relayService.publishEventToRelays(signedEvent);
if (published) { if (published) {
this.message = ''; this.message = '';
@@ -641,7 +870,6 @@ export class ChatService implements OnDestroy {
console.error('Failed to send the message.'); console.error('Failed to send the message.');
} }
} }
} catch (error) { } catch (error) {
console.error('Error sending private message:', error); console.error('Error sending private message:', error);
} }
@@ -649,20 +877,23 @@ export class ChatService implements OnDestroy {
private async handleMessageSendingWithExtension(): Promise<void> { private async handleMessageSendingWithExtension(): Promise<void> {
try { try {
const encryptedMessage = await this._signerService.encryptMessageWithExtension( const encryptedMessage =
this.message, await this._signerService.encryptMessageWithExtension(
this.recipientPublicKey this.message,
); this.recipientPublicKey
);
const signedEvent = await this._signerService.signEventWithExtension({ const signedEvent =
kind: 4, await this._signerService.signEventWithExtension({
pubkey: this._signerService.getPublicKey(), kind: 4,
tags: [['p', this.recipientPublicKey]], pubkey: this._signerService.getPublicKey(),
content: encryptedMessage, tags: [['p', this.recipientPublicKey]],
created_at: Math.floor(Date.now() / 1000), content: encryptedMessage,
}); created_at: Math.floor(Date.now() / 1000),
});
const published = await this._relayService.publishEventToRelays(signedEvent); const published =
await this._relayService.publishEventToRelays(signedEvent);
if (published) { if (published) {
this.message = ''; this.message = '';
@@ -682,6 +913,4 @@ export class ChatService implements OnDestroy {
this._unsubscribeAll.next(); this._unsubscribeAll.next();
this._unsubscribeAll.complete(); this._unsubscribeAll.complete();
} }
} }

View File

@@ -1,16 +1,20 @@
<div class="bg-card relative flex w-full flex-auto dark:bg-transparent"> <div class="bg-card relative flex w-full flex-auto dark:bg-transparent">
<mat-drawer-container class="h-full flex-auto" [hasBackdrop]="false"> <mat-drawer-container class="h-full flex-auto" [hasBackdrop]="false">
<!-- Drawer --> <!-- Drawer -->
<mat-drawer class="w-full dark:bg-gray-900 sm:w-100 lg:border-r lg:shadow-none" [autoFocus]="false" <mat-drawer
[(opened)]="drawerOpened" #drawer> class="w-full dark:bg-gray-900 sm:w-100 lg:border-r lg:shadow-none"
[autoFocus]="false"
[(opened)]="drawerOpened"
#drawer
>
<!-- New chat --> <!-- New chat -->
@if (drawerComponent === 'new-chat') { @if (drawerComponent === 'new-chat') {
<chat-new-chat [drawer]="drawer"></chat-new-chat> <chat-new-chat [drawer]="drawer"></chat-new-chat>
} }
<!-- Profile --> <!-- Profile -->
@if (drawerComponent === 'profile') { @if (drawerComponent === 'profile') {
<chat-profile [drawer]="drawer"></chat-profile> <chat-profile [drawer]="drawer"></chat-profile>
} }
</mat-drawer> </mat-drawer>
@@ -18,180 +22,276 @@
<mat-drawer-content class="flex overflow-hidden"> <mat-drawer-content class="flex overflow-hidden">
<!-- Chats list --> <!-- Chats list -->
@if (chats && chats.length > 0) { @if (chats && chats.length > 0) {
<div <div
class="bg-card relative flex w-full min-w-0 flex-auto flex-col dark:bg-transparent lg:min-w-100 lg:max-w-100"> class="bg-card relative flex w-full min-w-0 flex-auto flex-col dark:bg-transparent lg:min-w-100 lg:max-w-100"
<!-- Header --> >
<div class="flex flex-0 flex-col border-b bg-gray-50 px-8 py-4 dark:bg-transparent"> <!-- Header -->
<div class="flex items-center"> <div
<div class="mr-1 flex cursor-pointer items-center" (click)="openProfile()"> class="flex flex-0 flex-col border-b bg-gray-50 px-8 py-4 dark:bg-transparent"
<div class="h-10 w-10"> >
@if (profile?.picture) { <div class="flex items-center">
<img class="h-full w-full rounded-full object-cover" [src]="profile?.picture" <div
onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';" class="mr-1 flex cursor-pointer items-center"
alt="Profile picture" /> (click)="openProfile()"
} >
@if (!profile?.picture) { <div class="h-10 w-10">
<div @if (profile?.picture) {
class="flex h-full w-full items-center justify-center rounded-full bg-gray-200 text-lg uppercase text-gray-600 dark:bg-gray-700 dark:text-gray-200"> <img
{{ profile?.name?.charAt(0) }} class="h-full w-full rounded-full object-cover"
[src]="profile?.picture"
onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';"
alt="Profile picture"
/>
}
@if (!profile?.picture) {
<div
class="flex h-full w-full items-center justify-center rounded-full bg-gray-200 text-lg uppercase text-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
{{ profile?.name?.charAt(0) }}
</div>
}
</div>
<div class="ml-4 truncate font-medium">
{{ profile?.name }}
</div> </div>
}
</div> </div>
<div class="ml-4 truncate font-medium"> <button
{{ profile?.name }} class="ml-auto"
</div> mat-icon-button
</div> (click)="openNewChat()"
<button class="ml-auto" mat-icon-button (click)="openNewChat()"> >
<mat-icon [svgIcon]="'heroicons_outline:plus-circle'"></mat-icon> <mat-icon
</button> [svgIcon]="'heroicons_outline:plus-circle'"
<button class="-mr-4 ml-1" mat-icon-button [matMenuTriggerFor]="chatsHeaderMenu"> ></mat-icon>
<mat-icon [svgIcon]=" </button>
<button
class="-mr-4 ml-1"
mat-icon-button
[matMenuTriggerFor]="chatsHeaderMenu"
>
<mat-icon
[svgIcon]="
'heroicons_outline:ellipsis-vertical' 'heroicons_outline:ellipsis-vertical'
"></mat-icon> "
<mat-menu #chatsHeaderMenu> ></mat-icon>
<button mat-menu-item> <mat-menu #chatsHeaderMenu>
<mat-icon [svgIcon]=" <button mat-menu-item>
<mat-icon
[svgIcon]="
'heroicons_outline:user-group' 'heroicons_outline:user-group'
"></mat-icon> "
New group ></mat-icon>
</button> New group
<button mat-menu-item> </button>
<mat-icon [svgIcon]=" <button mat-menu-item>
<mat-icon
[svgIcon]="
'heroicons_outline:chat-bubble-left-right' 'heroicons_outline:chat-bubble-left-right'
"></mat-icon> "
Create a room ></mat-icon>
</button> Create a room
<button mat-menu-item (click)="openProfile()"> </button>
<mat-icon [svgIcon]=" <button
mat-menu-item
(click)="openProfile()"
>
<mat-icon
[svgIcon]="
'heroicons_outline:user-circle' 'heroicons_outline:user-circle'
"></mat-icon> "
Profile ></mat-icon>
</button> Profile
<button mat-menu-item> </button>
<mat-icon [svgIcon]=" <button mat-menu-item>
<mat-icon
[svgIcon]="
'heroicons_outline:archive-box' 'heroicons_outline:archive-box'
"></mat-icon> "
Archived ></mat-icon>
</button> Archived
<button mat-menu-item> </button>
<mat-icon [svgIcon]="'heroicons_outline:star'"></mat-icon> <button mat-menu-item>
Starred <mat-icon
</button> [svgIcon]="'heroicons_outline:star'"
<button mat-menu-item> ></mat-icon>
<mat-icon [svgIcon]=" Starred
</button>
<button mat-menu-item>
<mat-icon
[svgIcon]="
'heroicons_outline:cog-8-tooth' 'heroicons_outline:cog-8-tooth'
"></mat-icon> "
Settings ></mat-icon>
</button> Settings
</mat-menu> </button>
</button> </mat-menu>
</div> </button>
<!-- Search --> </div>
<div class="mt-4"> <!-- Search -->
<mat-form-field class="angor-mat-rounded angor-mat-dense w-full" [subscriptSizing]="'dynamic'"> <div class="mt-4">
<mat-icon matPrefix class="icon-size-5" [svgIcon]=" <mat-form-field
class="angor-mat-rounded angor-mat-dense w-full"
[subscriptSizing]="'dynamic'"
>
<mat-icon
matPrefix
class="icon-size-5"
[svgIcon]="
'heroicons_solid:magnifying-glass' 'heroicons_solid:magnifying-glass'
"></mat-icon> "
<input matInput [autocomplete]="'off'" [placeholder]="'Search or start new chat'" ></mat-icon>
(input)="filterChats(searchField.value)" #searchField /> <input
</mat-form-field> matInput
[autocomplete]="'off'"
[placeholder]="'Search or start new chat'"
(input)="filterChats(searchField.value)"
#searchField
/>
</mat-form-field>
</div>
</div> </div>
</div>
<!-- Chats --> <!-- Chats -->
<div class="flex-auto overflow-y-auto"> <div class="flex-auto overflow-y-auto">
@if (filteredChats.length > 0) { @if (filteredChats.length > 0) {
@for ( @for (
chat of filteredChats; chat of filteredChats;
track trackByFn($index, chat) track trackByFn($index, chat)
) { ) {
<a class="z-20 flex cursor-pointer items-center border-b px-8 py-5" [ngClass]="{ <a
class="z-20 flex cursor-pointer items-center border-b px-8 py-5"
[ngClass]="{
'dark:hover:bg-hover hover:bg-gray-100': 'dark:hover:bg-hover hover:bg-gray-100':
!selectedChat || !selectedChat ||
selectedChat.id !== chat.id, selectedChat.id !== chat.id,
'bg-primary-50 dark:bg-hover': 'bg-primary-50 dark:bg-hover':
selectedChat && selectedChat &&
selectedChat.id === chat.id, selectedChat.id === chat.id,
}" [routerLink]="[chat.id]"> }"
<div class="relative flex h-10 w-10 flex-0 items-center justify-center"> [routerLink]="[chat.id]"
@if (chat.unreadCount > 0) { >
<div class="ring-bg-card absolute bottom-0 right-0 -ml-0.5 h-2 w-2 flex-0 rounded-full bg-primary text-on-primary ring-2 dark:bg-primary-500 dark:ring-gray-900" <div
[class.ring-primary-50]=" class="relative flex h-10 w-10 flex-0 items-center justify-center"
>
@if (chat.unreadCount > 0) {
<div
class="ring-bg-card absolute bottom-0 right-0 -ml-0.5 h-2 w-2 flex-0 rounded-full bg-primary text-on-primary ring-2 dark:bg-primary-500 dark:ring-gray-900"
[class.ring-primary-50]="
selectedChat && selectedChat &&
selectedChat.id === chat.id selectedChat.id === chat.id
"></div> "
} ></div>
<!-- If contact has a picture --> }
<img *ngIf="chat.contact?.picture" class="h-full w-full rounded-full object-cover" <!-- If contact has a picture -->
[src]="chat.contact?.picture" (error)=" <img
*ngIf="chat.contact?.picture"
class="h-full w-full rounded-full object-cover"
[src]="chat.contact?.picture"
(error)="
this.src = this.src =
'/images/avatars/avatar-placeholder.png' '/images/avatars/avatar-placeholder.png'
" alt="Contact picture" /> "
alt="Contact picture"
/>
<!-- If contact doesn't have a picture, display the first letter of the name --> <!-- If contact doesn't have a picture, display the first letter of the name -->
<div *ngIf="!chat.contact?.picture" <div
class="flex h-full w-full items-center justify-center rounded-full bg-gray-200 text-lg uppercase text-gray-600 dark:bg-gray-700 dark:text-gray-200"> *ngIf="!chat.contact?.picture"
{{ chat.contact?.name?.charAt(0) }} class="flex h-full w-full items-center justify-center rounded-full bg-gray-200 text-lg uppercase text-gray-600 dark:bg-gray-700 dark:text-gray-200"
</div> >
</div> {{ chat.contact?.name?.charAt(0) }}
<div class="ml-4 min-w-0"> </div>
<div class="truncate font-medium leading-5"> </div>
{{ chat.contact?.name }} <div class="ml-4 min-w-0">
</div> <div
<div class="text-secondary truncate leading-5" [class.text-primary]=" class="truncate font-medium leading-5"
>
{{ chat.contact?.name }}
</div>
<div
class="text-secondary truncate leading-5"
[class.text-primary]="
chat.unreadCount > 0 chat.unreadCount > 0
" [class.dark:text-primary-500]=" "
[class.dark:text-primary-500]="
chat.unreadCount > 0 chat.unreadCount > 0
"> "
{{ chat.lastMessage | checkmessage}} >
</div> {{
</div> chat.lastMessage | checkmessage
<div class="ml-auto flex flex-col items-end self-start pl-2"> }}
<div class="text-secondary overflow-hidden whitespace-nowrap text-sm leading-5"> </div>
{{ chat.lastMessageAt | ago }} </div>
</div> <div
@if (chat.muted) { class="ml-auto flex flex-col items-end self-start pl-2"
<mat-icon class="text-hint icon-size-5" [svgIcon]=" >
<div
class="text-secondary overflow-hidden whitespace-nowrap text-sm leading-5"
>
{{ chat.lastMessageAt | ago }}
</div>
@if (chat.muted) {
<mat-icon
class="text-hint icon-size-5"
[svgIcon]="
'heroicons_solid:speaker-x-mark' 'heroicons_solid:speaker-x-mark'
"></mat-icon> "
></mat-icon>
}
</div>
</a>
} }
</div> } @else {
</a> <div
} class="flex h-full flex-auto flex-col items-center justify-center"
} @else { >
<div class="flex h-full flex-auto flex-col items-center justify-center"> <mat-icon
<mat-icon class="icon-size-24" [svgIcon]=" class="icon-size-24"
[svgIcon]="
'heroicons_outline:chat-bubble-oval-left-ellipsis' 'heroicons_outline:chat-bubble-oval-left-ellipsis'
"></mat-icon> "
<div class="text-secondary mt-4 text-2xl font-semibold tracking-tight"> ></mat-icon>
No chats <div
</div> class="text-secondary mt-4 text-2xl font-semibold tracking-tight"
>
No chats
</div>
</div>
}
</div> </div>
}
</div> </div>
</div>
} @else { } @else {
<div class="flex h-full flex-auto flex-col items-center justify-center"> <div
<mat-icon class="icon-size-24" [svgIcon]=" class="flex h-full flex-auto flex-col items-center justify-center"
>
<mat-icon
class="icon-size-24"
[svgIcon]="
'heroicons_outline:chat-bubble-oval-left-ellipsis' 'heroicons_outline:chat-bubble-oval-left-ellipsis'
"></mat-icon> "
<div class="text-secondary mt-4 text-2xl font-semibold tracking-tight"> ></mat-icon>
No chats <div
class="text-secondary mt-4 text-2xl font-semibold tracking-tight"
>
No chats
</div>
</div> </div>
</div>
} }
<!-- No chats template --> <!-- No chats template -->
<!-- Conversation --> <!-- Conversation -->
@if (chats && chats.length > 0) { @if (chats && chats.length > 0) {
<div class="flex-auto border-l" [ngClass]="{ <div
class="flex-auto border-l"
[ngClass]="{
'absolute inset-0 z-20 flex lg:static lg:inset-auto': 'absolute inset-0 z-20 flex lg:static lg:inset-auto':
selectedChat && selectedChat.id, selectedChat && selectedChat.id,
'hidden lg:flex': !selectedChat || !selectedChat.id, 'hidden lg:flex': !selectedChat || !selectedChat.id,
}"> }"
<router-outlet></router-outlet> >
</div> <router-outlet></router-outlet>
</div>
} }
</mat-drawer-content> </mat-drawer-content>
</mat-drawer-container> </mat-drawer-container>

View File

@@ -14,13 +14,13 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { ActivatedRoute, RouterLink, RouterOutlet } from '@angular/router'; import { ActivatedRoute, RouterLink, RouterOutlet } from '@angular/router';
import { catchError, of, Subject, takeUntil } from 'rxjs'; import { AgoPipe } from 'app/shared/pipes/ago.pipe';
import { CheckmessagePipe } from 'app/shared/pipes/checkmessage.pipe';
import { Subject, takeUntil } from 'rxjs';
import { ChatService } from '../chat.service'; import { ChatService } from '../chat.service';
import { Chat, Profile } from '../chat.types'; import { Chat, Profile } from '../chat.types';
import { NewChatComponent } from '../new-chat/new-chat.component'; import { NewChatComponent } from '../new-chat/new-chat.component';
import { ProfileComponent } from '../profile/profile.component'; import { ProfileComponent } from '../profile/profile.component';
import { AgoPipe } from 'app/shared/pipes/ago.pipe';
import { CheckmessagePipe } from 'app/shared/pipes/checkmessage.pipe';
@Component({ @Component({
selector: 'chat-chats', selector: 'chat-chats',
@@ -42,7 +42,7 @@ import { CheckmessagePipe } from 'app/shared/pipes/checkmessage.pipe';
RouterOutlet, RouterOutlet,
AgoPipe, AgoPipe,
CommonModule, CommonModule,
CheckmessagePipe CheckmessagePipe,
], ],
}) })
export class ChatsComponent implements OnInit, OnDestroy { export class ChatsComponent implements OnInit, OnDestroy {
@@ -67,9 +67,8 @@ export class ChatsComponent implements OnInit, OnDestroy {
constructor( constructor(
private _chatService: ChatService, private _chatService: ChatService,
private _changeDetectorRef: ChangeDetectorRef, private _changeDetectorRef: ChangeDetectorRef,
private route: ActivatedRoute, private route: ActivatedRoute
) {}
) { }
/** /**
* Angular lifecycle hook (ngOnInit) for component initialization. * Angular lifecycle hook (ngOnInit) for component initialization.
@@ -100,7 +99,7 @@ export class ChatsComponent implements OnInit, OnDestroy {
this._markForCheck(); this._markForCheck();
}); });
this._chatService.InitSubscribeToChatList(); this._chatService.InitSubscribeToChatList();
const savedChatId = localStorage.getItem('currentChatId'); const savedChatId = localStorage.getItem('currentChatId');
@@ -128,7 +127,7 @@ export class ChatsComponent implements OnInit, OnDestroy {
this.filteredChats = this.chats; this.filteredChats = this.chats;
} else { } else {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
this.filteredChats = this.chats.filter(chat => this.filteredChats = this.chats.filter((chat) =>
chat.contact?.name.toLowerCase().includes(lowerCaseQuery) chat.contact?.name.toLowerCase().includes(lowerCaseQuery)
); );
} }
@@ -169,6 +168,4 @@ export class ChatsComponent implements OnInit, OnDestroy {
private _markForCheck(): void { private _markForCheck(): void {
this._changeDetectorRef.markForCheck(); this._changeDetectorRef.markForCheck();
} }
} }

View File

@@ -29,12 +29,14 @@
</div> </div>
} }
</div> </div>
<div class="mt-4 text-lg font-medium">{{ chat.contact?.name }}</div> <div class="mt-4 text-lg font-medium">
<div class="text-secondary mt-0.5 text-md"> <a [routerLink]="['/profile', chat.contact?.pubKey]">
{{ chat.contact?.name }}
</a>
</div>
<div class="text-secondary ml-4 mr-4 mt-0.5 text-md">
{{ chat.contact?.about }} {{ chat.contact?.about }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import {
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav'; import { MatDrawer } from '@angular/material/sidenav';
import { RouterModule } from '@angular/router';
import { Chat } from '../chat.types'; import { Chat } from '../chat.types';
@Component({ @Component({
@@ -15,7 +16,7 @@ import { Chat } from '../chat.types';
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [MatButtonModule, MatIconModule], imports: [MatButtonModule, MatIconModule, RouterModule],
}) })
export class ContactInfoComponent { export class ContactInfoComponent {
@Input() chat: Chat; @Input() chat: Chat;

View File

@@ -1,7 +1,8 @@
.c-img{ .c-img {
max-width: 100%; border-radius: 10px; max-width: 100%;
border-radius: 10px;
} }
.c-video .c-video {
{ max-width: 100%;
max-width: 100%; border-radius: 10px; border-radius: 10px;
} }

View File

@@ -176,33 +176,44 @@
> >
<!-- Bubble --> <!-- Bubble -->
<div <div
class="relative max-w-3/4 rounded-lg px-2 py-2" class="relative max-w-3/4 rounded-lg px-2 py-2"
[ngClass]="{ [ngClass]="{
'bg-gray-400 text-blue-50': message.isMine, 'bg-gray-400 text-blue-50':
'bg-gray-500 text-gray-50': !message.isMine message.isMine,
}" 'bg-gray-500 text-gray-50':
> !message.isMine,
<!-- Speech bubble tail --> }"
@if ( >
last || <!-- Speech bubble tail -->
chat.messages[i + 1].isMine !== message.isMine @if (
) { last ||
chat.messages[i + 1].isMine !==
message.isMine
) {
<div
class="absolute bottom-0 w-3"
[ngClass]="{
'-right-1 -mr-px mb-px text-gray-400':
message.isMine,
'-left-1 -ml-px mb-px -scale-x-1 text-gray-500':
!message.isMine,
}"
>
<ng-container
*ngTemplateOutlet="
speechBubbleExtension
"
></ng-container>
</div>
}
<!-- Message -->
<div <div
class="absolute bottom-0 w-3" class="min-w-4 whitespace-normal break-words leading-5"
[ngClass]="{ [innerHTML]="
'-right-1 -mr-px mb-px text-gray-400': message.isMine, parseContent(message.value)
'-left-1 -ml-px mb-px -scale-x-1 text-gray-500': !message.isMine "
}" ></div>
> </div>
<ng-container *ngTemplateOutlet="speechBubbleExtension"></ng-container>
</div>
}
<!-- Message -->
<div
class="min-w-4 leading-5 break-words whitespace-normal"
[innerHTML]="parseContent(message.value)"
></div>
</div>
<!-- Time --> <!-- Time -->
@if ( @if (
@@ -237,7 +248,7 @@
class="flex items-end border-t bg-gray-50 p-4 dark:bg-transparent" class="flex items-end border-t bg-gray-50 p-4 dark:bg-transparent"
> >
<div class="my-px flex h-11 items-center"> <div class="my-px flex h-11 items-center">
<button mat-icon-button (click)="openGifDialog()"> <button mat-icon-button (click)="openGifDialog()">
<mat-icon <mat-icon
[svgIcon]="'heroicons_outline:gif'" [svgIcon]="'heroicons_outline:gif'"
></mat-icon> ></mat-icon>

View File

@@ -1,5 +1,11 @@
import { AngorMediaWatcherService } from '@angor/services/media-watcher';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule, DatePipe, NgClass, NgTemplateOutlet } from '@angular/common'; import {
CommonModule,
DatePipe,
NgClass,
NgTemplateOutlet,
} from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
@@ -19,17 +25,16 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AngorMediaWatcherService } from '@angor/services/media-watcher';
import { Subject, takeUntil } from 'rxjs';
import { ContactInfoComponent } from '../contact-info/contact-info.component';
import { Chat } from 'app/layout/common/quick-chat/quick-chat.types';
import { ChatService } from '../chat.service';
import { PickerComponent } from '@ctrl/ngx-emoji-mart';
import { AngorConfigService } from '@angor/services/config'; import { AngorConfigService } from '@angor/services/config';
import { GifDialogComponent } from 'app/shared/gif-dialog/gif-dialog.component';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { PickerComponent } from '@ctrl/ngx-emoji-mart';
import { Chat } from 'app/layout/common/quick-chat/quick-chat.types';
import { GifDialogComponent } from 'app/shared/gif-dialog/gif-dialog.component';
import { Subject, takeUntil } from 'rxjs';
import { ChatService } from '../chat.service';
import { ContactInfoComponent } from '../contact-info/contact-info.component';
@Component({ @Component({
selector: 'chat-conversation', selector: 'chat-conversation',
@@ -52,7 +57,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
TextFieldModule, TextFieldModule,
DatePipe, DatePipe,
PickerComponent, PickerComponent,
CommonModule CommonModule,
], ],
}) })
export class ConversationComponent implements OnInit, OnDestroy { export class ConversationComponent implements OnInit, OnDestroy {
@@ -68,7 +73,6 @@ export class ConversationComponent implements OnInit, OnDestroy {
isListening: boolean = false; isListening: boolean = false;
userEdited: boolean = false; userEdited: boolean = false;
constructor( constructor(
private _changeDetectorRef: ChangeDetectorRef, private _changeDetectorRef: ChangeDetectorRef,
private _chatService: ChatService, private _chatService: ChatService,
@@ -78,11 +82,15 @@ export class ConversationComponent implements OnInit, OnDestroy {
public dialog: MatDialog, public dialog: MatDialog,
private sanitizer: DomSanitizer private sanitizer: DomSanitizer
) { ) {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; const SpeechRecognition =
(window as any).SpeechRecognition ||
(window as any).webkitSpeechRecognition;
if (!SpeechRecognition) { if (!SpeechRecognition) {
console.error('Speech recognition is not supported in this browser.'); console.error(
return; 'Speech recognition is not supported in this browser.'
);
return;
} }
this.recognition = new SpeechRecognition(); this.recognition = new SpeechRecognition();
this.recognition.lang = 'en-US'; this.recognition.lang = 'en-US';
@@ -91,41 +99,35 @@ export class ConversationComponent implements OnInit, OnDestroy {
this.setupRecognitionEvents(); this.setupRecognitionEvents();
} }
openGifDialog(): void { openGifDialog(): void {
const dialogRef = this.dialog.open(GifDialogComponent, { const dialogRef = this.dialog.open(GifDialogComponent, {
width: '600px', width: '600px',
maxHeight: '80vh', maxHeight: '80vh',
data: { apiKey: 'LIVDSRZULELA' } data: { apiKey: 'LIVDSRZULELA' },
}); });
dialogRef.afterClosed().subscribe(result => { dialogRef.afterClosed().subscribe((result) => {
if (result) { if (result) {
const messageContent = result;
const messageContent = result if (messageContent) {
if (messageContent) {
this.messageInput.nativeElement.value = '';
this._chatService.sendPrivateMessage(messageContent)
.then(() => {
this.messageInput.nativeElement.value = ''; this.messageInput.nativeElement.value = '';
this._chatService
.sendPrivateMessage(messageContent)
.then(() => {
this.messageInput.nativeElement.value = '';
this.finalTranscript = '';
})
.catch((error) => {
console.error('Failed to send message:', error);
});
this.finalTranscript = ''; this.finalTranscript = '';
}) this.userEdited = false;
.catch((error) => { }
console.error('Failed to send message:', error); }
}); });
this.finalTranscript = '';
this.userEdited = false;
}
}
});
} }
ngOnInit(): void { ngOnInit(): void {
this._angorConfigService.config$.subscribe((config) => { this._angorConfigService.config$.subscribe((config) => {
if (config.scheme === 'auto') { if (config.scheme === 'auto') {
@@ -140,22 +142,18 @@ export class ConversationComponent implements OnInit, OnDestroy {
.subscribe((chat: Chat) => { .subscribe((chat: Chat) => {
this.chat = chat; this.chat = chat;
this._changeDetectorRef.markForCheck(); this._changeDetectorRef.markForCheck();
}); });
this._angorMediaWatcherService.onMediaChange$ this._angorMediaWatcherService.onMediaChange$
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe(({ matchingAliases }) => { .subscribe(({ matchingAliases }) => {
if (matchingAliases.includes('lg')) { if (matchingAliases.includes('lg')) {
this.drawerMode = 'side'; this.drawerMode = 'side';
} else { } else {
this.drawerMode = 'over'; this.drawerMode = 'over';
} }
this._changeDetectorRef.markForCheck(); this._changeDetectorRef.markForCheck();
}); });
} }
@@ -163,50 +161,50 @@ export class ConversationComponent implements OnInit, OnDestroy {
parseContent(content: string): SafeHtml { parseContent(content: string): SafeHtml {
const urlRegex = /(https?:\/\/[^\s]+)/g; const urlRegex = /(https?:\/\/[^\s]+)/g;
const cleanedContent = content.replace(/["]+/g, ''); const cleanedContent = content.replace(/["]+/g, '');
const parsedContent = cleanedContent.replace(urlRegex, (url) => { const parsedContent = cleanedContent
if (url.match(/\.(jpeg|jpg|gif|png|bmp|svg|webp|tiff)$/) != null) { .replace(urlRegex, (url) => {
return `<img src="${url}" alt="Image" width="100%" class="c-img">`; if (
} else if (url.match(/\.(mp4|webm)$/) != null) { url.match(/\.(jpeg|jpg|gif|png|bmp|svg|webp|tiff)$/) != null
return `<video controls width="100%" class="c-video"><source src="${url}" type="video/mp4">Your browser does not support the video tag.</video>`; ) {
} else if (url.match(/(youtu\.be\/|youtube\.com\/watch\?v=)/)) { return `<img src="${url}" alt="Image" width="100%" class="c-img">`;
let videoId; } else if (url.match(/\.(mp4|webm)$/) != null) {
if (url.includes('youtu.be/')) { return `<video controls width="100%" class="c-video"><source src="${url}" type="video/mp4">Your browser does not support the video tag.</video>`;
videoId = url.split('youtu.be/')[1]; } else if (url.match(/(youtu\.be\/|youtube\.com\/watch\?v=)/)) {
} else if (url.includes('watch?v=')) { let videoId;
const urlParams = new URLSearchParams(url.split('?')[1]); if (url.includes('youtu.be/')) {
videoId = urlParams.get('v'); videoId = url.split('youtu.be/')[1];
} } else if (url.includes('watch?v=')) {
return `<iframe width="100%" class="c-video" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`; const urlParams = new URLSearchParams(
} else { url.split('?')[1]
return `<a href="${url}" target="_blank">${url}</a>`; );
} videoId = urlParams.get('v');
}).replace(/\n/g, '<br>'); }
return `<iframe width="100%" class="c-video" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`;
} else {
return `<a href="${url}" target="_blank">${url}</a>`;
}
})
.replace(/\n/g, '<br>');
return this.sanitizer.bypassSecurityTrustHtml(parsedContent); return this.sanitizer.bypassSecurityTrustHtml(parsedContent);
} }
@HostListener('input') @HostListener('input')
@HostListener('ngModelChange') @HostListener('ngModelChange')
private _resizeMessageInput(): void { private _resizeMessageInput(): void {
this._ngZone.runOutsideAngular(() => { this._ngZone.runOutsideAngular(() => {
setTimeout(() => { setTimeout(() => {
this.messageInput.nativeElement.style.height = 'auto'; this.messageInput.nativeElement.style.height = 'auto';
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
this.messageInput.nativeElement.style.height = `${this.messageInput.nativeElement.scrollHeight}px`; this.messageInput.nativeElement.style.height = `${this.messageInput.nativeElement.scrollHeight}px`;
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
}); });
}); });
} }
setupRecognitionEvents(): void { setupRecognitionEvents(): void {
this.recognition.onresult = (event: any) => { this.recognition.onresult = (event: any) => {
let interimTranscript = ''; let interimTranscript = '';
@@ -220,9 +218,9 @@ export class ConversationComponent implements OnInit, OnDestroy {
} }
} }
if (!this.userEdited) { if (!this.userEdited) {
this.messageInput.nativeElement.value = this.finalTranscript + interimTranscript; this.messageInput.nativeElement.value =
this.finalTranscript + interimTranscript;
} }
}; };
@@ -235,7 +233,6 @@ export class ConversationComponent implements OnInit, OnDestroy {
}; };
} }
toggleSpeechRecognition(): void { toggleSpeechRecognition(): void {
this.finalTranscript = ''; this.finalTranscript = '';
if (this.isListening) { if (this.isListening) {
@@ -248,43 +245,32 @@ export class ConversationComponent implements OnInit, OnDestroy {
} }
} }
handleUserInput(event: Event): void { handleUserInput(event: Event): void {
this.userEdited = true; this.userEdited = true;
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this._unsubscribeAll.next(null); this._unsubscribeAll.next(null);
this._unsubscribeAll.complete(); this._unsubscribeAll.complete();
} }
openContactInfo(): void { openContactInfo(): void {
this.drawerOpened = true; this.drawerOpened = true;
this._changeDetectorRef.markForCheck(); this._changeDetectorRef.markForCheck();
} }
resetChat(): void { resetChat(): void {
this._chatService.resetChat(); this._chatService.resetChat();
this.drawerOpened = false; this.drawerOpened = false;
this._changeDetectorRef.markForCheck(); this._changeDetectorRef.markForCheck();
} }
toggleMuteNotifications(): void { toggleMuteNotifications(): void {
this.chat.muted = !this.chat.muted; this.chat.muted = !this.chat.muted;
this._chatService.updateChat(this.chat.id, this.chat).subscribe(); this._chatService.updateChat(this.chat.id, this.chat).subscribe();
} }
@@ -292,9 +278,10 @@ export class ConversationComponent implements OnInit, OnDestroy {
return item.id || index; return item.id || index;
} }
detectSystemTheme() { detectSystemTheme() {
const darkSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); const darkSchemeMedia = window.matchMedia(
'(prefers-color-scheme: dark)'
);
this.darkMode = darkSchemeMedia.matches; this.darkMode = darkSchemeMedia.matches;
darkSchemeMedia.addEventListener('change', (event) => { darkSchemeMedia.addEventListener('change', (event) => {
@@ -312,10 +299,10 @@ export class ConversationComponent implements OnInit, OnDestroy {
sendMessage(): void { sendMessage(): void {
const messageContent = this.messageInput.nativeElement.value.trim(); const messageContent = this.messageInput.nativeElement.value.trim();
if (messageContent) { if (messageContent) {
this.messageInput.nativeElement.value = ''; this.messageInput.nativeElement.value = '';
this._chatService.sendPrivateMessage(messageContent) this._chatService
.sendPrivateMessage(messageContent)
.then(() => { .then(() => {
this.messageInput.nativeElement.value = ''; this.messageInput.nativeElement.value = '';
this.finalTranscript = ''; this.finalTranscript = '';
@@ -336,5 +323,4 @@ export class ConversationComponent implements OnInit, OnDestroy {
toggleEmojiPicker() { toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker; this.showEmojiPicker = !this.showEmojiPicker;
} }
} }

View File

@@ -27,7 +27,8 @@
) { ) {
<div <div
class="text-secondary sticky top-0 z-10 -mt-px border-b border-t bg-gray-100 px-6 py-1 font-medium uppercase dark:bg-gray-900 md:px-8" class="text-secondary sticky top-0 z-10 -mt-px border-b border-t bg-gray-100 px-6 py-1 font-medium uppercase dark:bg-gray-900 md:px-8"
(click)="openChat(contact)"> (click)="openChat(contact)"
>
{{ contact.name.charAt(0) }} {{ contact.name.charAt(0) }}
</div> </div>
} }
@@ -35,7 +36,7 @@
<div <div
class="z-20 flex cursor-pointer items-center border-b px-6 py-4 dark:hover:bg-hover hover:bg-gray-100 md:px-8" class="z-20 flex cursor-pointer items-center border-b px-6 py-4 dark:hover:bg-hover hover:bg-gray-100 md:px-8"
(click)="openChat(contact)" (click)="openChat(contact)"
> >
<div <div
class="flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full" class="flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full"
> >

View File

@@ -9,10 +9,10 @@ import {
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatDrawer } from '@angular/material/sidenav'; import { MatDrawer } from '@angular/material/sidenav';
import { Subject, takeUntil } from 'rxjs';
import { Contact } from '../chat.types';
import { ChatService } from '../chat.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
import { ChatService } from '../chat.service';
import { Contact } from '../chat.types';
@Component({ @Component({
selector: 'chat-new-chat', selector: 'chat-new-chat',
@@ -30,8 +30,10 @@ export class NewChatComponent implements OnInit, OnDestroy {
/** /**
* Constructor * Constructor
*/ */
constructor(private _chatService: ChatService, private router: Router) {} constructor(
private _chatService: ChatService,
private router: Router
) {}
ngOnInit(): void { ngOnInit(): void {
// Contacts // Contacts
@@ -42,14 +44,12 @@ export class NewChatComponent implements OnInit, OnDestroy {
}); });
} }
ngOnDestroy(): void { ngOnDestroy(): void {
// Unsubscribe from all subscriptions // Unsubscribe from all subscriptions
this._unsubscribeAll.next(null); this._unsubscribeAll.next(null);
this._unsubscribeAll.complete(); this._unsubscribeAll.complete();
} }
trackByFn(index: number, item: any): any { trackByFn(index: number, item: any): any {
return item.id || index; return item.id || index;
} }
@@ -65,8 +65,7 @@ export class NewChatComponent implements OnInit, OnDestroy {
}, },
complete: () => { complete: () => {
this.drawer.close(); this.drawer.close();
} },
}); });
} }
} }

View File

@@ -13,8 +13,8 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatDrawer } from '@angular/material/sidenav'; import { MatDrawer } from '@angular/material/sidenav';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { Profile } from '../chat.types';
import { ChatService } from '../chat.service'; import { ChatService } from '../chat.service';
import { Profile } from '../chat.types';
@Component({ @Component({
selector: 'chat-profile', selector: 'chat-profile',

View File

@@ -0,0 +1,239 @@
<div
class=""
infiniteScroll
[infiniteScrollDistance]="2"
[infiniteScrollThrottle]="500"
(scrolled)="loadMoreEvents()"
[scrollWindow]="true"
>
<angor-card class="mb-8 flex w-full flex-col" #expandableComments="angorCard"
*ngFor="let event of events$ | async let i = index">
<div class="mx-6 mb-4 mt-6 flex items-center sm:mx-8">
<img class="mr-4 h-10 w-10 rounded-full"
[src]="event.picture || 'images/avatars/avatar-placeholder.png'"
onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';"
alt="{{ event.username }}" />
<div class="flex flex-col">
<span class="font-semibold leading-none">{{
event.username
}}</span>
<span class="text-secondary mt-1 text-sm leading-none">{{
getTimeFromNow(event)
}}</span>
</div>
<button class="-mr-4 ml-auto" mat-icon-button [matMenuTriggerFor]="postCardMenu02">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:ellipsis-vertical'"></mat-icon>
</button>
<mat-menu #postCardMenu02="matMenu">
<button mat-menu-item>
<span class="flex items-center">
<mat-icon class="mr-3 icon-size-5"
[svgIcon]="'heroicons_solid:arrow-up-tray'"></mat-icon>
<span>Save post</span>
</span>
</button>
<button mat-menu-item>
<span class="flex items-center">
<mat-icon class="mr-3 icon-size-5"
[svgIcon]="'heroicons_solid:eye-slash'"></mat-icon>
<span>Hide post</span>
</span>
</button>
<button mat-menu-item>
<span class="flex items-center">
<mat-icon class="mr-3 icon-size-5" [svgIcon]="'heroicons_solid:clock'"></mat-icon>
<span>Snooze for 30 days</span>
</span>
</button>
<button mat-menu-item>
<span class="flex items-center">
<mat-icon class="mr-3 icon-size-5"
[svgIcon]="'heroicons_solid:minus-circle'"></mat-icon>
<span>Hide all</span>
</span>
</button>
<mat-divider class="my-2"></mat-divider>
<button mat-menu-item>
<span class="flex items-center">
<mat-icon class="mr-3 icon-size-5"
[svgIcon]="'heroicons_solid:exclamation-triangle'"></mat-icon>
<span>Report post</span>
</span>
</button>
<button mat-menu-item>
<span class="flex items-center">
<mat-icon class="mr-3 icon-size-5" [svgIcon]="'heroicons_solid:bell'"></mat-icon>
<span>Turn on notifications for this post</span>
</span>
</button>
</mat-menu>
</div>
<div class="mx-6 mb-6 mt-2 sm:mx-8" [innerHTML]="parseContent(event.content)"></div>
<div class="relative mb-4">
<!-- image or video -->
</div>
<div class="mx-3 flex items-center sm:mx-5">
<button class="mr-1 px-3" mat-button (click)="toggleLike(event)">
<mat-icon class="text-red-500 icon-size-5" [ngClass]="{ 'heart-beat': event.likedByMe }"
[svgIcon]="
event.likedByMe
? 'heroicons_solid:heart'
: 'heroicons_outline:heart'
">
</mat-icon>
<span class="ml-2">{{ event.likeCount }} Like</span>
</button>
<button class="mr-1 px-3" mat-button (click)="
expandableComments.expanded = !expandableComments.expanded
">
<mat-icon class="icon-size-5"
[svgIcon]="'heroicons_solid:chat-bubble-left-ellipsis'"></mat-icon>
<span class="ml-2">Comment</span>
</button>
<button class="mr-1 px-3" mat-button>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:share'"></mat-icon>
<span class="ml-2">Share</span>
</button>
</div>
<hr class="mx-6 mb-6 mt-4 border-b sm:mx-8" />
<div class="mx-6 mb-4 flex flex-col sm:mx-8 sm:mb-6 sm:flex-row sm:items-center">
<div class="flex items-center">
<img class="text-card m-0.5 h-6 w-6 rounded-full ring-2 ring-white"
src="images/avatars/avatar-placeholder.png" alt="Card cover image" />
<img class="text-card m-0.5 -ml-3 h-6 w-6 rounded-full ring-2 ring-white"
src="images/avatars/avatar-placeholder.png" alt="Card cover image" />
<img class="text-card m-0.5 -ml-3 h-6 w-6 rounded-full ring-2 ring-white"
src="images/avatars/avatar-placeholder.png" alt="Card cover image" />
<img class="text-card m-0.5 -ml-3 h-6 w-6 rounded-full ring-2 ring-white"
src="images/avatars/avatar-placeholder.png" alt="Card cover image" />
<div class="ml-3 text-md tracking-tight">
⚡ {{ event.zapCount }} zap
</div>
</div>
<div class="hidden flex-auto sm:flex"></div>
<div class="mt-4 flex items-center sm:mt-0">
<button class="-ml-2 mr-1 px-3 sm:ml-0" mat-button>
{{ event.repostCount }} shares
</button>
<button class="px-3 sm:-mr-4" mat-button (click)="
expandableComments.expanded =
!expandableComments.expanded
">
<span class="mr-1">{{ event.replyCount }} Comments</span>
<mat-icon class="rotate-0 transition-transform duration-150 ease-in-out icon-size-5"
[ngClass]="{
'rotate-180': expandableComments.expanded,
}" [svgIcon]="'heroicons_mini:chevron-down'"></mat-icon>
</button>
</div>
</div>
<ng-container angorCardExpansion>
<hr class="m-0 border-b" />
<div class="mx-4 mb-3 mt-6 flex flex-col sm:mx-8">
<div class="flex items-start">
<img class="mr-5 h-12 w-12 rounded-full object-cover" [src]="
currentUserMetadata?.picture ||
'images/avatars/avatar-placeholder.png'
" onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';"
alt="{{
currentUserMetadata?.display_name ||
currentUserMetadata?.name ||
'Avatar'
}}" />
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<textarea
[ngModel]="getComment(i)"
(ngModelChange)="setComment(i, $event)"
placeholder="Write a comment..."
matInput>
</textarea>
</mat-form-field>
</div>
<div class="mt-3 flex items-center justify-between">
<div class="flex items-center">
<button mat-icon-button (click)="toggleCommentEmojiPicker(i)">
<mat-icon class="icon-size-5"
[svgIcon]="'heroicons_solid:face-smile'"></mat-icon>
</button>
<div *ngIf="eventStates[i]?.showEmojiPicker" class="emoji-picker-container-global">
<emoji-mart (emojiClick)="addEmojiToComment($event, i)" [darkMode]="darkMode"></emoji-mart>
</div>
<button mat-icon-button>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:photo'"></mat-icon>
</button>
<button mat-icon-button>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:sparkles'"></mat-icon>
</button>
</div>
<button mat-button (click)="sendComment(event, i)">
<mat-icon [svgIcon]="'heroicons_solid:paper-airplane'"></mat-icon>
<span>Send</span>
</button>
</div>
</div>
<div class="max-h-120 overflow-y-auto">
<div class="relative mx-4 my-6 flex flex-col sm:mx-8">
<div class="flex items-start mb-4" *ngFor="let reply of event.replies">
<img
class="mr-4 h-8 w-8 rounded-full"
[src]="reply.picture || 'images/avatars/avatar-placeholder.png'"
onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';"
alt="{{ reply.username }}"
/>
<div class="mt-0.5 flex flex-col">
<span>
<b>{{ reply.username }}: </b>
{{ reply.content }}
</span>
<div
class="text-secondary mt-2 flex items-center text-sm"
>
<span
class="mr-2 cursor-pointer hover:underline"
>Like</span
>
<span
class="mr-2 cursor-pointer hover:underline"
>Reply</span
>
<span
class="mr-2 cursor-pointer hover:underline"
>Hide replies</span
>
<span class="mr-2">&bull;</span>
<span>{{getTimeFromNow(reply) }}</span>
</div>
</div>
</div>
</div>
</div>
</ng-container>
</angor-card>
<div *ngIf="isLoading" class="loading-spinner">
<div class="spinner"></div>
Loading events...
</div>
<button
*ngIf="!noMoreEvents && !isLoading"
class="load-more-btn"
(click)="loadMoreEvents()"
>
Load More Events
</button>
</div>
<div *ngIf="noMoreEvents" class="no-more-events">No more events to load.</div>

View File

@@ -0,0 +1,158 @@
:host {
display: block;
max-width: 600px;
margin: 0 auto;
font-family: 'Arial', sans-serif;
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #009fb5;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
.event-list {
list-style: none;
padding: 0;
margin: 20px 0;
.event-item {
background-color: #ffffff;
border-radius: 10px;
padding: 15px;
margin-bottom: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.event-header {
display: flex;
align-items: center;
.profile-picture {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
border: 2px solid #009fb5;
}
.event-info {
.username {
font-weight: bold;
color: #333;
}
.timestamp {
font-size: 0.9em;
color: #888;
}
}
}
.event-content {
margin: 10px 0;
font-size: 1.1em;
color: #555;
}
.event-actions {
display: flex;
gap: 10px;
margin-top: 10px;
button {
background-color: transparent;
border: none;
cursor: pointer;
color: #009fb5;
font-size: 1.1em;
transition: color 0.2s ease;
&:hover {
color: #007f91;
}
&:disabled {
color: #999;
cursor: not-allowed;
}
}
}
.event-replies {
margin-top: 15px;
border-top: 1px solid #e0e0e0;
padding-top: 10px;
ul {
list-style: none;
padding: 0;
.reply-item {
margin: 5px 0;
font-size: 0.9em;
.reply-username {
font-weight: bold;
color: #009fb5;
}
.reply-content {
color: #555;
}
}
}
}
}
}
.no-more-events {
text-align: center;
color: #555;
margin: 20px 0;
font-size: 1.1em;
}
.load-more-btn {
display: block;
width: 100%;
padding: 10px;
font-size: 1.1em;
background-color: #009fb5;
color: #ffffff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover {
background-color: #007f91;
}
&:focus {
outline: none;
}
}

View File

@@ -0,0 +1,252 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { PaginatedEventService } from 'app/services/event.service';
import { NewEvent } from 'app/types/NewEvent';
import { AngorCardComponent } from '@angor/components/card';
import { TextFieldModule } from '@angular/cdk/text-field';
import { NgClass, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { PickerComponent } from '@ctrl/ngx-emoji-mart';
import { QRCodeModule } from 'angularx-qrcode';
import { SafeUrlPipe } from 'app/shared/pipes/safe-url.pipe';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
@Component({
selector: 'app-event-list',
templateUrl: './event-list.component.html',
styleUrls: ['./event-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
RouterLink,
AngorCardComponent,
MatIconModule,
MatButtonModule,
MatMenuModule,
MatFormFieldModule,
MatInputModule,
TextFieldModule,
MatDividerModule,
MatTooltipModule,
NgClass,
CommonModule,
FormsModule,
QRCodeModule,
PickerComponent,
MatSlideToggle,
SafeUrlPipe,
MatProgressSpinnerModule,
InfiniteScrollModule,
]
})
export class EventListComponent implements OnInit, OnDestroy {
@Input() pubkeys: string[] = [];
@Input() currentUserMetadata: any;
events$: Observable<NewEvent[]>;
eventStates: { showEmojiPicker: boolean; comment: string }[] = [];
subscriptions: Subscription[] = [];
isLoading = false;
noMoreEvents = false;
constructor(
private paginatedEventService: PaginatedEventService,
private changeDetectorRef: ChangeDetectorRef,
private sanitizer: DomSanitizer
) {
this.events$ = this.paginatedEventService.getEventStream();
}
ngOnInit(): void {
this.resetAll();
}
subscribeToEvents(): void {
this.unsubscribeAll();
if (!this.pubkeys || this.pubkeys.length === 0) {
console.warn('No public keys provided');
return;
}
this.paginatedEventService.subscribeToEvents(this.pubkeys)
.then(() => {
console.log('Subscribed to events for the new user.');
})
.catch(error => {
console.error('Error subscribing to events:', error);
});
const eventSub = this.events$.subscribe(events => {
const relevantEvents = events.filter(event => this.pubkeys.includes(event.pubkey));
this.eventStates = relevantEvents.map(() => ({
showEmojiPicker: false,
comment: ''
}));
this.changeDetectorRef.markForCheck();
});
this.subscriptions.push(eventSub);
}
resetAll(): void {
this.unsubscribeAll();
this.clearComponentState();
this.paginatedEventService.clearEvents();
this.subscribeToEvents();
this.loadInitialEvents();
}
unsubscribeAll(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions = [];
}
clearComponentState(): void {
this.eventStates = [];
this.isLoading = false;
this.noMoreEvents = false;
this.changeDetectorRef.markForCheck();
}
loadInitialEvents(): void {
if (this.pubkeys.length === 0) {
console.warn('No pubkeys provided');
return;
}
this.isLoading = true;
this.paginatedEventService.loadMoreEvents(this.pubkeys).finally(() => {
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
loadMoreEvents(): void {
if (!this.isLoading && !this.noMoreEvents) {
this.isLoading = true;
this.paginatedEventService.loadMoreEvents(this.pubkeys).finally(() => {
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
}
getComment(index: number): string {
return this.eventStates[index]?.comment || '';
}
setComment(index: number, value: string): void {
if (this.eventStates[index]) {
this.eventStates[index].comment = value;
}
}
getSanitizedContent(content: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(content);
}
sendLike(event: NewEvent): void {
if (!event.likedByMe) {
this.paginatedEventService.sendLikeEvent(event).then(() => {
event.likedByMe = true;
event.likeCount++;
this.changeDetectorRef.markForCheck();
}).catch(error => console.error('Failed to send like:', error));
}
}
toggleLike(event: NewEvent): void {
this.sendLike(event);
}
toggleCommentEmojiPicker(index: number): void {
this.eventStates[index].showEmojiPicker = !this.eventStates[index].showEmojiPicker;
}
addEmojiToComment(event: any, index: number): void {
this.eventStates[index].comment += event.emoji.native;
this.eventStates[index].showEmojiPicker = false;
}
sendComment(event: NewEvent, index: number): void {
const comment = this.eventStates[index].comment;
if (comment.trim() !== '') {
this.paginatedEventService.sendReplyEvent(event, comment).then(() => {
this.eventStates[index].comment = '';
this.changeDetectorRef.markForCheck();
});
}
}
trackById(index: number, item: NewEvent): string {
return item.id;
}
ngOnDestroy(): void {
this.unsubscribeAll();
}
getTimeFromNow(event: NewEvent): string {
return event.fromNow;
}
parseContent(content: string): SafeHtml {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const cleanedContent = content.replace(/["]+/g, '');
const parsedContent = cleanedContent
.replace(urlRegex, (url) => {
if (url.match(/\.(jpeg|jpg|gif|png|bmp|svg|webp|tiff)$/) != null) {
return `<img src="${url}" alt="Image" width="100%" class="c-img">`;
} else if (url.match(/\.(mp4|webm)$/) != null) {
return `<video controls width="100%" class="c-video"><source src="${url}" type="video/mp4">Your browser does not support the video tag.</video>`;
} else if (url.match(/(youtu\.be\/|youtube\.com\/watch\?v=)/)) {
let videoId;
if (url.includes('youtu.be/')) {
videoId = url.split('youtu.be/')[1];
} else if (url.includes('watch?v=')) {
const urlParams = new URLSearchParams(url.split('?')[1]);
videoId = urlParams.get('v');
}
return `<iframe width="100%" class="c-video" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`;
} else {
return `<a href="${url}" target="_blank">${url}</a>`;
}
})
.replace(/\n/g, '<br>');
return this.sanitizer.bypassSecurityTrustHtml(parsedContent);
}
}

View File

@@ -1,20 +1,37 @@
<div class="absolute inset-0 flex min-w-0 flex-col overflow-y-auto"> <div class="absolute inset-0 flex min-w-0 flex-col overflow-y-auto">
<!-- Header --> <!-- Header -->
<div class="dark relative flex-0 overflow-hidden bg-gray-800 px-4 py-8 sm:p-16"> <div
class="dark relative flex-0 overflow-hidden bg-gray-800 px-4 py-8 sm:p-16"
>
<!-- Background --> <!-- Background -->
<svg class="pointer-events-none absolute inset-0" viewBox="0 0 960 540" width="100%" height="100%" <svg
preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg"> class="pointer-events-none absolute inset-0"
<g class="text-gray-700 opacity-25" fill="none" stroke="currentColor" stroke-width="100"> viewBox="0 0 960 540"
width="100%"
height="100%"
preserveAspectRatio="xMidYMax slice"
xmlns="http://www.w3.org/2000/svg"
>
<g
class="text-gray-700 opacity-25"
fill="none"
stroke="currentColor"
stroke-width="100"
>
<circle r="234" cx="196" cy="23"></circle> <circle r="234" cx="196" cy="23"></circle>
<circle r="234" cx="790" cy="491"></circle> <circle r="234" cx="790" cy="491"></circle>
</g> </g>
</svg> </svg>
<div class="relative z-10 flex flex-col items-center"> <div class="relative z-10 flex flex-col items-center">
<h2 class="text-xl font-semibold">Explore Projects</h2> <h2 class="text-xl font-semibold">Explore Projects</h2>
<div class="mt-1 text-center text-4xl font-extrabold leading-tight tracking-tight sm:text-7xl"> <div
class="mt-1 text-center text-4xl font-extrabold leading-tight tracking-tight sm:text-7xl"
>
Whats your next investment? Whats your next investment?
</div> </div>
<div class="text-secondary mt-6 max-w-2xl text-center tracking-tight sm:text-2xl"> <div
class="text-secondary mt-6 max-w-2xl text-center tracking-tight sm:text-2xl"
>
Check out our projects and find your next investment Check out our projects and find your next investment
opportunity. opportunity.
</div> </div>
@@ -23,110 +40,188 @@
<!-- Main --> <!-- Main -->
<div class="p-6 sm:p-10"> <div class="p-6 sm:p-10">
<div class="mx-auto flex w-full max-w-xs flex-auto flex-col sm:max-w-5xl"> <div
class="mx-auto flex w-full max-w-xs flex-auto flex-col sm:max-w-5xl"
>
<!-- Filters --> <!-- Filters -->
<div class="flex w-full max-w-xs flex-col items-center justify-between sm:max-w-none sm:flex-row"> <div
class="flex w-full max-w-xs flex-col items-center justify-between sm:max-w-none sm:flex-row"
>
<!-- Search bar with clear button --> <!-- Search bar with clear button -->
<div class="flex items-center space-x-2 w-full sm:w-auto"> <div class="flex w-full items-center space-x-2 sm:w-auto">
<mat-form-field class="mt-4 w-full sm:w-80" [subscriptSizing]="'dynamic'"> <mat-form-field
<mat-icon matPrefix class="icon-size-5" [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon> class="mt-4 w-full sm:w-80"
<input (keyup.enter)="filterByQuery(query.value)" placeholder="Search ..." matInput #query /> [subscriptSizing]="'dynamic'"
>
<mat-icon
matPrefix
class="icon-size-5"
[svgIcon]="'heroicons_solid:magnifying-glass'"
></mat-icon>
<input
(keyup.enter)="filterByQuery(query.value)"
placeholder="Search ..."
matInput
#query
/>
</mat-form-field> </mat-form-field>
<!-- Clear search button --> <!-- Clear search button -->
<button mat-icon-button color="warn" *ngIf="showCloseSearchButton" (click)="resetSearch(query)" class="mt-4"> <button
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon> mat-icon-button
color="warn"
*ngIf="showCloseSearchButton"
(click)="resetSearch(query)"
class="mt-4"
>
<mat-icon
[svgIcon]="'heroicons_solid:x-mark'"
></mat-icon>
</button> </button>
<button mat-icon-button color="success" *ngIf="!showCloseSearchButton" (click)="filterByQuery(query.value)" class="mt-4"> <button
<mat-icon [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon> mat-icon-button
color="success"
*ngIf="!showCloseSearchButton"
(click)="filterByQuery(query.value)"
class="mt-4"
>
<mat-icon
[svgIcon]="'heroicons_solid:magnifying-glass'"
></mat-icon>
</button> </button>
</div> </div>
<!-- Toggle completed --> <!-- Toggle completed -->
<mat-slide-toggle class="mt-8 sm:ml-auto sm:mt-0" [color]="'primary'" (change)="toggleCompleted($event)"> <mat-slide-toggle
class="mt-8 sm:ml-auto sm:mt-0"
[color]="'primary'"
(change)="toggleCompleted($event)"
>
Hide completed Hide completed
</mat-slide-toggle> </mat-slide-toggle>
</div> </div>
</div> </div>
<div class="mx-auto flex w-full flex-auto flex-col sm:max-w-5xl"> <div class="mx-auto flex w-full flex-auto flex-col sm:max-w-5xl">
<!-- Project Cards --> <!-- Project Cards -->
<div class="mt-10 grid w-full min-w-0 grid-cols-1 gap-6 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2"> <div
class="mt-10 grid w-full min-w-0 grid-cols-1 gap-6 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2"
>
<!-- Loop through projects and render cards --> <!-- Loop through projects and render cards -->
<ng-container *ngFor="let project of filteredProjects"> <ng-container *ngFor="let project of filteredProjects">
<angor-card class="filter-info flex w-full flex-col"> <angor-card class="filter-info flex w-full flex-col">
<div class="flex h-32"> <div class="flex h-32">
<img class="object-cover" [src]=" <img
class="object-cover"
[src]="
getSafeUrl(project?.banner, true) || getSafeUrl(project?.banner, true) ||
'images/pages/profile/cover.jpg' 'images/pages/profile/cover.jpg'
" onerror="this.onerror=null; this.src='/images/pages/profile/cover.jpg';" "
alt="Card cover image" /> onerror="this.onerror=null; this.src='/images/pages/profile/cover.jpg';"
alt="Card cover image"
/>
</div> </div>
<div class="flex px-8"> <div class="flex px-8">
<div class="bg-card -mt-12 rounded-full p-1"> <div class="bg-card -mt-12 rounded-full p-1">
<img class="h-24 w-24 rounded-full object-cover" [src]=" <img
class="h-24 w-24 rounded-full object-cover"
[src]="
getSafeUrl(project?.picture, false) || getSafeUrl(project?.picture, false) ||
'images/avatars/avatar-placeholder.png' 'images/avatars/avatar-placeholder.png'
" onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';" "
alt="Project logo" /> onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';"
alt="Project logo"
/>
</div> </div>
</div> </div>
<div class="flex flex-col px-8 pb-6 pt-4"> <div class="flex flex-col px-8 pb-6 pt-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="mr-4 min-w-0 flex-1"> <div class="mr-4 min-w-0 flex-1">
@if (project.displayName || project.name) { @if (project.displayName || project.name) {
<div class="truncate text-2xl font-semibold leading-tight" role="button" (click)=" <div
class="truncate text-2xl font-semibold leading-tight"
role="button"
(click)="
goToProjectDetails(project) goToProjectDetails(project)
"> "
{{ >
project.displayName || {{
project.nostrPubKey project.displayName ||
}} project.nostrPubKey
</div> }}
</div>
} }
@if ( @if (
!project.name && !project.displayName !project.name && !project.displayName
) { ) {
<div class="truncate text-2xl font-semibold leading-tight"> <div
{{ class="truncate text-2xl font-semibold leading-tight"
project.displayName || >
project.nostrPubKey {{
}} project.displayName ||
</div> project.nostrPubKey
}}
</div>
} }
<div class="text-secondary mt-1 truncate leading-tight"> <div
class="text-secondary mt-1 truncate leading-tight"
>
{{ {{
project.about || project.about ||
'No description available' 'No description available'
}} }}
</div> </div>
</div> </div>
@if (project.displayName || project.name) { @if (project.displayName || project.name) {
<div class="flex h-10 w-10 items-center justify-center rounded-full border"> <div
<button mat-icon-button (click)=" class="flex h-10 w-10 items-center justify-center rounded-full border"
>
<button
mat-icon-button
(click)="
openChat(project.nostrPubKey) openChat(project.nostrPubKey)
"> "
<mat-icon class="icon-size-5" [svgIcon]=" >
<mat-icon
class="icon-size-5"
[svgIcon]="
'heroicons_outline:chat-bubble-left-right' 'heroicons_outline:chat-bubble-left-right'
"></mat-icon> "
</button> ></mat-icon>
</div> </button>
</div>
} }
</div> </div>
<hr class="my-6 w-full border-t" /> <hr class="my-6 w-full border-t" />
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-secondary mr-3 text-md font-medium"> <div
class="text-secondary mr-3 text-md font-medium"
>
{{ project.totalInvestmentsCount || 0 }} {{ project.totalInvestmentsCount || 0 }}
investors investors
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ng-container *ngFor=" <ng-container
let investor of [].constructor(project.totalInvestmentsCount || 0); *ngFor="
let i = index let investor of [].constructor(
"> project.totalInvestmentsCount ||
0
);
let i = index
"
>
<ng-container *ngIf="i < 10"> <ng-container *ngIf="i < 10">
<img class="text-card ring-bg-card m-0.5 h-6 w-6 rounded-full ring-2" <img
class="text-card ring-bg-card m-0.5 h-6 w-6 rounded-full ring-2"
[ngClass]="{ [ngClass]="{
'-ml-3': project.totalInvestmentsCount > 1 && i > 0 '-ml-3':
}" [src]="'images/avatars/avatar-placeholder.png'" project.totalInvestmentsCount >
alt="Investor avatar {{ i + 1 }}" /> 1 && i > 0,
}"
[src]="
'images/avatars/avatar-placeholder.png'
"
alt="Investor avatar {{
i + 1
}}"
/>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
@@ -135,18 +230,32 @@
</angor-card> </angor-card>
</ng-container> </ng-container>
</div> </div>
<ng-container *ngIf="filteredProjects.length ==0"> <ng-container *ngIf="filteredProjects.length == 0">
<div class="flex flex-auto flex-col items-center justify-center bg-gray-100 dark:bg-transparent"> <div
<mat-icon class="icon-size-24" class="flex flex-auto flex-col items-center justify-center bg-gray-100 dark:bg-transparent"
[svgIcon]="'heroicons_outline:archive-box-x-mark'"></mat-icon> >
<div class="text-secondary mt-4 text-2xl font-semibold tracking-tight"> <mat-icon
class="icon-size-24"
[svgIcon]="'heroicons_outline:archive-box-x-mark'"
></mat-icon>
<div
class="text-secondary mt-4 text-2xl font-semibold tracking-tight"
>
No project No project
</div> </div>
</div> </div>
</ng-container> </ng-container>
<!-- Load More Button --> <!-- Load More Button -->
<div *ngIf="filteredProjects.length >0" class="mt-10 flex justify-center"> <div
<button mat-raised-button color="primary" (click)="loadProjects()" [disabled]="loading"> *ngIf="filteredProjects.length > 0"
class="mt-10 flex justify-center"
>
<button
mat-raised-button
color="primary"
(click)="loadProjects()"
[disabled]="loading"
>
{{ loading ? 'Loading...' : 'Load More Projects' }} {{ loading ? 'Loading...' : 'Load More Projects' }}
</button> </button>
</div> </div>

View File

@@ -1,7 +1,19 @@
import { Component, OnInit, OnDestroy, ViewEncapsulation, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; import { AngorCardComponent } from '@angor/components/card';
import { Router } from '@angular/router'; import { AngorFindByKeyPipe } from '@angor/pipes/find-by-key';
import { ProjectsService } from '../../services/projects.service'; import { CdkScrollable } from '@angular/cdk/scrolling';
import { StateService } from '../../services/state.service'; import {
CommonModule,
I18nPluralPipe,
NgClass,
PercentPipe,
} from '@angular/common';
import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewEncapsulation,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -11,18 +23,16 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { AngorCardComponent } from '@angor/components/card'; import { Router, RouterLink } from '@angular/router';
import { AngorFindByKeyPipe } from '@angor/pipes/find-by-key'; import { Project } from 'app/interface/project.interface';
import { CdkScrollable } from '@angular/cdk/scrolling'; import { IndexedDBService } from 'app/services/indexed-db.service';
import { NgClass, PercentPipe, I18nPluralPipe, CommonModule } from '@angular/common';
import { MetadataService } from 'app/services/metadata.service'; import { MetadataService } from 'app/services/metadata.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { IndexedDBService } from 'app/services/indexed-db.service'; import { ProjectsService } from '../../services/projects.service';
import { Project } from 'app/interface/project.interface'; import { StateService } from '../../services/state.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { Contact } from '../chat/chat.types';
import { ChatService } from '../chat/chat.service'; import { ChatService } from '../chat/chat.service';
import { Contact } from '../chat/chat.types';
@Component({ @Component({
selector: 'explore', selector: 'explore',
@@ -30,10 +40,23 @@ import { ChatService } from '../chat/chat.service';
templateUrl: './explore.component.html', templateUrl: './explore.component.html',
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
imports: [ imports: [
MatButtonModule, RouterLink, MatIconModule, AngorCardComponent, MatButtonModule,
CdkScrollable, MatFormFieldModule, MatSelectModule, MatOptionModule, RouterLink,
MatInputModule, MatSlideToggleModule, NgClass, MatTooltipModule, MatIconModule,
MatProgressBarModule, AngorFindByKeyPipe, PercentPipe, I18nPluralPipe, CommonModule AngorCardComponent,
CdkScrollable,
MatFormFieldModule,
MatSelectModule,
MatOptionModule,
MatInputModule,
MatSlideToggleModule,
NgClass,
MatTooltipModule,
MatProgressBarModule,
AngorFindByKeyPipe,
PercentPipe,
I18nPluralPipe,
CommonModule,
], ],
}) })
export class ExploreComponent implements OnInit, OnDestroy { export class ExploreComponent implements OnInit, OnDestroy {
@@ -45,7 +68,6 @@ export class ExploreComponent implements OnInit, OnDestroy {
filteredProjects: Project[] = []; filteredProjects: Project[] = [];
showCloseSearchButton: boolean = false; showCloseSearchButton: boolean = false;
constructor( constructor(
private projectService: ProjectsService, private projectService: ProjectsService,
private router: Router, private router: Router,
@@ -55,7 +77,7 @@ export class ExploreComponent implements OnInit, OnDestroy {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private _chatService: ChatService private _chatService: ChatService
) { } ) {}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.loadInitialProjects(); this.loadInitialProjects();
@@ -96,21 +118,26 @@ export class ExploreComponent implements OnInit, OnDestroy {
this.filteredProjects = [...this.projects]; this.filteredProjects = [...this.projects];
this.stateService.setProjects(this.projects); this.stateService.setProjects(this.projects);
const pubkeys = projects.map(p => p.nostrPubKey); const pubkeys = projects.map((p) => p.nostrPubKey);
await this.loadMetadataForProjects(pubkeys); await this.loadMetadataForProjects(pubkeys);
} catch (error) { } catch (error) {
this.handleError('Error fetching projects from service'); this.handleError('Error fetching projects from service');
} }
} }
private subscribeToMetadataUpdates(): void { private subscribeToMetadataUpdates(): void {
this.indexedDBService.getMetadataStream() this.indexedDBService
.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata: any) => { .subscribe((updatedMetadata: any) => {
if (updatedMetadata) { if (updatedMetadata) {
const projectToUpdate = this.projects.find(p => p.nostrPubKey === updatedMetadata.pubkey); const projectToUpdate = this.projects.find(
(p) => p.nostrPubKey === updatedMetadata.pubkey
);
if (projectToUpdate) { if (projectToUpdate) {
this.updateProjectMetadata(projectToUpdate, updatedMetadata.metadata); this.updateProjectMetadata(
projectToUpdate,
updatedMetadata.metadata
);
} }
} }
}); });
@@ -118,14 +145,15 @@ export class ExploreComponent implements OnInit, OnDestroy {
private getProjectsWithoutMetadata(): string[] { private getProjectsWithoutMetadata(): string[] {
return this.projects return this.projects
.filter(project => !project.displayName || !project.about) .filter((project) => !project.displayName || !project.about)
.map(project => project.nostrPubKey); .map((project) => project.nostrPubKey);
} }
private async loadMetadataForProjects(pubkeys: string[]): Promise<void> { private async loadMetadataForProjects(pubkeys: string[]): Promise<void> {
const metadataPromises = pubkeys.map(async (pubkey) => { const metadataPromises = pubkeys.map(async (pubkey) => {
// Check cache first // Check cache first
const cachedMetadata = await this.indexedDBService.getUserMetadata(pubkey); const cachedMetadata =
await this.indexedDBService.getUserMetadata(pubkey);
if (cachedMetadata) { if (cachedMetadata) {
return { pubkey, metadata: cachedMetadata }; return { pubkey, metadata: cachedMetadata };
} }
@@ -136,13 +164,15 @@ export class ExploreComponent implements OnInit, OnDestroy {
// Filter out nulls (which represent pubkeys without cached metadata) // Filter out nulls (which represent pubkeys without cached metadata)
const missingPubkeys = metadataResults const missingPubkeys = metadataResults
.filter(result => result === null) .filter((result) => result === null)
.map((_, index) => pubkeys[index]); .map((_, index) => pubkeys[index]);
// Update projects that have cached metadata // Update projects that have cached metadata
metadataResults.forEach(result => { metadataResults.forEach((result) => {
if (result && result.metadata) { if (result && result.metadata) {
const project = this.projects.find(p => p.nostrPubKey === result.pubkey); const project = this.projects.find(
(p) => p.nostrPubKey === result.pubkey
);
if (project) { if (project) {
this.updateProjectMetadata(project, result.metadata); this.updateProjectMetadata(project, result.metadata);
} }
@@ -151,80 +181,103 @@ export class ExploreComponent implements OnInit, OnDestroy {
// Fetch metadata for pubkeys that are not cached // Fetch metadata for pubkeys that are not cached
if (missingPubkeys.length > 0) { if (missingPubkeys.length > 0) {
await this.metadataService.fetchMetadataForMultipleKeys(missingPubkeys) await this.metadataService
.fetchMetadataForMultipleKeys(missingPubkeys)
.then((metadataList: any[]) => { .then((metadataList: any[]) => {
metadataList.forEach(metadata => { metadataList.forEach((metadata) => {
const project = this.projects.find(p => p.nostrPubKey === metadata.pubkey); const project = this.projects.find(
(p) => p.nostrPubKey === metadata.pubkey
);
if (project) { if (project) {
this.updateProjectMetadata(project, metadata); this.updateProjectMetadata(project, metadata);
} }
}); });
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
}) })
.catch(error => { .catch((error) => {
console.error('Error fetching metadata for projects:', error); console.error(
'Error fetching metadata for projects:',
error
);
}); });
} }
} }
async loadProjects(): Promise<void> { async loadProjects(): Promise<void> {
if (this.loading || this.errorMessage === 'No more projects found') return; if (this.loading || this.errorMessage === 'No more projects found')
return;
this.loading = true; this.loading = true;
this.projectService.fetchProjects().then(async (projects: Project[]) => { this.projectService
if (projects.length === 0 && this.projects.length === 0) { .fetchProjects()
this.errorMessage = 'No projects found'; .then(async (projects: Project[]) => {
} else if (projects.length === 0) { if (projects.length === 0 && this.projects.length === 0) {
this.errorMessage = 'No more projects found'; this.errorMessage = 'No projects found';
} else { } else if (projects.length === 0) {
this.projects = [...this.projects, ...projects]; this.errorMessage = 'No more projects found';
this.filteredProjects = [...this.projects]; } else {
this.projects = [...this.projects, ...projects];
this.filteredProjects = [...this.projects];
const pubkeys = projects.map(project => project.nostrPubKey); const pubkeys = projects.map(
(project) => project.nostrPubKey
);
await this.loadMetadataForProjects(pubkeys); await this.loadMetadataForProjects(pubkeys);
this.stateService.setProjects(this.projects); this.stateService.setProjects(this.projects);
this.projects.forEach(project => this.subscribeToProjectMetadata(project)); this.projects.forEach((project) =>
} this.subscribeToProjectMetadata(project)
this.loading = false; );
this.changeDetectorRef.detectChanges(); }
}).catch((error: any) => { this.loading = false;
console.error('Error fetching projects:', error); this.changeDetectorRef.detectChanges();
this.errorMessage = 'Error fetching projects. Please try again later.'; })
this.loading = false; .catch((error: any) => {
this.changeDetectorRef.detectChanges(); console.error('Error fetching projects:', error);
}); this.errorMessage =
'Error fetching projects. Please try again later.';
this.loading = false;
this.changeDetectorRef.detectChanges();
});
} }
async loadMetadataForProject(project: Project): Promise<void> { async loadMetadataForProject(project: Project): Promise<void> {
try { try {
const metadata = await this.metadataService.fetchMetadataWithCache(project.nostrPubKey); const metadata = await this.metadataService.fetchMetadataWithCache(
project.nostrPubKey
);
if (metadata) { if (metadata) {
this.updateProjectMetadata(project, metadata); this.updateProjectMetadata(project, metadata);
} else { } else {
console.warn(`No metadata found for project ${project.nostrPubKey}`); console.warn(
`No metadata found for project ${project.nostrPubKey}`
);
} }
} catch (error) { } catch (error) {
console.error(`Error fetching metadata for project ${project.nostrPubKey}:`, error); console.error(
`Error fetching metadata for project ${project.nostrPubKey}:`,
error
);
} }
} }
updateProjectMetadata(project: Project, metadata: any): void { updateProjectMetadata(project: Project, metadata: any): void {
const updatedProject: Project = { const updatedProject: Project = {
...project, ...project,
displayName: metadata.name || '', displayName: metadata.name || '',
about: metadata.about ? metadata.about.replace(/<\/?[^>]+(>|$)/g, '') : '', about: metadata.about
? metadata.about.replace(/<\/?[^>]+(>|$)/g, '')
: '',
picture: metadata.picture || '', picture: metadata.picture || '',
banner: metadata.banner || '' banner: metadata.banner || '',
}; };
const index = this.projects.findIndex(p => p.projectIdentifier === project.projectIdentifier); const index = this.projects.findIndex(
(p) => p.projectIdentifier === project.projectIdentifier
);
if (index !== -1) { if (index !== -1) {
this.projects[index] = updatedProject; this.projects[index] = updatedProject;
this.projects = [...this.projects]; this.projects = [...this.projects];
@@ -235,11 +288,18 @@ export class ExploreComponent implements OnInit, OnDestroy {
} }
subscribeToProjectMetadata(project: Project): void { subscribeToProjectMetadata(project: Project): void {
this.metadataService.getMetadataStream() this.metadataService
.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata: any) => { .subscribe((updatedMetadata: any) => {
if (updatedMetadata && updatedMetadata.pubkey === project.nostrPubKey) { if (
this.updateProjectMetadata(project, updatedMetadata.metadata); updatedMetadata &&
updatedMetadata.pubkey === project.nostrPubKey
) {
this.updateProjectMetadata(
project,
updatedMetadata.metadata
);
} }
}); });
} }
@@ -247,7 +307,8 @@ export class ExploreComponent implements OnInit, OnDestroy {
goToProjectDetails(project: Project): void { goToProjectDetails(project: Project): void {
this.loading = true; this.loading = true;
this.projectService.fetchAndSaveProjectStats(project.projectIdentifier) this.projectService
.fetchAndSaveProjectStats(project.projectIdentifier)
.then((stats) => { .then((stats) => {
if (stats) { if (stats) {
this.navigateToProfile(project.nostrPubKey); this.navigateToProfile(project.nostrPubKey);
@@ -278,17 +339,30 @@ export class ExploreComponent implements OnInit, OnDestroy {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
this.filteredProjects = this.projects.filter(project => { this.filteredProjects = this.projects.filter((project) => {
return ( return (
(project.displayName && project.displayName.toLowerCase().includes(lowerCaseQuery)) || (project.displayName &&
(project.about && project.about.toLowerCase().includes(lowerCaseQuery)) || project.displayName
(project.displayName && project.displayName.toLowerCase().includes(lowerCaseQuery)) || .toLowerCase()
(project.nostrPubKey && project.nostrPubKey.toLowerCase().includes(lowerCaseQuery)) || .includes(lowerCaseQuery)) ||
(project.projectIdentifier && project.projectIdentifier.toLowerCase().includes(lowerCaseQuery)) (project.about &&
project.about.toLowerCase().includes(lowerCaseQuery)) ||
(project.displayName &&
project.displayName
.toLowerCase()
.includes(lowerCaseQuery)) ||
(project.nostrPubKey &&
project.nostrPubKey
.toLowerCase()
.includes(lowerCaseQuery)) ||
(project.projectIdentifier &&
project.projectIdentifier
.toLowerCase()
.includes(lowerCaseQuery))
); );
}); });
this.showCloseSearchButton = this.projects.length > 0 ; this.showCloseSearchButton = this.projects.length > 0;
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
} }
@@ -299,9 +373,7 @@ export class ExploreComponent implements OnInit, OnDestroy {
this.showCloseSearchButton = false; this.showCloseSearchButton = false;
} }
toggleCompleted(event: any): void { toggleCompleted(event: any): void {}
}
ngOnDestroy(): void { ngOnDestroy(): void {
this._unsubscribeAll.next(null); this._unsubscribeAll.next(null);
@@ -319,7 +391,9 @@ export class ExploreComponent implements OnInit, OnDestroy {
if (url && typeof url === 'string' && this.isImageUrl(url)) { if (url && typeof url === 'string' && this.isImageUrl(url)) {
return this.sanitizer.bypassSecurityTrustUrl(url); return this.sanitizer.bypassSecurityTrustUrl(url);
} else { } else {
const defaultImage = isBanner ? '/images/pages/profile/cover.jpg' : 'images/avatars/avatar-placeholder.png'; const defaultImage = isBanner
? '/images/pages/profile/cover.jpg'
: 'images/avatars/avatar-placeholder.png';
return this.sanitizer.bypassSecurityTrustUrl(defaultImage); return this.sanitizer.bypassSecurityTrustUrl(defaultImage);
} }
} }
@@ -330,26 +404,34 @@ export class ExploreComponent implements OnInit, OnDestroy {
async openChat(publicKey: string): Promise<void> { async openChat(publicKey: string): Promise<void> {
try { try {
const metadata = await this.metadataService.fetchMetadataWithCache(publicKey); const metadata =
await this.metadataService.fetchMetadataWithCache(publicKey);
if (metadata) { if (metadata) {
const contact: Contact = { const contact: Contact = {
pubKey: publicKey, pubKey: publicKey,
name: metadata.name || 'Unknown', name: metadata.name || 'Unknown',
picture: metadata.picture || '/images/avatars/avatar-placeholder.png', picture:
metadata.picture ||
'/images/avatars/avatar-placeholder.png',
about: metadata.about || '', about: metadata.about || '',
displayName: metadata.displayName || metadata.name || 'Unknown', displayName:
metadata.displayName || metadata.name || 'Unknown',
}; };
this._chatService.getChatById(contact.pubKey, contact).subscribe((chat) => { this._chatService
this.router.navigate(['/chat', contact.pubKey]); .getChatById(contact.pubKey, contact)
}); .subscribe((chat) => {
this.router.navigate(['/chat', contact.pubKey]);
});
} else { } else {
console.error('No metadata found for the public key:', publicKey); console.error(
'No metadata found for the public key:',
publicKey
);
} }
} catch (error) { } catch (error) {
console.error('Error opening chat:', error); console.error('Error opening chat:', error);
} }
} }
} }

View File

@@ -3,7 +3,7 @@ import { ExploreComponent } from 'app/components/explore/explore.component';
export default [ export default [
{ {
path : '', path: '',
component: ExploreComponent, component: ExploreComponent,
}, },
] as Routes; ] as Routes;

View File

@@ -3,10 +3,18 @@
<div class="prose prose-sm mx-auto max-w-none"> <div class="prose prose-sm mx-auto max-w-none">
<h1>Angor Hub</h1> <h1>Angor Hub</h1>
<p> <p>
Angor Hub is a Nostr client that is customized around the Angor protocol, a decentralized crowdfunding platform. Leveraging the power of Nostr the platform allows you to explore projects that are raising funds using Angor, engage with investors, and connect directly with founders. Angor Hub is a Nostr client that is customized around the Angor
protocol, a decentralized crowdfunding platform. Leveraging the
power of Nostr the platform allows you to explore projects that
are raising funds using Angor, engage with investors, and
connect directly with founders.
</p> </p>
<p> <p>
Whether you're an investor looking for the next big opportunity or a project founder seeking funding, Angor Hub offers the tools you need to succeed. From project pages, secure messaging to group channels, Angor Hub ensures seamless interaction within a decentralized Nostr. Whether you're an investor looking for the next big opportunity
or a project founder seeking funding, Angor Hub offers the tools
you need to succeed. From project pages, secure messaging to
group channels, Angor Hub ensures seamless interaction within a
decentralized Nostr.
</p> </p>
</div> </div>
<div> <div>

View File

@@ -8,7 +8,7 @@ import { RouterLink } from '@angular/router';
templateUrl: './home.component.html', templateUrl: './home.component.html',
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
standalone: true, standalone: true,
imports: [MatButtonModule, RouterLink, MatIconModule], imports: [MatButtonModule, RouterLink, MatIconModule ],
}) })
export class LandingHomeComponent { export class LandingHomeComponent {
/** /**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
.emoji-picker-container-global {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
width: 350px;
max-width: 100%;
}
.heart-beat {
animation: heartBeatAnimation 0.3s ease-in-out;
}
@keyframes heartBeatAnimation {
0% {
transform: scale(1);
}
30% {
transform: scale(2);
}
60% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #009fb5;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}

View File

@@ -1,45 +1,63 @@
import { AngorCardComponent } from '@angor/components/card';
import { AngorConfigService } from '@angor/services/config';
import { AngorConfirmationService } from '@angor/services/confirmation';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule, NgClass } from '@angular/common'; import { CommonModule, NgClass } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ViewEncapsulation, ElementRef,
OnDestroy,
OnInit, OnInit,
OnDestroy ViewChild,
ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MatSlideToggle } from '@angular/material/slide-toggle';
import { AngorCardComponent } from '@angor/components/card';
import { SignerService } from 'app/services/signer.service';
import { MetadataService } from 'app/services/metadata.service';
import { Subject, takeUntil } from 'rxjs';
import { IndexedDBService } from 'app/services/indexed-db.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { SocialService } from 'app/services/social.service';
import { MatDialog } from '@angular/material/dialog';
import { LightningInvoice, LightningResponse } from 'app/types/post';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { LightningService } from 'app/services/lightning.service'; import { MatTooltipModule } from '@angular/material/tooltip';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { PickerComponent } from '@ctrl/ngx-emoji-mart';
import { bech32 } from '@scure/base'; import { bech32 } from '@scure/base';
import { FormsModule } from '@angular/forms';
import { QRCodeModule } from 'angularx-qrcode'; import { QRCodeModule } from 'angularx-qrcode';
import { Clipboard } from '@angular/cdk/clipboard'; import { PaginatedEventService } from 'app/services/event.service';
import { SendDialogComponent } from './zap/send-dialog/send-dialog.component'; import { IndexedDBService } from 'app/services/indexed-db.service';
import { LightningService } from 'app/services/lightning.service';
import { MetadataService } from 'app/services/metadata.service';
import { SignerService } from 'app/services/signer.service';
import { SocialService } from 'app/services/social.service';
import { SafeUrlPipe } from 'app/shared/pipes/safe-url.pipe';
import { Paginator } from 'app/shared/utils';
import { LightningInvoice, LightningResponse, Post } from 'app/types/post';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { NostrEvent } from 'nostr-tools';
import { Subject, takeUntil } from 'rxjs';
import { EventListComponent } from '../event-list/event-list.component';
import { ReceiveDialogComponent } from './zap/receive-dialog/receive-dialog.component'; import { ReceiveDialogComponent } from './zap/receive-dialog/receive-dialog.component';
import { SendDialogComponent } from './zap/send-dialog/send-dialog.component';
interface Chip {
color?: string;
selected?: string;
name: string;
}
@Component({ @Component({
selector: 'profile', selector: 'profile',
templateUrl: './profile.component.html', templateUrl: './profile.component.html',
encapsulation: ViewEncapsulation.None, styleUrls: ['./profile.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true, standalone: true,
imports: [ imports: [
RouterLink, RouterLink,
@@ -56,28 +74,54 @@ import { ReceiveDialogComponent } from './zap/receive-dialog/receive-dialog.comp
CommonModule, CommonModule,
FormsModule, FormsModule,
QRCodeModule, QRCodeModule,
PickerComponent,
MatSlideToggle,
SafeUrlPipe,
MatProgressSpinnerModule,
InfiniteScrollModule,
EventListComponent,
], ],
}) })
export class ProfileComponent implements OnInit, OnDestroy { export class ProfileComponent implements OnInit, OnDestroy {
@ViewChild('eventInput', { static: false }) eventInput: ElementRef;
@ViewChild('commentInput') commentInput: ElementRef;
darkMode: boolean = false;
isLoading: boolean = true; isLoading: boolean = true;
errorMessage: string | null = null; errorMessage: string | null = null;
metadata: any; metadata: any;
currentUserMetadata: any; currentUserMetadata: any;
private _unsubscribeAll: Subject<any> = new Subject<any>(); private _unsubscribeAll: Subject<any> = new Subject<any>();
private userPubKey; public currentUserPubKey: string;
private routePubKey; public routePubKey;
followers: any[] = []; followers: any[] = [];
following: any[] = []; following: any[] = [];
allPublicKeys: string[] = []; allPublicKeys: string[] = [];
suggestions: { pubkey: string, metadata: any }[] = []; suggestions: { pubkey: string; metadata: any }[] = [];
isCurrentUserProfile: Boolean = false; isCurrentUserProfile: Boolean = false;
isFollowing = false; isFollowing = false;
showEmojiPicker = false;
showCommentEmojiPicker = false;
lightningResponse: LightningResponse | null = null; lightningResponse: LightningResponse | null = null;
lightningInvoice: LightningInvoice | null = null; lightningInvoice: LightningInvoice | null = null;
sats: string; sats: string;
paymentInvoice: string = ''; paymentInvoice: string = '';
invoiceAmount: string = '?'; invoiceAmount: string = '?';
isLiked = false;
isPreview = false;
posts: Post[] = [];
likes: any[] = [];
paginator: Paginator;
myLikes: NostrEvent[] = [];
myLikedNoteIds: string[] = [];
isLoadingPosts: boolean = true;
noEventsMessage: string = '';
loadingTimeout: any;
constructor( constructor(
@@ -90,52 +134,81 @@ export class ProfileComponent implements OnInit, OnDestroy {
private _socialService: SocialService, private _socialService: SocialService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private lightning: LightningService, private lightning: LightningService,
private _dialog: MatDialog // Add MatDialog here private _dialog: MatDialog,
private _angorConfigService: AngorConfigService,
private _angorConfirmationService: AngorConfirmationService,
private eventService: PaginatedEventService
) {
let baseTimeDiff = 12000;
let since = 0;
) { } this.paginator = new Paginator(0, since, (baseTimeDiff = baseTimeDiff));
}
ngOnInit(): void { ngOnInit(): void {
this._angorConfigService.config$.subscribe((config) => {
if (config.scheme === 'auto') {
this.detectSystemTheme();
} else {
this.darkMode = config.scheme === 'dark';
}
});
this._route.paramMap.subscribe((params) => { this._route.paramMap.subscribe((params) => {
const routePubKey = params.get('pubkey'); const routePubKey = params.get('pubkey');
this.routePubKey = routePubKey; this.routePubKey = routePubKey;
const userPubKey = this._signerService.getPublicKey(); const currentUserPubKey = this._signerService.getPublicKey();
this.isCurrentUserProfile = routePubKey === userPubKey; this.currentUserPubKey = currentUserPubKey;
const pubKeyToLoad = routePubKey || userPubKey; if (routePubKey || currentUserPubKey) {
this.loadProfile(pubKeyToLoad); this.isCurrentUserProfile = routePubKey === currentUserPubKey;
}
this.routePubKey = routePubKey || currentUserPubKey;
this.loadProfile(this.routePubKey);
if (!routePubKey) { if (!routePubKey) {
this.isCurrentUserProfile = true; this.isCurrentUserProfile = true;
} }
this.loadCurrentUserProfile(); this.loadCurrentUserProfile();
}); });
this._indexedDBService.getMetadataStream() this._indexedDBService
.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => { .subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === this.userPubKey) { if (
updatedMetadata &&
updatedMetadata.pubkey === this.currentUserPubKey
) {
this.currentUserMetadata = updatedMetadata.metadata; this.currentUserMetadata = updatedMetadata.metadata;
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
} }
}); });
if (this.routePubKey) { if (this.routePubKey) {
this._indexedDBService.getMetadataStream() this._indexedDBService
.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => { .subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === this.routePubKey) { if (
updatedMetadata &&
updatedMetadata.pubkey === this.routePubKey
) {
this.metadata = updatedMetadata.metadata; this.metadata = updatedMetadata.metadata;
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
} }
}); });
} }
this._socialService.getFollowersObservable() this._socialService
.getFollowersObservable()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((event) => { .subscribe((event) => {
this.followers.push(event.pubkey); this.followers.push(event.pubkey);
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
}); });
this._socialService.getFollowingObservable() this._socialService
.getFollowingObservable()
.pipe(takeUntil(this._unsubscribeAll)) .pipe(takeUntil(this._unsubscribeAll))
.subscribe((event) => { .subscribe((event) => {
const tags = event.tags.filter((tag) => tag[0] === 'p'); const tags = event.tags.filter((tag) => tag[0] === 'p');
@@ -144,8 +217,6 @@ export class ProfileComponent implements OnInit, OnDestroy {
}); });
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
}); });
this.updateSuggestionList();
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -153,6 +224,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
this._unsubscribeAll.complete(); this._unsubscribeAll.complete();
} }
async loadProfile(publicKey: string): Promise<void> { async loadProfile(publicKey: string): Promise<void> {
this.isLoading = true; this.isLoading = true;
this.errorMessage = null; this.errorMessage = null;
@@ -160,6 +232,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.metadata = null; this.metadata = null;
this.followers = []; this.followers = [];
this.following = []; this.following = [];
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
if (!publicKey) { if (!publicKey) {
@@ -170,27 +243,20 @@ export class ProfileComponent implements OnInit, OnDestroy {
} }
try { try {
const userMetadata = await this._metadataService.fetchMetadataWithCache(publicKey); const userMetadata = await this._metadataService.fetchMetadataWithCache(publicKey);
if (userMetadata) { if (userMetadata) {
this.metadata = userMetadata; this.metadata = userMetadata;
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
} }
await this._socialService.getFollowers(publicKey);
this.followers = await this._socialService.getFollowers(publicKey);
const currentUserPubKey = this._signerService.getPublicKey(); const currentUserPubKey = this._signerService.getPublicKey();
this.isFollowing = this.followers.includes(currentUserPubKey); this.isFollowing = this.followers.includes(currentUserPubKey);
await this._socialService.getFollowing(publicKey); this.following = await this._socialService.getFollowing(publicKey);
this._changeDetectorRef.detectChanges();
this._metadataService.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === publicKey) {
this.metadata = updatedMetadata;
this._changeDetectorRef.detectChanges();
}
});
} catch (error) { } catch (error) {
console.error('Failed to load profile data:', error); console.error('Failed to load profile data:', error);
this.errorMessage = 'Failed to load profile data. Please try again later.'; this.errorMessage = 'Failed to load profile data. Please try again later.';
@@ -201,25 +267,23 @@ export class ProfileComponent implements OnInit, OnDestroy {
} }
} }
private async loadCurrentUserProfile(): Promise<void> { private async loadCurrentUserProfile(): Promise<void> {
try { try {
this.currentUserMetadata = null; this.currentUserMetadata = null;
this.currentUserPubKey = this._signerService.getPublicKey();
const currentUserMetadata = await this._metadataService.fetchMetadataWithCache(
this.currentUserPubKey
);
this.userPubKey = this._signerService.getPublicKey();
const currentUserMetadata = await this._metadataService.fetchMetadataWithCache(this.userPubKey);
if (currentUserMetadata) { if (currentUserMetadata) {
this.currentUserMetadata = currentUserMetadata; this.currentUserMetadata = currentUserMetadata;
this._changeDetectorRef.detectChanges();
} }
this._metadataService.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => { this._changeDetectorRef.detectChanges();
if (updatedMetadata && updatedMetadata.pubkey === this.userPubKey) {
this.currentUserMetadata = updatedMetadata;
this._changeDetectorRef.detectChanges();
}
});
} catch (error) { } catch (error) {
console.error('Failed to load profile data:', error); console.error('Failed to load profile data:', error);
this.errorMessage = 'Failed to load profile data. Please try again later.'; this.errorMessage = 'Failed to load profile data. Please try again later.';
@@ -229,15 +293,8 @@ export class ProfileComponent implements OnInit, OnDestroy {
} }
} }
private updateSuggestionList(): void {
this._indexedDBService.getSuggestionUsers().then((suggestions) => {
this.suggestions = suggestions;
this._changeDetectorRef.detectChanges();
}).catch((error) => {
console.error('Error updating suggestion list:', error);
});
}
getSafeUrl(url: string): SafeUrl { getSafeUrl(url: string): SafeUrl {
return this._sanitizer.bypassSecurityTrustUrl(url); return this._sanitizer.bypassSecurityTrustUrl(url);
@@ -246,7 +303,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
async toggleFollow(): Promise<void> { async toggleFollow(): Promise<void> {
try { try {
const userPubKey = this._signerService.getPublicKey(); const userPubKey = this._signerService.getPublicKey();
const routePubKey = this.routePubKey || this.userPubKey; const routePubKey = this.routePubKey || this.currentUserPubKey;
if (!routePubKey || !userPubKey) { if (!routePubKey || !userPubKey) {
console.error('Public key missing. Unable to toggle follow.'); console.error('Public key missing. Unable to toggle follow.');
@@ -257,7 +314,9 @@ export class ProfileComponent implements OnInit, OnDestroy {
await this._socialService.unfollow(routePubKey); await this._socialService.unfollow(routePubKey);
console.log(`Unfollowed ${routePubKey}`); console.log(`Unfollowed ${routePubKey}`);
this.followers = this.followers.filter(pubkey => pubkey !== userPubKey); this.followers = this.followers.filter(
(pubkey) => pubkey !== userPubKey
);
} else { } else {
await this._socialService.follow(routePubKey); await this._socialService.follow(routePubKey);
console.log(`Followed ${routePubKey}`); console.log(`Followed ${routePubKey}`);
@@ -268,19 +327,15 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.isFollowing = !this.isFollowing; this.isFollowing = !this.isFollowing;
this._changeDetectorRef.detectChanges(); this._changeDetectorRef.detectChanges();
} catch (error) { } catch (error) {
console.error('Failed to toggle follow:', error); console.error('Failed to toggle follow:', error);
} }
} }
openSnackBar(message: string, action: string) { openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, { duration: 1300 }); this.snackBar.open(message, action, { duration: 1300 });
} }
getLightningInfo() { getLightningInfo() {
let lightningAddress = ''; let lightningAddress = '';
if (this.metadata?.lud06) { if (this.metadata?.lud06) {
@@ -292,19 +347,29 @@ export class ProfileComponent implements OnInit, OnDestroy {
const data = new Uint8Array(bech32.fromWords(words)); const data = new Uint8Array(bech32.fromWords(words));
lightningAddress = new TextDecoder().decode(Uint8Array.from(data)); lightningAddress = new TextDecoder().decode(Uint8Array.from(data));
} else if (this.metadata?.lud16) { } else if (this.metadata?.lud16) {
lightningAddress = this.lightning.getLightningAddress(this.metadata.lud16); lightningAddress = this.lightning.getLightningAddress(
this.metadata.lud16
);
} }
if (lightningAddress !== '') { if (lightningAddress !== '') {
this.lightning.getLightning(lightningAddress).subscribe((response) => { this.lightning
this.lightningResponse = response; .getLightning(lightningAddress)
if (this.lightningResponse.status === 'Failed') { .subscribe((response) => {
this.openSnackBar('Failed to lookup lightning address', 'dismiss'); this.lightningResponse = response;
} else if (this.lightningResponse.callback) { if (this.lightningResponse.status === 'Failed') {
this.openZapDialog(); // Open dialog when callback is available this.openSnackBar(
} else { 'Failed to lookup lightning address',
this.openSnackBar("couldn't find user's lightning address", 'dismiss'); 'dismiss'
} );
}); } else if (this.lightningResponse.callback) {
this.openZapDialog();
} else {
this.openSnackBar(
"couldn't find user's lightning address",
'dismiss'
);
}
});
} else { } else {
this.openSnackBar('No lightning address found', 'dismiss'); this.openSnackBar('No lightning address found', 'dismiss');
} }
@@ -322,7 +387,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
this._dialog.open(SendDialogComponent, { this._dialog.open(SendDialogComponent, {
width: '405px', width: '405px',
maxHeight: '90vh', maxHeight: '90vh',
data: this.metadata data: this.metadata,
}); });
} }
@@ -330,8 +395,95 @@ export class ProfileComponent implements OnInit, OnDestroy {
this._dialog.open(ReceiveDialogComponent, { this._dialog.open(ReceiveDialogComponent, {
width: '405px', width: '405px',
maxHeight: '90vh', maxHeight: '90vh',
data: this.metadata data: this.metadata,
}); });
} }
toggleLike() {
this.isLiked = !this.isLiked;
if (this.isLiked) {
setTimeout(() => {
this.isLiked = false;
this.isLiked = true;
}, 300);
}
}
addEmoji(event: any) {
this.eventInput.nativeElement.value += event.emoji.native;
this.showEmojiPicker = false;
}
toggleEmojiPicker() {
this.showCommentEmojiPicker = false;
this.showEmojiPicker = !this.showEmojiPicker;
}
addEmojiTocomment(event: any) {
this.commentInput.nativeElement.value += event.emoji.native;
this.showCommentEmojiPicker = false;
}
toggleCommentEmojiPicker() {
this.showEmojiPicker = false;
this.showCommentEmojiPicker = !this.showCommentEmojiPicker;
}
detectSystemTheme() {
const darkSchemeMedia = window.matchMedia(
'(prefers-color-scheme: dark)'
);
this.darkMode = darkSchemeMedia.matches;
darkSchemeMedia.addEventListener('change', (event) => {
this.darkMode = event.matches;
});
}
openConfirmationDialog(): void {
const dialogRef = this._angorConfirmationService.open({
title: 'Share Event',
message:
'Are you sure you want to share this event on your profile? <span class="font-medium">This action is permanent and cannot be undone.</span>',
icon: {
show: true,
name: 'heroicons_solid:share',
color: 'primary',
},
actions: {
confirm: {
show: true,
label: 'Yes, Share',
color: 'primary',
},
cancel: {
show: true,
label: 'Cancel',
},
},
dismissible: true,
});
dialogRef.afterClosed().subscribe((result) => {
console.log(result);
});
}
togglePreview() {
this.isPreview = !this.isPreview;
}
sendEvent() {
if (this.eventInput.nativeElement.value != '') {
this.eventService
.sendTextEvent(this.eventInput.nativeElement.value)
.then(() => {
this._changeDetectorRef.markForCheck();
})
.catch((error) => {
console.error('Failed to send Event:', error);
});
}
}
} }

View File

@@ -1,7 +1,20 @@
<div class="absolute right-0 top-0 pr-4 pt-4">
<button mat-icon-button [matDialogClose]="undefined">
<mat-icon
class="text-secondary"
[svgIcon]="'heroicons_outline:x-mark'"
></mat-icon>
</button>
</div>
<h2>⚡ Receive Zap</h2> <h2>⚡ Receive Zap</h2>
<mat-dialog-content *ngIf="!displayQRCode"> <mat-dialog-content *ngIf="!displayQRCode">
<div class="preset-buttons"> <div class="preset-buttons">
<button mat-mini-fab color="primary" *ngFor="let button of zapButtons" (click)="invoiceAmount = button.value"> <button
mat-mini-fab
color="primary"
*ngFor="let button of zapButtons"
(click)="invoiceAmount = button.value"
>
<mat-icon>{{ button.icon }}</mat-icon> <mat-icon>{{ button.icon }}</mat-icon>
<span>{{ button.label }}</span> <span>{{ button.label }}</span>
</button> </button>
@@ -9,12 +22,14 @@
<mat-divider></mat-divider> <mat-divider></mat-divider>
<mat-form-field appearance="outline" class="sats-input"> <mat-form-field appearance="outline" class="sats-input">
<mat-label>Zap Amount</mat-label> <mat-label>Zap Amount</mat-label>
<input matInput [(ngModel)]="invoiceAmount" placeholder="e.g., 100" type="number" /> <input
matInput
[(ngModel)]="invoiceAmount"
placeholder="e.g., 100"
type="number"
/>
</mat-form-field> </mat-form-field>
<mat-dialog-actions align="end"> <mat-dialog-actions align="end">
<button mat-icon-button color="warn" (click)="closeDialog()" [matTooltip]="'Close'">
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon>
</button>
<button mat-raised-button color="primary" (click)="generateInvoice()"> <button mat-raised-button color="primary" (click)="generateInvoice()">
Generate Invoice Generate Invoice
</button> </button>
@@ -25,14 +40,22 @@
<div *ngIf="displayQRCode" class="qrcode"> <div *ngIf="displayQRCode" class="qrcode">
<span>Scan with phone to pay ({{ invoiceAmount }} sats)</span> <span>Scan with phone to pay ({{ invoiceAmount }} sats)</span>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<qrcode [qrdata]="lightningInvoice" [matTooltip]="'Lightning Invoice'" class="qrcode-image" [errorCorrectionLevel]="'M'"></qrcode> <qrcode
[qrdata]="lightningInvoice"
[matTooltip]="'Lightning Invoice'"
class="qrcode-image"
[errorCorrectionLevel]="'M'"
></qrcode>
<mat-dialog-actions align="center"> <mat-dialog-actions align="center">
<button mat-icon-button color="warn" (click)="closeDialog()" [matTooltip]="'Close'"> <button
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon> mat-icon-button
</button> (click)="copyInvoice()"
<button mat-icon-button (click)="copyInvoice()" [matTooltip]="'Copy Invoice'"> [matTooltip]="'Copy Invoice'"
<mat-icon [svgIcon]="'heroicons_outline:clipboard-document'"></mat-icon> >
<mat-icon
[svgIcon]="'heroicons_outline:clipboard-document'"
></mat-icon>
</button> </button>
</mat-dialog-actions> </mat-dialog-actions>
</div> </div>

View File

@@ -4,9 +4,9 @@
gap: 15px; gap: 15px;
justify-items: center; justify-items: center;
margin-bottom: 20px; margin-bottom: 20px;
} }
.preset-buttons button { .preset-buttons button {
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
width: 70px; width: 70px;
@@ -16,24 +16,23 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
max-height: 60px !important; max-height: 60px !important;
} }
.sats-input { .sats-input {
margin-top: 20px; margin-top: 20px;
width: 100%; width: 100%;
} }
.lightning-buttons { .lightning-buttons {
display: flex; display: flex;
justify-content: space-evenly; justify-content: space-evenly;
margin: 10px 0; margin: 10px 0;
} }
.qrcode {
.qrcode {
text-align: center; text-align: center;
} }
.qrcode-image { .qrcode-image {
width: 100% !important; width: 100% !important;
} }

View File

@@ -1,19 +1,28 @@
import { Component } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { MatDialogActions, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; import { CommonModule, NgClass } from '@angular/common';
import { webln } from '@getalby/sdk'; import { Component } from '@angular/core';
import { NgClass, CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOption } from '@angular/material/core'; import { MatOption } from '@angular/material/core';
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
} from '@angular/material/dialog';
import { MatDivider } from '@angular/material/divider'; import { MatDivider } from '@angular/material/divider';
import { MatLabel, MatFormField, MatFormFieldModule } from '@angular/material/form-field'; import {
MatFormField,
MatFormFieldModule,
MatLabel,
} from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { webln } from '@getalby/sdk';
import { QRCodeModule } from 'angularx-qrcode'; import { QRCodeModule } from 'angularx-qrcode';
import { SettingsIndexerComponent } from 'app/components/settings/indexer/indexer.component'; import { SettingsIndexerComponent } from 'app/components/settings/indexer/indexer.component';
import { SettingsNetworkComponent } from 'app/components/settings/network/network.component'; import { SettingsNetworkComponent } from 'app/components/settings/network/network.component';
@@ -23,112 +32,116 @@ import { SettingsRelayComponent } from 'app/components/settings/relay/relay.comp
import { SettingsSecurityComponent } from 'app/components/settings/security/security.component'; import { SettingsSecurityComponent } from 'app/components/settings/security/security.component';
@Component({ @Component({
selector: 'app-receive-dialog', selector: 'app-receive-dialog',
standalone: true, standalone: true,
imports: [ imports: [
MatSidenavModule, MatSidenavModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
NgClass, NgClass,
SettingsProfileComponent, SettingsProfileComponent,
SettingsSecurityComponent, SettingsSecurityComponent,
SettingsNotificationsComponent, SettingsNotificationsComponent,
SettingsRelayComponent, SettingsRelayComponent,
SettingsNetworkComponent, SettingsNetworkComponent,
SettingsIndexerComponent, SettingsIndexerComponent,
FormsModule, FormsModule,
MatOption, MatOption,
MatLabel, MatLabel,
MatFormField, MatFormField,
ReactiveFormsModule, ReactiveFormsModule,
CommonModule, CommonModule,
MatSelectModule, MatSelectModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatSelectModule, MatSelectModule,
MatDialogContent, MatDialogContent,
MatDialogActions, MatDialogActions,
QRCodeModule, QRCodeModule,
MatDivider, MatDivider,
MatTooltip MatTooltip,
], MatDialogClose,
templateUrl: './receive-dialog.component.html', ],
styleUrls: ['./receive-dialog.component.scss'] templateUrl: './receive-dialog.component.html',
styleUrls: ['./receive-dialog.component.scss'],
}) })
export class ReceiveDialogComponent { export class ReceiveDialogComponent {
invoiceAmount: string = ''; invoiceAmount: string = '';
lightningInvoice: string = ''; lightningInvoice: string = '';
displayQRCode: boolean = false; displayQRCode: boolean = false;
nwc: any; nwc: any;
constructor( constructor(
private dialogRef: MatDialogRef<ReceiveDialogComponent>, private dialogRef: MatDialogRef<ReceiveDialogComponent>,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private clipboard: Clipboard private clipboard: Clipboard
) {} ) {}
zapButtons = [ zapButtons = [
{ icon: 'thumb_up', label: '50', value: 50 }, { icon: 'thumb_up', label: '50', value: 50 },
{ icon: 'favorite', label: '100', value: 100 }, { icon: 'favorite', label: '100', value: 100 },
{ icon: 'emoji_emotions', label: '500', value: 500 }, { icon: 'emoji_emotions', label: '500', value: 500 },
{ icon: 'star', label: '1k', value: 1000 }, { icon: 'star', label: '1k', value: 1000 },
{ icon: 'celebration', label: '5k', value: 5000 }, { icon: 'celebration', label: '5k', value: 5000 },
{ icon: 'rocket', label: '10k', value: 10000 }, { icon: 'rocket', label: '10k', value: 10000 },
{ icon: 'local_fire_department', label: '100k', value: 100000 }, { icon: 'local_fire_department', label: '100k', value: 100000 },
{ icon: 'flash_on', label: '500k', value: 500000 }, { icon: 'flash_on', label: '500k', value: 500000 },
{ icon: 'diamond', label: '1M', value: 1000000 } { icon: 'diamond', label: '1M', value: 1000000 },
]; ];
async generateInvoice(): Promise<void> { async generateInvoice(): Promise<void> {
if (!this.invoiceAmount || Number(this.invoiceAmount) <= 0) { if (!this.invoiceAmount || Number(this.invoiceAmount) <= 0) {
this.openSnackBar('Please enter a valid amount', 'dismiss'); this.openSnackBar('Please enter a valid amount', 'dismiss');
return; return;
}
try {
this.nwc = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: await this.loadNWCUrl(),
});
await this.nwc.enable();
const invoiceResponse = await this.nwc.makeInvoice({
amount: Number(this.invoiceAmount),
});
this.lightningInvoice = invoiceResponse.paymentRequest;
this.showQRCode();
} catch (error) {
console.error('Error generating invoice:', error);
this.openSnackBar('Failed to generate invoice', 'dismiss');
}
} }
try { async loadNWCUrl(): Promise<string> {
try {
this.nwc = new webln.NostrWebLNProvider({ nostrWalletConnectUrl: await this.loadNWCUrl() }); const nwc = webln.NostrWebLNProvider.withNewSecret();
await this.nwc.enable(); await nwc.initNWC({ name: 'Angor Hub' });
return nwc.getNostrWalletConnectUrl();
const invoiceResponse = await this.nwc.makeInvoice({ amount: Number(this.invoiceAmount) }); } catch (error) {
this.lightningInvoice = invoiceResponse.paymentRequest; console.error('Error initializing NWC:', error);
throw new Error('Failed to initialize NWC provider');
this.showQRCode(); }
} catch (error) {
console.error('Error generating invoice:', error);
this.openSnackBar('Failed to generate invoice', 'dismiss');
} }
}
async loadNWCUrl(): Promise<string> { showQRCode(): void {
try { this.displayQRCode = !this.displayQRCode;
const nwc = webln.NostrWebLNProvider.withNewSecret();
await nwc.initNWC({ name: 'Angor Hub' });
return nwc.getNostrWalletConnectUrl();
} catch (error) {
console.error('Error initializing NWC:', error);
throw new Error('Failed to initialize NWC provider');
} }
}
showQRCode(): void { copyInvoice(): void {
this.displayQRCode = !this.displayQRCode; if (this.lightningInvoice) {
} this.clipboard.copy(this.lightningInvoice);
this.openSnackBar('Invoice copied', 'dismiss');
copyInvoice(): void { } else {
if (this.lightningInvoice) { this.openSnackBar('No invoice available to copy', 'dismiss');
this.clipboard.copy(this.lightningInvoice); }
this.openSnackBar('Invoice copied', 'dismiss');
} else {
this.openSnackBar('No invoice available to copy', 'dismiss');
} }
}
openSnackBar(message: string, action: string): void { openSnackBar(message: string, action: string): void {
this.snackBar.open(message, action, { duration: 1300 }); this.snackBar.open(message, action, { duration: 1300 });
} }
closeDialog(): void { closeDialog(): void {
this.dialogRef.close(); this.dialogRef.close();
} }
} }

View File

@@ -1,7 +1,20 @@
<div class="absolute right-0 top-0 pr-4 pt-4">
<button mat-icon-button [matDialogClose]="undefined">
<mat-icon
class="text-secondary"
[svgIcon]="'heroicons_outline:x-mark'"
></mat-icon>
</button>
</div>
<h1>⚡ Send Zap</h1> <h1>⚡ Send Zap</h1>
<mat-dialog-content *ngIf="!showInvoiceSection || !lightningInvoice"> <mat-dialog-content *ngIf="!showInvoiceSection || !lightningInvoice">
<div class="preset-buttons"> <div class="preset-buttons">
<button mat-mini-fab color="primary" *ngFor="let button of zapButtons" (click)="sats = button.value"> <button
mat-mini-fab
color="primary"
*ngFor="let button of zapButtons"
(click)="sats = button.value"
>
<mat-icon>{{ button.icon }}</mat-icon> <mat-icon>{{ button.icon }}</mat-icon>
<span>{{ button.label }}</span> <span>{{ button.label }}</span>
</button> </button>
@@ -9,13 +22,15 @@
<mat-divider></mat-divider> <mat-divider></mat-divider>
<mat-form-field appearance="outline" class="sats-input"> <mat-form-field appearance="outline" class="sats-input">
<mat-label>Zap Amount</mat-label> <mat-label>Zap Amount</mat-label>
<input matInput [(ngModel)]="sats" placeholder="e.g., 100" type="number" /> <input
matInput
[(ngModel)]="sats"
placeholder="e.g., 100"
type="number"
/>
</mat-form-field> </mat-form-field>
<mat-dialog-actions align="end"> <mat-dialog-actions align="end">
<button mat-icon-button color="warn" (click)="closeDialog()" [matTooltip]="'Close'">
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon>
</button>
<button mat-raised-button color="primary" (click)="sendZap()"> <button mat-raised-button color="primary" (click)="sendZap()">
Create invoice Create invoice
</button> </button>
@@ -26,20 +41,30 @@
<div *ngIf="displayQRCode" class="qrcode"> <div *ngIf="displayQRCode" class="qrcode">
<span>Scan with phone to pay ({{ invoiceAmount }} sats)</span> <span>Scan with phone to pay ({{ invoiceAmount }} sats)</span>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<qrcode [qrdata]="lightningInvoice" [matTooltip]="'Lightning Invoice'" [errorCorrectionLevel]="'M'" <qrcode
class="qrcode-image"></qrcode> [qrdata]="lightningInvoice"
[matTooltip]="'Lightning Invoice'"
[errorCorrectionLevel]="'M'"
class="qrcode-image"
></qrcode>
</div> </div>
<mat-dialog-actions align="center"> <mat-dialog-actions align="center">
<button mat-icon-button color="warn" (click)="closeDialog()" [matTooltip]="'Close'"> <button
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon> mat-icon-button
(click)="copyInvoice()"
[matTooltip]="'Copy Invoice'"
>
<mat-icon
[svgIcon]="'heroicons_outline:clipboard-document'"
></mat-icon>
</button> </button>
<button mat-icon-button (click)="copyInvoice()" [matTooltip]="'Copy Invoice'"> <button
<mat-icon [svgIcon]="'heroicons_outline:clipboard-document'"></mat-icon> mat-icon-button
</button> (click)="payInvoice()"
[matTooltip]="'Pay Invoice'"
<button mat-icon-button (click)="payInvoice()" [matTooltip]="'Pay Invoice'"> >
<mat-icon color="#f79318" [svgIcon]="'feather:zap'"></mat-icon> <mat-icon color="#f79318" [svgIcon]="'feather:zap'"></mat-icon>
</button> </button>
</mat-dialog-actions> </mat-dialog-actions>

View File

@@ -4,9 +4,9 @@
gap: 15px; gap: 15px;
justify-items: center; justify-items: center;
margin-bottom: 20px; margin-bottom: 20px;
} }
.preset-buttons button { .preset-buttons button {
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
width: 70px; width: 70px;
@@ -16,24 +16,23 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
max-height: 60px !important; max-height: 60px !important;
} }
.sats-input { .sats-input {
margin-top: 20px; margin-top: 20px;
width: 100%; width: 100%;
} }
.lightning-buttons { .lightning-buttons {
display: flex; display: flex;
justify-content: space-evenly; justify-content: space-evenly;
margin: 10px 0; margin: 10px 0;
} }
.qrcode {
.qrcode {
text-align: center; text-align: center;
} }
.qrcode-image { .qrcode-image {
width: 100% !important; width: 100% !important;
} }

View File

@@ -1,29 +1,40 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { LightningService } from 'app/services/lightning.service';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { webln } from '@getalby/sdk'; import { CommonModule, NgClass } from '@angular/common';
import { decode } from '@gandlaf21/bolt11-decode'; import { Component, Inject } from '@angular/core';
import { bech32 } from '@scure/base';
import { NgClass, CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOption } from '@angular/material/core'; import { MatOption } from '@angular/material/core';
import { MatLabel, MatFormField, MatFormFieldModule } from '@angular/material/form-field'; import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { MatDivider } from '@angular/material/divider';
import {
MatFormField,
MatFormFieldModule,
MatLabel,
} from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { decode } from '@gandlaf21/bolt11-decode';
import { webln } from '@getalby/sdk';
import { bech32 } from '@scure/base';
import { QRCodeModule } from 'angularx-qrcode';
import { SettingsIndexerComponent } from 'app/components/settings/indexer/indexer.component'; import { SettingsIndexerComponent } from 'app/components/settings/indexer/indexer.component';
import { SettingsNetworkComponent } from 'app/components/settings/network/network.component'; import { SettingsNetworkComponent } from 'app/components/settings/network/network.component';
import { SettingsNotificationsComponent } from 'app/components/settings/notifications/notifications.component'; import { SettingsNotificationsComponent } from 'app/components/settings/notifications/notifications.component';
import { SettingsProfileComponent } from 'app/components/settings/profile/profile.component'; import { SettingsProfileComponent } from 'app/components/settings/profile/profile.component';
import { SettingsRelayComponent } from 'app/components/settings/relay/relay.component'; import { SettingsRelayComponent } from 'app/components/settings/relay/relay.component';
import { SettingsSecurityComponent } from 'app/components/settings/security/security.component'; import { SettingsSecurityComponent } from 'app/components/settings/security/security.component';
import { QRCodeModule } from 'angularx-qrcode'; import { LightningService } from 'app/services/lightning.service';
import { MatDivider } from '@angular/material/divider';
import { MatTooltip } from '@angular/material/tooltip';
@Component({ @Component({
selector: 'app-send-dialog', selector: 'app-send-dialog',
@@ -54,10 +65,11 @@ import { MatTooltip } from '@angular/material/tooltip';
QRCodeModule, QRCodeModule,
MatDivider, MatDivider,
MatTooltip, MatTooltip,
MatDialogTitle MatDialogTitle,
MatDialogClose,
], ],
templateUrl: './send-dialog.component.html', templateUrl: './send-dialog.component.html',
styleUrls: ['./send-dialog.component.scss'] styleUrls: ['./send-dialog.component.scss'],
}) })
export class SendDialogComponent { export class SendDialogComponent {
sats: string; sats: string;
@@ -66,7 +78,7 @@ export class SendDialogComponent {
showInvoiceSection: boolean = false; showInvoiceSection: boolean = false;
displayQRCode: boolean = false; displayQRCode: boolean = false;
invoiceAmount: string = '?'; invoiceAmount: string = '?';
nwc :any; nwc: any;
constructor( constructor(
private dialogRef: MatDialogRef<SendDialogComponent>, private dialogRef: MatDialogRef<SendDialogComponent>,
@Inject(MAT_DIALOG_DATA) public metadata: any, @Inject(MAT_DIALOG_DATA) public metadata: any,
@@ -86,7 +98,7 @@ export class SendDialogComponent {
{ icon: 'rocket', label: '10k', value: 10000 }, { icon: 'rocket', label: '10k', value: 10000 },
{ icon: 'local_fire_department', label: '100k', value: 100000 }, { icon: 'local_fire_department', label: '100k', value: 100000 },
{ icon: 'flash_on', label: '500k', value: 500000 }, { icon: 'flash_on', label: '500k', value: 500000 },
{ icon: 'diamond', label: '1M', value: 1000000 } { icon: 'diamond', label: '1M', value: 1000000 },
]; ];
getLightningInfo(): void { getLightningInfo(): void {
@@ -100,20 +112,30 @@ export class SendDialogComponent {
const data = new Uint8Array(bech32.fromWords(words)); const data = new Uint8Array(bech32.fromWords(words));
lightningAddress = new TextDecoder().decode(Uint8Array.from(data)); lightningAddress = new TextDecoder().decode(Uint8Array.from(data));
} else if (this.metadata?.lud16) { } else if (this.metadata?.lud16) {
lightningAddress = this.lightning.getLightningAddress(this.metadata.lud16); lightningAddress = this.lightning.getLightningAddress(
this.metadata.lud16
);
} }
if (lightningAddress !== '') { if (lightningAddress !== '') {
this.lightning.getLightning(lightningAddress).subscribe((response) => { this.lightning
this.lightningResponse = response; .getLightning(lightningAddress)
if (this.lightningResponse.status === 'Failed') { .subscribe((response) => {
this.openSnackBar('Failed to lookup lightning address', 'dismiss'); this.lightningResponse = response;
} else if (this.lightningResponse.callback) { if (this.lightningResponse.status === 'Failed') {
this.showInvoiceSection = true; this.openSnackBar(
} else { 'Failed to lookup lightning address',
this.openSnackBar("Couldn't find user's lightning address", 'dismiss'); 'dismiss'
} );
}); } else if (this.lightningResponse.callback) {
this.showInvoiceSection = true;
} else {
this.openSnackBar(
"Couldn't find user's lightning address",
'dismiss'
);
}
});
} else { } else {
this.openSnackBar('No lightning address found', 'dismiss'); this.openSnackBar('No lightning address found', 'dismiss');
} }
@@ -121,8 +143,9 @@ export class SendDialogComponent {
getLightningInvoice(amount: string): void { getLightningInvoice(amount: string): void {
if (this.lightningResponse && this.lightningResponse.callback) { if (this.lightningResponse && this.lightningResponse.callback) {
this.lightning.getLightningInvoice(this.lightningResponse.callback, amount) this.lightning
.subscribe(async response => { .getLightningInvoice(this.lightningResponse.callback, amount)
.subscribe(async (response) => {
this.lightningInvoice = response.pr; this.lightningInvoice = response.pr;
this.setInvoiceAmount(this.lightningInvoice); this.setInvoiceAmount(this.lightningInvoice);
this.showInvoiceSection = true; this.showInvoiceSection = true;
@@ -134,7 +157,9 @@ export class SendDialogComponent {
setInvoiceAmount(invoice: string): void { setInvoiceAmount(invoice: string): void {
if (invoice) { if (invoice) {
const decodedInvoice = decode(invoice); const decodedInvoice = decode(invoice);
const amountSection = decodedInvoice.sections.find((s) => s.name === 'amount'); const amountSection = decodedInvoice.sections.find(
(s) => s.name === 'amount'
);
if (amountSection) { if (amountSection) {
this.invoiceAmount = String(Number(amountSection.value) / 1000); this.invoiceAmount = String(Number(amountSection.value) / 1000);
} }
@@ -149,29 +174,32 @@ export class SendDialogComponent {
this.getLightningInvoice(String(Number(this.sats) * 1000)); this.getLightningInvoice(String(Number(this.sats) * 1000));
} }
async payInvoice(): Promise<void> { async payInvoice(): Promise<void> {
if (!this.lightningInvoice) { if (!this.lightningInvoice) {
console.error('Lightning invoice is not set'); console.error('Lightning invoice is not set');
return; return;
} }
const nwc = new webln.NostrWebLNProvider({ nostrWalletConnectUrl: await this.loadNWCUrl() }); const nwc = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: await this.loadNWCUrl(),
});
nwc.enable() nwc.enable()
.then(() => { .then(() => {
return nwc.sendPayment(this.lightningInvoice); return nwc.sendPayment(this.lightningInvoice);
}) })
.then(response => { .then((response) => {
if (response && response.preimage) { if (response && response.preimage) {
console.log(`Payment successful, preimage: ${response.preimage}`); console.log(
`Payment successful, preimage: ${response.preimage}`
);
this.openSnackBar('Zapped!', 'dismiss'); this.openSnackBar('Zapped!', 'dismiss');
this.dialogRef.close(); this.dialogRef.close();
} else { } else {
this.listenForPaymentStatus(nwc); this.listenForPaymentStatus(nwc);
} }
}) })
.catch(error => { .catch((error) => {
console.error('Payment failed:', error); console.error('Payment failed:', error);
this.openSnackBar('Failed to pay invoice', 'dismiss'); this.openSnackBar('Failed to pay invoice', 'dismiss');
this.listenForPaymentStatus(nwc); this.listenForPaymentStatus(nwc);
@@ -179,13 +207,14 @@ export class SendDialogComponent {
} }
loadNWCUrl(): Promise<string> { loadNWCUrl(): Promise<string> {
const nwc = webln.NostrWebLNProvider.withNewSecret(); const nwc = webln.NostrWebLNProvider.withNewSecret();
return nwc.initNWC({ name: 'Angor Hub' }) return nwc
.initNWC({ name: 'Angor Hub' })
.then(() => { .then(() => {
return nwc.getNostrWalletConnectUrl(); return nwc.getNostrWalletConnectUrl();
}) })
.catch(error => { .catch((error) => {
console.error('Error initializing NWC:', error); console.error('Error initializing NWC:', error);
throw error; throw error;
}); });
@@ -194,16 +223,19 @@ export class SendDialogComponent {
listenForPaymentStatus(nwc): void { listenForPaymentStatus(nwc): void {
const checkPaymentStatus = () => { const checkPaymentStatus = () => {
nwc.sendPayment(this.lightningInvoice) nwc.sendPayment(this.lightningInvoice)
.then(response => { .then((response) => {
if (response && response.preimage) { if (response && response.preimage) {
console.log('Payment confirmed, preimage:', response.preimage); console.log(
'Payment confirmed, preimage:',
response.preimage
);
this.openSnackBar('Payment confirmed!', 'dismiss'); this.openSnackBar('Payment confirmed!', 'dismiss');
this.dialogRef.close(); this.dialogRef.close();
} else { } else {
setTimeout(checkPaymentStatus, 5000); setTimeout(checkPaymentStatus, 5000);
} }
}) })
.catch(error => { .catch((error) => {
console.error('Error checking payment status:', error); console.error('Error checking payment status:', error);
setTimeout(checkPaymentStatus, 5000); setTimeout(checkPaymentStatus, 5000);
}); });
@@ -228,5 +260,4 @@ export class SendDialogComponent {
closeDialog(): void { closeDialog(): void {
this.dialogRef.close(); this.dialogRef.close();
} }
} }

View File

@@ -1,12 +1,23 @@
<div class="w-full max-w-3xl"> <div class="w-full max-w-3xl">
<!-- Add Mainnet Indexer --> <!-- Add Mainnet Indexer -->
<div class="w-full mb-8"> <div class="mb-8 w-full">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'"> <mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>Add Mainnet Indexer</mat-label> <mat-label>Add Mainnet Indexer</mat-label>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:link'" matPrefix></mat-icon> <mat-icon
<input matInput [(ngModel)]="newMainnetIndexerUrl" placeholder="Mainnet Indexer URL" /> class="icon-size-5"
[svgIcon]="'heroicons_solid:link'"
matPrefix
></mat-icon>
<input
matInput
[(ngModel)]="newMainnetIndexerUrl"
placeholder="Mainnet Indexer URL"
/>
<button mat-icon-button matSuffix (click)="addIndexer('mainnet')"> <button mat-icon-button matSuffix (click)="addIndexer('mainnet')">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:plus-circle'"></mat-icon> <mat-icon
class="icon-size-5"
[svgIcon]="'heroicons_solid:plus-circle'"
></mat-icon>
</button> </button>
</mat-form-field> </mat-form-field>
</div> </div>
@@ -15,22 +26,43 @@
<div class="mt-8"> <div class="mt-8">
<h3>Mainnet Indexers</h3> <h3>Mainnet Indexers</h3>
<div class="flex flex-col divide-y border-b border-t"> <div class="flex flex-col divide-y border-b border-t">
<div *ngFor="let indexer of mainnetIndexers; trackBy: trackByFn" class="flex flex-col py-6 sm:flex-row sm:items-center"> <div
*ngFor="let indexer of mainnetIndexers; trackBy: trackByFn"
class="flex flex-col py-6 sm:flex-row sm:items-center"
>
<div class="flex items-center"> <div class="flex items-center">
<div class="ml-4"> <div class="ml-4">
<div class="font-medium">{{ indexer.url }}</div> <div class="font-medium">{{ indexer.url }}</div>
<div class="text-sm text-gray-500">Primary: {{ indexer.primary ? 'Yes' : 'No' }}</div> <div class="text-sm text-gray-500">
Primary: {{ indexer.primary ? 'Yes' : 'No' }}
</div>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center sm:ml-auto sm:mt-0"> <div class="mt-4 flex items-center sm:ml-auto sm:mt-0">
<button mat-icon-button (click)="setPrimaryIndexer('mainnet', indexer)"> <button
<mat-icon *ngIf="indexer.primary; else nonPrimaryIcon" class="text-primary" [svgIcon]="'heroicons_solid:check-circle'"></mat-icon> mat-icon-button
(click)="setPrimaryIndexer('mainnet', indexer)"
>
<mat-icon
*ngIf="indexer.primary; else nonPrimaryIcon"
class="text-primary"
[svgIcon]="'heroicons_solid:check-circle'"
></mat-icon>
<ng-template #nonPrimaryIcon> <ng-template #nonPrimaryIcon>
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:check-circle'"></mat-icon> <mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:check-circle'"
></mat-icon>
</ng-template> </ng-template>
</button> </button>
<button mat-icon-button (click)="removeIndexer('mainnet', indexer)"> <button
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:trash'"></mat-icon> mat-icon-button
(click)="removeIndexer('mainnet', indexer)"
>
<mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:trash'"
></mat-icon>
</button> </button>
</div> </div>
</div> </div>
@@ -38,13 +70,24 @@
</div> </div>
<!-- Add Testnet Indexer --> <!-- Add Testnet Indexer -->
<div class="w-full mb-8 mt-10"> <div class="mb-8 mt-10 w-full">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'"> <mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>Add Testnet Indexer</mat-label> <mat-label>Add Testnet Indexer</mat-label>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:link'" matPrefix></mat-icon> <mat-icon
<input matInput [(ngModel)]="newTestnetIndexerUrl" placeholder="Testnet Indexer URL" /> class="icon-size-5"
[svgIcon]="'heroicons_solid:link'"
matPrefix
></mat-icon>
<input
matInput
[(ngModel)]="newTestnetIndexerUrl"
placeholder="Testnet Indexer URL"
/>
<button mat-icon-button matSuffix (click)="addIndexer('testnet')"> <button mat-icon-button matSuffix (click)="addIndexer('testnet')">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:plus-circle'"></mat-icon> <mat-icon
class="icon-size-5"
[svgIcon]="'heroicons_solid:plus-circle'"
></mat-icon>
</button> </button>
</mat-form-field> </mat-form-field>
</div> </div>
@@ -53,22 +96,43 @@
<div class="mt-8"> <div class="mt-8">
<h3>Testnet Indexers</h3> <h3>Testnet Indexers</h3>
<div class="flex flex-col divide-y border-b border-t"> <div class="flex flex-col divide-y border-b border-t">
<div *ngFor="let indexer of testnetIndexers; trackBy: trackByFn" class="flex flex-col py-6 sm:flex-row sm:items-center"> <div
*ngFor="let indexer of testnetIndexers; trackBy: trackByFn"
class="flex flex-col py-6 sm:flex-row sm:items-center"
>
<div class="flex items-center"> <div class="flex items-center">
<div class="ml-4"> <div class="ml-4">
<div class="font-medium">{{ indexer.url }}</div> <div class="font-medium">{{ indexer.url }}</div>
<div class="text-sm text-gray-500">Primary: {{ indexer.primary ? 'Yes' : 'No' }}</div> <div class="text-sm text-gray-500">
Primary: {{ indexer.primary ? 'Yes' : 'No' }}
</div>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center sm:ml-auto sm:mt-0"> <div class="mt-4 flex items-center sm:ml-auto sm:mt-0">
<button mat-icon-button (click)="setPrimaryIndexer('testnet', indexer)"> <button
<mat-icon *ngIf="indexer.primary; else nonPrimaryIcon" class="text-primary" [svgIcon]="'heroicons_solid:check-circle'"></mat-icon> mat-icon-button
(click)="setPrimaryIndexer('testnet', indexer)"
>
<mat-icon
*ngIf="indexer.primary; else nonPrimaryIcon"
class="text-primary"
[svgIcon]="'heroicons_solid:check-circle'"
></mat-icon>
<ng-template #nonPrimaryIcon> <ng-template #nonPrimaryIcon>
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:check-circle'"></mat-icon> <mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:check-circle'"
></mat-icon>
</ng-template> </ng-template>
</button> </button>
<button mat-icon-button (click)="removeIndexer('testnet', indexer)"> <button
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:trash'"></mat-icon> mat-icon-button
(click)="removeIndexer('testnet', indexer)"
>
<mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:trash'"
></mat-icon>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
import { AngorAlertComponent } from '@angor/components/alert';
import { CommonModule, CurrencyPipe, NgClass } from '@angular/common'; import { CommonModule, CurrencyPipe, NgClass } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -5,12 +6,7 @@ import {
OnInit, OnInit,
ViewEncapsulation, ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { import { FormsModule, ReactiveFormsModule } from '@angular/forms';
FormsModule,
ReactiveFormsModule,
UntypedFormBuilder,
UntypedFormGroup,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -18,7 +14,6 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { AngorAlertComponent } from '@angor/components/alert';
import { IndexerService } from 'app/services/indexer.service'; import { IndexerService } from 'app/services/indexer.service';
@Component({ @Component({
@@ -40,59 +35,76 @@ import { IndexerService } from 'app/services/indexer.service';
MatOptionModule, MatOptionModule,
MatButtonModule, MatButtonModule,
CurrencyPipe, CurrencyPipe,
CommonModule CommonModule,
], ],
}) })
export class SettingsIndexerComponent implements OnInit { export class SettingsIndexerComponent implements OnInit {
mainnetIndexers: Array<{ url: string, primary: boolean }> = []; mainnetIndexers: Array<{ url: string; primary: boolean }> = [];
testnetIndexers: Array<{ url: string, primary: boolean }> = []; testnetIndexers: Array<{ url: string; primary: boolean }> = [];
newMainnetIndexerUrl: string = ''; newMainnetIndexerUrl: string = '';
newTestnetIndexerUrl: string = ''; newTestnetIndexerUrl: string = '';
constructor(private indexerService: IndexerService) {} constructor(private indexerService: IndexerService) {}
ngOnInit(): void { ngOnInit(): void {
this.loadIndexers(); this.loadIndexers();
}
loadIndexers(): void {
this.mainnetIndexers = this.indexerService.getIndexers('mainnet').map(url => ({
url,
primary: url === this.indexerService.getPrimaryIndexer('mainnet')
}));
this.testnetIndexers = this.indexerService.getIndexers('testnet').map(url => ({
url,
primary: url === this.indexerService.getPrimaryIndexer('testnet')
}));
console.log('Mainnet Indexers:', this.mainnetIndexers);
console.log('Testnet Indexers:', this.testnetIndexers);
}
addIndexer(network: 'mainnet' | 'testnet'): void {
if (network === 'mainnet' && this.newMainnetIndexerUrl) {
this.indexerService.addIndexer(this.newMainnetIndexerUrl, 'mainnet');
this.loadIndexers();
this.newMainnetIndexerUrl = '';
} else if (network === 'testnet' && this.newTestnetIndexerUrl) {
this.indexerService.addIndexer(this.newTestnetIndexerUrl, 'testnet');
this.loadIndexers();
this.newTestnetIndexerUrl = '';
} }
}
removeIndexer(network: 'mainnet' | 'testnet', indexer: { url: string, primary: boolean }): void { loadIndexers(): void {
this.indexerService.removeIndexer(indexer.url, network); this.mainnetIndexers = this.indexerService
this.loadIndexers(); .getIndexers('mainnet')
} .map((url) => ({
url,
primary:
url === this.indexerService.getPrimaryIndexer('mainnet'),
}));
this.testnetIndexers = this.indexerService
.getIndexers('testnet')
.map((url) => ({
url,
primary:
url === this.indexerService.getPrimaryIndexer('testnet'),
}));
setPrimaryIndexer(network: 'mainnet' | 'testnet', indexer: { url: string, primary: boolean }): void { console.log('Mainnet Indexers:', this.mainnetIndexers);
this.indexerService.setPrimaryIndexer(indexer.url, network); console.log('Testnet Indexers:', this.testnetIndexers);
this.loadIndexers(); }
}
trackByFn(index: number, item: any): any { addIndexer(network: 'mainnet' | 'testnet'): void {
return item.url; if (network === 'mainnet' && this.newMainnetIndexerUrl) {
} this.indexerService.addIndexer(
this.newMainnetIndexerUrl,
'mainnet'
);
this.loadIndexers();
this.newMainnetIndexerUrl = '';
} else if (network === 'testnet' && this.newTestnetIndexerUrl) {
this.indexerService.addIndexer(
this.newTestnetIndexerUrl,
'testnet'
);
this.loadIndexers();
this.newTestnetIndexerUrl = '';
}
}
removeIndexer(
network: 'mainnet' | 'testnet',
indexer: { url: string; primary: boolean }
): void {
this.indexerService.removeIndexer(indexer.url, network);
this.loadIndexers();
}
setPrimaryIndexer(
network: 'mainnet' | 'testnet',
indexer: { url: string; primary: boolean }
): void {
this.indexerService.setPrimaryIndexer(indexer.url, network);
this.loadIndexers();
}
trackByFn(index: number, item: any): any {
return item.url;
}
} }

View File

@@ -46,11 +46,18 @@
</div> </div>
<!-- Divider --> <!-- Divider -->
<div class="mb-10 mt-11 border-t w-full max-w-3xl"></div> <div class="mb-10 mt-11 w-full max-w-3xl border-t"></div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center justify-end w-full max-w-3xl"> <div class="flex w-full max-w-3xl items-center justify-end">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button> <button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-flat-button class="ml-4" type="button" color="primary" (click)="save()">Save</button> <button
mat-flat-button
class="ml-4"
type="button"
color="primary"
(click)="save()"
>
Save
</button>
</div> </div>

View File

@@ -1,7 +1,12 @@
import { AngorAlertComponent } from '@angor/components/alert'; import { AngorAlertComponent } from '@angor/components/alert';
import { NgClass, CurrencyPipe, CommonModule } from '@angular/common'; import { CommonModule, CurrencyPipe, NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -12,49 +17,52 @@ import { MatSelectModule } from '@angular/material/select';
import { IndexerService } from 'app/services/indexer.service'; import { IndexerService } from 'app/services/indexer.service';
@Component({ @Component({
selector: 'settings-network', selector: 'settings-network',
templateUrl: './network.component.html', templateUrl: './network.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [ imports: [
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
AngorAlertComponent, AngorAlertComponent,
MatRadioModule, MatRadioModule,
NgClass, NgClass,
MatIconModule, MatIconModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatSelectModule, MatSelectModule,
MatOptionModule, MatOptionModule,
MatButtonModule, MatButtonModule,
CurrencyPipe, CurrencyPipe,
CommonModule CommonModule,
], ],
}) })
export class SettingsNetworkComponent implements OnInit { export class SettingsNetworkComponent implements OnInit {
networkForm: FormGroup; networkForm: FormGroup;
selectedNetwork: 'mainnet' | 'testnet' = 'testnet'; selectedNetwork: 'mainnet' | 'testnet' = 'testnet';
constructor(private fb: FormBuilder, private indexerService: IndexerService) {} constructor(
private fb: FormBuilder,
private indexerService: IndexerService
) {}
ngOnInit(): void { ngOnInit(): void {
this.networkForm = this.fb.group({ this.networkForm = this.fb.group({
network: [this.indexerService.getNetwork()] network: [this.indexerService.getNetwork()],
}); });
this.selectedNetwork = this.indexerService.getNetwork(); this.selectedNetwork = this.indexerService.getNetwork();
} }
setNetwork(network: 'mainnet' | 'testnet'): void { setNetwork(network: 'mainnet' | 'testnet'): void {
this.selectedNetwork = network; this.selectedNetwork = network;
this.indexerService.setNetwork(this.selectedNetwork); this.indexerService.setNetwork(this.selectedNetwork);
} }
save(): void { save(): void {
this.indexerService.setNetwork(this.selectedNetwork); this.indexerService.setNetwork(this.selectedNetwork);
} }
cancel(): void { cancel(): void {
this.selectedNetwork = this.indexerService.getNetwork(); this.selectedNetwork = this.indexerService.getNetwork();
} }
} }

View File

@@ -5,38 +5,78 @@
<div class="mt-8 grid w-full grid-cols-1 gap-6"> <div class="mt-8 grid w-full grid-cols-1 gap-6">
<!-- Mention --> <!-- Mention -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-auto cursor-pointer" (click)="mentionToggle.toggle()"> <div
class="flex-auto cursor-pointer"
(click)="mentionToggle.toggle()"
>
<div class="font-medium leading-6">Mention</div> <div class="font-medium leading-6">Mention</div>
<div class="text-secondary text-md">Receive notifications when someone mentions you.</div> <div class="text-secondary text-md">
Receive notifications when someone mentions you.
</div>
</div> </div>
<mat-slide-toggle class="ml-2" [color]="'primary'" [formControlName]="'mention'" #mentionToggle></mat-slide-toggle> <mat-slide-toggle
class="ml-2"
[color]="'primary'"
[formControlName]="'mention'"
#mentionToggle
></mat-slide-toggle>
</div> </div>
<!-- Private Message --> <!-- Private Message -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-auto cursor-pointer" (click)="privateMessageToggle.toggle()"> <div
class="flex-auto cursor-pointer"
(click)="privateMessageToggle.toggle()"
>
<div class="font-medium leading-6">Private Message</div> <div class="font-medium leading-6">Private Message</div>
<div class="text-secondary text-md">Receive notifications for private messages.</div> <div class="text-secondary text-md">
Receive notifications for private messages.
</div>
</div> </div>
<mat-slide-toggle class="ml-2" [color]="'primary'" [formControlName]="'privateMessage'" #privateMessageToggle></mat-slide-toggle> <mat-slide-toggle
class="ml-2"
[color]="'primary'"
[formControlName]="'privateMessage'"
#privateMessageToggle
></mat-slide-toggle>
</div> </div>
<!-- Zap --> <!-- Zap -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-auto cursor-pointer" (click)="zapToggle.toggle()"> <div
class="flex-auto cursor-pointer"
(click)="zapToggle.toggle()"
>
<div class="font-medium leading-6">Zap</div> <div class="font-medium leading-6">Zap</div>
<div class="text-secondary text-md">Receive notifications when you get a zap.</div> <div class="text-secondary text-md">
Receive notifications when you get a zap.
</div>
</div> </div>
<mat-slide-toggle class="ml-2" [color]="'primary'" [formControlName]="'zap'" #zapToggle></mat-slide-toggle> <mat-slide-toggle
class="ml-2"
[color]="'primary'"
[formControlName]="'zap'"
#zapToggle
></mat-slide-toggle>
</div> </div>
<!-- New Follower --> <!-- New Follower -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-auto cursor-pointer" (click)="followerToggle.toggle()"> <div
class="flex-auto cursor-pointer"
(click)="followerToggle.toggle()"
>
<div class="font-medium leading-6">New Follower</div> <div class="font-medium leading-6">New Follower</div>
<div class="text-secondary text-md">Receive notifications when someone follows you.</div> <div class="text-secondary text-md">
Receive notifications when someone follows you.
</div>
</div> </div>
<mat-slide-toggle class="ml-2" [color]="'primary'" [formControlName]="'follower'" #followerToggle></mat-slide-toggle> <mat-slide-toggle
class="ml-2"
[color]="'primary'"
[formControlName]="'follower'"
#followerToggle
></mat-slide-toggle>
</div> </div>
</div> </div>
@@ -45,7 +85,15 @@
<!-- Actions --> <!-- Actions -->
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button mat-stroked-button type="button">Cancel</button> <button mat-stroked-button type="button">Cancel</button>
<button class="ml-4" mat-flat-button type="button" [color]="'primary'" (click)="saveSettings()">Save</button> <button
class="ml-4"
mat-flat-button
type="button"
[color]="'primary'"
(click)="saveSettings()"
>
Save
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -42,7 +42,9 @@ export class SettingsNotificationsComponent implements OnInit {
this.notificationsForm = this._formBuilder.group({ this.notificationsForm = this._formBuilder.group({
mention: [savedSettings.includes(this.notificationKinds.mention)], mention: [savedSettings.includes(this.notificationKinds.mention)],
privateMessage: [savedSettings.includes(this.notificationKinds.privateMessage)], privateMessage: [
savedSettings.includes(this.notificationKinds.privateMessage),
],
zap: [savedSettings.includes(this.notificationKinds.zap)], zap: [savedSettings.includes(this.notificationKinds.zap)],
follower: [savedSettings.includes(this.notificationKinds.follower)], follower: [savedSettings.includes(this.notificationKinds.follower)],
}); });
@@ -75,6 +77,6 @@ export class SettingsNotificationsComponent implements OnInit {
private loadNotificationSettings(): number[] { private loadNotificationSettings(): number[] {
const storedSettings = localStorage.getItem('notificationSettings'); const storedSettings = localStorage.getItem('notificationSettings');
return storedSettings ? JSON.parse(storedSettings) : [1, 3, 4, 9735]; // Default to all kinds if not set return storedSettings ? JSON.parse(storedSettings) : [1, 3, 4, 7, 9735]; // Default to all kinds if not set
} }
} }

View File

@@ -3,7 +3,7 @@
<form [formGroup]="profileForm" (ngSubmit)="onSubmit()"> <form [formGroup]="profileForm" (ngSubmit)="onSubmit()">
<!-- Section --> <!-- Section -->
<div class="w-full"> <div class="w-full">
<div class="text-secondary"> <div class="text-secondary">
Following information is publicly displayed, be careful! Following information is publicly displayed, be careful!
</div> </div>
</div> </div>
@@ -13,7 +13,11 @@
<div class="sm:col-span-4"> <div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'"> <mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>Name</mat-label> <mat-label>Name</mat-label>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:user'" matPrefix></mat-icon> <mat-icon
class="icon-size-5"
[svgIcon]="'heroicons_solid:user'"
matPrefix
></mat-icon>
<input [formControlName]="'name'" matInput /> <input [formControlName]="'name'" matInput />
</mat-form-field> </mat-form-field>
</div> </div>
@@ -46,10 +50,16 @@
<div class="sm:col-span-4"> <div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'"> <mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>About</mat-label> <mat-label>About</mat-label>
<textarea matInput [formControlName]="'about'" cdkTextareaAutosize [cdkAutosizeMinRows]="5"></textarea> <textarea
matInput
[formControlName]="'about'"
cdkTextareaAutosize
[cdkAutosizeMinRows]="5"
></textarea>
</mat-form-field> </mat-form-field>
<div class="text-hint mt-1 text-md"> <div class="text-hint mt-1 text-md">
Brief description for your profile. Basic HTML and Emoji are allowed. Brief description for your profile. Basic HTML and Emoji are
allowed.
</div> </div>
</div> </div>
@@ -69,45 +79,51 @@
</mat-form-field> </mat-form-field>
</div> </div>
<!-- LUD06 --> <!-- LUD06 -->
<div class="sm:col-span-4"> <div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'"> <mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>LUD06</mat-label> <mat-label>LUD06</mat-label>
<input [formControlName]="'lud06'" matInput /> <input [formControlName]="'lud06'" matInput />
<mat-hint> <mat-hint>
LUD06 is an LNURL (Lightning Network URL) for receiving Bitcoin payments over the Lightning Network. LUD06 is an LNURL (Lightning Network URL) for receiving
</mat-hint> Bitcoin payments over the Lightning Network.
</mat-form-field> </mat-hint>
</div> </mat-form-field>
</div>
<!-- LUD16 --> <!-- LUD16 -->
<div class="sm:col-span-4"> <div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'"> <mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>LUD16</mat-label> <mat-label>LUD16</mat-label>
<input [formControlName]="'lud16'" matInput /> <input [formControlName]="'lud16'" matInput />
<mat-hint> <mat-hint>
LUD16 is a Lightning address, similar to an email format, used to receive Bitcoin payments via the Lightning Network. LUD16 is a Lightning address, similar to an email
</mat-hint> format, used to receive Bitcoin payments via the
</mat-form-field> Lightning Network.
</div> </mat-hint>
</mat-form-field>
<!-- NIP05 --> </div>
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>NIP05</mat-label>
<input [formControlName]="'nip05'" matInput />
<mat-hint>
NIP05 provides a user-friendly identifier for Nostr, similar to an email address, to help identify and verify your public identity.
</mat-hint>
</mat-form-field>
</div>
<!-- NIP05 -->
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>NIP05</mat-label>
<input [formControlName]="'nip05'" matInput />
<mat-hint>
NIP05 provides a user-friendly identifier for Nostr,
similar to an email address, to help identify and verify
your public identity.
</mat-hint>
</mat-form-field>
</div>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center justify-end mt-8"> <div class="mt-8 flex items-center justify-end">
<button mat-stroked-button type="button">Cancel</button> <button mat-stroked-button type="button">Cancel</button>
<button class="ml-4" mat-flat-button type="submit" color="primary">Save</button> <button class="ml-4" mat-flat-button type="submit" color="primary">
Save
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,10 +1,21 @@
import { MatDialog } from '@angular/material/dialog';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, ViewEncapsulation, ChangeDetectionStrategy, OnInit } from '@angular/core'; import {
import { FormsModule, ReactiveFormsModule, FormGroup, FormBuilder, Validators } from '@angular/forms'; ChangeDetectionStrategy,
Component,
OnInit,
ViewEncapsulation,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core'; import { MatOptionModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
@@ -14,8 +25,8 @@ import { hexToBytes } from '@noble/hashes/utils';
import { MetadataService } from 'app/services/metadata.service'; import { MetadataService } from 'app/services/metadata.service';
import { RelayService } from 'app/services/relay.service'; import { RelayService } from 'app/services/relay.service';
import { SignerService } from 'app/services/signer.service'; import { SignerService } from 'app/services/signer.service';
import { UnsignedEvent, NostrEvent, finalizeEvent } from 'nostr-tools';
import { PasswordDialogComponent } from 'app/shared/password-dialog/password-dialog.component'; import { PasswordDialogComponent } from 'app/shared/password-dialog/password-dialog.component';
import { NostrEvent, UnsignedEvent, finalizeEvent } from 'nostr-tools';
@Component({ @Component({
selector: 'settings-profile', selector: 'settings-profile',
@@ -33,7 +44,7 @@ import { PasswordDialogComponent } from 'app/shared/password-dialog/password-dia
MatSelectModule, MatSelectModule,
MatOptionModule, MatOptionModule,
MatButtonModule, MatButtonModule,
CommonModule CommonModule,
], ],
}) })
export class SettingsProfileComponent implements OnInit { export class SettingsProfileComponent implements OnInit {
@@ -46,8 +57,8 @@ export class SettingsProfileComponent implements OnInit {
private metadataService: MetadataService, private metadataService: MetadataService,
private relayService: RelayService, private relayService: RelayService,
private router: Router, private router: Router,
private dialog: MatDialog, private dialog: MatDialog
) { } ) {}
ngOnInit(): void { ngOnInit(): void {
this.profileForm = this.fb.group({ this.profileForm = this.fb.group({
@@ -59,15 +70,23 @@ export class SettingsProfileComponent implements OnInit {
picture: [''], picture: [''],
banner: [''], banner: [''],
lud06: [''], lud06: [''],
lud16: ['', Validators.pattern("^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,4}$")], lud16: [
nip05: ['', Validators.pattern("^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$")] '',
Validators.pattern('^[a-z0-9._-]+@[a-z0-9.-]+.[a-z]{2,4}$'),
],
nip05: [
'',
Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+.[a-z]{2,4}$'),
],
}); });
this.setValues(); this.setValues();
} }
async setValues() { async setValues() {
let kind0 = await this.metadataService.getUserMetadata(this.signerService.getPublicKey()); let kind0 = await this.metadataService.getUserMetadata(
this.signerService.getPublicKey()
);
if (kind0) { if (kind0) {
this.profileForm.setValue({ this.profileForm.setValue({
name: kind0.name || '', name: kind0.name || '',
@@ -100,7 +119,8 @@ export class SettingsProfileComponent implements OnInit {
const storedPassword = this.signerService.getPassword(); const storedPassword = this.signerService.getPassword();
if (storedPassword) { if (storedPassword) {
try { try {
const privateKey = await this.signerService.getSecretKey(storedPassword); const privateKey =
await this.signerService.getSecretKey(storedPassword);
this.signEvent(privateKey); this.signEvent(privateKey);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -108,46 +128,54 @@ export class SettingsProfileComponent implements OnInit {
} else { } else {
const dialogRef = this.dialog.open(PasswordDialogComponent, { const dialogRef = this.dialog.open(PasswordDialogComponent, {
width: '300px', width: '300px',
disableClose: true disableClose: true,
}); });
dialogRef.afterClosed().subscribe(async result => { dialogRef.afterClosed().subscribe(async (result) => {
if (result && result.password) { if (result && result.password) {
try { try {
const privateKey = await this.signerService.getSecretKey(result.password); const privateKey =
await this.signerService.getSecretKey(
result.password
);
this.signEvent(privateKey); this.signEvent(privateKey);
if (result.duration != 0) { if (result.duration != 0) {
this.signerService.savePassword(result.password, result.duration); this.signerService.savePassword(
result.password,
result.duration
);
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
} else { } else {
console.error('Password not provided'); console.error('Password not provided');
} }
}); });
} }
} else if (this.signerService.isUsingExtension()) { } else if (this.signerService.isUsingExtension()) {
const unsignedEvent: UnsignedEvent = this.signerService.getUnsignedEvent(0, [], this.content); const unsignedEvent: UnsignedEvent =
const signedEvent = await this.signerService.signEventWithExtension(unsignedEvent); this.signerService.getUnsignedEvent(0, [], this.content);
const signedEvent =
await this.signerService.signEventWithExtension(unsignedEvent);
this.publishSignedEvent(signedEvent); this.publishSignedEvent(signedEvent);
} }
} }
async signEvent(privateKey: string) { async signEvent(privateKey: string) {
const unsignedEvent: UnsignedEvent = this.signerService.getUnsignedEvent(0, [], this.content); const unsignedEvent: UnsignedEvent =
this.signerService.getUnsignedEvent(0, [], this.content);
const privateKeyBytes = hexToBytes(privateKey); const privateKeyBytes = hexToBytes(privateKey);
const signedEvent: NostrEvent = finalizeEvent(unsignedEvent, privateKeyBytes); const signedEvent: NostrEvent = finalizeEvent(
unsignedEvent,
privateKeyBytes
);
this.publishSignedEvent(signedEvent); this.publishSignedEvent(signedEvent);
} }
publishSignedEvent(signedEvent: NostrEvent) { publishSignedEvent(signedEvent: NostrEvent) {
this.relayService.publishEventToRelays(signedEvent); this.relayService.publishEventToRelays(signedEvent);
console.log("Profile Updated!"); console.log('Profile Updated!');
this.router.navigate([`/profile`]); this.router.navigate([`/profile`]);
} }
} }

View File

@@ -3,42 +3,72 @@
<div class="w-full"> <div class="w-full">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'"> <mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>Add Relay</mat-label> <mat-label>Add Relay</mat-label>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:link'" matPrefix></mat-icon> <mat-icon
class="icon-size-5"
[svgIcon]="'heroicons_solid:link'"
matPrefix
></mat-icon>
<input matInput [(ngModel)]="newRelayUrl" placeholder="Relay URL" /> <input matInput [(ngModel)]="newRelayUrl" placeholder="Relay URL" />
<button mat-icon-button matSuffix (click)="addRelay()"> <button mat-icon-button matSuffix (click)="addRelay()">
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:plus-circle'"></mat-icon> <mat-icon
class="icon-size-5"
[svgIcon]="'heroicons_solid:plus-circle'"
></mat-icon>
</button> </button>
</mat-form-field> </mat-form-field>
</div> </div>
<!-- Relays --> <!-- Relays -->
<div class="mt-8 flex flex-col divide-y border-b border-t"> <div class="mt-8 flex flex-col divide-y border-b border-t">
<div *ngFor="let relay of relays; trackBy: trackByFn" class="flex flex-col py-6 sm:flex-row sm:items-center"> <div
*ngFor="let relay of relays; trackBy: trackByFn"
class="flex flex-col py-6 sm:flex-row sm:items-center"
>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full"> <div
<img class="h-full w-full object-cover" [src]="getSafeUrl(relayFavIcon(relay.url))" class="flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full"
onerror="this.src='/images/avatars/avatar-placeholder.png'" alt="relay avatar" /> >
<img
class="h-full w-full object-cover"
[src]="getSafeUrl(relayFavIcon(relay.url))"
onerror="this.src='/images/avatars/avatar-placeholder.png'"
alt="relay avatar"
/>
</div> </div>
<div class="ml-4"> <div class="ml-4">
<div class="font-medium">{{ relay.url }}</div> <div class="font-medium">{{ relay.url }}</div>
<div class="text-sm" [ngClass]="getRelayStatusClass(relay)">Status: {{ getRelayStatus(relay) }} <div class="text-sm" [ngClass]="getRelayStatusClass(relay)">
Status: {{ getRelayStatus(relay) }}
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 flex items-center sm:ml-auto sm:mt-0"> <div class="mt-4 flex items-center sm:ml-auto sm:mt-0">
<mat-form-field class="angor-mat-dense w-50" [subscriptSizing]="'dynamic'"> <mat-form-field
<mat-select [(ngModel)]="relay.accessType" (selectionChange)="updateRelayAccess(relay)"> class="angor-mat-dense w-50"
[subscriptSizing]="'dynamic'"
>
<mat-select
[(ngModel)]="relay.accessType"
(selectionChange)="updateRelayAccess(relay)"
>
<mat-select-trigger class="text-md"> <mat-select-trigger class="text-md">
<span class="ml-1 font-medium">{{ relay.accessType | titlecase }}</span> <span class="ml-1 font-medium">{{
relay.accessType | titlecase
}}</span>
</mat-select-trigger> </mat-select-trigger>
<mat-option *ngFor="let option of accessOptions" [value]="option.value"> <mat-option
*ngFor="let option of accessOptions"
[value]="option.value"
>
<div class="font-medium">{{ option.label }}</div> <div class="font-medium">{{ option.label }}</div>
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<button mat-icon-button (click)="removeRelay(relay.url)"> <button mat-icon-button (click)="removeRelay(relay.url)">
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:trash'"></mat-icon> <mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:trash'"
></mat-icon>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,11 @@
import { CommonModule, TitleCasePipe } from '@angular/common'; import { CommonModule, TitleCasePipe } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
NgZone,
OnInit, OnInit,
ViewEncapsulation, ViewEncapsulation,
ChangeDetectorRef,
NgZone,
} from '@angular/core'; } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@@ -52,7 +52,7 @@ export class SettingsRelayComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
// Subscribe to relays observable // Subscribe to relays observable
this.subscriptions.add( this.subscriptions.add(
this.relayService.getRelays().subscribe(relays => { this.relayService.getRelays().subscribe((relays) => {
this.zone.run(() => { this.zone.run(() => {
this.relays = relays; this.relays = relays;
this.cdr.markForCheck(); // Mark the component for check this.cdr.markForCheck(); // Mark the component for check
@@ -65,17 +65,20 @@ export class SettingsRelayComponent implements OnInit {
{ {
label: 'Read', label: 'Read',
value: 'read', value: 'read',
description: 'Reads only, does not write, unless explicitly specified on publish action.', description:
'Reads only, does not write, unless explicitly specified on publish action.',
}, },
{ {
label: 'Write', label: 'Write',
value: 'write', value: 'write',
description: 'Writes your events, profile, and other metadata updates. Connects on-demand.', description:
'Writes your events, profile, and other metadata updates. Connects on-demand.',
}, },
{ {
label: 'Read and Write', label: 'Read and Write',
value: 'read-write', value: 'read-write',
description: 'Reads and writes events, profiles, and other metadata. Always connected.', description:
'Reads and writes events, profiles, and other metadata. Always connected.',
}, },
]; ];
} }
@@ -113,13 +116,14 @@ export class SettingsRelayComponent implements OnInit {
} }
relayFavIcon(url: string): string { relayFavIcon(url: string): string {
let safeUrl = url.replace('wss://', 'https://').replace('ws://', 'https://'); let safeUrl = url
.replace('wss://', 'https://')
.replace('ws://', 'https://');
return safeUrl + '/favicon.ico'; return safeUrl + '/favicon.ico';
} }
getSafeUrl(url: string): SafeUrl { getSafeUrl(url: string): SafeUrl {
return this.sanitizer.bypassSecurityTrustUrl(url); return this.sanitizer.bypassSecurityTrustUrl(url);
} }
} }

Some files were not shown because too many files have changed in this diff Show More