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