]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Merge remote-tracking branch 'weblate/main' into main
[lemmy.git] / ui / src / utils.ts
1 import 'moment/locale/es';
2 import 'moment/locale/el';
3 import 'moment/locale/eu';
4 import 'moment/locale/eo';
5 import 'moment/locale/de';
6 import 'moment/locale/zh-cn';
7 import 'moment/locale/fr';
8 import 'moment/locale/sv';
9 import 'moment/locale/ru';
10 import 'moment/locale/nl';
11 import 'moment/locale/it';
12 import 'moment/locale/fi';
13 import 'moment/locale/ca';
14 import 'moment/locale/fa';
15 import 'moment/locale/pl';
16 import 'moment/locale/pt-br';
17 import 'moment/locale/ja';
18 import 'moment/locale/ka';
19 import 'moment/locale/hi';
20 import 'moment/locale/gl';
21 import 'moment/locale/tr';
22 import 'moment/locale/hu';
23 import 'moment/locale/uk';
24 import 'moment/locale/sq';
25 import 'moment/locale/km';
26 import 'moment/locale/ga';
27 import 'moment/locale/sr';
28
29 import {
30   UserOperation,
31   Comment,
32   CommentNode as CommentNodeI,
33   Post,
34   PrivateMessage,
35   User,
36   SortType,
37   CommentSortType,
38   ListingType,
39   DataType,
40   SearchType,
41   WebSocketResponse,
42   WebSocketJsonResponse,
43   SearchForm,
44   SearchResponse,
45   CommentResponse,
46   PostResponse,
47 } from './interfaces';
48 import { UserService, WebSocketService } from './services';
49
50 import Tribute from 'tributejs/src/Tribute.js';
51 import markdown_it from 'markdown-it';
52 import markdown_it_sub from 'markdown-it-sub';
53 import markdown_it_sup from 'markdown-it-sup';
54 import markdownitEmoji from 'markdown-it-emoji/light';
55 import markdown_it_container from 'markdown-it-container';
56 import emojiShortName from 'emoji-short-name';
57 import Toastify from 'toastify-js';
58 import tippy from 'tippy.js';
59 import moment from 'moment';
60
61 export const favIconUrl = '/static/assets/favicon.svg';
62 export const favIconPngUrl = '/static/assets/apple-touch-icon.png';
63 export const defaultFavIcon = `${window.location.protocol}//${window.location.host}${favIconPngUrl}`;
64 export const repoUrl = 'https://github.com/LemmyNet/lemmy';
65 export const helpGuideUrl = '/docs/about_guide.html';
66 export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
67 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
68 export const archiveUrl = 'https://archive.is';
69 export const elementUrl = 'https://element.io/';
70
71 export const postRefetchSeconds: number = 60 * 1000;
72 export const fetchLimit: number = 20;
73 export const mentionDropdownFetchLimit = 10;
74
75 export const languages = [
76   { code: 'ca', name: 'Català' },
77   { code: 'en', name: 'English' },
78   { code: 'el', name: 'Ελληνικά' },
79   { code: 'eu', name: 'Euskara' },
80   { code: 'eo', name: 'Esperanto' },
81   { code: 'es', name: 'Español' },
82   { code: 'de', name: 'Deutsch' },
83   { code: 'ga', name: 'Gaeilge' },
84   { code: 'gl', name: 'Galego' },
85   { code: 'hu', name: 'Magyar Nyelv' },
86   { code: 'ka', name: 'ქართული ენა' },
87   { code: 'km', name: 'ភាសាខ្មែរ' },
88   { code: 'hi', name: 'मानक हिन्दी' },
89   { code: 'fa', name: 'فارسی' },
90   { code: 'ja', name: '日本語' },
91   { code: 'pl', name: 'Polski' },
92   { code: 'pt_BR', name: 'Português Brasileiro' },
93   { code: 'zh', name: '中文' },
94   { code: 'fi', name: 'Suomi' },
95   { code: 'fr', name: 'Français' },
96   { code: 'sv', name: 'Svenska' },
97   { code: 'sq', name: 'Shqip' },
98   { code: 'sr_Latn', name: 'srpski' },
99   { code: 'tr', name: 'Türkçe' },
100   { code: 'uk', name: 'Українська Mова' },
101   { code: 'ru', name: 'Русский' },
102   { code: 'nl', name: 'Nederlands' },
103   { code: 'it', name: 'Italiano' },
104 ];
105
106 export const themes = [
107   'litera',
108   'materia',
109   'minty',
110   'solar',
111   'united',
112   'cyborg',
113   'darkly',
114   'journal',
115   'sketchy',
116   'vaporwave',
117   'vaporwave-dark',
118   'i386',
119   'litely',
120 ];
121
122 const DEFAULT_ALPHABET =
123   'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
124
125 function getRandomCharFromAlphabet(alphabet: string): string {
126   return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
127 }
128
129 export function randomStr(
130   idDesiredLength: number = 20,
131   alphabet = DEFAULT_ALPHABET
132 ): string {
133   /**
134    * Create n-long array and map it to random chars from given alphabet.
135    * Then join individual chars as string
136    */
137   return Array.from({ length: idDesiredLength })
138     .map(() => {
139       return getRandomCharFromAlphabet(alphabet);
140     })
141     .join('');
142 }
143
144 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
145   let opStr: string = msg.op;
146   return {
147     op: UserOperation[opStr],
148     data: msg.data,
149   };
150 }
151
152 export const md = new markdown_it({
153   html: false,
154   linkify: true,
155   typographer: true,
156 })
157   .use(markdown_it_sub)
158   .use(markdown_it_sup)
159   .use(markdown_it_container, 'spoiler', {
160     validate: function (params: any) {
161       return params.trim().match(/^spoiler\s+(.*)$/);
162     },
163
164     render: function (tokens: any, idx: any) {
165       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
166
167       if (tokens[idx].nesting === 1) {
168         // opening tag
169         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
170       } else {
171         // closing tag
172         return '</details>\n';
173       }
174     },
175   })
176   .use(markdownitEmoji, {
177     defs: objectFlip(emojiShortName),
178   });
179
180 export function hotRankComment(comment: Comment): number {
181   return hotRank(comment.score, comment.published);
182 }
183
184 export function hotRankPost(post: Post): number {
185   return hotRank(post.score, post.newest_activity_time);
186 }
187
188 export function hotRank(score: number, timeStr: string): number {
189   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
190   let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
191   let now: Date = new Date();
192   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
193
194   let rank =
195     (10000 * Math.log10(Math.max(1, 3 + score))) /
196     Math.pow(hoursElapsed + 2, 1.8);
197
198   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
199
200   return rank;
201 }
202
203 export function mdToHtml(text: string) {
204   return { __html: md.render(text) };
205 }
206
207 export function getUnixTime(text: string): number {
208   return text ? new Date(text).getTime() / 1000 : undefined;
209 }
210
211 export function addTypeInfo<T>(
212   arr: Array<T>,
213   name: string
214 ): Array<{ type_: string; data: T }> {
215   return arr.map(e => {
216     return { type_: name, data: e };
217   });
218 }
219
220 export function canMod(
221   user: User,
222   modIds: Array<number>,
223   creator_id: number,
224   onSelf: boolean = false
225 ): boolean {
226   // You can do moderator actions only on the mods added after you.
227   if (user) {
228     let yourIndex = modIds.findIndex(id => id == user.id);
229     if (yourIndex == -1) {
230       return false;
231     } else {
232       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
233       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
234       return !modIds.includes(creator_id);
235     }
236   } else {
237     return false;
238   }
239 }
240
241 export function isMod(modIds: Array<number>, creator_id: number): boolean {
242   return modIds.includes(creator_id);
243 }
244
245 const imageRegex = new RegExp(
246   /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/
247 );
248 const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
249
250 export function isImage(url: string) {
251   return imageRegex.test(url);
252 }
253
254 export function isVideo(url: string) {
255   return videoRegex.test(url);
256 }
257
258 export function validURL(str: string) {
259   try {
260     return !!new URL(str);
261   } catch {
262     return false;
263   }
264 }
265
266 export function validEmail(email: string) {
267   let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
268   return re.test(String(email).toLowerCase());
269 }
270
271 export function capitalizeFirstLetter(str: string): string {
272   return str.charAt(0).toUpperCase() + str.slice(1);
273 }
274
275 export function routeSortTypeToEnum(sort: string): SortType {
276   if (sort == 'new') {
277     return SortType.New;
278   } else if (sort == 'hot') {
279     return SortType.Hot;
280   } else if (sort == 'active') {
281     return SortType.Active;
282   } else if (sort == 'topday') {
283     return SortType.TopDay;
284   } else if (sort == 'topweek') {
285     return SortType.TopWeek;
286   } else if (sort == 'topmonth') {
287     return SortType.TopMonth;
288   } else if (sort == 'topyear') {
289     return SortType.TopYear;
290   } else if (sort == 'topall') {
291     return SortType.TopAll;
292   }
293 }
294
295 export function routeListingTypeToEnum(type: string): ListingType {
296   return ListingType[capitalizeFirstLetter(type)];
297 }
298
299 export function routeDataTypeToEnum(type: string): DataType {
300   return DataType[capitalizeFirstLetter(type)];
301 }
302
303 export function routeSearchTypeToEnum(type: string): SearchType {
304   return SearchType[capitalizeFirstLetter(type)];
305 }
306
307 export async function getPageTitle(url: string) {
308   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
309   let title = await res.title;
310   return title;
311 }
312
313 export function debounce(
314   func: any,
315   wait: number = 1000,
316   immediate: boolean = false
317 ) {
318   // 'private' variable for instance
319   // The returned function will be able to reference this due to closure.
320   // Each call to the returned function will share this common timer.
321   let timeout: any;
322
323   // Calling debounce returns a new anonymous function
324   return function () {
325     // reference the context and args for the setTimeout function
326     var context = this,
327       args = arguments;
328
329     // Should the function be called now? If immediate is true
330     //   and not already in a timeout then the answer is: Yes
331     var callNow = immediate && !timeout;
332
333     // This is the basic debounce behaviour where you can call this
334     //   function several times, but it will only execute once
335     //   [before or after imposing a delay].
336     //   Each time the returned function is called, the timer starts over.
337     clearTimeout(timeout);
338
339     // Set the new timeout
340     timeout = setTimeout(function () {
341       // Inside the timeout function, clear the timeout variable
342       // which will let the next execution run when in 'immediate' mode
343       timeout = null;
344
345       // Check if the function already ran with the immediate flag
346       if (!immediate) {
347         // Call the original function with apply
348         // apply lets you define the 'this' object as well as the arguments
349         //    (both captured before setTimeout)
350         func.apply(context, args);
351       }
352     }, wait);
353
354     // Immediate mode and no wait timer? Execute the function..
355     if (callNow) func.apply(context, args);
356   };
357 }
358
359 export function getLanguage(override?: string): string {
360   let user = UserService.Instance.user;
361   let lang = override || (user && user.lang ? user.lang : 'browser');
362
363   if (lang == 'browser') {
364     return getBrowserLanguage();
365   } else {
366     return lang;
367   }
368 }
369
370 export function getBrowserLanguage(): string {
371   return navigator.language;
372 }
373
374 export function getMomentLanguage(): string {
375   let lang = getLanguage();
376   if (lang.startsWith('zh')) {
377     lang = 'zh-cn';
378   } else if (lang.startsWith('sv')) {
379     lang = 'sv';
380   } else if (lang.startsWith('fr')) {
381     lang = 'fr';
382   } else if (lang.startsWith('de')) {
383     lang = 'de';
384   } else if (lang.startsWith('ru')) {
385     lang = 'ru';
386   } else if (lang.startsWith('es')) {
387     lang = 'es';
388   } else if (lang.startsWith('eo')) {
389     lang = 'eo';
390   } else if (lang.startsWith('nl')) {
391     lang = 'nl';
392   } else if (lang.startsWith('it')) {
393     lang = 'it';
394   } else if (lang.startsWith('fi')) {
395     lang = 'fi';
396   } else if (lang.startsWith('ca')) {
397     lang = 'ca';
398   } else if (lang.startsWith('fa')) {
399     lang = 'fa';
400   } else if (lang.startsWith('pl')) {
401     lang = 'pl';
402   } else if (lang.startsWith('pt')) {
403     lang = 'pt-br';
404   } else if (lang.startsWith('ja')) {
405     lang = 'ja';
406   } else if (lang.startsWith('ka')) {
407     lang = 'ka';
408   } else if (lang.startsWith('hi')) {
409     lang = 'hi';
410   } else if (lang.startsWith('el')) {
411     lang = 'el';
412   } else if (lang.startsWith('eu')) {
413     lang = 'eu';
414   } else if (lang.startsWith('gl')) {
415     lang = 'gl';
416   } else if (lang.startsWith('tr')) {
417     lang = 'tr';
418   } else if (lang.startsWith('hu')) {
419     lang = 'hu';
420   } else if (lang.startsWith('uk')) {
421     lang = 'uk';
422   } else if (lang.startsWith('sq')) {
423     lang = 'sq';
424   } else if (lang.startsWith('km')) {
425     lang = 'km';
426   } else if (lang.startsWith('ga')) {
427     lang = 'ga';
428   } else if (lang.startsWith('sr')) {
429     lang = 'sr';
430   } else {
431     lang = 'en';
432   }
433   return lang;
434 }
435
436 export function setTheme(theme: string = 'darkly', loggedIn: boolean = false) {
437   // unload all the other themes
438   for (var i = 0; i < themes.length; i++) {
439     let styleSheet = document.getElementById(themes[i]);
440     if (styleSheet) {
441       styleSheet.setAttribute('disabled', 'disabled');
442     }
443   }
444
445   // if the user is not logged in, we load the default themes and let the browser decide
446   if (!loggedIn) {
447     document.getElementById('default-light').removeAttribute('disabled');
448     document.getElementById('default-dark').removeAttribute('disabled');
449   } else {
450     document
451       .getElementById('default-light')
452       .setAttribute('disabled', 'disabled');
453     document
454       .getElementById('default-dark')
455       .setAttribute('disabled', 'disabled');
456
457     // Load the theme dynamically
458     let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
459     loadCss(theme, cssLoc);
460     document.getElementById(theme).removeAttribute('disabled');
461   }
462 }
463
464 export function loadCss(id: string, loc: string) {
465   if (!document.getElementById(id)) {
466     var head = document.getElementsByTagName('head')[0];
467     var link = document.createElement('link');
468     link.id = id;
469     link.rel = 'stylesheet';
470     link.type = 'text/css';
471     link.href = loc;
472     link.media = 'all';
473     head.appendChild(link);
474   }
475 }
476
477 export function objectFlip(obj: any) {
478   const ret = {};
479   Object.keys(obj).forEach(key => {
480     ret[obj[key]] = key;
481   });
482   return ret;
483 }
484
485 export function pictrsAvatarThumbnail(src: string): string {
486   // sample url: http://localhost:8535/pictrs/image/thumbnail256/gs7xuu.jpg
487   let split = src.split('/pictrs/image');
488   let out = `${split[0]}/pictrs/image/${
489     canUseWebP() ? 'webp/' : ''
490   }thumbnail96${split[1]}`;
491   return out;
492 }
493
494 export function showAvatars(): boolean {
495   return (
496     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
497     !UserService.Instance.user
498   );
499 }
500
501 export function isCakeDay(published: string): boolean {
502   // moment(undefined) or moment.utc(undefined) returns the current date/time
503   // moment(null) or moment.utc(null) returns null
504   const userCreationDate = moment.utc(published || null).local();
505   const currentDate = moment(new Date());
506
507   return (
508     userCreationDate.date() === currentDate.date() &&
509     userCreationDate.month() === currentDate.month() &&
510     userCreationDate.year() !== currentDate.year()
511   );
512 }
513
514 // Converts to image thumbnail
515 export function pictrsImage(hash: string, thumbnail: boolean = false): string {
516   let root = `/pictrs/image`;
517
518   // Necessary for other servers / domains
519   if (hash.includes('pictrs')) {
520     let split = hash.split('/pictrs/image/');
521     root = `${split[0]}/pictrs/image`;
522     hash = split[1];
523   }
524
525   let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
526     thumbnail ? 'thumbnail256/' : ''
527   }${hash}`;
528   return out;
529 }
530
531 export function isCommentType(
532   item: Comment | PrivateMessage | Post
533 ): item is Comment {
534   return (
535     (item as Comment).community_id !== undefined &&
536     (item as Comment).content !== undefined
537   );
538 }
539
540 export function isPostType(
541   item: Comment | PrivateMessage | Post
542 ): item is Post {
543   return (item as Post).stickied !== undefined;
544 }
545
546 export function toast(text: string, background: string = 'success') {
547   let backgroundColor = `var(--${background})`;
548   Toastify({
549     text: text,
550     backgroundColor: backgroundColor,
551     gravity: 'bottom',
552     position: 'left',
553   }).showToast();
554 }
555
556 export function pictrsDeleteToast(
557   clickToDeleteText: string,
558   deletePictureText: string,
559   deleteUrl: string
560 ) {
561   let backgroundColor = `var(--light)`;
562   let toast = Toastify({
563     text: clickToDeleteText,
564     backgroundColor: backgroundColor,
565     gravity: 'top',
566     position: 'right',
567     duration: 10000,
568     onClick: () => {
569       if (toast) {
570         window.location.replace(deleteUrl);
571         alert(deletePictureText);
572         toast.hideToast();
573       }
574     },
575     close: true,
576   }).showToast();
577 }
578
579 interface NotifyInfo {
580   name: string;
581   icon: string;
582   link: string;
583   body: string;
584 }
585
586 export function messageToastify(info: NotifyInfo, router: any) {
587   let htmlBody = info.body ? md.render(info.body) : '';
588   let backgroundColor = `var(--light)`;
589
590   let toast = Toastify({
591     text: `${htmlBody}<br />${info.name}`,
592     avatar: info.icon,
593     backgroundColor: backgroundColor,
594     className: 'text-dark',
595     close: true,
596     gravity: 'top',
597     position: 'right',
598     duration: 5000,
599     onClick: () => {
600       if (toast) {
601         toast.hideToast();
602         router.history.push(info.link);
603       }
604     },
605   }).showToast();
606 }
607
608 export function notifyPost(post: Post, router: any) {
609   let info: NotifyInfo = {
610     name: post.community_name,
611     icon: post.community_icon ? post.community_icon : defaultFavIcon,
612     link: `/post/${post.id}`,
613     body: post.name,
614   };
615   notify(info, router);
616 }
617
618 export function notifyComment(comment: Comment, router: any) {
619   let info: NotifyInfo = {
620     name: comment.creator_name,
621     icon: comment.creator_avatar ? comment.creator_avatar : defaultFavIcon,
622     link: `/post/${comment.post_id}/comment/${comment.id}`,
623     body: comment.content,
624   };
625   notify(info, router);
626 }
627
628 export function notifyPrivateMessage(pm: PrivateMessage, router: any) {
629   let info: NotifyInfo = {
630     name: pm.creator_name,
631     icon: pm.creator_avatar ? pm.creator_avatar : defaultFavIcon,
632     link: `/inbox`,
633     body: pm.content,
634   };
635   notify(info, router);
636 }
637
638 function notify(info: NotifyInfo, router: any) {
639   messageToastify(info, router);
640
641   if (Notification.permission !== 'granted') Notification.requestPermission();
642   else {
643     var notification = new Notification(info.name, {
644       icon: info.icon,
645       body: info.body,
646     });
647
648     notification.onclick = () => {
649       event.preventDefault();
650       router.history.push(info.link);
651     };
652   }
653 }
654
655 export function setupTribute(): Tribute {
656   return new Tribute({
657     noMatchTemplate: function () {
658       return '';
659     },
660     collection: [
661       // Emojis
662       {
663         trigger: ':',
664         menuItemTemplate: (item: any) => {
665           let shortName = `:${item.original.key}:`;
666           return `${item.original.val} ${shortName}`;
667         },
668         selectTemplate: (item: any) => {
669           return `:${item.original.key}:`;
670         },
671         values: Object.entries(emojiShortName).map(e => {
672           return { key: e[1], val: e[0] };
673         }),
674         allowSpaces: false,
675         autocompleteMode: true,
676         menuItemLimit: mentionDropdownFetchLimit,
677         menuShowMinLength: 2,
678       },
679       // Users
680       {
681         trigger: '@',
682         selectTemplate: (item: any) => {
683           let link = item.original.local
684             ? `[${item.original.key}](/u/${item.original.name})`
685             : `[${item.original.key}](/user/${item.original.id})`;
686           return link;
687         },
688         values: (text: string, cb: any) => {
689           userSearch(text, (users: any) => cb(users));
690         },
691         allowSpaces: false,
692         autocompleteMode: true,
693         menuItemLimit: mentionDropdownFetchLimit,
694         menuShowMinLength: 2,
695       },
696
697       // Communities
698       {
699         trigger: '!',
700         selectTemplate: (item: any) => {
701           let link = item.original.local
702             ? `[${item.original.key}](/c/${item.original.name})`
703             : `[${item.original.key}](/community/${item.original.id})`;
704           return link;
705         },
706         values: (text: string, cb: any) => {
707           communitySearch(text, (communities: any) => cb(communities));
708         },
709         allowSpaces: false,
710         autocompleteMode: true,
711         menuItemLimit: mentionDropdownFetchLimit,
712         menuShowMinLength: 2,
713       },
714     ],
715   });
716 }
717
718 let tippyInstance = tippy('[data-tippy-content]');
719
720 export function setupTippy() {
721   tippyInstance.forEach(e => e.destroy());
722   tippyInstance = tippy('[data-tippy-content]', {
723     delay: [500, 0],
724     // Display on "long press"
725     touch: ['hold', 500],
726   });
727 }
728
729 function userSearch(text: string, cb: any) {
730   if (text) {
731     let form: SearchForm = {
732       q: text,
733       type_: SearchType[SearchType.Users],
734       sort: SortType[SortType.TopAll],
735       page: 1,
736       limit: mentionDropdownFetchLimit,
737     };
738
739     WebSocketService.Instance.search(form);
740
741     let userSub = WebSocketService.Instance.subject.subscribe(
742       msg => {
743         let res = wsJsonToRes(msg);
744         if (res.op == UserOperation.Search) {
745           let data = res.data as SearchResponse;
746           let users = data.users.map(u => {
747             return {
748               key: `@${u.name}@${hostname(u.actor_id)}`,
749               name: u.name,
750               local: u.local,
751               id: u.id,
752             };
753           });
754           cb(users);
755           userSub.unsubscribe();
756         }
757       },
758       err => console.error(err),
759       () => console.log('complete')
760     );
761   } else {
762     cb([]);
763   }
764 }
765
766 function communitySearch(text: string, cb: any) {
767   if (text) {
768     let form: SearchForm = {
769       q: text,
770       type_: SearchType[SearchType.Communities],
771       sort: SortType[SortType.TopAll],
772       page: 1,
773       limit: mentionDropdownFetchLimit,
774     };
775
776     WebSocketService.Instance.search(form);
777
778     let communitySub = WebSocketService.Instance.subject.subscribe(
779       msg => {
780         let res = wsJsonToRes(msg);
781         if (res.op == UserOperation.Search) {
782           let data = res.data as SearchResponse;
783           let communities = data.communities.map(c => {
784             return {
785               key: `!${c.name}@${hostname(c.actor_id)}`,
786               name: c.name,
787               local: c.local,
788               id: c.id,
789             };
790           });
791           cb(communities);
792           communitySub.unsubscribe();
793         }
794       },
795       err => console.error(err),
796       () => console.log('complete')
797     );
798   } else {
799     cb([]);
800   }
801 }
802
803 export function getListingTypeFromProps(props: any): ListingType {
804   return props.match.params.listing_type
805     ? routeListingTypeToEnum(props.match.params.listing_type)
806     : UserService.Instance.user
807     ? UserService.Instance.user.default_listing_type
808     : ListingType.All;
809 }
810
811 // TODO might need to add a user setting for this too
812 export function getDataTypeFromProps(props: any): DataType {
813   return props.match.params.data_type
814     ? routeDataTypeToEnum(props.match.params.data_type)
815     : DataType.Post;
816 }
817
818 export function getSortTypeFromProps(props: any): SortType {
819   return props.match.params.sort
820     ? routeSortTypeToEnum(props.match.params.sort)
821     : UserService.Instance.user
822     ? UserService.Instance.user.default_sort_type
823     : SortType.Active;
824 }
825
826 export function getPageFromProps(props: any): number {
827   return props.match.params.page ? Number(props.match.params.page) : 1;
828 }
829
830 export function editCommentRes(
831   data: CommentResponse,
832   comments: Array<Comment>
833 ) {
834   let found = comments.find(c => c.id == data.comment.id);
835   if (found) {
836     found.content = data.comment.content;
837     found.updated = data.comment.updated;
838     found.removed = data.comment.removed;
839     found.deleted = data.comment.deleted;
840     found.upvotes = data.comment.upvotes;
841     found.downvotes = data.comment.downvotes;
842     found.score = data.comment.score;
843   }
844 }
845
846 export function saveCommentRes(
847   data: CommentResponse,
848   comments: Array<Comment>
849 ) {
850   let found = comments.find(c => c.id == data.comment.id);
851   if (found) {
852     found.saved = data.comment.saved;
853   }
854 }
855
856 export function createCommentLikeRes(
857   data: CommentResponse,
858   comments: Array<Comment>
859 ) {
860   let found: Comment = comments.find(c => c.id === data.comment.id);
861   if (found) {
862     found.score = data.comment.score;
863     found.upvotes = data.comment.upvotes;
864     found.downvotes = data.comment.downvotes;
865     if (data.comment.my_vote !== null) {
866       found.my_vote = data.comment.my_vote;
867     }
868   }
869 }
870
871 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
872   let found = posts.find(c => c.id == data.post.id);
873   if (found) {
874     createPostLikeRes(data, found);
875   }
876 }
877
878 export function createPostLikeRes(data: PostResponse, post: Post) {
879   if (post) {
880     post.score = data.post.score;
881     post.upvotes = data.post.upvotes;
882     post.downvotes = data.post.downvotes;
883     if (data.post.my_vote !== null) {
884       post.my_vote = data.post.my_vote;
885     }
886   }
887 }
888
889 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
890   let found = posts.find(c => c.id == data.post.id);
891   if (found) {
892     editPostRes(data, found);
893   }
894 }
895
896 export function editPostRes(data: PostResponse, post: Post) {
897   if (post) {
898     post.url = data.post.url;
899     post.name = data.post.name;
900     post.nsfw = data.post.nsfw;
901     post.deleted = data.post.deleted;
902     post.removed = data.post.removed;
903     post.stickied = data.post.stickied;
904     post.body = data.post.body;
905     post.locked = data.post.locked;
906   }
907 }
908
909 export function commentsToFlatNodes(
910   comments: Array<Comment>
911 ): Array<CommentNodeI> {
912   let nodes: Array<CommentNodeI> = [];
913   for (let comment of comments) {
914     nodes.push({ comment: comment });
915   }
916   return nodes;
917 }
918
919 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
920   // First, put removed and deleted comments at the bottom, then do your other sorts
921   if (sort == CommentSortType.Top) {
922     tree.sort(
923       (a, b) =>
924         +a.comment.removed - +b.comment.removed ||
925         +a.comment.deleted - +b.comment.deleted ||
926         b.comment.score - a.comment.score
927     );
928   } else if (sort == CommentSortType.New) {
929     tree.sort(
930       (a, b) =>
931         +a.comment.removed - +b.comment.removed ||
932         +a.comment.deleted - +b.comment.deleted ||
933         b.comment.published.localeCompare(a.comment.published)
934     );
935   } else if (sort == CommentSortType.Old) {
936     tree.sort(
937       (a, b) =>
938         +a.comment.removed - +b.comment.removed ||
939         +a.comment.deleted - +b.comment.deleted ||
940         a.comment.published.localeCompare(b.comment.published)
941     );
942   } else if (sort == CommentSortType.Hot) {
943     tree.sort(
944       (a, b) =>
945         +a.comment.removed - +b.comment.removed ||
946         +a.comment.deleted - +b.comment.deleted ||
947         hotRankComment(b.comment) - hotRankComment(a.comment)
948     );
949   }
950
951   // Go through the children recursively
952   for (let node of tree) {
953     if (node.children) {
954       commentSort(node.children, sort);
955     }
956   }
957 }
958
959 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
960   commentSort(tree, convertCommentSortType(sort));
961 }
962
963 function convertCommentSortType(sort: SortType): CommentSortType {
964   if (
965     sort == SortType.TopAll ||
966     sort == SortType.TopDay ||
967     sort == SortType.TopWeek ||
968     sort == SortType.TopMonth ||
969     sort == SortType.TopYear
970   ) {
971     return CommentSortType.Top;
972   } else if (sort == SortType.New) {
973     return CommentSortType.New;
974   } else if (sort == SortType.Hot || sort == SortType.Active) {
975     return CommentSortType.Hot;
976   } else {
977     return CommentSortType.Hot;
978   }
979 }
980
981 export function postSort(
982   posts: Array<Post>,
983   sort: SortType,
984   communityType: boolean
985 ) {
986   // First, put removed and deleted comments at the bottom, then do your other sorts
987   if (
988     sort == SortType.TopAll ||
989     sort == SortType.TopDay ||
990     sort == SortType.TopWeek ||
991     sort == SortType.TopMonth ||
992     sort == SortType.TopYear
993   ) {
994     posts.sort(
995       (a, b) =>
996         +a.removed - +b.removed ||
997         +a.deleted - +b.deleted ||
998         (communityType && +b.stickied - +a.stickied) ||
999         b.score - a.score
1000     );
1001   } else if (sort == SortType.New) {
1002     posts.sort(
1003       (a, b) =>
1004         +a.removed - +b.removed ||
1005         +a.deleted - +b.deleted ||
1006         (communityType && +b.stickied - +a.stickied) ||
1007         b.published.localeCompare(a.published)
1008     );
1009   } else if (sort == SortType.Hot) {
1010     posts.sort(
1011       (a, b) =>
1012         +a.removed - +b.removed ||
1013         +a.deleted - +b.deleted ||
1014         (communityType && +b.stickied - +a.stickied) ||
1015         b.hot_rank - a.hot_rank
1016     );
1017   } else if (sort == SortType.Active) {
1018     posts.sort(
1019       (a, b) =>
1020         +a.removed - +b.removed ||
1021         +a.deleted - +b.deleted ||
1022         (communityType && +b.stickied - +a.stickied) ||
1023         b.hot_rank_active - a.hot_rank_active
1024     );
1025   }
1026 }
1027
1028 export const colorList: Array<string> = [
1029   hsl(0),
1030   hsl(100),
1031   hsl(150),
1032   hsl(200),
1033   hsl(250),
1034   hsl(300),
1035 ];
1036
1037 function hsl(num: number) {
1038   return `hsla(${num}, 35%, 50%, 1)`;
1039 }
1040
1041 function randomHsl() {
1042   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
1043 }
1044
1045 export function previewLines(
1046   text: string,
1047   maxChars: number = 300,
1048   maxLines: number = 1
1049 ): string {
1050   return (
1051     text
1052       .slice(0, maxChars)
1053       .split('\n')
1054       // Use lines * 2 because markdown requires 2 lines
1055       .slice(0, maxLines * 2)
1056       .join('\n') + '...'
1057   );
1058 }
1059
1060 export function hostname(url: string): string {
1061   let cUrl = new URL(url);
1062   return window.location.port
1063     ? `${cUrl.hostname}:${cUrl.port}`
1064     : `${cUrl.hostname}`;
1065 }
1066
1067 function canUseWebP() {
1068   // TODO pictshare might have a webp conversion bug, try disabling this
1069   return false;
1070
1071   // var elem = document.createElement('canvas');
1072   // if (!!(elem.getContext && elem.getContext('2d'))) {
1073   //   var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
1074   //   // was able or not to get WebP representation
1075   //   return (
1076   //     elem.toDataURL('image/webp').startsWith('data:image/' + testString)
1077   //   );
1078   // }
1079
1080   // // very old browser like IE 8, canvas not supported
1081   // return false;
1082 }
1083
1084 export function validTitle(title?: string): boolean {
1085   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1086   if (title === null || title.length < 3) return true;
1087
1088   const regex = new RegExp(/.*\S.*/, 'g');
1089
1090   return regex.test(title);
1091 }
1092
1093 export function siteBannerCss(banner: string): string {
1094   return ` \
1095     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1096     background-attachment: fixed; \
1097     background-position: top; \
1098     background-repeat: no-repeat; \
1099     background-size: 100% cover; \
1100
1101     width: 100%; \
1102     max-height: 100vh; \
1103     `;
1104 }