bed5de79e5f40e84691faf853c0f432ed2fd8b75
[oweals/peertube.git] / client / src / app / search / search.component.ts
1 import { forkJoin, of, Subscription } from 'rxjs'
2 import { Component, OnDestroy, OnInit } from '@angular/core'
3 import { ActivatedRoute, Router } from '@angular/router'
4 import { AuthService, Notifier, ServerService } from '@app/core'
5 import { HooksService } from '@app/core/plugins/hooks.service'
6 import { AdvancedSearch } from '@app/search/advanced-search.model'
7 import { SearchService } from '@app/search/search.service'
8 import { UserService } from '@app/shared'
9 import { immutableAssign } from '@app/shared/misc/utils'
10 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
11 import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
12 import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
13 import { Video } from '@app/shared/video/video.model'
14 import { MetaService } from '@ngx-meta/core'
15 import { I18n } from '@ngx-translate/i18n-polyfill'
16 import { ServerConfig, User } from '@shared/models'
17 import { SearchTargetType } from '@shared/models/search/search-target-query.model'
18
19 @Component({
20   selector: 'my-search',
21   styleUrls: [ './search.component.scss' ],
22   templateUrl: './search.component.html'
23 })
24 export class SearchComponent implements OnInit, OnDestroy {
25   results: (Video | VideoChannel)[] = []
26
27   pagination: ComponentPagination = {
28     currentPage: 1,
29     itemsPerPage: 10, // Only for videos, use another variable for channels
30     totalItems: null
31   }
32   advancedSearch: AdvancedSearch = new AdvancedSearch()
33   isSearchFilterCollapsed = true
34   currentSearch: string
35
36   videoDisplayOptions: MiniatureDisplayOptions = {
37     date: true,
38     views: true,
39     by: true,
40     avatar: false,
41     privacyLabel: false,
42     privacyText: false,
43     state: false,
44     blacklistInfo: false
45   }
46
47   errorMessage: string
48   serverConfig: ServerConfig
49
50   userMiniature: User
51
52   private subActivatedRoute: Subscription
53   private isInitialLoad = false // set to false to show the search filters on first arrival
54   private firstSearch = true
55
56   private channelsPerPage = 2
57
58   private lastSearchTarget: SearchTargetType
59
60   constructor (
61     private i18n: I18n,
62     private route: ActivatedRoute,
63     private router: Router,
64     private metaService: MetaService,
65     private notifier: Notifier,
66     private searchService: SearchService,
67     private authService: AuthService,
68     private userService: UserService,
69     private hooks: HooksService,
70     private serverService: ServerService
71   ) { }
72
73   ngOnInit () {
74     this.serverService.getConfig()
75       .subscribe(config => this.serverConfig = config)
76
77     this.subActivatedRoute = this.route.queryParams.subscribe(
78       async queryParams => {
79         const querySearch = queryParams['search']
80         const searchTarget = queryParams['searchTarget']
81
82         // Search updated, reset filters
83         if (this.currentSearch !== querySearch || searchTarget !== this.advancedSearch.searchTarget) {
84           this.resetPagination()
85           this.advancedSearch.reset()
86
87           this.currentSearch = querySearch || undefined
88           this.updateTitle()
89         }
90
91         this.advancedSearch = new AdvancedSearch(queryParams)
92         if (!this.advancedSearch.searchTarget) {
93           this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget()
94         }
95
96         // Don't hide filters if we have some of them AND the user just came on the webpage
97         this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
98         this.isInitialLoad = false
99
100         this.search()
101       },
102
103       err => this.notifier.error(err.text)
104     )
105
106     this.userService.getAnonymousOrLoggedUser()
107       .subscribe(user => this.userMiniature = user)
108
109     this.hooks.runAction('action:search.init', 'search')
110   }
111
112   ngOnDestroy () {
113     if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
114   }
115
116   isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
117     return d instanceof VideoChannel
118   }
119
120   isVideo (v: VideoChannel | Video): v is Video {
121     return v instanceof Video
122   }
123
124   isUserLoggedIn () {
125     return this.authService.isLoggedIn()
126   }
127
128   search () {
129     forkJoin([
130       this.getVideosObs(),
131       this.getVideoChannelObs()
132     ]).subscribe(
133       ([videosResult, videoChannelsResult]) => {
134         this.results = this.results
135           .concat(videoChannelsResult.data)
136           .concat(videosResult.data)
137
138         this.pagination.totalItems = videosResult.total + videoChannelsResult.total
139         this.lastSearchTarget = this.advancedSearch.searchTarget
140
141         // Focus on channels if there are no enough videos
142         if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
143           this.resetPagination()
144           this.firstSearch = false
145
146           this.channelsPerPage = 10
147           this.search()
148         }
149
150         this.firstSearch = false
151       },
152
153       err => {
154         if (this.advancedSearch.searchTarget !== 'search-index') {
155           this.notifier.error(err.message)
156           return
157         }
158
159         this.notifier.error(
160           this.i18n('Search index is unavailable. Retrying with instance results instead.'),
161           this.i18n('Search error')
162         )
163         this.advancedSearch.searchTarget = 'local'
164         this.search()
165       }
166     )
167   }
168
169   onNearOfBottom () {
170     // Last page
171     if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
172
173     this.pagination.currentPage += 1
174     this.search()
175   }
176
177   onFiltered () {
178     this.resetPagination()
179
180     this.updateUrlFromAdvancedSearch()
181   }
182
183   numberOfFilters () {
184     return this.advancedSearch.size()
185   }
186
187   // Add VideoChannel for typings, but the template already checks "video" argument is a video
188   removeVideoFromArray (video: Video | VideoChannel) {
189     this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
190   }
191
192   getChannelUrl (channel: VideoChannel) {
193     if (this.advancedSearch.searchTarget === 'search-index' && channel.url) {
194       const remoteUriConfig = this.serverConfig.search.remoteUri
195
196       // Redirect on the external instance if not allowed to fetch remote data
197       const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
198       const fromPath = window.location.pathname + window.location.search
199
200       return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ]
201     }
202
203     return [ '/video-channels', channel.nameWithHost ]
204   }
205
206   hideActions () {
207     return this.lastSearchTarget === 'search-index'
208   }
209
210   private resetPagination () {
211     this.pagination.currentPage = 1
212     this.pagination.totalItems = null
213     this.channelsPerPage = 2
214
215     this.results = []
216   }
217
218   private updateTitle () {
219     const suffix = this.currentSearch ? ' ' + this.currentSearch : ''
220     this.metaService.setTitle(this.i18n('Search') + suffix)
221   }
222
223   private updateUrlFromAdvancedSearch () {
224     const search = this.currentSearch || undefined
225
226     this.router.navigate([], {
227       relativeTo: this.route,
228       queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search })
229     })
230   }
231
232   private getVideosObs () {
233     const params = {
234       search: this.currentSearch,
235       componentPagination: this.pagination,
236       advancedSearch: this.advancedSearch
237     }
238
239     return this.hooks.wrapObsFun(
240       this.searchService.searchVideos.bind(this.searchService),
241       params,
242       'search',
243       'filter:api.search.videos.list.params',
244       'filter:api.search.videos.list.result'
245     )
246   }
247
248   private getVideoChannelObs () {
249     if (!this.currentSearch) return of({ data: [], total: 0 })
250
251     const params = {
252       search: this.currentSearch,
253       componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
254       searchTarget: this.advancedSearch.searchTarget
255     }
256
257     return this.hooks.wrapObsFun(
258       this.searchService.searchVideoChannels.bind(this.searchService),
259       params,
260       'search',
261       'filter:api.search.video-channels.list.params',
262       'filter:api.search.video-channels.list.result'
263     )
264   }
265 }