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",
"buffer",
"localforage",
"moment"
"moment",
"bech32",
"bn.js",
"qrcode",
"dayjs",
"dayjs/plugin/relativeTime"
],
"assets": [
{

6
package-lock.json generated
View File

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

View File

@@ -12,7 +12,8 @@
"test": "ng test",
"deploy": "ng deploy",
"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": {
"@angular-builders/custom-webpack": "^18.0.0",
@@ -98,7 +99,7 @@
"karma-jasmine-html-reporter": "2.1.0",
"lodash": "4.17.21",
"postcss": "8.4.47",
"prettier": "3.3.3",
"prettier": "^3.3.3",
"prettier-plugin-organize-imports": "4.1.0",
"prettier-plugin-tailwindcss": "0.6.8",
"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 {
ANGOR_MOCK_API_DEFAULT_DELAY,
mockApiInterceptor,
@@ -25,6 +13,18 @@ import { AngorMediaWatcherService } from '@angor/services/media-watcher';
import { AngorPlatformService } from '@angor/services/platform';
import { AngorSplashScreenService } from '@angor/services/splash-screen';
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 = {
mockApi?: {
@@ -40,10 +40,8 @@ export type AngorProviderConfig = {
export const provideAngor = (
config: AngorProviderConfig
): Array<Provider | EnvironmentProviders> => {
const providers: Array<Provider | EnvironmentProviders> = [
{
provide: MATERIAL_SANITY_CHECKS,
useValue: {
doctype: true,
@@ -52,7 +50,6 @@ export const provideAngor = (
},
},
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
useValue: {
appearance: 'fill',
@@ -103,7 +100,6 @@ export const provideAngor = (
},
];
if (config?.mockApi?.services) {
providers.push(
provideHttpClient(withInterceptors([mockApiInterceptor])),
@@ -116,6 +112,5 @@ export const provideAngor = (
);
}
return providers;
};

View File

@@ -2,17 +2,17 @@
* Defines animation curves for Angor.
*/
export class AngorAnimationCurves {
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 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 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 acceleration = 'cubic-bezier(0.4, 0.0, 1, 1)'; // Acceleration curve
static sharp = 'cubic-bezier(0.4, 0.0, 0.6, 1)'; // Sharp curve
}
/**
* Defines animation durations for Angor.
*/
export class AngorAnimationDurations {
static complex = '375ms'; // Duration for complex animations
static entering = '225ms'; // Duration for entering animations
static exiting = '195ms'; // Duration for exiting animations
static complex = '375ms'; // Duration for complex animations
static entering = '225ms'; // Duration for entering animations
static exiting = '195ms'; // Duration for exiting animations
}

View File

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

View File

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

View File

@@ -21,15 +21,42 @@ const shake = trigger('shake', [
'{{timings}}',
keyframes([
style({ transform: 'translate3d(0, 0, 0)', offset: 0 }),
style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.1 }),
style({ transform: 'translate3d(10px, 0, 0)', offset: 0.2 }),
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(-10px, 0, 0)',
offset: 0.1,
}),
style({
transform: 'translate3d(10px, 0, 0)',
offset: 0.2,
}),
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 }),
])
),

View File

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

View File

@@ -1,3 +1,7 @@
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
import {
animate,
state,
@@ -5,10 +9,6 @@ import {
transition,
trigger,
} from '@angular/animations';
import {
AngorAnimationCurves,
AngorAnimationDurations,
} from '@angor/animations/defaults';
/**
* 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 {
ChangeDetectionStrategy,
@@ -16,13 +23,6 @@ import {
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
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';
@Component({
@@ -86,16 +86,22 @@ export class AngorAlertComponent implements OnChanges, OnInit, OnDestroy {
*/
ngOnChanges(changes: SimpleChanges): void {
if ('dismissed' in changes) {
this.dismissed = coerceBooleanProperty(changes.dismissed.currentValue);
this.dismissed = coerceBooleanProperty(
changes.dismissed.currentValue
);
this._toggleDismiss(this.dismissed);
}
if ('dismissible' in changes) {
this.dismissible = coerceBooleanProperty(changes.dismissible.currentValue);
this.dismissible = coerceBooleanProperty(
changes.dismissible.currentValue
);
}
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 {
Component,
@@ -7,8 +9,6 @@ import {
SimpleChanges,
ViewEncapsulation,
} from '@angular/core';
import { angorAnimations } from '@angor/animations';
import { AngorCardFace } from '@angor/components/card/card.types';
@Component({
selector: 'angor-card',
@@ -47,11 +47,15 @@ export class AngorCardComponent implements OnChanges {
*/
ngOnChanges(changes: SimpleChanges): void {
if ('expanded' in changes) {
this.expanded = coerceBooleanProperty(changes.expanded.currentValue);
this.expanded = coerceBooleanProperty(
changes.expanded.currentValue
);
}
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 {
animate,
AnimationBuilder,
@@ -11,6 +17,7 @@ import {
EventEmitter,
HostBinding,
HostListener,
inject,
Input,
OnChanges,
OnDestroy,
@@ -19,14 +26,7 @@ import {
Renderer2,
SimpleChanges,
ViewEncapsulation,
inject,
} 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({
selector: 'angor-drawer',
@@ -56,7 +56,8 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy {
@Output() readonly fixedChanged = new EventEmitter<boolean>();
@Output() readonly modeChanged = new EventEmitter<AngorDrawerMode>();
@Output() readonly openedChanged = new EventEmitter<boolean>();
@Output() readonly positionChanged = new EventEmitter<AngorDrawerPosition>();
@Output() readonly positionChanged =
new EventEmitter<AngorDrawerPosition>();
private _animationsEnabled: boolean = false;
private _hovered: boolean = false;
@@ -107,7 +108,11 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy {
this._hideOverlay();
}
if (previousMode === 'side' && currentMode === 'over' && this.opened) {
if (
previousMode === 'side' &&
currentMode === 'over' &&
this.opened
) {
this._showOverlay();
}
@@ -125,7 +130,9 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy {
}
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 { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AngorDrawerService {

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { angorAnimations } from '@angor/animations';
import { NgTemplateOutlet } from '@angular/common';
import {
AfterViewInit,
@@ -8,7 +9,6 @@ import {
TemplateRef,
ViewEncapsulation,
} from '@angular/core';
import { angorAnimations } from '@angor/animations';
@Component({
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 {
ChangeDetectionStrategy,
@@ -16,10 +20,6 @@ import {
RouterLink,
RouterLinkActive,
} 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';
@Component({
@@ -69,7 +69,7 @@ export class AngorHorizontalNavigationBasicItemComponent
// "isActiveMatchOptions" or the equivalent form of
// item's "exactMatch" option
this.isActiveMatchOptions =
this.item.isActiveMatchOptions ?? this.item.exactMatch
(this.item.isActiveMatchOptions ?? this.item.exactMatch)
? this._angorUtilsService.exactMatchOptions
: this._angorUtilsService.subsetMatchOptions;

View File

@@ -66,7 +66,10 @@
<!-- 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
[item]="item"
[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 { NgClass, NgTemplateOutlet } from '@angular/common';
import {
@@ -14,11 +19,6 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuModule } from '@angular/material/menu';
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';
@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 {
ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy,
OnInit,
} 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';
@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 {
ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy,
OnInit,
} 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';
@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 {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -10,10 +14,6 @@ import {
ViewEncapsulation,
inject,
} 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 { AngorHorizontalNavigationBasicItemComponent } from './components/basic/basic.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 { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AngorNavigationService {
@@ -93,7 +93,10 @@ export class AngorNavigationService {
* @param id
* @param navigation
*/
getItem(id: string, navigation: AngorNavigationItem[]): AngorNavigationItem | null {
getItem(
id: string,
navigation: AngorNavigationItem[]
): AngorNavigationItem | null {
for (const item of navigation) {
if (item.id === id) return item;
if (item.children) {

View File

@@ -38,6 +38,10 @@ export interface AngorNavigationItem {
meta?: any;
}
export type AngorVerticalNavigationAppearance = 'default' | 'compact' | 'dense' | 'thin';
export type AngorVerticalNavigationAppearance =
| 'default'
| 'compact'
| 'dense'
| 'thin';
export type AngorVerticalNavigationMode = 'over' | 'side';
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 { NgClass } from '@angular/common';
import {
@@ -14,14 +22,6 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
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';
@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 {
ChangeDetectionStrategy,
@@ -15,10 +19,6 @@ import {
RouterLink,
RouterLinkActive,
} 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';
@Component({
@@ -67,7 +67,7 @@ export class AngorVerticalNavigationBasicItemComponent
// "isActiveMatchOptions" or the equivalent form of
// item's "exactMatch" option
this.isActiveMatchOptions =
this.item.isActiveMatchOptions ?? this.item.exactMatch
(this.item.isActiveMatchOptions ?? this.item.exactMatch)
? this._angorUtilsService.exactMatchOptions
: 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 { NgClass } from '@angular/common';
import {
@@ -14,14 +22,6 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
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';
@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 {
ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy,
OnInit,
} 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';
@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 { NgClass } from '@angular/common';
import {
@@ -11,13 +18,6 @@ import {
inject,
} from '@angular/core';
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';
@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 {
ChangeDetectionStrategy,
@@ -8,9 +11,6 @@ import {
OnDestroy,
OnInit,
} 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';
@Component({

View File

@@ -80,8 +80,6 @@
}
}
}
</div>
<!-- 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 {
animate,
AnimationBuilder,
@@ -30,22 +46,6 @@ import {
ViewEncapsulation,
} from '@angular/core';
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 {
delay,
filter,

View File

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

View File

@@ -1,3 +1,7 @@
import {
ScrollbarGeometry,
ScrollbarPosition,
} from '@angor/directives/scrollbar/scrollbar.types';
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
import { Platform } from '@angular/cdk/platform';
import {
@@ -10,10 +14,6 @@ import {
SimpleChanges,
inject,
} from '@angular/core';
import {
ScrollbarGeometry,
ScrollbarPosition,
} from '@angor/directives/scrollbar/scrollbar.types';
import { merge } from 'lodash-es';
import PerfectScrollbar from 'perfect-scrollbar';
import { Subject, debounceTime, fromEvent, takeUntil } from 'rxjs';
@@ -47,12 +47,20 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
ngOnChanges(changes: SimpleChanges): void {
if ('angorScrollbar' in changes) {
this.angorScrollbar = coerceBooleanProperty(changes.angorScrollbar.currentValue);
this.angorScrollbar ? this._initScrollbar() : this._destroyScrollbar();
this.angorScrollbar = coerceBooleanProperty(
changes.angorScrollbar.currentValue
);
this.angorScrollbar
? this._initScrollbar()
: this._destroyScrollbar();
}
if ('angorScrollbarOptions' in changes) {
this._options = merge({}, this._options, changes.angorScrollbarOptions.currentValue);
this._options = merge(
{},
this._options,
changes.angorScrollbarOptions.currentValue
);
this._reinitializeScrollbar();
}
}
@@ -92,7 +100,10 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
position(absolute: boolean = false): ScrollbarPosition {
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 {
return new ScrollbarPosition(
this._elementRef.nativeElement.scrollLeft,
@@ -115,7 +126,6 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
}
}
scrollToX(x: number, speed?: number): void {
this.animateScrolling('scrollLeft', x, speed);
}
@@ -129,7 +139,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
}
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);
}
@@ -138,7 +150,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
}
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);
}
@@ -152,14 +166,29 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
if (!element) return;
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')) {
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')) {
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 {
if (this._ps || this._platform.ANDROID || this._platform.IOS || !this._platform.isBrowser) return;
this._ps = new PerfectScrollbar(this._elementRef.nativeElement, { ...this._options });
if (
this._ps ||
this._platform.ANDROID ||
this._platform.IOS ||
!this._platform.isBrowser
)
return;
this._ps = new PerfectScrollbar(this._elementRef.nativeElement, {
...this._options,
});
}
private _destroyScrollbar(): void {
@@ -198,7 +235,8 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
ignoreVisible: boolean,
speed?: number
): void {
if (ignoreVisible && elementPos <= scrollerPos - Math.abs(offset)) return;
if (ignoreVisible && elementPos <= scrollerPos - Math.abs(offset))
return;
const currentPos = this._elementRef.nativeElement[target];
const position = elementPos - scrollerPos + currentPos;
@@ -213,7 +251,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy {
const step = (newTimestamp: number) => {
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 (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 {
HttpErrorResponse,
HttpEvent,
@@ -6,8 +8,6 @@ import {
HttpResponse,
} from '@angular/common/http';
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 { AngorMockApiMethods } from './mock-api.types';
@@ -43,11 +43,14 @@ export const mockApiInterceptor = (
switchMap((response) => {
// If no response is returned, generate a 404 error
if (!response) {
return throwError(() => new HttpErrorResponse({
error: 'NOT FOUND',
status: 404,
statusText: 'NOT FOUND',
}));
return throwError(
() =>
new HttpErrorResponse({
error: 'NOT FOUND',
status: 404,
statusText: 'NOT FOUND',
})
);
}
// 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 (data.status >= 200 && data.status < 300) {
return of(new HttpResponse({
body: data.body,
status: data.status,
statusText: 'OK',
}));
return of(
new HttpResponse({
body: data.body,
status: data.status,
statusText: 'OK',
})
);
}
// For other status codes, throw an error response
return throwError(() => new HttpErrorResponse({
error: data.body?.error,
status: data.status,
statusText: 'ERROR',
}));
return throwError(
() =>
new HttpErrorResponse({
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 { HttpRequest } from '@angular/common/http';
import { Observable, of, take, throwError } from 'rxjs';
export class AngorMockApiHandler {
@@ -17,7 +17,10 @@ export class AngorMockApiHandler {
* @param url - The URL for the mock API handler
* @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.
@@ -27,12 +30,16 @@ export class AngorMockApiHandler {
get response(): Observable<any> {
// Check if the execution limit has been reached
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
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

View File

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

View File

@@ -18,7 +18,11 @@ export class AngorFindByKeyPipe implements PipeTransform {
* @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.
*/
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 (Array.isArray(value)) {
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 { inject, Injectable } from '@angular/core';
import { merge } from 'lodash-es';
import { BehaviorSubject, Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AngorConfigService {
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.

View File

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

View File

@@ -1,9 +1,9 @@
import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types';
import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types';
@Component({
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 { inject } from '@angular/core';
import { AngorLoadingService } from '@angor/services/loading/loading.service';
import { Observable, finalize, take } from 'rxjs';
export const angorLoadingInterceptor = (

View File

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

View File

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

View File

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

View File

@@ -13,17 +13,6 @@ const jsonToSassMap = require(
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) => {
return _.fromPairs(
_.map(
@@ -43,17 +32,10 @@ const normalizeTheme = (theme) => {
);
};
// -----------------------------------------------------------------------------------------------------
// @ ANGOR TailwindCSS Main Plugin
// -----------------------------------------------------------------------------------------------------
const theming = plugin.withOptions(
(options) =>
({ 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(
_.map(options.themes, (theme, 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(
_.map(userThemes, (theme, 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(
_.map(themes, (theme, 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(
_.map(themes, (theme, themeName) => [
themeName,
@@ -119,18 +89,15 @@ const theming = plugin.withOptions(
])
);
/* Generate the SASS map using the themes object */
const sassMap = jsonToSassMap(
JSON.stringify({ 'user-themes': themes })
);
/* Get the file path */
const filename = path.resolve(
__dirname,
'../../styles/user-themes.scss'
);
/* Read the file and get its data */
let data;
try {
data = fs.readFileSync(filename, { encoding: 'utf8' });
@@ -138,7 +105,6 @@ const theming = plugin.withOptions(
console.error(err);
}
/* Write the file if the map has been changed */
if (data !== sassMap) {
try {
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(
_.fromPairs(
_.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(
['light', 'dark'],
(colorScheme) => {
@@ -234,31 +193,9 @@ const theming = plugin.withOptions(
return {
[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' } : {}),
/* Generate custom properties from customProps */
..._.fromPairs(
_.flatten(
_.map(background, (value, key) => [
@@ -287,7 +224,6 @@ const theming = plugin.withOptions(
);
const schemeUtilities = (() => {
/* Generate general styles & utilities */
return {};
})();
@@ -298,11 +234,6 @@ const theming = plugin.withOptions(
return {
theme: {
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(
_.flatten(
_.map(
@@ -398,7 +329,6 @@ const theming = plugin.withOptions(
},
},
},
},
};
}

View File

@@ -1,11 +1,6 @@
const plugin = require('tailwindcss/plugin');
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({
'.mat-icon': {
'--tw-text-opacity': '1',

View File

@@ -1,6 +1,6 @@
:host {
display: flex;
flex: 1 1 auto; // Flex-grow, flex-shrink, and basis set to auto
width: 100%; // Full width of the container
height: 100%; // Full height of the container
flex: 1 1 auto; // Flex-grow, flex-shrink, and basis set to auto
width: 100%; // Full width 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 { 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 { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { provideAnimations } from '@angular/platform-browser/animations';
@@ -9,20 +15,18 @@ import {
withInMemoryScrolling,
withPreloading,
} from '@angular/router';
import { provideAngor } from '@angor';
import { provideServiceWorker } from '@angular/service-worker';
import { TranslocoService, provideTransloco } from '@ngneat/transloco';
import { WebLNProvider } from '@webbtc/webln-types';
import { appRoutes } from 'app/app.routes';
import { provideIcons } from 'app/core/icons/icons.provider';
import { firstValueFrom } from 'rxjs';
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 { WebLNProvider } from '@webbtc/webln-types';
import { HashService } from './services/hash.service';
import { NostrWindow } from './types/nostr';
export function initializeApp(hashService: HashService) {
console.log('initializeApp. Getting hashService.load.');
return (): Promise<void> => hashService.load();
}
export const appConfig: ApplicationConfig = {
@@ -31,7 +35,7 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
registrationStrategy: 'registerWhenStable:30000',
}),
{
provide: APP_INITIALIZER,
@@ -54,12 +58,12 @@ export const appConfig: ApplicationConfig = {
provide: MAT_DATE_FORMATS,
useValue: {
parse: {
dateInput: 'D', // Date format for parsing
dateInput: 'D', // Date format for parsing
},
display: {
dateInput: 'DDD', // Date format for input display
monthYearLabel: 'LLL yyyy', // Format for month-year labels
dateA11yLabel: 'DD', // Accessible format for dates
dateInput: 'DDD', // Date format for input display
monthYearLabel: 'LLL yyyy', // Format for month-year labels
dateA11yLabel: 'DD', // Accessible format for dates
monthYearA11yLabel: 'LLLL yyyy', // Accessible format for month-year
},
},
@@ -72,7 +76,7 @@ export const appConfig: ApplicationConfig = {
{
id: 'en',
label: 'English',
}
},
],
defaultLang: 'en',
fallbackLang: 'en',
@@ -121,13 +125,11 @@ export const appConfig: ApplicationConfig = {
],
},
}),
],
};
declare global {
interface Window {
webln?: WebLNProvider;
nostr?: NostrWindow;
webln?: WebLNProvider;
nostr?: NostrWindow;
}
}
}

View File

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

View File

@@ -1,31 +1,30 @@
import { Route } from '@angular/router';
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';
/**
* Application routes configuration
*/
export const appRoutes: Route[] = [
// Redirect root path to '/explore'
{
path: '',
pathMatch: 'full',
redirectTo: 'home'
redirectTo: 'home',
},
{
path: 'project/:pubkey',
pathMatch: 'full',
redirectTo: 'explore'
redirectTo: 'explore',
},
// Redirect login user to '/explore'
{
path: 'login-redirect',
pathMatch: 'full',
redirectTo: 'explore'
redirectTo: 'explore',
},
// Routes for guests
@@ -36,13 +35,15 @@ export const appRoutes: Route[] = [
children: [
{
path: 'login',
loadChildren: () => import('app/components/auth/login/login.routes')
loadChildren: () =>
import('app/components/auth/login/login.routes'),
},
{
path: 'register',
loadChildren: () => import('app/components/auth/register/register.routes')
}
]
loadChildren: () =>
import('app/components/auth/register/register.routes'),
},
],
},
// Routes for authenticated users
@@ -55,13 +56,12 @@ export const appRoutes: Route[] = [
children: [
{
path: 'logout',
loadChildren: () => import('app/components/auth/logout/logout.routes')
}
]
loadChildren: () =>
import('app/components/auth/logout/logout.routes'),
},
],
},
// Authenticated routes for Angor
{
path: '',
@@ -72,41 +72,49 @@ export const appRoutes: Route[] = [
children: [
{
path: 'home',
loadChildren: () => import('app/components/home/home.routes')
loadChildren: () => import('app/components/home/home.routes'),
},
{
path: 'explore',
loadChildren: () => import('app/components/explore/explore.routes')
loadChildren: () =>
import('app/components/explore/explore.routes'),
},
{
path: 'profile',
loadChildren: () => import('app/components/profile/profile.routes')
loadChildren: () =>
import('app/components/profile/profile.routes'),
},
{
path: 'profile/:pubkey',
loadChildren: () => import('app/components/profile/profile.routes')
loadChildren: () =>
import('app/components/profile/profile.routes'),
},
{
path: 'settings',
loadChildren: () => import('app/components/settings/settings.routes')
loadChildren: () =>
import('app/components/settings/settings.routes'),
},
{
path: 'settings/:id',
loadChildren: () => import('app/components/settings/settings.routes')
loadChildren: () =>
import('app/components/settings/settings.routes'),
},
{
path: 'chat',
loadChildren: () => import('app/components/chat/chat.routes')
loadChildren: () => import('app/components/chat/chat.routes'),
},
{
path: '404-not-found',
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: '**',
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
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">
<!-- 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
</div>
<div class="mt-0.5 flex items-baseline font-medium">
<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>
<angor-alert *ngIf="showSecAlert" class="mt-8" [appearance]="'outline'" [showIcon]="false" [type]="secAlert.type"
[@shake]="secAlert.type === 'error'">
<angor-alert
*ngIf="showSecAlert"
class="mt-8"
[appearance]="'outline'"
[showIcon]="false"
[type]="secAlert.type"
[@shake]="secAlert.type === 'error'"
>
{{ secAlert.message }}
</angor-alert>
@@ -26,8 +41,16 @@
<!-- extension login buttons -->
<div class="mt-8 flex items-center space-x-4">
<button class="flex-auto space-x-2" type="button" mat-stroked-button (click)="loginWithNostrExtension()">
<mat-icon class="icon-size-5" [svgIcon]="'feather:zap'"></mat-icon>
<button
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>
</button>
</div>
@@ -40,7 +63,11 @@
</div>
<!-- Login form with Secret Key -->
<form class="mt-8" [formGroup]="SecretKeyLoginForm" (ngSubmit)="loginWithSecretKey()">
<form
class="mt-8"
[formGroup]="SecretKeyLoginForm"
(ngSubmit)="loginWithSecretKey()"
>
<!-- secret key field -->
<div class="mt-8 flex items-center">
<div class="mt-px flex-auto border-t"></div>
@@ -49,8 +76,14 @@
</div>
<mat-form-field class="w-full">
<mat-label>Secret Key</mat-label>
<input matInput formControlName="secretKey" autocomplete="secretKey" />
@if (SecretKeyLoginForm.get('secretKey').hasError('required')) {
<input
matInput
formControlName="secretKey"
autocomplete="secretKey"
/>
@if (
SecretKeyLoginForm.get('secretKey').hasError('required')
) {
<mat-error> Secret key is required </mat-error>
}
</mat-form-field>
@@ -58,47 +91,94 @@
<!-- Password field -->
<mat-form-field class="w-full">
<mat-label>Password</mat-label>
<input matInput type="password" [formControlName]="'password'" autocomplete="current-password-seckey" #secretPasswordField />
<button mat-icon-button 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>
<input
matInput
type="password"
[formControlName]="'password'"
autocomplete="current-password-seckey"
#secretPasswordField
/>
<button
mat-icon-button
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>
<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-form-field>
<!-- Submit button -->
<button class="angor-mat-button-large mt-6 w-full" mat-flat-button color="primary"
[disabled]="SecretKeyLoginForm.invalid">
<button
class="angor-mat-button-large mt-6 w-full"
mat-flat-button
color="primary"
[disabled]="SecretKeyLoginForm.invalid"
>
<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>
</form>
<div class="mt-8 flex items-center">
<div class="mt-px flex-auto border-t"></div>
<div class="text-secondary mx-2">Or enter menemonic</div>
<div class="mt-px flex-auto border-t"></div>
</div>
<angor-alert *ngIf="showMenemonicAlert" class="mt-8" [appearance]="'outline'" [showIcon]="false" [type]="menemonicAlert.type"
[@shake]="menemonicAlert.type === 'error'">
<angor-alert
*ngIf="showMenemonicAlert"
class="mt-8"
[appearance]="'outline'"
[showIcon]="false"
[type]="menemonicAlert.type"
[@shake]="menemonicAlert.type === 'error'"
>
{{ menemonicAlert.message }}
</angor-alert>
<!-- Login form with Menemonic -->
<form class="mt-8" [formGroup]="MenemonicLoginForm" (ngSubmit)="loginWithMenemonic()">
<form
class="mt-8"
[formGroup]="MenemonicLoginForm"
(ngSubmit)="loginWithMenemonic()"
>
<!-- Menemonic field -->
<mat-form-field class="w-full">
<mat-label>Menemonic</mat-label>
<input matInput formControlName="menemonic" autocomplete="menemonic" />
@if (MenemonicLoginForm.get('menemonic').hasError('required')) {
<input
matInput
formControlName="menemonic"
autocomplete="menemonic"
/>
@if (
MenemonicLoginForm.get('menemonic').hasError('required')
) {
<mat-error> Menemonic is required </mat-error>
}
</mat-form-field>
@@ -106,66 +186,156 @@
<!-- Passphrase field -->
<mat-form-field class="w-full">
<mat-label>Passphrase (Optional)</mat-label>
<input matInput type="password" [formControlName]="'passphrase'" autocomplete="current-passphrase-menemonic"
#passphraseField />
<button 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>
<input
matInput
type="password"
[formControlName]="'passphrase'"
autocomplete="current-passphrase-menemonic"
#passphraseField
/>
<button
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>
<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-form-field>
<!-- Password field -->
<mat-form-field class="w-full">
<mat-label>Password</mat-label>
<input matInput type="password" [formControlName]="'password'" autocomplete="current-password-menemonic"
#menemonicPasswordField />
<button 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>
<input
matInput
type="password"
[formControlName]="'password'"
autocomplete="current-password-menemonic"
#menemonicPasswordField
/>
<button
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>
<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-form-field>
<!-- Submit button -->
<button class="angor-mat-button-large mt-6 w-full" mat-flat-button color="primary"
[disabled]="MenemonicLoginForm.invalid">
<button
class="angor-mat-button-large mt-6 w-full"
mat-flat-button
color="primary"
[disabled]="MenemonicLoginForm.invalid"
>
<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>
</form>
</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">
<svg class="absolute inset-0 pointer-events-none" 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">
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="pointer-events-none absolute inset-0"
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="790" cy="491"></circle>
</g>
</svg>
<svg class="absolute -top-16 -right-16 text-gray-700" viewBox="0 0 220 192" width="220" height="192"
fill="none">
<svg
class="absolute -right-16 -top-16 text-gray-700"
viewBox="0 0 220 192"
width="220"
height="192"
fill="none"
>
<defs>
<pattern id="837c3e70-6c3a-44e6-8854-cc48c737b659" x="0" y="0" width="20" height="20"
patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="4" height="4" fill="currentColor"></rect>
<pattern
id="837c3e70-6c3a-44e6-8854-cc48c737b659"
x="0"
y="0"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width="4"
height="4"
fill="currentColor"
></rect>
</pattern>
</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>
<!-- Background and Content -->
<div class="relative z-10 w-full max-w-2xl">

View File

@@ -1,7 +1,13 @@
import { AngorAlertComponent } from '@angor/components/alert';
import { CommonModule } from '@angular/common';
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 { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,7 +32,7 @@ import { SignerService } from 'app/services/signer.service';
MatIconModule,
MatCheckboxModule,
MatProgressSpinnerModule,
CommonModule
CommonModule,
],
})
export class LoginComponent implements OnInit {
@@ -41,15 +47,15 @@ export class LoginComponent implements OnInit {
loading = false;
isInstalledExtension = false;
privateKey: Uint8Array = new Uint8Array();
publicKey: string = "";
npub: string = "";
nsec: string = "";
publicKey: string = '';
npub: string = '';
nsec: string = '';
constructor(
private _formBuilder: FormBuilder,
private _router: Router,
private _signerService: SignerService
) { }
) {}
ngOnInit(): void {
this.initializeForms();
@@ -59,20 +65,25 @@ export class LoginComponent implements OnInit {
private initializeForms(): void {
this.SecretKeyLoginForm = this._formBuilder.group({
secretKey: ['', [Validators.required, Validators.minLength(3)]],
password: ['', Validators.required]
password: ['', Validators.required],
});
this.MenemonicLoginForm = this._formBuilder.group({
menemonic: ['', [Validators.required, Validators.minLength(3)]],
passphrase: [''], // Passphrase is optional
password: ['', Validators.required]
password: ['', Validators.required],
});
}
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;
} else {
this.isInstalledExtension = false;
@@ -91,7 +102,10 @@ export class LoginComponent implements OnInit {
this.showSecAlert = false;
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) {
// Successful login
@@ -102,26 +116,33 @@ export class LoginComponent implements OnInit {
} catch (error) {
// Handle login failure
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;
console.error("Login error: ", error);
console.error('Login error: ', error);
}
}
loginWithMenemonic(): void {
if (this.MenemonicLoginForm.invalid) {
return;
}
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;
this.loading = true;
this.showMenemonicAlert = false;
const success = this._signerService.handleLoginWithMenemonic(menemonic, passphrase,password);
const success = this._signerService.handleLoginWithMenemonic(
menemonic,
passphrase,
password
);
if (success) {
this._router.navigateByUrl('/home');
@@ -140,5 +161,4 @@ export class LoginComponent implements OnInit {
console.error('Failed to log in using Nostr extension');
}
}
}

View File

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

View File

@@ -19,7 +19,10 @@ export class LogoutComponent implements OnInit, OnDestroy {
};
private _unsubscribeAll: Subject<any> = new Subject<any>();
constructor(private _router: Router, private _signerService: SignerService) {}
constructor(
private _router: Router,
private _signerService: SignerService
) {}
ngOnInit(): void {
timer(1000, 1000)
@@ -35,7 +38,6 @@ export class LogoutComponent implements OnInit, OnDestroy {
.subscribe();
}
ngOnDestroy(): void {
this._unsubscribeAll.next(null);
this._unsubscribeAll.complete();
@@ -44,6 +46,6 @@ export class LogoutComponent implements OnInit, OnDestroy {
logout(): void {
this._signerService.clearPassword();
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
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">
<!-- 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
</div>
<div class="mt-0.5 flex items-baseline font-medium">
<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>
</div>
<!-- Alert -->
@if (showAlert) {
<angor-alert class="mt-8" [appearance]="'outline'" [showIcon]="false" [type]="alert.type"
[@shake]="alert.type === 'error'">
{{ alert.message }}
</angor-alert>
<angor-alert
class="mt-8"
[appearance]="'outline'"
[showIcon]="false"
[type]="alert.type"
[@shake]="alert.type === 'error'"
>
{{ alert.message }}
</angor-alert>
}
<!-- Register form -->
<form class="mt-8" [formGroup]="registerForm" #registerNgForm="ngForm">
<form
class="mt-8"
[formGroup]="registerForm"
#registerNgForm="ngForm"
>
<!-- Name field (secretKey) -->
<mat-form-field class="w-full">
<mat-label>Full name</mat-label>
<input id="name" matInput [formControlName]="'name'" autocomplete="name"/>
<mat-error *ngIf="registerForm.get('name').hasError('required')"> Full name is required </mat-error>
<input
id="name"
matInput
[formControlName]="'name'"
autocomplete="name"
/>
<mat-error
*ngIf="registerForm.get('name').hasError('required')"
>
Full name is required
</mat-error>
</mat-form-field>
<!-- Username field -->
<mat-form-field class="w-full">
<mat-label>Username</mat-label>
<input id="username" matInput [formControlName]="'username'" autocomplete="username"/>
<mat-error *ngIf="registerForm.get('username').hasError('required')"> Username is required </mat-error>
<input
id="username"
matInput
[formControlName]="'username'"
autocomplete="username"
/>
<mat-error
*ngIf="
registerForm.get('username').hasError('required')
"
>
Username is required
</mat-error>
</mat-form-field>
<!-- About field -->
<mat-form-field class="w-full">
<mat-label>About</mat-label>
<textarea id="about" matInput [formControlName]="'about'"></textarea>
<textarea
id="about"
matInput
[formControlName]="'about'"
></textarea>
</mat-form-field>
<!-- Avatar URL field -->
<mat-form-field class="w-full">
<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>
<!-- Password field -->
<!-- Password field -->
<mat-form-field class="w-full">
<mat-label>Password</mat-label>
<input id="password" matInput type="password" [formControlName]="'password'" autocomplete="password" #passwordField />
<button mat-icon-button type="button" (click)="
<input
id="password"
matInput
type="password"
[formControlName]="'password'"
autocomplete="password"
#passwordField
/>
<button
mat-icon-button
type="button"
(click)="
passwordField.type === 'password'
? (passwordField.type = 'text')
: (passwordField.type = 'password')
" matSuffix>
<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>
"
matSuffix
>
<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>
<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>
<!-- ToS and PP -->
<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>
<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>
<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>
</div>
<!-- Submit button -->
<button class="angor-mat-button-large mt-6 w-full" mat-flat-button [color]="'primary'"
[disabled]="registerForm.invalid" (click)="register()">
<button
class="angor-mat-button-large mt-6 w-full"
mat-flat-button
[color]="'primary'"
[disabled]="registerForm.invalid"
(click)="register()"
>
<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>
</form>
</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">
<svg class="absolute inset-0 pointer-events-none" 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">
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="pointer-events-none absolute inset-0"
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="790" cy="491"></circle>
</g>
</svg>
<svg class="absolute -top-16 -right-16 text-gray-700" viewBox="0 0 220 192" width="220" height="192"
fill="none">
<svg
class="absolute -right-16 -top-16 text-gray-700"
viewBox="0 0 220 192"
width="220"
height="192"
fill="none"
>
<defs>
<pattern id="837c3e70-6c3a-44e6-8854-cc48c737b659" x="0" y="0" width="20" height="20"
patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="4" height="4" fill="currentColor"></rect>
<pattern
id="837c3e70-6c3a-44e6-8854-cc48c737b659"
x="0"
y="0"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width="4"
height="4"
fill="currentColor"
></rect>
</pattern>
</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>
<!-- Content -->
<div class="relative z-10 w-full max-w-2xl">
@@ -110,8 +234,8 @@
<div>Angor Hub</div>
</div>
<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
platform.
Angor Hub is a Nostr client that is customized around the Angor
protocol, a decentralized crowdfunding platform.
</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 {
FormsModule,
@@ -14,11 +17,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
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 { CommonModule } from '@angular/common';
@Component({
selector: 'auth-register',
@@ -37,7 +36,7 @@ import { CommonModule } from '@angular/common';
MatIconModule,
MatCheckboxModule,
MatProgressSpinnerModule,
CommonModule
CommonModule,
],
})
export class RegisterComponent implements OnInit {
@@ -89,7 +88,10 @@ export class RegisterComponent implements OnInit {
if (!keys) {
// If key generation failed, enable the form and show an error
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;
return;
}
@@ -112,11 +114,13 @@ export class RegisterComponent implements OnInit {
console.log('User Metadata:', userMetadata);
// Display success alert
this.alert = { type: 'success', message: 'Account created successfully!' };
this.alert = {
type: 'success',
message: 'Account created successfully!',
};
this.showAlert = true;
// Redirect to home
this._router.navigateByUrl('/home');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,12 +29,14 @@
</div>
}
</div>
<div class="mt-4 text-lg font-medium">{{ chat.contact?.name }}</div>
<div class="text-secondary mt-0.5 text-md">
<div class="mt-4 text-lg font-medium">
<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 }}
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
import { AngorMediaWatcherService } from '@angor/services/media-watcher';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule, DatePipe, NgClass, NgTemplateOutlet } from '@angular/common';
import {
CommonModule,
DatePipe,
NgClass,
NgTemplateOutlet,
} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -19,17 +25,16 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSidenavModule } from '@angular/material/sidenav';
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 { GifDialogComponent } from 'app/shared/gif-dialog/gif-dialog.component';
import { MatDialog } from '@angular/material/dialog';
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({
selector: 'chat-conversation',
@@ -52,7 +57,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
TextFieldModule,
DatePipe,
PickerComponent,
CommonModule
CommonModule,
],
})
export class ConversationComponent implements OnInit, OnDestroy {
@@ -68,7 +73,6 @@ export class ConversationComponent implements OnInit, OnDestroy {
isListening: boolean = false;
userEdited: boolean = false;
constructor(
private _changeDetectorRef: ChangeDetectorRef,
private _chatService: ChatService,
@@ -78,11 +82,15 @@ export class ConversationComponent implements OnInit, OnDestroy {
public dialog: MatDialog,
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) {
console.error('Speech recognition is not supported in this browser.');
return;
console.error(
'Speech recognition is not supported in this browser.'
);
return;
}
this.recognition = new SpeechRecognition();
this.recognition.lang = 'en-US';
@@ -91,41 +99,35 @@ export class ConversationComponent implements OnInit, OnDestroy {
this.setupRecognitionEvents();
}
openGifDialog(): void {
const dialogRef = this.dialog.open(GifDialogComponent, {
width: '600px',
maxHeight: '80vh',
data: { apiKey: 'LIVDSRZULELA' }
});
const dialogRef = this.dialog.open(GifDialogComponent, {
width: '600px',
maxHeight: '80vh',
data: { apiKey: 'LIVDSRZULELA' },
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
dialogRef.afterClosed().subscribe((result) => {
if (result) {
const messageContent = result;
const messageContent = result
if (messageContent) {
this.messageInput.nativeElement.value = '';
this._chatService.sendPrivateMessage(messageContent)
.then(() => {
if (messageContent) {
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 = '';
})
.catch((error) => {
console.error('Failed to send message:', error);
});
this.finalTranscript = '';
this.userEdited = false;
}
}
});
this.userEdited = false;
}
}
});
}
ngOnInit(): void {
this._angorConfigService.config$.subscribe((config) => {
if (config.scheme === 'auto') {
@@ -140,22 +142,18 @@ export class ConversationComponent implements OnInit, OnDestroy {
.subscribe((chat: Chat) => {
this.chat = chat;
this._changeDetectorRef.markForCheck();
});
this._angorMediaWatcherService.onMediaChange$
.pipe(takeUntil(this._unsubscribeAll))
.subscribe(({ matchingAliases }) => {
if (matchingAliases.includes('lg')) {
this.drawerMode = 'side';
} else {
this.drawerMode = 'over';
}
this._changeDetectorRef.markForCheck();
});
}
@@ -163,50 +161,50 @@ export class ConversationComponent implements OnInit, OnDestroy {
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>');
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);
}
}
@HostListener('input')
@HostListener('ngModelChange')
private _resizeMessageInput(): void {
this._ngZone.runOutsideAngular(() => {
setTimeout(() => {
this.messageInput.nativeElement.style.height = 'auto';
this._changeDetectorRef.detectChanges();
this.messageInput.nativeElement.style.height = `${this.messageInput.nativeElement.scrollHeight}px`;
this._changeDetectorRef.detectChanges();
});
});
}
setupRecognitionEvents(): void {
this.recognition.onresult = (event: any) => {
let interimTranscript = '';
@@ -220,9 +218,9 @@ export class ConversationComponent implements OnInit, OnDestroy {
}
}
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 {
this.finalTranscript = '';
if (this.isListening) {
@@ -248,43 +245,32 @@ export class ConversationComponent implements OnInit, OnDestroy {
}
}
handleUserInput(event: Event): void {
this.userEdited = true;
}
ngOnDestroy(): void {
this._unsubscribeAll.next(null);
this._unsubscribeAll.complete();
}
openContactInfo(): void {
this.drawerOpened = true;
this._changeDetectorRef.markForCheck();
}
resetChat(): void {
this._chatService.resetChat();
this.drawerOpened = false;
this._changeDetectorRef.markForCheck();
}
toggleMuteNotifications(): void {
this.chat.muted = !this.chat.muted;
this._chatService.updateChat(this.chat.id, this.chat).subscribe();
}
@@ -292,9 +278,10 @@ export class ConversationComponent implements OnInit, OnDestroy {
return item.id || index;
}
detectSystemTheme() {
const darkSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
const darkSchemeMedia = window.matchMedia(
'(prefers-color-scheme: dark)'
);
this.darkMode = darkSchemeMedia.matches;
darkSchemeMedia.addEventListener('change', (event) => {
@@ -312,10 +299,10 @@ export class ConversationComponent implements OnInit, OnDestroy {
sendMessage(): void {
const messageContent = this.messageInput.nativeElement.value.trim();
if (messageContent) {
if (messageContent) {
this.messageInput.nativeElement.value = '';
this._chatService.sendPrivateMessage(messageContent)
this._chatService
.sendPrivateMessage(messageContent)
.then(() => {
this.messageInput.nativeElement.value = '';
this.finalTranscript = '';
@@ -336,5 +323,4 @@ export class ConversationComponent implements OnInit, OnDestroy {
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
}
}

View File

@@ -27,7 +27,8 @@
) {
<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"
(click)="openChat(contact)">
(click)="openChat(contact)"
>
{{ contact.name.charAt(0) }}
</div>
}
@@ -35,7 +36,7 @@
<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"
(click)="openChat(contact)"
>
>
<div
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 { MatIconModule } from '@angular/material/icon';
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 { Subject, takeUntil } from 'rxjs';
import { ChatService } from '../chat.service';
import { Contact } from '../chat.types';
@Component({
selector: 'chat-new-chat',
@@ -30,8 +30,10 @@ export class NewChatComponent implements OnInit, OnDestroy {
/**
* Constructor
*/
constructor(private _chatService: ChatService, private router: Router) {}
constructor(
private _chatService: ChatService,
private router: Router
) {}
ngOnInit(): void {
// Contacts
@@ -42,14 +44,12 @@ export class NewChatComponent implements OnInit, OnDestroy {
});
}
ngOnDestroy(): void {
// Unsubscribe from all subscriptions
this._unsubscribeAll.next(null);
this._unsubscribeAll.complete();
}
trackByFn(index: number, item: any): any {
return item.id || index;
}
@@ -65,8 +65,7 @@ export class NewChatComponent implements OnInit, OnDestroy {
},
complete: () => {
this.drawer.close();
}
},
});
}
}

View File

@@ -13,8 +13,8 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatDrawer } from '@angular/material/sidenav';
import { Subject, takeUntil } from 'rxjs';
import { Profile } from '../chat.types';
import { ChatService } from '../chat.service';
import { Profile } from '../chat.types';
@Component({
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">
<!-- 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 -->
<svg class="pointer-events-none absolute inset-0" 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">
<svg
class="pointer-events-none absolute inset-0"
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="790" cy="491"></circle>
</g>
</svg>
<div class="relative z-10 flex flex-col items-center">
<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?
</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
opportunity.
</div>
@@ -23,110 +40,188 @@
<!-- Main -->
<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 -->
<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 -->
<div class="flex items-center space-x-2 w-full sm:w-auto">
<mat-form-field class="mt-4 w-full sm:w-80" [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 />
<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-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>
<!-- Clear search button -->
<button mat-icon-button color="warn" *ngIf="showCloseSearchButton" (click)="resetSearch(query)" class="mt-4">
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon>
<button
mat-icon-button
color="warn"
*ngIf="showCloseSearchButton"
(click)="resetSearch(query)"
class="mt-4"
>
<mat-icon
[svgIcon]="'heroicons_solid:x-mark'"
></mat-icon>
</button>
<button mat-icon-button color="success" *ngIf="!showCloseSearchButton" (click)="filterByQuery(query.value)" class="mt-4">
<mat-icon [svgIcon]="'heroicons_solid:magnifying-glass'"></mat-icon>
<button
mat-icon-button
color="success"
*ngIf="!showCloseSearchButton"
(click)="filterByQuery(query.value)"
class="mt-4"
>
<mat-icon
[svgIcon]="'heroicons_solid:magnifying-glass'"
></mat-icon>
</button>
</div>
<!-- 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
</mat-slide-toggle>
</div>
</div>
<div class="mx-auto flex w-full flex-auto flex-col sm:max-w-5xl">
<!-- 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 -->
<ng-container *ngFor="let project of filteredProjects">
<angor-card class="filter-info flex w-full flex-col">
<div class="flex h-32">
<img class="object-cover" [src]="
<img
class="object-cover"
[src]="
getSafeUrl(project?.banner, true) ||
'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 class="flex px-8">
<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) ||
'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 class="flex flex-col px-8 pb-6 pt-4">
<div class="flex items-center justify-between">
<div class="mr-4 min-w-0 flex-1">
@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)
">
{{
project.displayName ||
project.nostrPubKey
}}
</div>
"
>
{{
project.displayName ||
project.nostrPubKey
}}
</div>
}
@if (
!project.name && !project.displayName
!project.name && !project.displayName
) {
<div class="truncate text-2xl font-semibold leading-tight">
{{
project.displayName ||
project.nostrPubKey
}}
</div>
<div
class="truncate text-2xl font-semibold leading-tight"
>
{{
project.displayName ||
project.nostrPubKey
}}
</div>
}
<div class="text-secondary mt-1 truncate leading-tight">
<div
class="text-secondary mt-1 truncate leading-tight"
>
{{
project.about ||
'No description available'
project.about ||
'No description available'
}}
</div>
</div>
@if (project.displayName || project.name) {
<div class="flex h-10 w-10 items-center justify-center rounded-full border">
<button mat-icon-button (click)="
<div
class="flex h-10 w-10 items-center justify-center rounded-full border"
>
<button
mat-icon-button
(click)="
openChat(project.nostrPubKey)
">
<mat-icon class="icon-size-5" [svgIcon]="
"
>
<mat-icon
class="icon-size-5"
[svgIcon]="
'heroicons_outline:chat-bubble-left-right'
"></mat-icon>
</button>
</div>
"
></mat-icon>
</button>
</div>
}
</div>
<hr class="my-6 w-full border-t" />
<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 }}
investors
</div>
<div class="flex items-center">
<ng-container *ngFor="
let investor of [].constructor(project.totalInvestmentsCount || 0);
let i = index
">
<ng-container
*ngFor="
let investor of [].constructor(
project.totalInvestmentsCount ||
0
);
let i = index
"
>
<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]="{
'-ml-3': project.totalInvestmentsCount > 1 && i > 0
}" [src]="'images/avatars/avatar-placeholder.png'"
alt="Investor avatar {{ i + 1 }}" />
'-ml-3':
project.totalInvestmentsCount >
1 && i > 0,
}"
[src]="
'images/avatars/avatar-placeholder.png'
"
alt="Investor avatar {{
i + 1
}}"
/>
</ng-container>
</ng-container>
</div>
@@ -135,18 +230,32 @@
</angor-card>
</ng-container>
</div>
<ng-container *ngIf="filteredProjects.length ==0">
<div class="flex flex-auto flex-col items-center justify-center bg-gray-100 dark:bg-transparent">
<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">
<ng-container *ngIf="filteredProjects.length == 0">
<div
class="flex flex-auto flex-col items-center justify-center bg-gray-100 dark:bg-transparent"
>
<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
</div>
</div>
</ng-container>
<!-- Load More Button -->
<div *ngIf="filteredProjects.length >0" class="mt-10 flex justify-center">
<button mat-raised-button color="primary" (click)="loadProjects()" [disabled]="loading">
<div
*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' }}
</button>
</div>

View File

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

View File

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

View File

@@ -3,10 +3,18 @@
<div class="prose prose-sm mx-auto max-w-none">
<h1>Angor Hub</h1>
<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>
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>
</div>
<div>

View File

@@ -8,7 +8,7 @@ import { RouterLink } from '@angular/router';
templateUrl: './home.component.html',
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [MatButtonModule, RouterLink, MatIconModule],
imports: [MatButtonModule, RouterLink, MatIconModule ],
})
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 { CommonModule, NgClass } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ViewEncapsulation,
ElementRef,
OnDestroy,
OnInit,
OnDestroy
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
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 { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggle } from '@angular/material/slide-toggle';
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 { FormsModule } from '@angular/forms';
import { QRCodeModule } from 'angularx-qrcode';
import { Clipboard } from '@angular/cdk/clipboard';
import { SendDialogComponent } from './zap/send-dialog/send-dialog.component';
import { PaginatedEventService } from 'app/services/event.service';
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 { SendDialogComponent } from './zap/send-dialog/send-dialog.component';
interface Chip {
color?: string;
selected?: string;
name: string;
}
@Component({
selector: 'profile',
templateUrl: './profile.component.html',
encapsulation: ViewEncapsulation.None,
styleUrls: ['./profile.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
RouterLink,
@@ -56,28 +74,54 @@ import { ReceiveDialogComponent } from './zap/receive-dialog/receive-dialog.comp
CommonModule,
FormsModule,
QRCodeModule,
PickerComponent,
MatSlideToggle,
SafeUrlPipe,
MatProgressSpinnerModule,
InfiniteScrollModule,
EventListComponent,
],
})
export class ProfileComponent implements OnInit, OnDestroy {
@ViewChild('eventInput', { static: false }) eventInput: ElementRef;
@ViewChild('commentInput') commentInput: ElementRef;
darkMode: boolean = false;
isLoading: boolean = true;
errorMessage: string | null = null;
metadata: any;
currentUserMetadata: any;
private _unsubscribeAll: Subject<any> = new Subject<any>();
private userPubKey;
private routePubKey;
public currentUserPubKey: string;
public routePubKey;
followers: any[] = [];
following: any[] = [];
allPublicKeys: string[] = [];
suggestions: { pubkey: string, metadata: any }[] = [];
suggestions: { pubkey: string; metadata: any }[] = [];
isCurrentUserProfile: Boolean = false;
isFollowing = false;
showEmojiPicker = false;
showCommentEmojiPicker = false;
lightningResponse: LightningResponse | null = null;
lightningInvoice: LightningInvoice | null = null;
sats: string;
paymentInvoice: 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(
@@ -90,52 +134,81 @@ export class ProfileComponent implements OnInit, OnDestroy {
private _socialService: SocialService,
private snackBar: MatSnackBar,
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 {
this._angorConfigService.config$.subscribe((config) => {
if (config.scheme === 'auto') {
this.detectSystemTheme();
} else {
this.darkMode = config.scheme === 'dark';
}
});
this._route.paramMap.subscribe((params) => {
const routePubKey = params.get('pubkey');
this.routePubKey = routePubKey;
const userPubKey = this._signerService.getPublicKey();
this.isCurrentUserProfile = routePubKey === userPubKey;
const pubKeyToLoad = routePubKey || userPubKey;
this.loadProfile(pubKeyToLoad);
const currentUserPubKey = this._signerService.getPublicKey();
this.currentUserPubKey = currentUserPubKey;
if (routePubKey || currentUserPubKey) {
this.isCurrentUserProfile = routePubKey === currentUserPubKey;
}
this.routePubKey = routePubKey || currentUserPubKey;
this.loadProfile(this.routePubKey);
if (!routePubKey) {
this.isCurrentUserProfile = true;
}
this.loadCurrentUserProfile();
});
this._indexedDBService.getMetadataStream()
this._indexedDBService
.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === this.userPubKey) {
if (
updatedMetadata &&
updatedMetadata.pubkey === this.currentUserPubKey
) {
this.currentUserMetadata = updatedMetadata.metadata;
this._changeDetectorRef.detectChanges();
}
});
if (this.routePubKey) {
this._indexedDBService.getMetadataStream()
this._indexedDBService
.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === this.routePubKey) {
if (
updatedMetadata &&
updatedMetadata.pubkey === this.routePubKey
) {
this.metadata = updatedMetadata.metadata;
this._changeDetectorRef.detectChanges();
}
});
}
this._socialService.getFollowersObservable()
this._socialService
.getFollowersObservable()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((event) => {
this.followers.push(event.pubkey);
this._changeDetectorRef.detectChanges();
});
this._socialService.getFollowingObservable()
this._socialService
.getFollowingObservable()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((event) => {
const tags = event.tags.filter((tag) => tag[0] === 'p');
@@ -144,8 +217,6 @@ export class ProfileComponent implements OnInit, OnDestroy {
});
this._changeDetectorRef.detectChanges();
});
this.updateSuggestionList();
}
ngOnDestroy(): void {
@@ -153,6 +224,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
this._unsubscribeAll.complete();
}
async loadProfile(publicKey: string): Promise<void> {
this.isLoading = true;
this.errorMessage = null;
@@ -160,6 +232,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.metadata = null;
this.followers = [];
this.following = [];
this._changeDetectorRef.detectChanges();
if (!publicKey) {
@@ -170,27 +243,20 @@ export class ProfileComponent implements OnInit, OnDestroy {
}
try {
const userMetadata = await this._metadataService.fetchMetadataWithCache(publicKey);
if (userMetadata) {
this.metadata = userMetadata;
this._changeDetectorRef.detectChanges();
}
await this._socialService.getFollowers(publicKey);
this.followers = await this._socialService.getFollowers(publicKey);
const currentUserPubKey = this._signerService.getPublicKey();
this.isFollowing = this.followers.includes(currentUserPubKey);
await this._socialService.getFollowing(publicKey);
this._metadataService.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === publicKey) {
this.metadata = updatedMetadata;
this._changeDetectorRef.detectChanges();
}
});
this.following = await this._socialService.getFollowing(publicKey);
this._changeDetectorRef.detectChanges();
} catch (error) {
console.error('Failed to load profile data:', error);
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> {
try {
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) {
this.currentUserMetadata = currentUserMetadata;
this._changeDetectorRef.detectChanges();
}
this._metadataService.getMetadataStream()
.pipe(takeUntil(this._unsubscribeAll))
.subscribe((updatedMetadata) => {
if (updatedMetadata && updatedMetadata.pubkey === this.userPubKey) {
this.currentUserMetadata = updatedMetadata;
this._changeDetectorRef.detectChanges();
}
});
this._changeDetectorRef.detectChanges();
} catch (error) {
console.error('Failed to load profile data:', error);
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 {
return this._sanitizer.bypassSecurityTrustUrl(url);
@@ -246,7 +303,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
async toggleFollow(): Promise<void> {
try {
const userPubKey = this._signerService.getPublicKey();
const routePubKey = this.routePubKey || this.userPubKey;
const routePubKey = this.routePubKey || this.currentUserPubKey;
if (!routePubKey || !userPubKey) {
console.error('Public key missing. Unable to toggle follow.');
@@ -257,7 +314,9 @@ export class ProfileComponent implements OnInit, OnDestroy {
await this._socialService.unfollow(routePubKey);
console.log(`Unfollowed ${routePubKey}`);
this.followers = this.followers.filter(pubkey => pubkey !== userPubKey);
this.followers = this.followers.filter(
(pubkey) => pubkey !== userPubKey
);
} else {
await this._socialService.follow(routePubKey);
console.log(`Followed ${routePubKey}`);
@@ -268,19 +327,15 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.isFollowing = !this.isFollowing;
this._changeDetectorRef.detectChanges();
} catch (error) {
console.error('Failed to toggle follow:', error);
}
}
openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, { duration: 1300 });
}
getLightningInfo() {
let lightningAddress = '';
if (this.metadata?.lud06) {
@@ -292,19 +347,29 @@ export class ProfileComponent implements OnInit, OnDestroy {
const data = new Uint8Array(bech32.fromWords(words));
lightningAddress = new TextDecoder().decode(Uint8Array.from(data));
} else if (this.metadata?.lud16) {
lightningAddress = this.lightning.getLightningAddress(this.metadata.lud16);
lightningAddress = this.lightning.getLightningAddress(
this.metadata.lud16
);
}
if (lightningAddress !== '') {
this.lightning.getLightning(lightningAddress).subscribe((response) => {
this.lightningResponse = response;
if (this.lightningResponse.status === 'Failed') {
this.openSnackBar('Failed to lookup lightning address', 'dismiss');
} else if (this.lightningResponse.callback) {
this.openZapDialog(); // Open dialog when callback is available
} else {
this.openSnackBar("couldn't find user's lightning address", 'dismiss');
}
});
this.lightning
.getLightning(lightningAddress)
.subscribe((response) => {
this.lightningResponse = response;
if (this.lightningResponse.status === 'Failed') {
this.openSnackBar(
'Failed to lookup lightning address',
'dismiss'
);
} else if (this.lightningResponse.callback) {
this.openZapDialog();
} else {
this.openSnackBar(
"couldn't find user's lightning address",
'dismiss'
);
}
});
} else {
this.openSnackBar('No lightning address found', 'dismiss');
}
@@ -322,7 +387,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
this._dialog.open(SendDialogComponent, {
width: '405px',
maxHeight: '90vh',
data: this.metadata
data: this.metadata,
});
}
@@ -330,8 +395,95 @@ export class ProfileComponent implements OnInit, OnDestroy {
this._dialog.open(ReceiveDialogComponent, {
width: '405px',
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>
<mat-dialog-content *ngIf="!displayQRCode">
<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>
<span>{{ button.label }}</span>
</button>
@@ -9,12 +22,14 @@
<mat-divider></mat-divider>
<mat-form-field appearance="outline" class="sats-input">
<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-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()">
Generate Invoice
</button>
@@ -25,14 +40,22 @@
<div *ngIf="displayQRCode" class="qrcode">
<span>Scan with phone to pay ({{ invoiceAmount }} sats)</span>
<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">
<button mat-icon-button color="warn" (click)="closeDialog()" [matTooltip]="'Close'">
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon>
</button>
<button mat-icon-button (click)="copyInvoice()" [matTooltip]="'Copy Invoice'">
<mat-icon [svgIcon]="'heroicons_outline:clipboard-document'"></mat-icon>
<button
mat-icon-button
(click)="copyInvoice()"
[matTooltip]="'Copy Invoice'"
>
<mat-icon
[svgIcon]="'heroicons_outline:clipboard-document'"
></mat-icon>
</button>
</mat-dialog-actions>
</div>

View File

@@ -4,9 +4,9 @@
gap: 15px;
justify-items: center;
margin-bottom: 20px;
}
}
.preset-buttons button {
.preset-buttons button {
font-size: 14px;
font-weight: bold;
width: 70px;
@@ -16,24 +16,23 @@
align-items: center;
justify-content: center;
max-height: 60px !important;
}
}
.sats-input {
.sats-input {
margin-top: 20px;
width: 100%;
}
}
.lightning-buttons {
.lightning-buttons {
display: flex;
justify-content: space-evenly;
margin: 10px 0;
}
}
.qrcode {
.qrcode {
text-align: center;
}
}
.qrcode-image {
.qrcode-image {
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 { MatDialogActions, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import { webln } from '@getalby/sdk';
import { NgClass, CommonModule } from '@angular/common';
import { CommonModule, NgClass } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatOption } from '@angular/material/core';
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
} from '@angular/material/dialog';
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 { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { webln } from '@getalby/sdk';
import { QRCodeModule } from 'angularx-qrcode';
import { SettingsIndexerComponent } from 'app/components/settings/indexer/indexer.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';
@Component({
selector: 'app-receive-dialog',
standalone: true,
imports: [
MatSidenavModule,
MatButtonModule,
MatIconModule,
NgClass,
SettingsProfileComponent,
SettingsSecurityComponent,
SettingsNotificationsComponent,
SettingsRelayComponent,
SettingsNetworkComponent,
SettingsIndexerComponent,
FormsModule,
MatOption,
MatLabel,
MatFormField,
ReactiveFormsModule,
CommonModule,
MatSelectModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDialogContent,
MatDialogActions,
QRCodeModule,
MatDivider,
MatTooltip
],
templateUrl: './receive-dialog.component.html',
styleUrls: ['./receive-dialog.component.scss']
selector: 'app-receive-dialog',
standalone: true,
imports: [
MatSidenavModule,
MatButtonModule,
MatIconModule,
NgClass,
SettingsProfileComponent,
SettingsSecurityComponent,
SettingsNotificationsComponent,
SettingsRelayComponent,
SettingsNetworkComponent,
SettingsIndexerComponent,
FormsModule,
MatOption,
MatLabel,
MatFormField,
ReactiveFormsModule,
CommonModule,
MatSelectModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDialogContent,
MatDialogActions,
QRCodeModule,
MatDivider,
MatTooltip,
MatDialogClose,
],
templateUrl: './receive-dialog.component.html',
styleUrls: ['./receive-dialog.component.scss'],
})
export class ReceiveDialogComponent {
invoiceAmount: string = '';
lightningInvoice: string = '';
displayQRCode: boolean = false;
nwc: any;
invoiceAmount: string = '';
lightningInvoice: string = '';
displayQRCode: boolean = false;
nwc: any;
constructor(
private dialogRef: MatDialogRef<ReceiveDialogComponent>,
private snackBar: MatSnackBar,
private clipboard: Clipboard
) {}
constructor(
private dialogRef: MatDialogRef<ReceiveDialogComponent>,
private snackBar: MatSnackBar,
private clipboard: Clipboard
) {}
zapButtons = [
{ icon: 'thumb_up', label: '50', value: 50 },
{ icon: 'favorite', label: '100', value: 100 },
{ icon: 'emoji_emotions', label: '500', value: 500 },
{ icon: 'star', label: '1k', value: 1000 },
{ icon: 'celebration', label: '5k', value: 5000 },
{ icon: 'rocket', label: '10k', value: 10000 },
{ icon: 'local_fire_department', label: '100k', value: 100000 },
{ icon: 'flash_on', label: '500k', value: 500000 },
{ icon: 'diamond', label: '1M', value: 1000000 }
];
zapButtons = [
{ icon: 'thumb_up', label: '50', value: 50 },
{ icon: 'favorite', label: '100', value: 100 },
{ icon: 'emoji_emotions', label: '500', value: 500 },
{ icon: 'star', label: '1k', value: 1000 },
{ icon: 'celebration', label: '5k', value: 5000 },
{ icon: 'rocket', label: '10k', value: 10000 },
{ icon: 'local_fire_department', label: '100k', value: 100000 },
{ icon: 'flash_on', label: '500k', value: 500000 },
{ icon: 'diamond', label: '1M', value: 1000000 },
];
async generateInvoice(): Promise<void> {
if (!this.invoiceAmount || Number(this.invoiceAmount) <= 0) {
this.openSnackBar('Please enter a valid amount', 'dismiss');
return;
async generateInvoice(): Promise<void> {
if (!this.invoiceAmount || Number(this.invoiceAmount) <= 0) {
this.openSnackBar('Please enter a valid amount', 'dismiss');
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 {
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');
async loadNWCUrl(): Promise<string> {
try {
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');
}
}
}
async loadNWCUrl(): Promise<string> {
try {
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 {
this.displayQRCode = !this.displayQRCode;
}
}
showQRCode(): void {
this.displayQRCode = !this.displayQRCode;
}
copyInvoice(): void {
if (this.lightningInvoice) {
this.clipboard.copy(this.lightningInvoice);
this.openSnackBar('Invoice copied', 'dismiss');
} else {
this.openSnackBar('No invoice available to copy', 'dismiss');
copyInvoice(): void {
if (this.lightningInvoice) {
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 {
this.snackBar.open(message, action, { duration: 1300 });
}
openSnackBar(message: string, action: string): void {
this.snackBar.open(message, action, { duration: 1300 });
}
closeDialog(): void {
this.dialogRef.close();
}
closeDialog(): void {
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>
<mat-dialog-content *ngIf="!showInvoiceSection || !lightningInvoice">
<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>
<span>{{ button.label }}</span>
</button>
@@ -9,13 +22,15 @@
<mat-divider></mat-divider>
<mat-form-field appearance="outline" class="sats-input">
<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-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()">
Create invoice
</button>
@@ -26,20 +41,30 @@
<div *ngIf="displayQRCode" class="qrcode">
<span>Scan with phone to pay ({{ invoiceAmount }} sats)</span>
<mat-divider></mat-divider>
<qrcode [qrdata]="lightningInvoice" [matTooltip]="'Lightning Invoice'" [errorCorrectionLevel]="'M'"
class="qrcode-image"></qrcode>
<qrcode
[qrdata]="lightningInvoice"
[matTooltip]="'Lightning Invoice'"
[errorCorrectionLevel]="'M'"
class="qrcode-image"
></qrcode>
</div>
<mat-dialog-actions align="center">
<button mat-icon-button color="warn" (click)="closeDialog()" [matTooltip]="'Close'">
<mat-icon [svgIcon]="'heroicons_solid:x-mark'"></mat-icon>
<button
mat-icon-button
(click)="copyInvoice()"
[matTooltip]="'Copy Invoice'"
>
<mat-icon
[svgIcon]="'heroicons_outline:clipboard-document'"
></mat-icon>
</button>
<button mat-icon-button (click)="copyInvoice()" [matTooltip]="'Copy Invoice'">
<mat-icon [svgIcon]="'heroicons_outline:clipboard-document'"></mat-icon>
</button>
<button mat-icon-button (click)="payInvoice()" [matTooltip]="'Pay Invoice'">
<button
mat-icon-button
(click)="payInvoice()"
[matTooltip]="'Pay Invoice'"
>
<mat-icon color="#f79318" [svgIcon]="'feather:zap'"></mat-icon>
</button>
</mat-dialog-actions>

View File

@@ -4,9 +4,9 @@
gap: 15px;
justify-items: center;
margin-bottom: 20px;
}
}
.preset-buttons button {
.preset-buttons button {
font-size: 14px;
font-weight: bold;
width: 70px;
@@ -16,24 +16,23 @@
align-items: center;
justify-content: center;
max-height: 60px !important;
}
}
.sats-input {
.sats-input {
margin-top: 20px;
width: 100%;
}
}
.lightning-buttons {
.lightning-buttons {
display: flex;
justify-content: space-evenly;
margin: 10px 0;
}
}
.qrcode {
.qrcode {
text-align: center;
}
}
.qrcode-image {
.qrcode-image {
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 { webln } from '@getalby/sdk';
import { decode } from '@gandlaf21/bolt11-decode';
import { bech32 } from '@scure/base';
import { NgClass, CommonModule } from '@angular/common';
import { CommonModule, NgClass } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
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 { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
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 { SettingsNetworkComponent } from 'app/components/settings/network/network.component';
import { SettingsNotificationsComponent } from 'app/components/settings/notifications/notifications.component';
import { SettingsProfileComponent } from 'app/components/settings/profile/profile.component';
import { SettingsRelayComponent } from 'app/components/settings/relay/relay.component';
import { SettingsSecurityComponent } from 'app/components/settings/security/security.component';
import { QRCodeModule } from 'angularx-qrcode';
import { MatDivider } from '@angular/material/divider';
import { MatTooltip } from '@angular/material/tooltip';
import { LightningService } from 'app/services/lightning.service';
@Component({
selector: 'app-send-dialog',
@@ -54,10 +65,11 @@ import { MatTooltip } from '@angular/material/tooltip';
QRCodeModule,
MatDivider,
MatTooltip,
MatDialogTitle
MatDialogTitle,
MatDialogClose,
],
templateUrl: './send-dialog.component.html',
styleUrls: ['./send-dialog.component.scss']
styleUrls: ['./send-dialog.component.scss'],
})
export class SendDialogComponent {
sats: string;
@@ -66,7 +78,7 @@ export class SendDialogComponent {
showInvoiceSection: boolean = false;
displayQRCode: boolean = false;
invoiceAmount: string = '?';
nwc :any;
nwc: any;
constructor(
private dialogRef: MatDialogRef<SendDialogComponent>,
@Inject(MAT_DIALOG_DATA) public metadata: any,
@@ -86,7 +98,7 @@ export class SendDialogComponent {
{ icon: 'rocket', label: '10k', value: 10000 },
{ icon: 'local_fire_department', label: '100k', value: 100000 },
{ icon: 'flash_on', label: '500k', value: 500000 },
{ icon: 'diamond', label: '1M', value: 1000000 }
{ icon: 'diamond', label: '1M', value: 1000000 },
];
getLightningInfo(): void {
@@ -100,20 +112,30 @@ export class SendDialogComponent {
const data = new Uint8Array(bech32.fromWords(words));
lightningAddress = new TextDecoder().decode(Uint8Array.from(data));
} else if (this.metadata?.lud16) {
lightningAddress = this.lightning.getLightningAddress(this.metadata.lud16);
lightningAddress = this.lightning.getLightningAddress(
this.metadata.lud16
);
}
if (lightningAddress !== '') {
this.lightning.getLightning(lightningAddress).subscribe((response) => {
this.lightningResponse = response;
if (this.lightningResponse.status === 'Failed') {
this.openSnackBar('Failed to lookup lightning address', 'dismiss');
} else if (this.lightningResponse.callback) {
this.showInvoiceSection = true;
} else {
this.openSnackBar("Couldn't find user's lightning address", 'dismiss');
}
});
this.lightning
.getLightning(lightningAddress)
.subscribe((response) => {
this.lightningResponse = response;
if (this.lightningResponse.status === 'Failed') {
this.openSnackBar(
'Failed to lookup lightning address',
'dismiss'
);
} else if (this.lightningResponse.callback) {
this.showInvoiceSection = true;
} else {
this.openSnackBar(
"Couldn't find user's lightning address",
'dismiss'
);
}
});
} else {
this.openSnackBar('No lightning address found', 'dismiss');
}
@@ -121,8 +143,9 @@ export class SendDialogComponent {
getLightningInvoice(amount: string): void {
if (this.lightningResponse && this.lightningResponse.callback) {
this.lightning.getLightningInvoice(this.lightningResponse.callback, amount)
.subscribe(async response => {
this.lightning
.getLightningInvoice(this.lightningResponse.callback, amount)
.subscribe(async (response) => {
this.lightningInvoice = response.pr;
this.setInvoiceAmount(this.lightningInvoice);
this.showInvoiceSection = true;
@@ -134,7 +157,9 @@ export class SendDialogComponent {
setInvoiceAmount(invoice: string): void {
if (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) {
this.invoiceAmount = String(Number(amountSection.value) / 1000);
}
@@ -149,29 +174,32 @@ export class SendDialogComponent {
this.getLightningInvoice(String(Number(this.sats) * 1000));
}
async payInvoice(): Promise<void> {
if (!this.lightningInvoice) {
console.error('Lightning invoice is not set');
return;
}
const nwc = new webln.NostrWebLNProvider({ nostrWalletConnectUrl: await this.loadNWCUrl() });
const nwc = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: await this.loadNWCUrl(),
});
nwc.enable()
.then(() => {
return nwc.sendPayment(this.lightningInvoice);
})
.then(response => {
.then((response) => {
if (response && response.preimage) {
console.log(`Payment successful, preimage: ${response.preimage}`);
console.log(
`Payment successful, preimage: ${response.preimage}`
);
this.openSnackBar('Zapped!', 'dismiss');
this.dialogRef.close();
} else {
this.listenForPaymentStatus(nwc);
}
})
.catch(error => {
.catch((error) => {
console.error('Payment failed:', error);
this.openSnackBar('Failed to pay invoice', 'dismiss');
this.listenForPaymentStatus(nwc);
@@ -179,13 +207,14 @@ export class SendDialogComponent {
}
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(() => {
return nwc.getNostrWalletConnectUrl();
})
.catch(error => {
.catch((error) => {
console.error('Error initializing NWC:', error);
throw error;
});
@@ -194,16 +223,19 @@ export class SendDialogComponent {
listenForPaymentStatus(nwc): void {
const checkPaymentStatus = () => {
nwc.sendPayment(this.lightningInvoice)
.then(response => {
.then((response) => {
if (response && response.preimage) {
console.log('Payment confirmed, preimage:', response.preimage);
console.log(
'Payment confirmed, preimage:',
response.preimage
);
this.openSnackBar('Payment confirmed!', 'dismiss');
this.dialogRef.close();
} else {
setTimeout(checkPaymentStatus, 5000);
}
})
.catch(error => {
.catch((error) => {
console.error('Error checking payment status:', error);
setTimeout(checkPaymentStatus, 5000);
});
@@ -228,5 +260,4 @@ export class SendDialogComponent {
closeDialog(): void {
this.dialogRef.close();
}
}

View File

@@ -1,12 +1,23 @@
<div class="w-full max-w-3xl">
<!-- Add Mainnet Indexer -->
<div class="w-full mb-8">
<div class="mb-8 w-full">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>Add Mainnet Indexer</mat-label>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:link'" matPrefix></mat-icon>
<input matInput [(ngModel)]="newMainnetIndexerUrl" placeholder="Mainnet Indexer URL" />
<mat-icon
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')">
<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>
</mat-form-field>
</div>
@@ -15,22 +26,43 @@
<div class="mt-8">
<h3>Mainnet Indexers</h3>
<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="ml-4">
<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 class="mt-4 flex items-center sm:ml-auto sm:mt-0">
<button mat-icon-button (click)="setPrimaryIndexer('mainnet', indexer)">
<mat-icon *ngIf="indexer.primary; else nonPrimaryIcon" class="text-primary" [svgIcon]="'heroicons_solid:check-circle'"></mat-icon>
<button
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>
<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>
</button>
<button mat-icon-button (click)="removeIndexer('mainnet', indexer)">
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
<button
mat-icon-button
(click)="removeIndexer('mainnet', indexer)"
>
<mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:trash'"
></mat-icon>
</button>
</div>
</div>
@@ -38,13 +70,24 @@
</div>
<!-- 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-label>Add Testnet Indexer</mat-label>
<mat-icon class="icon-size-5" [svgIcon]="'heroicons_solid:link'" matPrefix></mat-icon>
<input matInput [(ngModel)]="newTestnetIndexerUrl" placeholder="Testnet Indexer URL" />
<mat-icon
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')">
<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>
</mat-form-field>
</div>
@@ -53,22 +96,43 @@
<div class="mt-8">
<h3>Testnet Indexers</h3>
<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="ml-4">
<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 class="mt-4 flex items-center sm:ml-auto sm:mt-0">
<button mat-icon-button (click)="setPrimaryIndexer('testnet', indexer)">
<mat-icon *ngIf="indexer.primary; else nonPrimaryIcon" class="text-primary" [svgIcon]="'heroicons_solid:check-circle'"></mat-icon>
<button
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>
<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>
</button>
<button mat-icon-button (click)="removeIndexer('testnet', indexer)">
<mat-icon class="text-hint" [svgIcon]="'heroicons_outline:trash'"></mat-icon>
<button
mat-icon-button
(click)="removeIndexer('testnet', indexer)"
>
<mat-icon
class="text-hint"
[svgIcon]="'heroicons_outline:trash'"
></mat-icon>
</button>
</div>
</div>

View File

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

View File

@@ -1,7 +1,12 @@
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 { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import {
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -12,49 +17,52 @@ import { MatSelectModule } from '@angular/material/select';
import { IndexerService } from 'app/services/indexer.service';
@Component({
selector: 'settings-network',
templateUrl: './network.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
AngorAlertComponent,
MatRadioModule,
NgClass,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatOptionModule,
MatButtonModule,
CurrencyPipe,
CommonModule
],
selector: 'settings-network',
templateUrl: './network.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
AngorAlertComponent,
MatRadioModule,
NgClass,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatOptionModule,
MatButtonModule,
CurrencyPipe,
CommonModule,
],
})
export class SettingsNetworkComponent implements OnInit {
networkForm: FormGroup;
selectedNetwork: 'mainnet' | 'testnet' = 'testnet';
constructor(private fb: FormBuilder, private indexerService: IndexerService) {}
constructor(
private fb: FormBuilder,
private indexerService: IndexerService
) {}
ngOnInit(): void {
this.networkForm = this.fb.group({
network: [this.indexerService.getNetwork()]
});
this.networkForm = this.fb.group({
network: [this.indexerService.getNetwork()],
});
this.selectedNetwork = this.indexerService.getNetwork();
this.selectedNetwork = this.indexerService.getNetwork();
}
setNetwork(network: 'mainnet' | 'testnet'): void {
this.selectedNetwork = network;
this.indexerService.setNetwork(this.selectedNetwork);
this.selectedNetwork = network;
this.indexerService.setNetwork(this.selectedNetwork);
}
save(): void {
this.indexerService.setNetwork(this.selectedNetwork);
}
this.indexerService.setNetwork(this.selectedNetwork);
}
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">
<!-- Mention -->
<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="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>
<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>
<!-- Private Message -->
<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="text-secondary text-md">Receive notifications for private messages.</div>
<div class="text-secondary text-md">
Receive notifications for private messages.
</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>
<!-- Zap -->
<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="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>
<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>
<!-- New Follower -->
<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="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>
<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>
@@ -45,7 +85,15 @@
<!-- Actions -->
<div class="flex items-center justify-end">
<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>
</form>
</div>

View File

@@ -42,7 +42,9 @@ export class SettingsNotificationsComponent implements OnInit {
this.notificationsForm = this._formBuilder.group({
mention: [savedSettings.includes(this.notificationKinds.mention)],
privateMessage: [savedSettings.includes(this.notificationKinds.privateMessage)],
privateMessage: [
savedSettings.includes(this.notificationKinds.privateMessage),
],
zap: [savedSettings.includes(this.notificationKinds.zap)],
follower: [savedSettings.includes(this.notificationKinds.follower)],
});
@@ -75,6 +77,6 @@ export class SettingsNotificationsComponent implements OnInit {
private loadNotificationSettings(): number[] {
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()">
<!-- Section -->
<div class="w-full">
<div class="text-secondary">
<div class="text-secondary">
Following information is publicly displayed, be careful!
</div>
</div>
@@ -13,7 +13,11 @@
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<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 />
</mat-form-field>
</div>
@@ -46,10 +50,16 @@
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>About</mat-label>
<textarea matInput [formControlName]="'about'" cdkTextareaAutosize [cdkAutosizeMinRows]="5"></textarea>
<textarea
matInput
[formControlName]="'about'"
cdkTextareaAutosize
[cdkAutosizeMinRows]="5"
></textarea>
</mat-form-field>
<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>
@@ -69,45 +79,51 @@
</mat-form-field>
</div>
<!-- LUD06 -->
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>LUD06</mat-label>
<input [formControlName]="'lud06'" matInput />
<mat-hint>
LUD06 is an LNURL (Lightning Network URL) for receiving Bitcoin payments over the Lightning Network.
</mat-hint>
</mat-form-field>
</div>
<!-- LUD06 -->
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>LUD06</mat-label>
<input [formControlName]="'lud06'" matInput />
<mat-hint>
LUD06 is an LNURL (Lightning Network URL) for receiving
Bitcoin payments over the Lightning Network.
</mat-hint>
</mat-form-field>
</div>
<!-- LUD16 -->
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>LUD16</mat-label>
<input [formControlName]="'lud16'" matInput />
<mat-hint>
LUD16 is a Lightning address, similar to an email format, used to receive Bitcoin payments via the Lightning Network.
</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>
<!-- LUD16 -->
<div class="sm:col-span-4">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<mat-label>LUD16</mat-label>
<input [formControlName]="'lud16'" matInput />
<mat-hint>
LUD16 is a Lightning address, similar to an email
format, used to receive Bitcoin payments via the
Lightning Network.
</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>
<!-- 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 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>
</form>
</div>

View File

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

View File

@@ -3,42 +3,72 @@
<div class="w-full">
<mat-form-field class="w-full" [subscriptSizing]="'dynamic'">
<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" />
<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>
</mat-form-field>
</div>
<!-- Relays -->
<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 h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full">
<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
class="flex h-10 w-10 flex-0 items-center justify-center overflow-hidden rounded-full"
>
<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 class="ml-4">
<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 class="mt-4 flex items-center sm:ml-auto sm:mt-0">
<mat-form-field class="angor-mat-dense w-50" [subscriptSizing]="'dynamic'">
<mat-select [(ngModel)]="relay.accessType" (selectionChange)="updateRelayAccess(relay)">
<mat-form-field
class="angor-mat-dense w-50"
[subscriptSizing]="'dynamic'"
>
<mat-select
[(ngModel)]="relay.accessType"
(selectionChange)="updateRelayAccess(relay)"
>
<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-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>
</mat-option>
</mat-select>
</mat-form-field>
<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>
</div>
</div>

View File

@@ -1,11 +1,11 @@
import { CommonModule, TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
NgZone,
OnInit,
ViewEncapsulation,
ChangeDetectorRef,
NgZone,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@@ -52,7 +52,7 @@ export class SettingsRelayComponent implements OnInit {
ngOnInit(): void {
// Subscribe to relays observable
this.subscriptions.add(
this.relayService.getRelays().subscribe(relays => {
this.relayService.getRelays().subscribe((relays) => {
this.zone.run(() => {
this.relays = relays;
this.cdr.markForCheck(); // Mark the component for check
@@ -65,17 +65,20 @@ export class SettingsRelayComponent implements OnInit {
{
label: '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',
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',
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 {
let safeUrl = url.replace('wss://', 'https://').replace('ws://', 'https://');
let safeUrl = url
.replace('wss://', 'https://')
.replace('ws://', 'https://');
return safeUrl + '/favicon.ico';
}
}
getSafeUrl(url: string): SafeUrl {
getSafeUrl(url: string): SafeUrl {
return this.sanitizer.bypassSecurityTrustUrl(url);
}
}
}

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