diff options
Diffstat (limited to 'client/src/app/shared/shared-video-miniature/videos-list.component.ts')
-rw-r--r-- | client/src/app/shared/shared-video-miniature/videos-list.component.ts | 396 |
1 files changed, 396 insertions, 0 deletions
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts new file mode 100644 index 000000000..10de97298 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts | |||
@@ -0,0 +1,396 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { fromEvent, Observable, Subject, Subscription } from 'rxjs' | ||
3 | import { debounceTime, switchMap } from 'rxjs/operators' | ||
4 | import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' | ||
5 | import { ActivatedRoute } from '@angular/router' | ||
6 | import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core' | ||
7 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
8 | import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' | ||
9 | import { ResultList, UserRight, VideoSortField } from '@shared/models' | ||
10 | import { Syndication, Video } from '../shared-main' | ||
11 | import { VideoFilters, VideoFilterScope } from './video-filters.model' | ||
12 | import { MiniatureDisplayOptions } from './video-miniature.component' | ||
13 | |||
14 | const logger = debug('peertube:videos:VideosListComponent') | ||
15 | |||
16 | export type HeaderAction = { | ||
17 | iconName: GlobalIconName | ||
18 | label: string | ||
19 | justIcon?: boolean | ||
20 | routerLink?: string | ||
21 | href?: string | ||
22 | click?: (e: Event) => void | ||
23 | } | ||
24 | |||
25 | enum GroupDate { | ||
26 | UNKNOWN = 0, | ||
27 | TODAY = 1, | ||
28 | YESTERDAY = 2, | ||
29 | THIS_WEEK = 3, | ||
30 | THIS_MONTH = 4, | ||
31 | LAST_MONTH = 5, | ||
32 | OLDER = 6 | ||
33 | } | ||
34 | |||
35 | @Component({ | ||
36 | selector: 'my-videos-list', | ||
37 | templateUrl: './videos-list.component.html', | ||
38 | styleUrls: [ './videos-list.component.scss' ] | ||
39 | }) | ||
40 | export class VideosListComponent implements OnInit, OnChanges, OnDestroy { | ||
41 | @Input() getVideosObservableFunction: (pagination: ComponentPaginationLight, filters: VideoFilters) => Observable<ResultList<Video>> | ||
42 | @Input() getSyndicationItemsFunction: (filters: VideoFilters) => Promise<Syndication[]> | Syndication[] | ||
43 | @Input() baseRouteBuilderFunction: (filters: VideoFilters) => string[] | ||
44 | |||
45 | @Input() title: string | ||
46 | @Input() titleTooltip: string | ||
47 | @Input() displayTitle = true | ||
48 | |||
49 | @Input() defaultSort: VideoSortField | ||
50 | @Input() defaultScope: VideoFilterScope = 'federated' | ||
51 | @Input() displayFilters = false | ||
52 | @Input() displayModerationBlock = false | ||
53 | |||
54 | @Input() loadUserVideoPreferences = false | ||
55 | |||
56 | @Input() displayAsRow = false | ||
57 | @Input() displayVideoActions = true | ||
58 | @Input() groupByDate = false | ||
59 | |||
60 | @Input() headerActions: HeaderAction[] = [] | ||
61 | |||
62 | @Input() displayOptions: MiniatureDisplayOptions = { | ||
63 | date: true, | ||
64 | views: true, | ||
65 | by: true, | ||
66 | avatar: false, | ||
67 | privacyLabel: true, | ||
68 | privacyText: false, | ||
69 | state: false, | ||
70 | blacklistInfo: false | ||
71 | } | ||
72 | |||
73 | @Input() disabled = false | ||
74 | |||
75 | @Output() filtersChanged = new EventEmitter<VideoFilters>() | ||
76 | |||
77 | videos: Video[] = [] | ||
78 | filters: VideoFilters | ||
79 | syndicationItems: Syndication[] | ||
80 | |||
81 | onDataSubject = new Subject<any[]>() | ||
82 | hasDoneFirstQuery = false | ||
83 | |||
84 | userMiniature: User | ||
85 | |||
86 | private routeSub: Subscription | ||
87 | private userSub: Subscription | ||
88 | private resizeSub: Subscription | ||
89 | |||
90 | private pagination: ComponentPaginationLight = { | ||
91 | currentPage: 1, | ||
92 | itemsPerPage: 25 | ||
93 | } | ||
94 | |||
95 | private groupedDateLabels: { [id in GroupDate]: string } | ||
96 | private groupedDates: { [id: number]: GroupDate } = {} | ||
97 | |||
98 | private lastQueryLength: number | ||
99 | |||
100 | constructor ( | ||
101 | private notifier: Notifier, | ||
102 | private authService: AuthService, | ||
103 | private userService: UserService, | ||
104 | private route: ActivatedRoute, | ||
105 | private screenService: ScreenService, | ||
106 | private peertubeRouter: PeerTubeRouterService | ||
107 | ) { | ||
108 | |||
109 | } | ||
110 | |||
111 | ngOnInit () { | ||
112 | this.filters = new VideoFilters(this.defaultSort, this.defaultScope) | ||
113 | this.filters.load({ ...this.route.snapshot.queryParams, scope: this.defaultScope }) | ||
114 | |||
115 | this.groupedDateLabels = { | ||
116 | [GroupDate.UNKNOWN]: null, | ||
117 | [GroupDate.TODAY]: $localize`Today`, | ||
118 | [GroupDate.YESTERDAY]: $localize`Yesterday`, | ||
119 | [GroupDate.THIS_WEEK]: $localize`This week`, | ||
120 | [GroupDate.THIS_MONTH]: $localize`This month`, | ||
121 | [GroupDate.LAST_MONTH]: $localize`Last month`, | ||
122 | [GroupDate.OLDER]: $localize`Older` | ||
123 | } | ||
124 | |||
125 | this.resizeSub = fromEvent(window, 'resize') | ||
126 | .pipe(debounceTime(500)) | ||
127 | .subscribe(() => this.calcPageSizes()) | ||
128 | |||
129 | this.calcPageSizes() | ||
130 | |||
131 | this.userService.getAnonymousOrLoggedUser() | ||
132 | .subscribe(user => { | ||
133 | this.userMiniature = user | ||
134 | |||
135 | if (this.loadUserVideoPreferences) { | ||
136 | this.loadUserSettings(user) | ||
137 | } | ||
138 | |||
139 | this.scheduleOnFiltersChanged(false) | ||
140 | |||
141 | this.subscribeToAnonymousUpdate() | ||
142 | this.subscribeToSearchChange() | ||
143 | }) | ||
144 | |||
145 | // Display avatar in mobile view | ||
146 | if (this.screenService.isInMobileView()) { | ||
147 | this.displayOptions.avatar = true | ||
148 | } | ||
149 | } | ||
150 | |||
151 | ngOnDestroy () { | ||
152 | if (this.resizeSub) this.resizeSub.unsubscribe() | ||
153 | if (this.routeSub) this.routeSub.unsubscribe() | ||
154 | if (this.userSub) this.userSub.unsubscribe() | ||
155 | } | ||
156 | |||
157 | ngOnChanges (changes: SimpleChanges) { | ||
158 | if (!this.filters) return | ||
159 | |||
160 | let updated = false | ||
161 | |||
162 | if (changes['defaultScope']) { | ||
163 | updated = true | ||
164 | this.filters.setDefaultScope(this.defaultScope) | ||
165 | } | ||
166 | |||
167 | if (changes['defaultSort']) { | ||
168 | updated = true | ||
169 | this.filters.setDefaultSort(this.defaultSort) | ||
170 | } | ||
171 | |||
172 | if (!updated) return | ||
173 | |||
174 | const customizedByUser = this.hasBeenCustomizedByUser() | ||
175 | |||
176 | if (!customizedByUser) { | ||
177 | if (this.loadUserVideoPreferences) { | ||
178 | this.loadUserSettings(this.userMiniature) | ||
179 | } | ||
180 | |||
181 | this.filters.reset('scope') | ||
182 | this.filters.reset('sort') | ||
183 | } | ||
184 | |||
185 | this.scheduleOnFiltersChanged(customizedByUser) | ||
186 | } | ||
187 | |||
188 | videoById (_index: number, video: Video) { | ||
189 | return video.id | ||
190 | } | ||
191 | |||
192 | onNearOfBottom () { | ||
193 | if (this.disabled) return | ||
194 | |||
195 | // No more results | ||
196 | if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return | ||
197 | |||
198 | this.pagination.currentPage += 1 | ||
199 | |||
200 | this.loadMoreVideos() | ||
201 | } | ||
202 | |||
203 | loadMoreVideos (reset = false) { | ||
204 | this.getVideosObservableFunction(this.pagination, this.filters) | ||
205 | .subscribe({ | ||
206 | next: ({ data }) => { | ||
207 | this.hasDoneFirstQuery = true | ||
208 | this.lastQueryLength = data.length | ||
209 | |||
210 | if (reset) this.videos = [] | ||
211 | this.videos = this.videos.concat(data) | ||
212 | |||
213 | if (this.groupByDate) this.buildGroupedDateLabels() | ||
214 | |||
215 | this.onDataSubject.next(data) | ||
216 | }, | ||
217 | |||
218 | error: err => { | ||
219 | const message = $localize`Cannot load more videos. Try again later.` | ||
220 | |||
221 | console.error(message, { err }) | ||
222 | this.notifier.error(message) | ||
223 | } | ||
224 | }) | ||
225 | } | ||
226 | |||
227 | reloadVideos () { | ||
228 | this.pagination.currentPage = 1 | ||
229 | this.loadMoreVideos(true) | ||
230 | } | ||
231 | |||
232 | removeVideoFromArray (video: Video) { | ||
233 | this.videos = this.videos.filter(v => v.id !== video.id) | ||
234 | } | ||
235 | |||
236 | buildGroupedDateLabels () { | ||
237 | let currentGroupedDate: GroupDate = GroupDate.UNKNOWN | ||
238 | |||
239 | const periods = [ | ||
240 | { | ||
241 | value: GroupDate.TODAY, | ||
242 | validator: (d: Date) => isToday(d) | ||
243 | }, | ||
244 | { | ||
245 | value: GroupDate.YESTERDAY, | ||
246 | validator: (d: Date) => isYesterday(d) | ||
247 | }, | ||
248 | { | ||
249 | value: GroupDate.THIS_WEEK, | ||
250 | validator: (d: Date) => isLastWeek(d) | ||
251 | }, | ||
252 | { | ||
253 | value: GroupDate.THIS_MONTH, | ||
254 | validator: (d: Date) => isThisMonth(d) | ||
255 | }, | ||
256 | { | ||
257 | value: GroupDate.LAST_MONTH, | ||
258 | validator: (d: Date) => isLastMonth(d) | ||
259 | }, | ||
260 | { | ||
261 | value: GroupDate.OLDER, | ||
262 | validator: () => true | ||
263 | } | ||
264 | ] | ||
265 | |||
266 | for (const video of this.videos) { | ||
267 | const publishedDate = video.publishedAt | ||
268 | |||
269 | for (let i = 0; i < periods.length; i++) { | ||
270 | const period = periods[i] | ||
271 | |||
272 | if (currentGroupedDate <= period.value && period.validator(publishedDate)) { | ||
273 | |||
274 | if (currentGroupedDate !== period.value) { | ||
275 | currentGroupedDate = period.value | ||
276 | this.groupedDates[video.id] = currentGroupedDate | ||
277 | } | ||
278 | |||
279 | break | ||
280 | } | ||
281 | } | ||
282 | } | ||
283 | } | ||
284 | |||
285 | getCurrentGroupedDateLabel (video: Video) { | ||
286 | if (this.groupByDate === false) return undefined | ||
287 | |||
288 | return this.groupedDateLabels[this.groupedDates[video.id]] | ||
289 | } | ||
290 | |||
291 | scheduleOnFiltersChanged (customizedByUser: boolean) { | ||
292 | // We'll reload videos, but avoid weird UI effect | ||
293 | this.videos = [] | ||
294 | |||
295 | setTimeout(() => this.onFiltersChanged(customizedByUser)) | ||
296 | } | ||
297 | |||
298 | onFiltersChanged (customizedByUser: boolean) { | ||
299 | logger('Running on filters changed') | ||
300 | |||
301 | this.updateUrl(customizedByUser) | ||
302 | |||
303 | this.filters.triggerChange() | ||
304 | |||
305 | this.reloadSyndicationItems() | ||
306 | this.reloadVideos() | ||
307 | } | ||
308 | |||
309 | protected enableAllFilterIfPossible () { | ||
310 | if (!this.authService.isLoggedIn()) return | ||
311 | |||
312 | this.authService.userInformationLoaded | ||
313 | .subscribe(() => { | ||
314 | const user = this.authService.getUser() | ||
315 | this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) | ||
316 | }) | ||
317 | } | ||
318 | |||
319 | private calcPageSizes () { | ||
320 | if (this.screenService.isInMobileView()) { | ||
321 | this.pagination.itemsPerPage = 5 | ||
322 | } | ||
323 | } | ||
324 | |||
325 | private loadUserSettings (user: User) { | ||
326 | this.filters.setNSFWPolicy(user.nsfwPolicy) | ||
327 | |||
328 | // Don't reset language filter if we don't want to refresh the component | ||
329 | if (!this.hasBeenCustomizedByUser()) { | ||
330 | this.filters.load({ languageOneOf: user.videoLanguages }) | ||
331 | } | ||
332 | } | ||
333 | |||
334 | private reloadSyndicationItems () { | ||
335 | Promise.resolve(this.getSyndicationItemsFunction(this.filters)) | ||
336 | .then(items => { | ||
337 | if (!items || items.length === 0) this.syndicationItems = undefined | ||
338 | else this.syndicationItems = items | ||
339 | }) | ||
340 | .catch(err => console.error('Cannot get syndication items.', err)) | ||
341 | } | ||
342 | |||
343 | private updateUrl (customizedByUser: boolean) { | ||
344 | const baseQuery = this.filters.toUrlObject() | ||
345 | |||
346 | // Set or reset customized by user query param | ||
347 | const queryParams = customizedByUser || this.hasBeenCustomizedByUser() | ||
348 | ? { ...baseQuery, c: customizedByUser } | ||
349 | : baseQuery | ||
350 | |||
351 | logger('Will inject %O in URL query', queryParams) | ||
352 | |||
353 | const baseRoute = this.baseRouteBuilderFunction | ||
354 | ? this.baseRouteBuilderFunction(this.filters) | ||
355 | : [] | ||
356 | |||
357 | const pathname = window.location.pathname | ||
358 | |||
359 | const baseRouteChanged = baseRoute.length !== 0 && | ||
360 | pathname !== '/' && // Exclude special '/' case, we'll be redirected without component change | ||
361 | baseRoute.length !== 0 && pathname !== baseRoute.join('/') | ||
362 | |||
363 | if (baseRouteChanged || Object.keys(baseQuery).length !== 0 || customizedByUser) { | ||
364 | this.peertubeRouter.silentNavigate(baseRoute, queryParams) | ||
365 | } | ||
366 | |||
367 | this.filtersChanged.emit(this.filters) | ||
368 | } | ||
369 | |||
370 | private hasBeenCustomizedByUser () { | ||
371 | return this.route.snapshot.queryParams['c'] === 'true' | ||
372 | } | ||
373 | |||
374 | private subscribeToAnonymousUpdate () { | ||
375 | this.userSub = this.userService.listenAnonymousUpdate() | ||
376 | .pipe(switchMap(() => this.userService.getAnonymousOrLoggedUser())) | ||
377 | .subscribe(user => { | ||
378 | if (this.loadUserVideoPreferences) { | ||
379 | this.loadUserSettings(user) | ||
380 | } | ||
381 | |||
382 | if (this.hasDoneFirstQuery) { | ||
383 | this.reloadVideos() | ||
384 | } | ||
385 | }) | ||
386 | } | ||
387 | |||
388 | private subscribeToSearchChange () { | ||
389 | this.routeSub = this.route.queryParams.subscribe(param => { | ||
390 | if (!param['search']) return | ||
391 | |||
392 | this.filters.load({ search: param['search'] }) | ||
393 | this.onFiltersChanged(true) | ||
394 | }) | ||
395 | } | ||
396 | } | ||