diff options
author | Chocobozzz <me@florianbigard.com> | 2020-06-23 14:10:17 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-06-23 16:00:49 +0200 |
commit | 67ed6552b831df66713bac9e672738796128d33f (patch) | |
tree | 59c97d41e0b49d75a90aa3de987968ab9b1ff447 /client/src/app/shared/shared-user-subscription | |
parent | 0c4bacbff53bc732f5a2677d62a6ead7752e2405 (diff) | |
download | PeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.gz PeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.zst PeerTube-67ed6552b831df66713bac9e672738796128d33f.zip |
Reorganize client shared modules
Diffstat (limited to 'client/src/app/shared/shared-user-subscription')
9 files changed, 687 insertions, 0 deletions
diff --git a/client/src/app/shared/shared-user-subscription/index.ts b/client/src/app/shared/shared-user-subscription/index.ts new file mode 100644 index 000000000..fd53d14b5 --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/index.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export * from './user-subscription.service' | ||
2 | export * from './subscribe-button.component' | ||
3 | export * from './remote-subscribe.component' | ||
4 | |||
5 | export * from './shared-user-subscription.module' | ||
diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.html b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.html new file mode 100644 index 000000000..acfec0a8e --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.html | |||
@@ -0,0 +1,32 @@ | |||
1 | <form novalidate [formGroup]="form" (ngSubmit)="formValidated()"> | ||
2 | <div class="form-group mb-2"> | ||
3 | <input type="email" | ||
4 | formControlName="text" | ||
5 | class="form-control" | ||
6 | (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()" | ||
7 | placeholder="jane_doe@example.com"> | ||
8 | </div> | ||
9 | |||
10 | <button type="submit" [disabled]="!form.valid" class="btn btn-sm btn-remote-follow" i18n> | ||
11 | <span *ngIf="!interact">Remote subscribe</span> | ||
12 | <span *ngIf="interact">Remote interact</span> | ||
13 | </button> | ||
14 | |||
15 | <my-help *ngIf="!interact && showHelp"> | ||
16 | <ng-template ptTemplate="customHtml"> | ||
17 | <ng-container i18n> | ||
18 | You can subscribe to the channel via any ActivityPub-capable fediverse instance.<br /><br /> | ||
19 | For instance with Mastodon or Pleroma you can type the channel URL in the search box and subscribe there. | ||
20 | </ng-container> | ||
21 | </ng-template> | ||
22 | </my-help> | ||
23 | |||
24 | <my-help *ngIf="showHelp && interact"> | ||
25 | <ng-template ptTemplate="customHtml"> | ||
26 | <ng-container i18n> | ||
27 | You can interact with this via any ActivityPub-capable fediverse instance.<br /><br /> | ||
28 | For instance with Mastodon or Pleroma you can type the current URL in the search box and interact with it there. | ||
29 | </ng-container> | ||
30 | </ng-template> | ||
31 | </my-help> | ||
32 | </form> | ||
diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss new file mode 100644 index 000000000..698c5866a --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.scss | |||
@@ -0,0 +1,6 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | .btn-remote-follow { | ||
4 | @include peertube-button; | ||
5 | @include orange-button; | ||
6 | } \ No newline at end of file | ||
diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts new file mode 100644 index 000000000..09164a5d3 --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts | |||
@@ -0,0 +1,58 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-remote-subscribe', | ||
6 | templateUrl: './remote-subscribe.component.html', | ||
7 | styleUrls: ['./remote-subscribe.component.scss'] | ||
8 | }) | ||
9 | export class RemoteSubscribeComponent extends FormReactive implements OnInit { | ||
10 | @Input() uri: string | ||
11 | @Input() interact = false | ||
12 | @Input() showHelp = false | ||
13 | |||
14 | constructor ( | ||
15 | protected formValidatorService: FormValidatorService, | ||
16 | private userValidatorsService: UserValidatorsService | ||
17 | ) { | ||
18 | super() | ||
19 | } | ||
20 | |||
21 | ngOnInit () { | ||
22 | this.buildForm({ | ||
23 | text: this.userValidatorsService.USER_EMAIL | ||
24 | }) | ||
25 | } | ||
26 | |||
27 | onValidKey () { | ||
28 | this.check() | ||
29 | if (!this.form.valid) return | ||
30 | |||
31 | this.formValidated() | ||
32 | } | ||
33 | |||
34 | formValidated () { | ||
35 | const address = this.form.value['text'] | ||
36 | const [ username, hostname ] = address.split('@') | ||
37 | |||
38 | // Should not have CORS error because https://tools.ietf.org/html/rfc7033#section-5 | ||
39 | fetch(`https://${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`) | ||
40 | .then(response => response.json()) | ||
41 | .then(data => new Promise((resolve, reject) => { | ||
42 | console.log(data) | ||
43 | |||
44 | if (data && Array.isArray(data.links)) { | ||
45 | const link: { template: string } = data.links.find((link: any) => { | ||
46 | return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe' | ||
47 | }) | ||
48 | |||
49 | if (link && link.template.includes('{uri}')) { | ||
50 | resolve(link.template.replace('{uri}', encodeURIComponent(this.uri))) | ||
51 | } | ||
52 | } | ||
53 | reject() | ||
54 | })) | ||
55 | .then(window.open) | ||
56 | .catch(err => console.error(err)) | ||
57 | } | ||
58 | } | ||
diff --git a/client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts b/client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts new file mode 100644 index 000000000..cddea80bf --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/shared-user-subscription.module.ts | |||
@@ -0,0 +1,29 @@ | |||
1 | |||
2 | import { NgModule } from '@angular/core' | ||
3 | import { SharedFormModule } from '../shared-forms' | ||
4 | import { SharedMainModule } from '../shared-main/shared-main.module' | ||
5 | import { RemoteSubscribeComponent } from './remote-subscribe.component' | ||
6 | import { SubscribeButtonComponent } from './subscribe-button.component' | ||
7 | import { UserSubscriptionService } from './user-subscription.service' | ||
8 | |||
9 | @NgModule({ | ||
10 | imports: [ | ||
11 | SharedMainModule, | ||
12 | SharedFormModule | ||
13 | ], | ||
14 | |||
15 | declarations: [ | ||
16 | RemoteSubscribeComponent, | ||
17 | SubscribeButtonComponent | ||
18 | ], | ||
19 | |||
20 | exports: [ | ||
21 | RemoteSubscribeComponent, | ||
22 | SubscribeButtonComponent | ||
23 | ], | ||
24 | |||
25 | providers: [ | ||
26 | UserSubscriptionService | ||
27 | ] | ||
28 | }) | ||
29 | export class SharedUserSubscriptionModule { } | ||
diff --git a/client/src/app/shared/shared-user-subscription/subscribe-button.component.html b/client/src/app/shared/shared-user-subscription/subscribe-button.component.html new file mode 100644 index 000000000..85b3d1fdb --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/subscribe-button.component.html | |||
@@ -0,0 +1,67 @@ | |||
1 | <div class="btn-group-subscribe btn-group" | ||
2 | [ngClass]="{'subscribe-button': !isAllChannelsSubscribed, 'unsubscribe-button': isAllChannelsSubscribed, 'big': isBigButton }"> | ||
3 | |||
4 | <ng-template #userLoggedOut> | ||
5 | <span [ngClass]="{ 'extra-text': isAtLeastOneChannelSubscribed }"> | ||
6 | <ng-container *ngIf="account; then multiple; else single"></ng-container> | ||
7 | <ng-template i18n #single>Subscribe</ng-template> | ||
8 | <ng-template #multiple> | ||
9 | <span i18n>Subscribe to all channels</span> | ||
10 | <span *ngIf="isAtLeastOneChannelSubscribed">{{ subscribeStatus(true).length }}/{{ subscribed.size }} | ||
11 | <ng-container i18n>channels subscribed</ng-container> | ||
12 | </span> | ||
13 | </ng-template> | ||
14 | </span> | ||
15 | <span *ngIf="!isBigButton && displayFollowers && videoChannels.length > 1 && videoChannel.followersCount !== 0" class="followers-count"> | ||
16 | {{ videoChannels[0].followersCount | myNumberFormatter }} | ||
17 | </span> | ||
18 | </ng-template> | ||
19 | |||
20 | <ng-template #userLoggedIn> | ||
21 | <button *ngIf="!isAllChannelsSubscribed" type="button" | ||
22 | class="btn btn-sm" role="button" | ||
23 | (click)="subscribe()"> | ||
24 | <ng-template [ngTemplateOutlet]="userLoggedOut"></ng-template> | ||
25 | </button> | ||
26 | |||
27 | <button | ||
28 | *ngIf="isAllChannelsSubscribed" type="button" | ||
29 | class="btn btn-sm" role="button" | ||
30 | (click)="unsubscribe()"> | ||
31 | <ng-container i18n>{account + "", select, undefined {Unsubscribe} other {Unsubscribe from all channels}}</ng-container> | ||
32 | </button> | ||
33 | </ng-template> | ||
34 | |||
35 | <ng-container | ||
36 | *ngIf="isUserLoggedIn(); then userLoggedIn"> | ||
37 | </ng-container> | ||
38 | |||
39 | <div class="btn-group" ngbDropdown autoClose="outside" | ||
40 | placement="bottom-right" role="group" | ||
41 | aria-label="Multiple ways to subscribe to the current channel"> | ||
42 | <button class="btn btn-sm dropdown-toggle-split" ngbDropdownToggle> | ||
43 | <ng-container | ||
44 | *ngIf="!isUserLoggedIn(); then userLoggedOut"> | ||
45 | </ng-container> | ||
46 | </button> | ||
47 | |||
48 | <div class="dropdown-menu" ngbDropdownMenu> | ||
49 | |||
50 | <h6 class="dropdown-header" i18n>Using an ActivityPub account</h6> | ||
51 | |||
52 | <button class="dropdown-item" (click)="subscribe()"> | ||
53 | <span *ngIf="!isUserLoggedIn()" i18n>Subscribe with an account on this instance</span> | ||
54 | <span *ngIf="isUserLoggedIn()" i18n>Subscribe with your local account</span> | ||
55 | </button> | ||
56 | |||
57 | <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button> | ||
58 | <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe> | ||
59 | |||
60 | <div class="dropdown-divider"></div> | ||
61 | |||
62 | <h6 class="dropdown-header" i18n>Using a syndication feed</h6> | ||
63 | <a [href]="rssUri" target="_blank" class="dropdown-item" i18n>Subscribe via RSS</a> | ||
64 | |||
65 | </div> | ||
66 | </div> | ||
67 | </div> | ||
diff --git a/client/src/app/shared/shared-user-subscription/subscribe-button.component.scss b/client/src/app/shared/shared-user-subscription/subscribe-button.component.scss new file mode 100644 index 000000000..b739c5ae2 --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/subscribe-button.component.scss | |||
@@ -0,0 +1,112 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .btn-group-subscribe { | ||
5 | @include peertube-button; | ||
6 | @include disable-default-a-behaviour; | ||
7 | |||
8 | float: right; | ||
9 | padding: 0; | ||
10 | |||
11 | & > .btn, | ||
12 | & > .dropdown > .dropdown-toggle { | ||
13 | font-size: 15px; | ||
14 | } | ||
15 | |||
16 | &:not(.big) { | ||
17 | white-space: nowrap; | ||
18 | } | ||
19 | |||
20 | &.big { | ||
21 | height: 35px; | ||
22 | |||
23 | & > button:first-child { | ||
24 | width: 175px; | ||
25 | } | ||
26 | |||
27 | button .extra-text { | ||
28 | span:first-child { | ||
29 | line-height: 80%; | ||
30 | } | ||
31 | |||
32 | span:not(:first-child) { | ||
33 | font-size: 75%; | ||
34 | } | ||
35 | } | ||
36 | } | ||
37 | |||
38 | // Unlogged | ||
39 | & > .dropdown > .dropdown-toggle span { | ||
40 | padding-right: 3px; | ||
41 | } | ||
42 | |||
43 | // Logged | ||
44 | & > .btn { | ||
45 | padding-right: 4px; | ||
46 | |||
47 | & + .dropdown > button { | ||
48 | padding-left: 2px; | ||
49 | |||
50 | &::after { | ||
51 | position: relative; | ||
52 | top: 1px; | ||
53 | } | ||
54 | } | ||
55 | } | ||
56 | |||
57 | &.subscribe-button { | ||
58 | .btn { | ||
59 | @include orange-button; | ||
60 | font-weight: 600; | ||
61 | } | ||
62 | |||
63 | span.followers-count { | ||
64 | padding-left: 5px; | ||
65 | } | ||
66 | } | ||
67 | &.unsubscribe-button { | ||
68 | .btn { | ||
69 | @include grey-button; | ||
70 | font-weight: 600; | ||
71 | } | ||
72 | } | ||
73 | |||
74 | .dropdown-menu { | ||
75 | cursor: default; | ||
76 | |||
77 | button { | ||
78 | cursor: pointer; | ||
79 | } | ||
80 | |||
81 | .dropdown-item-neutral { | ||
82 | cursor: default; | ||
83 | |||
84 | &:hover, | ||
85 | &:focus { | ||
86 | background-color: inherit; | ||
87 | } | ||
88 | } | ||
89 | } | ||
90 | |||
91 | ::ng-deep form { | ||
92 | padding: 0.25rem 1rem; | ||
93 | } | ||
94 | |||
95 | input { | ||
96 | @include peertube-input-text(100%); | ||
97 | } | ||
98 | } | ||
99 | |||
100 | .extra-text { | ||
101 | display: flex; | ||
102 | flex-direction: column; | ||
103 | |||
104 | span:first-child { | ||
105 | line-height: 75%; | ||
106 | } | ||
107 | |||
108 | span:not(:first-child) { | ||
109 | font-size: 60%; | ||
110 | text-align: left; | ||
111 | } | ||
112 | } | ||
diff --git a/client/src/app/shared/shared-user-subscription/subscribe-button.component.ts b/client/src/app/shared/shared-user-subscription/subscribe-button.component.ts new file mode 100644 index 000000000..72fa3f4fd --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/subscribe-button.component.ts | |||
@@ -0,0 +1,196 @@ | |||
1 | import { concat, forkJoin, merge } from 'rxjs' | ||
2 | import { Component, Input, OnChanges, OnInit } from '@angular/core' | ||
3 | import { Router } from '@angular/router' | ||
4 | import { AuthService, Notifier } from '@app/core' | ||
5 | import { Account, VideoChannel, VideoService } from '@app/shared/shared-main' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { FeedFormat } from '@shared/models' | ||
8 | import { UserSubscriptionService } from './user-subscription.service' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-subscribe-button', | ||
12 | templateUrl: './subscribe-button.component.html', | ||
13 | styleUrls: [ './subscribe-button.component.scss' ] | ||
14 | }) | ||
15 | export class SubscribeButtonComponent implements OnInit, OnChanges { | ||
16 | /** | ||
17 | * SubscribeButtonComponent can be used with a single VideoChannel passed as [VideoChannel], | ||
18 | * or with an account and a full list of that account's videoChannels. The latter is intended | ||
19 | * to allow mass un/subscription from an account's page, while keeping the channel-centric | ||
20 | * subscription model. | ||
21 | */ | ||
22 | @Input() account: Account | ||
23 | @Input() videoChannels: VideoChannel[] | ||
24 | @Input() displayFollowers = false | ||
25 | @Input() size: 'small' | 'normal' = 'normal' | ||
26 | |||
27 | subscribed = new Map<string, boolean>() | ||
28 | |||
29 | constructor ( | ||
30 | private authService: AuthService, | ||
31 | private router: Router, | ||
32 | private notifier: Notifier, | ||
33 | private userSubscriptionService: UserSubscriptionService, | ||
34 | private i18n: I18n, | ||
35 | private videoService: VideoService | ||
36 | ) { } | ||
37 | |||
38 | get handle () { | ||
39 | return this.account | ||
40 | ? this.account.nameWithHost | ||
41 | : this.videoChannel.name + '@' + this.videoChannel.host | ||
42 | } | ||
43 | |||
44 | get channelHandle () { | ||
45 | return this.getChannelHandler(this.videoChannel) | ||
46 | } | ||
47 | |||
48 | get uri () { | ||
49 | return this.account | ||
50 | ? this.account.url | ||
51 | : this.videoChannels[0].url | ||
52 | } | ||
53 | |||
54 | get rssUri () { | ||
55 | const rssFeed = this.account | ||
56 | ? this.videoService | ||
57 | .getAccountFeedUrls(this.account.id) | ||
58 | .find(i => i.format === FeedFormat.RSS) | ||
59 | : this.videoService | ||
60 | .getVideoChannelFeedUrls(this.videoChannels[0].id) | ||
61 | .find(i => i.format === FeedFormat.RSS) | ||
62 | |||
63 | return rssFeed.url | ||
64 | } | ||
65 | |||
66 | get videoChannel () { | ||
67 | return this.videoChannels[0] | ||
68 | } | ||
69 | |||
70 | get isAllChannelsSubscribed () { | ||
71 | return this.subscribeStatus(true).length === this.videoChannels.length | ||
72 | } | ||
73 | |||
74 | get isAtLeastOneChannelSubscribed () { | ||
75 | return this.subscribeStatus(true).length > 0 | ||
76 | } | ||
77 | |||
78 | get isBigButton () { | ||
79 | return this.isUserLoggedIn() && this.videoChannels.length > 1 && this.isAtLeastOneChannelSubscribed | ||
80 | } | ||
81 | |||
82 | ngOnInit () { | ||
83 | this.loadSubscribedStatus() | ||
84 | } | ||
85 | |||
86 | ngOnChanges () { | ||
87 | this.ngOnInit() | ||
88 | } | ||
89 | |||
90 | subscribe () { | ||
91 | if (this.isUserLoggedIn()) { | ||
92 | return this.localSubscribe() | ||
93 | } | ||
94 | |||
95 | return this.gotoLogin() | ||
96 | } | ||
97 | |||
98 | localSubscribe () { | ||
99 | const subscribedStatus = this.subscribeStatus(false) | ||
100 | |||
101 | const observableBatch = this.videoChannels | ||
102 | .map(videoChannel => this.getChannelHandler(videoChannel)) | ||
103 | .filter(handle => subscribedStatus.includes(handle)) | ||
104 | .map(handle => this.userSubscriptionService.addSubscription(handle)) | ||
105 | |||
106 | forkJoin(observableBatch) | ||
107 | .subscribe( | ||
108 | () => { | ||
109 | this.notifier.success( | ||
110 | this.account | ||
111 | ? this.i18n( | ||
112 | 'Subscribed to all current channels of {{nameWithHost}}. You will be notified of all their new videos.', | ||
113 | { nameWithHost: this.account.displayName } | ||
114 | ) | ||
115 | : this.i18n( | ||
116 | 'Subscribed to {{nameWithHost}}. You will be notified of all their new videos.', | ||
117 | { nameWithHost: this.videoChannels[0].displayName } | ||
118 | ) | ||
119 | , | ||
120 | this.i18n('Subscribed') | ||
121 | ) | ||
122 | }, | ||
123 | |||
124 | err => this.notifier.error(err.message) | ||
125 | ) | ||
126 | } | ||
127 | |||
128 | unsubscribe () { | ||
129 | if (this.isUserLoggedIn()) { | ||
130 | this.localUnsubscribe() | ||
131 | } | ||
132 | } | ||
133 | |||
134 | localUnsubscribe () { | ||
135 | const subscribeStatus = this.subscribeStatus(true) | ||
136 | |||
137 | const observableBatch = this.videoChannels | ||
138 | .map(videoChannel => this.getChannelHandler(videoChannel)) | ||
139 | .filter(handle => subscribeStatus.includes(handle)) | ||
140 | .map(handle => this.userSubscriptionService.deleteSubscription(handle)) | ||
141 | |||
142 | concat(...observableBatch) | ||
143 | .subscribe({ | ||
144 | complete: () => { | ||
145 | this.notifier.success( | ||
146 | this.account | ||
147 | ? this.i18n('Unsubscribed from all channels of {{nameWithHost}}', { nameWithHost: this.account.nameWithHost }) | ||
148 | : this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannels[ 0 ].nameWithHost }) | ||
149 | , | ||
150 | this.i18n('Unsubscribed') | ||
151 | ) | ||
152 | }, | ||
153 | |||
154 | error: err => this.notifier.error(err.message) | ||
155 | }) | ||
156 | } | ||
157 | |||
158 | isUserLoggedIn () { | ||
159 | return this.authService.isLoggedIn() | ||
160 | } | ||
161 | |||
162 | gotoLogin () { | ||
163 | this.router.navigate([ '/login' ]) | ||
164 | } | ||
165 | |||
166 | subscribeStatus (subscribed: boolean) { | ||
167 | const accumulator: string[] = [] | ||
168 | for (const [key, value] of this.subscribed.entries()) { | ||
169 | if (value === subscribed) accumulator.push(key) | ||
170 | } | ||
171 | |||
172 | return accumulator | ||
173 | } | ||
174 | |||
175 | private getChannelHandler (videoChannel: VideoChannel) { | ||
176 | return videoChannel.name + '@' + videoChannel.host | ||
177 | } | ||
178 | |||
179 | private loadSubscribedStatus () { | ||
180 | if (!this.isUserLoggedIn()) return | ||
181 | |||
182 | for (const videoChannel of this.videoChannels) { | ||
183 | const handle = this.getChannelHandler(videoChannel) | ||
184 | this.subscribed.set(handle, false) | ||
185 | |||
186 | merge( | ||
187 | this.userSubscriptionService.listenToSubscriptionCacheChange(handle), | ||
188 | this.userSubscriptionService.doesSubscriptionExist(handle) | ||
189 | ).subscribe( | ||
190 | res => this.subscribed.set(handle, res), | ||
191 | |||
192 | err => this.notifier.error(err.message) | ||
193 | ) | ||
194 | } | ||
195 | } | ||
196 | } | ||
diff --git a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts new file mode 100644 index 000000000..732ed6bcb --- /dev/null +++ b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts | |||
@@ -0,0 +1,182 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { uniq } from 'lodash-es' | ||
3 | import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs' | ||
4 | import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators' | ||
5 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
6 | import { Injectable, NgZone } from '@angular/core' | ||
7 | import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' | ||
8 | import { enterZone, leaveZone } from '@app/helpers' | ||
9 | import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' | ||
10 | import { ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models' | ||
11 | import { environment } from '../../../environments/environment' | ||
12 | |||
13 | const logger = debug('peertube:subscriptions:UserSubscriptionService') | ||
14 | |||
15 | type SubscriptionExistResult = { [ uri: string ]: boolean } | ||
16 | type SubscriptionExistResultObservable = { [ uri: string ]: Observable<boolean> } | ||
17 | |||
18 | @Injectable() | ||
19 | export class UserSubscriptionService { | ||
20 | static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' | ||
21 | |||
22 | // Use a replay subject because we "next" a value before subscribing | ||
23 | private existsSubject = new ReplaySubject<string>(1) | ||
24 | private readonly existsObservable: Observable<SubscriptionExistResult> | ||
25 | |||
26 | private myAccountSubscriptionCache: SubscriptionExistResult = {} | ||
27 | private myAccountSubscriptionCacheObservable: SubscriptionExistResultObservable = {} | ||
28 | private myAccountSubscriptionCacheSubject = new Subject<SubscriptionExistResult>() | ||
29 | |||
30 | constructor ( | ||
31 | private authHttp: HttpClient, | ||
32 | private restExtractor: RestExtractor, | ||
33 | private videoService: VideoService, | ||
34 | private restService: RestService, | ||
35 | private ngZone: NgZone | ||
36 | ) { | ||
37 | this.existsObservable = merge( | ||
38 | this.existsSubject.pipe( | ||
39 | // We leave Angular zone so Protractor does not get stuck | ||
40 | bufferTime(500, leaveZone(this.ngZone, asyncScheduler)), | ||
41 | filter(uris => uris.length !== 0), | ||
42 | map(uris => uniq(uris)), | ||
43 | observeOn(enterZone(this.ngZone, asyncScheduler)), | ||
44 | switchMap(uris => this.doSubscriptionsExist(uris)), | ||
45 | share() | ||
46 | ), | ||
47 | |||
48 | this.myAccountSubscriptionCacheSubject | ||
49 | ) | ||
50 | } | ||
51 | |||
52 | getUserSubscriptionVideos (parameters: { | ||
53 | videoPagination: ComponentPaginationLight, | ||
54 | sort: VideoSortField, | ||
55 | skipCount?: boolean | ||
56 | }): Observable<ResultList<Video>> { | ||
57 | const { videoPagination, sort, skipCount } = parameters | ||
58 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
59 | |||
60 | let params = new HttpParams() | ||
61 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
62 | |||
63 | if (skipCount) params = params.set('skipCount', skipCount + '') | ||
64 | |||
65 | return this.authHttp | ||
66 | .get<ResultList<Video>>(UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/videos', { params }) | ||
67 | .pipe( | ||
68 | switchMap(res => this.videoService.extractVideos(res)), | ||
69 | catchError(err => this.restExtractor.handleError(err)) | ||
70 | ) | ||
71 | } | ||
72 | |||
73 | /** | ||
74 | * Subscription part | ||
75 | */ | ||
76 | |||
77 | deleteSubscription (nameWithHost: string) { | ||
78 | const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost | ||
79 | |||
80 | return this.authHttp.delete(url) | ||
81 | .pipe( | ||
82 | map(this.restExtractor.extractDataBool), | ||
83 | tap(() => { | ||
84 | this.myAccountSubscriptionCache[nameWithHost] = false | ||
85 | |||
86 | this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache) | ||
87 | }), | ||
88 | catchError(err => this.restExtractor.handleError(err)) | ||
89 | ) | ||
90 | } | ||
91 | |||
92 | addSubscription (nameWithHost: string) { | ||
93 | const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL | ||
94 | |||
95 | const body = { uri: nameWithHost } | ||
96 | return this.authHttp.post(url, body) | ||
97 | .pipe( | ||
98 | map(this.restExtractor.extractDataBool), | ||
99 | tap(() => { | ||
100 | this.myAccountSubscriptionCache[nameWithHost] = true | ||
101 | |||
102 | this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache) | ||
103 | }), | ||
104 | catchError(err => this.restExtractor.handleError(err)) | ||
105 | ) | ||
106 | } | ||
107 | |||
108 | listSubscriptions (componentPagination: ComponentPaginationLight): Observable<ResultList<VideoChannel>> { | ||
109 | const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL | ||
110 | |||
111 | const pagination = this.restService.componentPaginationToRestPagination(componentPagination) | ||
112 | |||
113 | let params = new HttpParams() | ||
114 | params = this.restService.addRestGetParams(params, pagination) | ||
115 | |||
116 | return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params }) | ||
117 | .pipe( | ||
118 | map(res => VideoChannelService.extractVideoChannels(res)), | ||
119 | catchError(err => this.restExtractor.handleError(err)) | ||
120 | ) | ||
121 | } | ||
122 | |||
123 | /** | ||
124 | * SubscriptionExist part | ||
125 | */ | ||
126 | |||
127 | listenToMyAccountSubscriptionCacheSubject () { | ||
128 | return this.myAccountSubscriptionCacheSubject.asObservable() | ||
129 | } | ||
130 | |||
131 | listenToSubscriptionCacheChange (nameWithHost: string) { | ||
132 | if (nameWithHost in this.myAccountSubscriptionCacheObservable) { | ||
133 | return this.myAccountSubscriptionCacheObservable[ nameWithHost ] | ||
134 | } | ||
135 | |||
136 | const obs = this.existsObservable | ||
137 | .pipe( | ||
138 | filter(existsResult => existsResult[ nameWithHost ] !== undefined), | ||
139 | map(existsResult => existsResult[ nameWithHost ]) | ||
140 | ) | ||
141 | |||
142 | this.myAccountSubscriptionCacheObservable[ nameWithHost ] = obs | ||
143 | return obs | ||
144 | } | ||
145 | |||
146 | doesSubscriptionExist (nameWithHost: string) { | ||
147 | logger('Running subscription check for %d.', nameWithHost) | ||
148 | |||
149 | if (nameWithHost in this.myAccountSubscriptionCache) { | ||
150 | logger('Found cache for %d.', nameWithHost) | ||
151 | |||
152 | return of(this.myAccountSubscriptionCache[ nameWithHost ]) | ||
153 | } | ||
154 | |||
155 | this.existsSubject.next(nameWithHost) | ||
156 | |||
157 | logger('Fetching from network for %d.', nameWithHost) | ||
158 | return this.existsObservable.pipe( | ||
159 | filter(existsResult => existsResult[ nameWithHost ] !== undefined), | ||
160 | map(existsResult => existsResult[ nameWithHost ]), | ||
161 | tap(result => this.myAccountSubscriptionCache[ nameWithHost ] = result) | ||
162 | ) | ||
163 | } | ||
164 | |||
165 | private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> { | ||
166 | const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' | ||
167 | let params = new HttpParams() | ||
168 | |||
169 | params = this.restService.addObjectParams(params, { uris }) | ||
170 | |||
171 | return this.authHttp.get<SubscriptionExistResult>(url, { params }) | ||
172 | .pipe( | ||
173 | tap(res => { | ||
174 | this.myAccountSubscriptionCache = { | ||
175 | ...this.myAccountSubscriptionCache, | ||
176 | ...res | ||
177 | } | ||
178 | }), | ||
179 | catchError(err => this.restExtractor.handleError(err)) | ||
180 | ) | ||
181 | } | ||
182 | } | ||