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