Feat/community marketplace (#1790)

* add community marketplace

* Update embassy-mock-api.service.ts

* expect ui/marketplace to be undefined

* possible undefined from getpackage

* fix marketplace pages

* rework marketplace infrastructure

* fix bugs

Co-authored-by: Lucy C <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Matt Hill
2022-10-06 17:27:17 -06:00
committed by Aiden McClelland
parent e2db3d84d8
commit 9998ed177b
55 changed files with 754 additions and 879 deletions

View File

@@ -161,10 +161,7 @@ export class AppActionsPage {
try {
await this.embassyApi.uninstallPackage({ id: this.pkgId })
this.embassyApi
.setDbValue({
pointer: `/ack-instructions/${this.pkgId}`,
value: false,
})
.setDbValue(['ack-instructions', this.pkgId], false)
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navCtrl.navigateRoot('/services')
} catch (e: any) {

View File

@@ -8,15 +8,10 @@
</ion-header>
<ion-content class="ion-padding">
<!-- loading -->
<ng-container *ngIf="loading else loaded">
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-container>
<!-- loaded -->
<ng-template #loaded>
<ng-container *ngIf="pkgs$ | async as pkgs; else loading">
<app-list-empty
*ngIf="empty; else list"
*ngIf="!pkgs.length; else list"
class="ion-text-center ion-padding"
></app-list-empty>
@@ -36,5 +31,10 @@
</ion-row>
</ion-grid>
</ng-template>
</ng-container>
<!-- loading -->
<ng-template #loading>
<text-spinner text="Connecting to Embassy"></text-spinner>
</ng-template>
</ion-content>

View File

@@ -1,46 +1,27 @@
import { Component } from '@angular/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { filter, takeUntil, tap } from 'rxjs/operators'
import { DestroyService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { filter, map, pairwise, startWith } from 'rxjs/operators'
@Component({
selector: 'app-list',
templateUrl: './app-list.page.html',
styleUrls: ['./app-list.page.scss'],
providers: [DestroyService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListPage {
loading = true
pkgs: readonly PackageDataEntry[] = []
readonly pkgs$ = this.patch.watch$('package-data').pipe(
map(pkgs => Object.values(pkgs)),
startWith([]),
pairwise(),
filter(([prev, next]) => {
const length = next.length
return !length || prev.length !== length
}),
map(([_, pkgs]) => {
return pkgs.sort((a, b) => (b.manifest.title > a.manifest.title ? -1 : 1))
}),
)
constructor(
private readonly api: ApiService,
private readonly destroy$: DestroyService,
private readonly patch: PatchDB<DataModel>,
) {}
get empty(): boolean {
return !this.pkgs.length
}
ngOnInit() {
this.patch
.watch$('package-data')
.pipe(
filter(pkgs => Object.keys(pkgs).length !== this.pkgs.length),
tap(pkgs => {
this.loading = false
this.pkgs = Object.values(pkgs).sort((a, b) =>
b.manifest.title > a.manifest.title ? -1 : 1,
)
}),
takeUntil(this.destroy$),
)
.subscribe()
}
constructor(private readonly patch: PatchDB<DataModel>) {}
}

View File

@@ -20,7 +20,6 @@ import { ToButtonsPipe } from './pipes/to-buttons.pipe'
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
import { ToStatusPipe } from './pipes/to-status.pipe'
import { ProgressDataPipe } from './pipes/progress-data.pipe'
import { ActionMarketplaceComponentModule } from 'src/app/modals/action-marketplace/action-marketplace.component.module'
const routes: Routes = [
{
@@ -54,7 +53,6 @@ const routes: Routes = [
EmverPipesModule,
LaunchablePipeModule,
UiPipeModule,
ActionMarketplaceComponentModule,
],
})
export class AppShowPageModule {}

View File

@@ -22,9 +22,7 @@
[dependencies]="dependencies"
></app-show-dependencies>
<!-- ** menu ** -->
<app-show-menu
[buttons]="pkg | toButtons: (currentMarketplace$ | async): (altMarketplaceData$ | async)"
></app-show-menu>
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
</ng-container>
</ion-item-group>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { PatchDB } from 'patch-db-client'
import {
@@ -14,8 +14,6 @@ import {
import { filter, tap } from 'rxjs/operators'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
const STATES = [
PackageState.Installing,
@@ -49,16 +47,10 @@ export class AppShowPage {
),
)
readonly currentMarketplace$ = this.marketplaceService.getMarketplace()
readonly altMarketplaceData$ = this.marketplaceService.getAltMarketplaceData()
constructor(
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {}
isInstalled({ state }: PackageDataEntry): boolean {

View File

@@ -2,17 +2,14 @@ import { Inject, Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { DOCUMENT } from '@angular/common'
import { AlertController, ModalController, NavController } from '@ionic/angular'
import { getUrlHostname, MarkdownComponent } from '@start9labs/shared'
import { MarkdownComponent } from '@start9labs/shared'
import {
DataModel,
PackageDataEntry,
UIMarketplaceData,
} from 'src/app/services/patch-db/data-model'
import { ModalService } from 'src/app/services/modal.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable } from 'rxjs'
import { Marketplace } from '@start9labs/marketplace'
import { ActionMarketplaceComponent } from 'src/app/modals/action-marketplace/action-marketplace.component'
import { PatchDB } from 'patch-db-client'
export interface Button {
@@ -39,11 +36,7 @@ export class ToButtonsPipe implements PipeTransform {
private readonly patch: PatchDB<DataModel>,
) {}
transform(
pkg: PackageDataEntry,
currentMarketplace: Marketplace | null,
altMarketplaces: UIMarketplaceData | null | undefined,
): Button[] {
transform(pkg: PackageDataEntry): Button[] {
const pkgTitle = pkg.manifest.title
return [
@@ -103,7 +96,7 @@ export class ToButtonsPipe implements PipeTransform {
icon: 'receipt-outline',
},
// view in marketplace
this.viewInMarketplaceButton(pkg, currentMarketplace, altMarketplaces),
this.viewInMarketplaceButton(pkg),
// donate
{
action: () => this.donate(pkg),
@@ -116,10 +109,7 @@ export class ToButtonsPipe implements PipeTransform {
private async presentModalInstructions(pkg: PackageDataEntry) {
this.apiService
.setDbValue({
pointer: `/ack-instructions/${pkg.manifest.id}`,
value: true,
})
.setDbValue(['ack-instructions', pkg.manifest.id], true)
.catch(e => console.error('Failed to mark instructions as seen', e))
const modal = await this.modalCtrl.create({
@@ -135,51 +125,27 @@ export class ToButtonsPipe implements PipeTransform {
await modal.present()
}
private viewInMarketplaceButton(
pkg: PackageDataEntry,
currentMarketplace: Marketplace | null,
altMarketplaces: UIMarketplaceData | null | undefined,
): Button {
const pkgMarketplaceUrl = pkg.installed?.['marketplace-url']
// default button if package marketplace and current marketplace are the same
private viewInMarketplaceButton(pkg: PackageDataEntry): Button {
const url = pkg.installed?.['marketplace-url']
const queryParams = url ? { url } : {}
let button: Button = {
title: 'Marketplace',
icon: 'storefront-outline',
action: () =>
this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`]),
this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`], {
queryParams,
}),
disabled: false,
description: 'View service in marketplace',
}
if (!pkgMarketplaceUrl) {
if (!url) {
button.disabled = true
button.description = 'This package was not installed from a marketplace.'
button.action = () => {}
} else if (
pkgMarketplaceUrl &&
currentMarketplace &&
getUrlHostname(pkgMarketplaceUrl) !==
getUrlHostname(currentMarketplace.url)
) {
// attempt to get name for pkg marketplace
let pkgMarketplaceName = getUrlHostname(pkgMarketplaceUrl)
if (altMarketplaces) {
const pkgMarketplaces = Object.values(
altMarketplaces['known-hosts'],
).filter(m => getUrlHostname(m.url) === pkgMarketplaceName)
if (pkgMarketplaces.length) {
// if multiple of the same url exist, they will have the same name, so fine to grab first
pkgMarketplaceName = pkgMarketplaces[0].name
}
}
button.action = () =>
this.differentMarketplaceAction(
pkgMarketplaceName,
currentMarketplace.name,
pkg.manifest.id,
)
button.description = 'Service was installed from a different marketplace'
}
return button
}
@@ -195,22 +161,4 @@ export class ToButtonsPipe implements PipeTransform {
await alert.present()
}
}
private async differentMarketplaceAction(
packageMarketplace: string,
currentMarketplace: string,
pkgId: string,
) {
const modal = await this.modalCtrl.create({
component: ActionMarketplaceComponent,
componentProps: {
title: 'Marketplace Conflict',
packageMarketplace,
currentMarketplace,
pkgId,
},
cssClass: 'medium-modal',
})
await modal.present()
}
}

View File

@@ -5,8 +5,8 @@ import {
PackageDataEntry,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import { exists, isEmptyObject } from '@start9labs/shared'
import { filter, map, startWith } from 'rxjs/operators'
import { isEmptyObject } from '@start9labs/shared'
import { map, startWith } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { Observable } from 'rxjs'
@@ -27,7 +27,6 @@ export class ToHealthChecksPipe implements PipeTransform {
const healthChecks$ = this.patch
.watch$('package-data', pkg.manifest.id, 'installed', 'status', 'main')
.pipe(
filter(obj => exists(obj)),
map(main => {
// Question: is this ok or do we have to use Object.keys
// to maintain order and the keys initially present in pkg?

View File

@@ -69,10 +69,7 @@ export class DevConfigPage {
async save() {
this.saving = true
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/config`,
value: this.code,
})
await this.api.setDbValue(['dev', this.projectId, 'config'], this.code)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -56,10 +56,10 @@ export class DevInstructionsPage {
async save() {
this.saving = true
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/instructions`,
value: this.code,
})
await this.api.setDbValue(
['dev', this.projectId, 'instructions'],
this.code,
)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -148,7 +148,7 @@ export class DeveloperListPage {
.replace(/warning:/g, '# Optional\n warning:')
const def = { name, config, instructions: SAMPLE_INSTUCTIONS }
await this.api.setDbValue({ pointer: `/dev/${id}`, value: def })
await this.api.setDbValue(['dev', id], def)
} catch (e: any) {
this.errToast.present(e)
} finally {
@@ -184,7 +184,7 @@ export class DeveloperListPage {
await loader.present()
try {
await this.api.setDbValue({ pointer: `/dev/${id}/name`, value: newName })
await this.api.setDbValue(['dev', id, 'name'], newName)
} catch (e: any) {
this.errToast.present(e)
} finally {
@@ -201,7 +201,7 @@ export class DeveloperListPage {
try {
const devDataToSave: DevData = JSON.parse(JSON.stringify(this.devData))
delete devDataToSave[id]
await this.api.setDbValue({ pointer: `/dev`, value: devDataToSave })
await this.api.setDbValue(['dev'], devDataToSave)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -55,10 +55,10 @@ export class DeveloperMenuPage {
await loader.present()
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/basic-info`,
value: basicInfo,
})
await this.api.setDbValue(
['dev', this.projectId, 'basic-info'],
basicInfo,
)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -1,5 +1,6 @@
<ion-header>
<ion-toolbar>
<ion-title>Marketplace</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
@@ -28,14 +29,13 @@
</ion-row>
<ion-row class="ion-align-items-center">
<ion-col size="12">
<ng-container *ngIf="pkgs$ | async as pkgs; else loading">
<ng-container *ngIf="marketplace$ | async as marketplace; else loading">
<ng-container *ngIf="localPkgs$ | async as localPkgs">
<marketplace-categories
*ngIf="categories$ | async as categories"
[categories]="categories"
[categories]="marketplace.categories"
[category]="category"
[updatesAvailable]="
(pkgs | filterPackages: '':'updates':localPkgs).length
(marketplace.pkgs | filterPackages: '':'updates':localPkgs).length
"
(categoryChange)="onCategoryChange($event)"
></marketplace-categories>
@@ -43,7 +43,7 @@
<div class="divider"></div>
<ion-grid
*ngIf="pkgs | filterPackages: query:category:localPkgs as filtered"
*ngIf="marketplace.pkgs | filterPackages: query:category:localPkgs as filtered"
>
<div
*ngIf="!filtered.length && category === 'updates'"

View File

@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { getUrlHostname } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { combineLatest, map } from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
@@ -12,25 +12,45 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceListPage {
private readonly pkgs$ = this.marketplaceService.getPackages$()
private readonly categories$ = this.marketplaceService
.getMarketplaceInfo$()
.pipe(
map(({ categories }) => {
const set = new Set<string>()
if (categories.includes('featured')) set.add('featured')
set.add('updates')
categories.forEach(c => set.add(c))
set.add('all')
return set
}),
)
readonly marketplace$ = combineLatest([this.pkgs$, this.categories$]).pipe(
map(arr => {
return { pkgs: arr[0], categories: arr[1] }
}),
)
readonly localPkgs$ = this.patch.watch$('package-data')
readonly categories$ = this.marketplaceService.getCategories()
readonly pkgs$ = this.marketplaceService.getPackages()
readonly details$ = this.marketplaceService.getMarketplace().pipe(
map(d => {
readonly details$ = this.marketplaceService.getUiMarketplace$().pipe(
map(({ url, name }) => {
let color: string
let description: string
switch (getUrlHostname(d.url)) {
case 'registry.start9.com':
switch (url) {
case 'https://registry.start9.com/':
color = 'success'
description =
'Services in this marketplace are packaged and maintained by the Start9 team. If you experience an issue or have a questions related to a service in this marketplace, one of our dedicated support staff will be happy to assist you.'
break
case 'beta-registry-0-3.start9labs.com':
case 'https://beta-registry-0-3.start9labs.com/':
color = 'primary'
description =
'Services in this marketplace are undergoing active testing and may contain bugs. <b>Install at your own risk</b>. If you discover a bug or have a suggestion for improvement, please report it to the Start9 team in our community testing channel on Matrix.'
break
case 'community.start9labs.com':
case 'https://community.start9labs.com/':
color = 'tertiary'
description =
'Services in this marketplace are packaged and maintained by members of the Start9 community. <b>Install at your own risk</b>. If you experience an issue or have a question related to a service in this marketplace, please reach out to the package developer for assistance.'
@@ -43,7 +63,8 @@ export class MarketplaceListPage {
}
return {
...d,
name,
url,
color,
description,
}
@@ -52,7 +73,8 @@ export class MarketplaceListPage {
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly marketplaceService: AbstractMarketplaceService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {}
category = 'featured'

View File

@@ -31,6 +31,9 @@ import { firstValueFrom } from 'rxjs'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceShowControlsComponent {
@Input()
url?: string
@Input()
pkg!: MarketplacePkg
@@ -58,22 +61,81 @@ export class MarketplaceShowControlsComponent {
}
async tryInstall() {
const currentMarketplace = await firstValueFrom(
this.marketplaceService.getUiMarketplace$(),
)
const url = this.url || currentMarketplace.url
if (!this.localPkg) {
this.alertInstall()
this.alertInstall(url)
} else {
const originalUrl = this.localPkg.installed?.['marketplace-url']
if (url !== originalUrl) {
const proceed = await this.presentAlertDifferentMarketplace(
url,
originalUrl,
)
if (!proceed) return
}
if (
this.emver.compare(this.localVersion, this.pkg.manifest.version) !==
0 &&
hasCurrentDeps(this.localPkg)
) {
this.dryInstall()
this.dryInstall(url)
} else {
this.install()
this.install(url)
}
}
}
private async dryInstall() {
private async presentAlertDifferentMarketplace(
url: string,
originalUrl: string | null | undefined,
): Promise<boolean> {
const marketplaces = await firstValueFrom(
this.patch.watch$('ui', 'marketplace'),
)
const name = marketplaces['known-hosts'][url] || url
let originalName: string | undefined
if (originalUrl) {
originalName = marketplaces['known-hosts'][originalUrl] || originalUrl
}
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message: `This service was originally ${
originalName ? 'installed from ' + originalName : 'side loaded'
}, but you are currently connected to ${name}. To install from ${name} anyway, click "Continue".`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
})
}
private async dryInstall(url: string) {
const loader = await this.loadingCtrl.create({
message: 'Checking dependent services...',
})
@@ -88,12 +150,12 @@ export class MarketplaceShowControlsComponent {
})
if (isEmptyObject(breakages)) {
this.install(loader)
this.install(url, loader)
} else {
await loader.dismiss()
const proceed = await this.presentAlertBreakages(breakages)
if (proceed) {
this.install()
this.install(url)
}
}
} catch (e: any) {
@@ -101,10 +163,10 @@ export class MarketplaceShowControlsComponent {
}
}
private async alertInstall() {
private async alertInstall(url: string) {
const installAlert = this.pkg.manifest.alerts.install
if (!installAlert) return this.install()
if (!installAlert) return this.install(url)
const alert = await this.alertCtrl.create({
header: 'Alert',
@@ -117,7 +179,7 @@ export class MarketplaceShowControlsComponent {
{
text: 'Install',
handler: () => {
this.install()
this.install(url)
},
cssClass: 'enter-click',
},
@@ -126,7 +188,7 @@ export class MarketplaceShowControlsComponent {
await alert.present()
}
private async install(loader?: HTMLIonLoadingElement) {
private async install(url: string, loader?: HTMLIonLoadingElement) {
const message = 'Beginning Install...'
if (loader) {
loader.message = message
@@ -138,12 +200,7 @@ export class MarketplaceShowControlsComponent {
const { id, version } = this.pkg.manifest
try {
await firstValueFrom(
this.marketplaceService.installPackage({
id,
'version-spec': `=${version}`,
}),
)
await this.marketplaceService.installPackage(id, version, url)
} catch (e: any) {
this.errToast.present(e)
} finally {

View File

@@ -5,6 +5,7 @@
<ng-container *ngIf="!(pkg | empty)">
<marketplace-package [pkg]="pkg"></marketplace-package>
<marketplace-show-controls
[url]="url"
[pkg]="pkg"
[localPkg]="localPkg$ | async"
></marketplace-show-controls>

View File

@@ -1,13 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ErrorToastService, getPkgId } from '@start9labs/shared'
import {
MarketplacePkg,
AbstractMarketplaceService,
} from '@start9labs/marketplace'
import { getPkgId } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject, Observable, of } from 'rxjs'
import { catchError, filter, shareReplay, switchMap } from 'rxjs/operators'
import { BehaviorSubject } from 'rxjs'
import { filter, shareReplay, switchMap } from 'rxjs/operators'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
@@ -18,6 +15,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
})
export class MarketplaceShowPage {
private readonly pkgId = getPkgId(this.route)
readonly url = this.route.snapshot.queryParamMap.get('url') || undefined
readonly loadVersion$ = new BehaviorSubject<string>('*')
@@ -25,26 +23,15 @@ export class MarketplaceShowPage {
.watch$('package-data', this.pkgId)
.pipe(filter(Boolean), shareReplay({ bufferSize: 1, refCount: true }))
readonly pkg$: Observable<MarketplacePkg | null> = this.loadVersion$.pipe(
readonly pkg$ = this.loadVersion$.pipe(
switchMap(version =>
this.marketplaceService.getPackage(this.pkgId, version),
this.marketplaceService.getPackage(this.pkgId, version, this.url),
),
// TODO: Better fallback
catchError(e => {
this.errToast.present(e)
return of({} as MarketplacePkg)
}),
)
constructor(
private readonly route: ActivatedRoute,
private readonly errToast: ErrorToastService,
private readonly patch: PatchDB<DataModel>,
private readonly marketplaceService: AbstractMarketplaceService,
) {}
getIcon(icon: string): string {
return `data:image/png;base64,${icon}`
}
}

View File

@@ -8,8 +8,34 @@
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group>
<ion-item-divider>Saved Marketplaces</ion-item-divider>
<ion-item-group *ngIf="marketplace$ | async as m">
<ion-item>
<ion-label>
Connect to a standard marketplaces or an alternative marketplace.
</ion-label>
</ion-item>
<ion-item-divider>Standard Marketplaces</ion-item-divider>
<ion-item
*ngFor="let s of m.standard"
detail="false"
[button]="s.url !== m.selected"
(click)="s.url === m.selected ? '' : presentAction(s)"
>
<ion-icon
*ngIf="s.url === m.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
<ion-label>
<h2>{{ s.name }}</h2>
<p>{{ s.url }}</p>
</ion-label>
</ion-item>
<ion-item-divider>Alt Marketplaces</ion-item-divider>
<ion-item button detail="false" (click)="presentModalAdd()">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
@@ -20,22 +46,21 @@
</ion-item>
<ion-item
*ngFor="let mp of marketplaces"
*ngFor="let a of m.alt"
detail="false"
[button]="mp.id !== selectedId"
(click)="presentAction(mp.id)"
[button]="a.url !== m.selected"
(click)="a.url === m.selected ? '' : presentAction(a, true)"
>
<div *ngIf="mp.id !== selectedId" slot="start" class="padding"></div>
<ion-icon
*ngIf="mp.id === selectedId"
slot="start"
*ngIf="a.url === m.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
<ion-label>
<h2>{{ mp.name }}</h2>
<p>{{ mp.url }}</p>
<h2>{{ a.name }}</h2>
<p>{{ a.url }}</p>
</ion-label>
</ion-item>
</ion-item-group>

View File

@@ -1,4 +1,4 @@
import { Component, Inject } from '@angular/core'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import {
ActionSheetController,
AlertController,
@@ -6,47 +6,44 @@ import {
ModalController,
} from '@ionic/angular'
import { ActionSheetButton } from '@ionic/core'
import {
DestroyService,
ErrorToastService,
getUrlHostname,
} from '@start9labs/shared'
import { ErrorToastService } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ValueSpecObject } from 'src/app/pkg-config/config-types'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { PatchDB } from 'patch-db-client'
import { v4 } from 'uuid'
import {
DataModel,
UIMarketplaceData,
} from '../../../services/patch-db/data-model'
import { ConfigService } from '../../../services/config.service'
import { DataModel } from '../../../services/patch-db/data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
distinctUntilChanged,
finalize,
first,
takeUntil,
} from 'rxjs/operators'
import { getServerInfo } from '../../../util/get-server-info'
import { getMarketplace } from '../../../util/get-marketplace'
type Marketplaces = {
id: string | null
name: string
url: string
}[]
import { map } from 'rxjs/operators'
import { firstValueFrom } from 'rxjs'
@Component({
selector: 'marketplaces',
templateUrl: 'marketplaces.page.html',
styleUrls: ['marketplaces.page.scss'],
providers: [DestroyService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplacesPage {
selectedId: string | null = null
marketplaces: Marketplaces = []
marketplace$ = this.patch.watch$('ui', 'marketplace').pipe(
map(m => {
const selected = m['selected-url']
const hosts = Object.entries(m['known-hosts'])
const standard = hosts
.map(([url, name]) => {
return { url, name }
})
.slice(0, 2) // 0 and 1 will always be prod and community
const alt = hosts
.map(([url, name]) => {
return { url, name }
})
.slice(2) // 2 and beyond will always be alts
return { selected, standard, alt }
}),
)
constructor(
private readonly api: ApiService,
@@ -56,55 +53,28 @@ export class MarketplacesPage {
private readonly actionCtrl: ActionSheetController,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly config: ConfigService,
private readonly patch: PatchDB<DataModel>,
private readonly destroy$: DestroyService,
private readonly alertCtrl: AlertController,
) {}
ngOnInit() {
this.patch
.watch$('ui', 'marketplace')
.pipe(distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((mp: UIMarketplaceData) => {
let marketplaces: Marketplaces = [
{
id: null,
name: this.config.marketplace.name,
url: this.config.marketplace.url,
},
]
this.selectedId = mp['selected-id']
const alts = Object.entries(mp['known-hosts']).map(([k, v]) => {
return {
id: k,
name: v.name,
url: v.url,
}
})
marketplaces = marketplaces.concat(alts)
this.marketplaces = marketplaces
})
}
async presentModalAdd() {
const marketplaceSpec = getMarketplaceValueSpec()
const { name, spec } = getMarketplaceValueSpec()
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: marketplaceSpec.name,
spec: marketplaceSpec.spec,
title: name,
spec,
buttons: [
{
text: 'Save for Later',
handler: (value: { url: string }) => {
this.save(value.url)
this.saveOnly(new URL(value.url))
},
},
{
text: 'Save and Connect',
handler: (value: { url: string }) => {
this.saveAndConnect(value.url)
this.saveAndConnect(new URL(value.url))
},
isSubmit: true,
},
@@ -116,32 +86,31 @@ export class MarketplacesPage {
await modal.present()
}
async presentAction(id: string | null) {
// no need to view actions if is selected marketplace
const marketplace = await getMarketplace(this.patch)
if (id === marketplace['selected-id']) return
async presentAction(
{ url, name }: { url: string; name: string },
canDelete = false,
) {
const buttons: ActionSheetButton[] = [
{
text: 'Connect',
handler: () => {
this.connect(id)
this.connect(url)
},
},
]
if (id) {
if (canDelete) {
buttons.unshift({
text: 'Delete',
role: 'destructive',
handler: () => {
this.presentAlertDelete(id)
this.presentAlertDelete(url, name)
},
})
}
const action = await this.actionCtrl.create({
header: this.marketplaces.find(mp => mp.id === id)?.name,
header: name,
mode: 'ios',
buttons,
})
@@ -149,55 +118,7 @@ export class MarketplacesPage {
await action.present()
}
private async connect(id: string | null): Promise<void> {
const marketplace = await getMarketplace(this.patch)
const url = id
? marketplace['known-hosts'][id].url
: this.config.marketplace.url
const loader = await this.loadingCtrl.create({
message: 'Validating Marketplace...',
})
await loader.present()
try {
const { id } = await getServerInfo(this.patch)
await this.marketplaceService.getMarketplaceData({ 'server-id': id }, url)
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Changing Marketplace...'
const value: UIMarketplaceData = {
...marketplace,
'selected-id': id,
}
try {
await this.api.setDbValue({ pointer: `/marketplace`, value })
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
}
loader.message = 'Syncing store...'
this.marketplaceService
.getPackages()
.pipe(
first(),
finalize(() => loader.dismiss()),
)
.subscribe()
}
private async presentAlertDelete(id: string) {
const name = this.marketplaces.find(m => m.id === id)?.name
private async presentAlertDelete(url: string, name: string) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to delete ${name}?`,
@@ -208,7 +129,7 @@ export class MarketplacesPage {
},
{
text: 'Delete',
handler: () => this.delete(id),
handler: () => this.delete(url),
cssClass: 'enter-click',
},
],
@@ -217,125 +138,104 @@ export class MarketplacesPage {
await alert.present()
}
private async delete(id: string): Promise<void> {
const data = await getMarketplace(this.patch)
const marketplace: UIMarketplaceData = JSON.parse(JSON.stringify(data))
private async connect(
url: string,
loader?: HTMLIonLoadingElement,
): Promise<void> {
const message = 'Changing Marketplace...'
if (!loader) {
loader = await this.loadingCtrl.create({ message })
await loader.present()
} else {
loader.message = message
}
try {
await this.api.setDbValue(['marketplace', 'selected-url'], url)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async saveOnly(url: URL): Promise<void> {
const loader = await this.loadingCtrl.create()
try {
await this.validateAndSave(url, loader)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async saveAndConnect(url: URL): Promise<void> {
const loader = await this.loadingCtrl.create()
try {
await this.validateAndSave(url, loader)
await this.connect(url.toString(), loader)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async validateAndSave(
urlObj: URL,
loader: HTMLIonLoadingElement,
): Promise<void> {
const url = urlObj.toString()
// Error on duplicates
const hosts = await firstValueFrom(
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
)
const currentUrls = Object.keys(hosts)
if (currentUrls.includes(url)) throw new Error('marketplace already added')
// Validate
loader.message = 'Validating marketplace...'
await loader.present()
const name = await this.marketplaceService.validateMarketplace(url)
// Save
loader.message = 'Saving...'
await this.api.setDbValue(['marketplace', 'known-hosts', url], name)
}
private async delete(url: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Deleting...',
})
await loader.present()
const hosts = await firstValueFrom(
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
)
const filtered = Object.keys(hosts)
.filter(key => key !== url)
.reduce((prev, curr) => {
const name = hosts[curr]
return {
...prev,
[curr]: name,
}
}, {})
try {
delete marketplace['known-hosts'][id]
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
await this.api.setDbValue(['marketplace', 'known-hosts'], filtered)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async save(url: string): Promise<void> {
const data = await getMarketplace(this.patch)
const marketplace: UIMarketplaceData = data
? JSON.parse(JSON.stringify(data))
: {
'selected-id': null,
'known-hosts': {},
}
// no-op on duplicates
const currentUrls = this.marketplaces.map(mp => getUrlHostname(mp.url))
if (currentUrls.includes(getUrlHostname(url))) return
const loader = await this.loadingCtrl.create({
message: 'Validating Marketplace...',
})
await loader.present()
try {
const id = v4()
const { id: serverId } = await getServerInfo(this.patch)
const { name } = await this.marketplaceService.getMarketplaceData(
{ 'server-id': serverId },
url,
)
marketplace['known-hosts'][id] = { name, url }
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Saving...'
try {
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async saveAndConnect(url: string): Promise<void> {
const data = await getMarketplace(this.patch)
const marketplace: UIMarketplaceData = data
? JSON.parse(JSON.stringify(data))
: {
'selected-id': null,
'known-hosts': {},
}
// no-op on duplicates
const currentUrls = this.marketplaces.map(mp => getUrlHostname(mp.url))
if (currentUrls.includes(getUrlHostname(url))) {
this.errToast.present({ message: 'Marketplace already added' })
return
}
const loader = await this.loadingCtrl.create({
message: 'Validating Marketplace...',
})
await loader.present()
try {
const id = v4()
const { id: serverId } = await getServerInfo(this.patch)
const { name } = await this.marketplaceService.getMarketplaceData(
{ 'server-id': serverId },
url,
)
marketplace['known-hosts'][id] = { name, url }
marketplace['selected-id'] = id
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Saving...'
try {
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
}
loader.message = 'Syncing marketplace data...'
this.marketplaceService
.getPackages()
.pipe(
first(),
finalize(() => loader.dismiss()),
)
.subscribe()
}
}
function getMarketplaceValueSpec(): ValueSpecObject {

View File

@@ -73,7 +73,7 @@ export class PreferencesPage {
await loader.present()
try {
await this.api.setDbValue({ pointer: `/${key}`, value })
await this.api.setDbValue([key], value)
} finally {
loader.dismiss()
}

View File

@@ -48,9 +48,6 @@ export class ServerShowPage {
async updateEos(): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
releaseNotes: this.eosService.eos?.['release-notes'],
},
component: OSUpdatePage,
})
modal.present()