]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Switch front end to use lemmy-js-client. Fixes #1090 (#1097)
[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   ListingType,
38   SearchType,
39   WebSocketResponse,
40   WebSocketJsonResponse,
41   SearchForm,
42   SearchResponse,
43   CommentResponse,
44   PostResponse,
45 } from 'lemmy-js-client';
46
47 import { CommentSortType, DataType } 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   return SortType[sort];
277 }
278
279 export function routeListingTypeToEnum(type: string): ListingType {
280   return ListingType[type];
281 }
282
283 export function routeDataTypeToEnum(type: string): DataType {
284   return DataType[capitalizeFirstLetter(type)];
285 }
286
287 export function routeSearchTypeToEnum(type: string): SearchType {
288   return SearchType[capitalizeFirstLetter(type)];
289 }
290
291 export async function getPageTitle(url: string) {
292   let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
293   let title = await res.title;
294   return title;
295 }
296
297 export function debounce(
298   func: any,
299   wait: number = 1000,
300   immediate: boolean = false
301 ) {
302   // 'private' variable for instance
303   // The returned function will be able to reference this due to closure.
304   // Each call to the returned function will share this common timer.
305   let timeout: any;
306
307   // Calling debounce returns a new anonymous function
308   return function () {
309     // reference the context and args for the setTimeout function
310     var context = this,
311       args = arguments;
312
313     // Should the function be called now? If immediate is true
314     //   and not already in a timeout then the answer is: Yes
315     var callNow = immediate && !timeout;
316
317     // This is the basic debounce behaviour where you can call this
318     //   function several times, but it will only execute once
319     //   [before or after imposing a delay].
320     //   Each time the returned function is called, the timer starts over.
321     clearTimeout(timeout);
322
323     // Set the new timeout
324     timeout = setTimeout(function () {
325       // Inside the timeout function, clear the timeout variable
326       // which will let the next execution run when in 'immediate' mode
327       timeout = null;
328
329       // Check if the function already ran with the immediate flag
330       if (!immediate) {
331         // Call the original function with apply
332         // apply lets you define the 'this' object as well as the arguments
333         //    (both captured before setTimeout)
334         func.apply(context, args);
335       }
336     }, wait);
337
338     // Immediate mode and no wait timer? Execute the function..
339     if (callNow) func.apply(context, args);
340   };
341 }
342
343 export function getLanguage(override?: string): string {
344   let user = UserService.Instance.user;
345   let lang = override || (user && user.lang ? user.lang : 'browser');
346
347   if (lang == 'browser') {
348     return getBrowserLanguage();
349   } else {
350     return lang;
351   }
352 }
353
354 export function getBrowserLanguage(): string {
355   return navigator.language;
356 }
357
358 export function getMomentLanguage(): string {
359   let lang = getLanguage();
360   if (lang.startsWith('zh')) {
361     lang = 'zh-cn';
362   } else if (lang.startsWith('sv')) {
363     lang = 'sv';
364   } else if (lang.startsWith('fr')) {
365     lang = 'fr';
366   } else if (lang.startsWith('de')) {
367     lang = 'de';
368   } else if (lang.startsWith('ru')) {
369     lang = 'ru';
370   } else if (lang.startsWith('es')) {
371     lang = 'es';
372   } else if (lang.startsWith('eo')) {
373     lang = 'eo';
374   } else if (lang.startsWith('nl')) {
375     lang = 'nl';
376   } else if (lang.startsWith('it')) {
377     lang = 'it';
378   } else if (lang.startsWith('fi')) {
379     lang = 'fi';
380   } else if (lang.startsWith('ca')) {
381     lang = 'ca';
382   } else if (lang.startsWith('fa')) {
383     lang = 'fa';
384   } else if (lang.startsWith('pl')) {
385     lang = 'pl';
386   } else if (lang.startsWith('pt')) {
387     lang = 'pt-br';
388   } else if (lang.startsWith('ja')) {
389     lang = 'ja';
390   } else if (lang.startsWith('ka')) {
391     lang = 'ka';
392   } else if (lang.startsWith('hi')) {
393     lang = 'hi';
394   } else if (lang.startsWith('el')) {
395     lang = 'el';
396   } else if (lang.startsWith('eu')) {
397     lang = 'eu';
398   } else if (lang.startsWith('gl')) {
399     lang = 'gl';
400   } else if (lang.startsWith('tr')) {
401     lang = 'tr';
402   } else if (lang.startsWith('hu')) {
403     lang = 'hu';
404   } else if (lang.startsWith('uk')) {
405     lang = 'uk';
406   } else if (lang.startsWith('sq')) {
407     lang = 'sq';
408   } else if (lang.startsWith('km')) {
409     lang = 'km';
410   } else if (lang.startsWith('ga')) {
411     lang = 'ga';
412   } else if (lang.startsWith('sr')) {
413     lang = 'sr';
414   } else {
415     lang = 'en';
416   }
417   return lang;
418 }
419
420 export function setTheme(theme: string = 'darkly', loggedIn: boolean = false) {
421   // unload all the other themes
422   for (var i = 0; i < themes.length; i++) {
423     let styleSheet = document.getElementById(themes[i]);
424     if (styleSheet) {
425       styleSheet.setAttribute('disabled', 'disabled');
426     }
427   }
428
429   // if the user is not logged in, we load the default themes and let the browser decide
430   if (!loggedIn) {
431     document.getElementById('default-light').removeAttribute('disabled');
432     document.getElementById('default-dark').removeAttribute('disabled');
433   } else {
434     document
435       .getElementById('default-light')
436       .setAttribute('disabled', 'disabled');
437     document
438       .getElementById('default-dark')
439       .setAttribute('disabled', 'disabled');
440
441     // Load the theme dynamically
442     let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
443     loadCss(theme, cssLoc);
444     document.getElementById(theme).removeAttribute('disabled');
445   }
446 }
447
448 export function loadCss(id: string, loc: string) {
449   if (!document.getElementById(id)) {
450     var head = document.getElementsByTagName('head')[0];
451     var link = document.createElement('link');
452     link.id = id;
453     link.rel = 'stylesheet';
454     link.type = 'text/css';
455     link.href = loc;
456     link.media = 'all';
457     head.appendChild(link);
458   }
459 }
460
461 export function objectFlip(obj: any) {
462   const ret = {};
463   Object.keys(obj).forEach(key => {
464     ret[obj[key]] = key;
465   });
466   return ret;
467 }
468
469 export function pictrsAvatarThumbnail(src: string): string {
470   // sample url: http://localhost:8535/pictrs/image/thumbnail256/gs7xuu.jpg
471   let split = src.split('/pictrs/image');
472   let out = `${split[0]}/pictrs/image/${
473     canUseWebP() ? 'webp/' : ''
474   }thumbnail96${split[1]}`;
475   return out;
476 }
477
478 export function showAvatars(): boolean {
479   return (
480     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
481     !UserService.Instance.user
482   );
483 }
484
485 export function isCakeDay(published: string): boolean {
486   // moment(undefined) or moment.utc(undefined) returns the current date/time
487   // moment(null) or moment.utc(null) returns null
488   const userCreationDate = moment.utc(published || null).local();
489   const currentDate = moment(new Date());
490
491   return (
492     userCreationDate.date() === currentDate.date() &&
493     userCreationDate.month() === currentDate.month() &&
494     userCreationDate.year() !== currentDate.year()
495   );
496 }
497
498 // Converts to image thumbnail
499 export function pictrsImage(hash: string, thumbnail: boolean = false): string {
500   let root = `/pictrs/image`;
501
502   // Necessary for other servers / domains
503   if (hash.includes('pictrs')) {
504     let split = hash.split('/pictrs/image/');
505     root = `${split[0]}/pictrs/image`;
506     hash = split[1];
507   }
508
509   let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
510     thumbnail ? 'thumbnail256/' : ''
511   }${hash}`;
512   return out;
513 }
514
515 export function isCommentType(
516   item: Comment | PrivateMessage | Post
517 ): item is Comment {
518   return (
519     (item as Comment).community_id !== undefined &&
520     (item as Comment).content !== undefined
521   );
522 }
523
524 export function isPostType(
525   item: Comment | PrivateMessage | Post
526 ): item is Post {
527   return (item as Post).stickied !== undefined;
528 }
529
530 export function toast(text: string, background: string = 'success') {
531   let backgroundColor = `var(--${background})`;
532   Toastify({
533     text: text,
534     backgroundColor: backgroundColor,
535     gravity: 'bottom',
536     position: 'left',
537   }).showToast();
538 }
539
540 export function pictrsDeleteToast(
541   clickToDeleteText: string,
542   deletePictureText: string,
543   deleteUrl: string
544 ) {
545   let backgroundColor = `var(--light)`;
546   let toast = Toastify({
547     text: clickToDeleteText,
548     backgroundColor: backgroundColor,
549     gravity: 'top',
550     position: 'right',
551     duration: 10000,
552     onClick: () => {
553       if (toast) {
554         window.location.replace(deleteUrl);
555         alert(deletePictureText);
556         toast.hideToast();
557       }
558     },
559     close: true,
560   }).showToast();
561 }
562
563 interface NotifyInfo {
564   name: string;
565   icon: string;
566   link: string;
567   body: string;
568 }
569
570 export function messageToastify(info: NotifyInfo, router: any) {
571   let htmlBody = info.body ? md.render(info.body) : '';
572   let backgroundColor = `var(--light)`;
573
574   let toast = Toastify({
575     text: `${htmlBody}<br />${info.name}`,
576     avatar: info.icon,
577     backgroundColor: backgroundColor,
578     className: 'text-dark',
579     close: true,
580     gravity: 'top',
581     position: 'right',
582     duration: 5000,
583     onClick: () => {
584       if (toast) {
585         toast.hideToast();
586         router.history.push(info.link);
587       }
588     },
589   }).showToast();
590 }
591
592 export function notifyPost(post: Post, router: any) {
593   let info: NotifyInfo = {
594     name: post.community_name,
595     icon: post.community_icon ? post.community_icon : defaultFavIcon,
596     link: `/post/${post.id}`,
597     body: post.name,
598   };
599   notify(info, router);
600 }
601
602 export function notifyComment(comment: Comment, router: any) {
603   let info: NotifyInfo = {
604     name: comment.creator_name,
605     icon: comment.creator_avatar ? comment.creator_avatar : defaultFavIcon,
606     link: `/post/${comment.post_id}/comment/${comment.id}`,
607     body: comment.content,
608   };
609   notify(info, router);
610 }
611
612 export function notifyPrivateMessage(pm: PrivateMessage, router: any) {
613   let info: NotifyInfo = {
614     name: pm.creator_name,
615     icon: pm.creator_avatar ? pm.creator_avatar : defaultFavIcon,
616     link: `/inbox`,
617     body: pm.content,
618   };
619   notify(info, router);
620 }
621
622 function notify(info: NotifyInfo, router: any) {
623   messageToastify(info, router);
624
625   if (Notification.permission !== 'granted') Notification.requestPermission();
626   else {
627     var notification = new Notification(info.name, {
628       icon: info.icon,
629       body: info.body,
630     });
631
632     notification.onclick = () => {
633       event.preventDefault();
634       router.history.push(info.link);
635     };
636   }
637 }
638
639 export function setupTribute(): Tribute {
640   return new Tribute({
641     noMatchTemplate: function () {
642       return '';
643     },
644     collection: [
645       // Emojis
646       {
647         trigger: ':',
648         menuItemTemplate: (item: any) => {
649           let shortName = `:${item.original.key}:`;
650           return `${item.original.val} ${shortName}`;
651         },
652         selectTemplate: (item: any) => {
653           return `:${item.original.key}:`;
654         },
655         values: Object.entries(emojiShortName).map(e => {
656           return { key: e[1], val: e[0] };
657         }),
658         allowSpaces: false,
659         autocompleteMode: true,
660         menuItemLimit: mentionDropdownFetchLimit,
661         menuShowMinLength: 2,
662       },
663       // Users
664       {
665         trigger: '@',
666         selectTemplate: (item: any) => {
667           let link = item.original.local
668             ? `[${item.original.key}](/u/${item.original.name})`
669             : `[${item.original.key}](/user/${item.original.id})`;
670           return link;
671         },
672         values: (text: string, cb: any) => {
673           userSearch(text, (users: any) => cb(users));
674         },
675         allowSpaces: false,
676         autocompleteMode: true,
677         menuItemLimit: mentionDropdownFetchLimit,
678         menuShowMinLength: 2,
679       },
680
681       // Communities
682       {
683         trigger: '!',
684         selectTemplate: (item: any) => {
685           let link = item.original.local
686             ? `[${item.original.key}](/c/${item.original.name})`
687             : `[${item.original.key}](/community/${item.original.id})`;
688           return link;
689         },
690         values: (text: string, cb: any) => {
691           communitySearch(text, (communities: any) => cb(communities));
692         },
693         allowSpaces: false,
694         autocompleteMode: true,
695         menuItemLimit: mentionDropdownFetchLimit,
696         menuShowMinLength: 2,
697       },
698     ],
699   });
700 }
701
702 let tippyInstance = tippy('[data-tippy-content]');
703
704 export function setupTippy() {
705   tippyInstance.forEach(e => e.destroy());
706   tippyInstance = tippy('[data-tippy-content]', {
707     delay: [500, 0],
708     // Display on "long press"
709     touch: ['hold', 500],
710   });
711 }
712
713 function userSearch(text: string, cb: any) {
714   if (text) {
715     let form: SearchForm = {
716       q: text,
717       type_: SearchType.Users,
718       sort: SortType.TopAll,
719       page: 1,
720       limit: mentionDropdownFetchLimit,
721     };
722
723     WebSocketService.Instance.search(form);
724
725     let userSub = WebSocketService.Instance.subject.subscribe(
726       msg => {
727         let res = wsJsonToRes(msg);
728         if (res.op == UserOperation.Search) {
729           let data = res.data as SearchResponse;
730           let users = data.users.map(u => {
731             return {
732               key: `@${u.name}@${hostname(u.actor_id)}`,
733               name: u.name,
734               local: u.local,
735               id: u.id,
736             };
737           });
738           cb(users);
739           userSub.unsubscribe();
740         }
741       },
742       err => console.error(err),
743       () => console.log('complete')
744     );
745   } else {
746     cb([]);
747   }
748 }
749
750 function communitySearch(text: string, cb: any) {
751   if (text) {
752     let form: SearchForm = {
753       q: text,
754       type_: SearchType.Communities,
755       sort: SortType.TopAll,
756       page: 1,
757       limit: mentionDropdownFetchLimit,
758     };
759
760     WebSocketService.Instance.search(form);
761
762     let communitySub = WebSocketService.Instance.subject.subscribe(
763       msg => {
764         let res = wsJsonToRes(msg);
765         if (res.op == UserOperation.Search) {
766           let data = res.data as SearchResponse;
767           let communities = data.communities.map(c => {
768             return {
769               key: `!${c.name}@${hostname(c.actor_id)}`,
770               name: c.name,
771               local: c.local,
772               id: c.id,
773             };
774           });
775           cb(communities);
776           communitySub.unsubscribe();
777         }
778       },
779       err => console.error(err),
780       () => console.log('complete')
781     );
782   } else {
783     cb([]);
784   }
785 }
786
787 export function getListingTypeFromProps(props: any): ListingType {
788   return props.match.params.listing_type
789     ? routeListingTypeToEnum(props.match.params.listing_type)
790     : UserService.Instance.user
791     ? Object.values(ListingType)[UserService.Instance.user.default_listing_type]
792     : ListingType.All;
793 }
794
795 // TODO might need to add a user setting for this too
796 export function getDataTypeFromProps(props: any): DataType {
797   return props.match.params.data_type
798     ? routeDataTypeToEnum(props.match.params.data_type)
799     : DataType.Post;
800 }
801
802 export function getSortTypeFromProps(props: any): SortType {
803   return props.match.params.sort
804     ? routeSortTypeToEnum(props.match.params.sort)
805     : UserService.Instance.user
806     ? Object.values(SortType)[UserService.Instance.user.default_sort_type]
807     : SortType.Active;
808 }
809
810 export function getPageFromProps(props: any): number {
811   return props.match.params.page ? Number(props.match.params.page) : 1;
812 }
813
814 export function editCommentRes(
815   data: CommentResponse,
816   comments: Array<Comment>
817 ) {
818   let found = comments.find(c => c.id == data.comment.id);
819   if (found) {
820     found.content = data.comment.content;
821     found.updated = data.comment.updated;
822     found.removed = data.comment.removed;
823     found.deleted = data.comment.deleted;
824     found.upvotes = data.comment.upvotes;
825     found.downvotes = data.comment.downvotes;
826     found.score = data.comment.score;
827   }
828 }
829
830 export function saveCommentRes(
831   data: CommentResponse,
832   comments: Array<Comment>
833 ) {
834   let found = comments.find(c => c.id == data.comment.id);
835   if (found) {
836     found.saved = data.comment.saved;
837   }
838 }
839
840 export function createCommentLikeRes(
841   data: CommentResponse,
842   comments: Array<Comment>
843 ) {
844   let found: Comment = comments.find(c => c.id === data.comment.id);
845   if (found) {
846     found.score = data.comment.score;
847     found.upvotes = data.comment.upvotes;
848     found.downvotes = data.comment.downvotes;
849     if (data.comment.my_vote !== null) {
850       found.my_vote = data.comment.my_vote;
851     }
852   }
853 }
854
855 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
856   let found = posts.find(c => c.id == data.post.id);
857   if (found) {
858     createPostLikeRes(data, found);
859   }
860 }
861
862 export function createPostLikeRes(data: PostResponse, post: Post) {
863   if (post) {
864     post.score = data.post.score;
865     post.upvotes = data.post.upvotes;
866     post.downvotes = data.post.downvotes;
867     if (data.post.my_vote !== null) {
868       post.my_vote = data.post.my_vote;
869     }
870   }
871 }
872
873 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
874   let found = posts.find(c => c.id == data.post.id);
875   if (found) {
876     editPostRes(data, found);
877   }
878 }
879
880 export function editPostRes(data: PostResponse, post: Post) {
881   if (post) {
882     post.url = data.post.url;
883     post.name = data.post.name;
884     post.nsfw = data.post.nsfw;
885     post.deleted = data.post.deleted;
886     post.removed = data.post.removed;
887     post.stickied = data.post.stickied;
888     post.body = data.post.body;
889     post.locked = data.post.locked;
890   }
891 }
892
893 export function commentsToFlatNodes(
894   comments: Array<Comment>
895 ): Array<CommentNodeI> {
896   let nodes: Array<CommentNodeI> = [];
897   for (let comment of comments) {
898     nodes.push({ comment: comment });
899   }
900   return nodes;
901 }
902
903 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
904   // First, put removed and deleted comments at the bottom, then do your other sorts
905   if (sort == CommentSortType.Top) {
906     tree.sort(
907       (a, b) =>
908         +a.comment.removed - +b.comment.removed ||
909         +a.comment.deleted - +b.comment.deleted ||
910         b.comment.score - a.comment.score
911     );
912   } else if (sort == CommentSortType.New) {
913     tree.sort(
914       (a, b) =>
915         +a.comment.removed - +b.comment.removed ||
916         +a.comment.deleted - +b.comment.deleted ||
917         b.comment.published.localeCompare(a.comment.published)
918     );
919   } else if (sort == CommentSortType.Old) {
920     tree.sort(
921       (a, b) =>
922         +a.comment.removed - +b.comment.removed ||
923         +a.comment.deleted - +b.comment.deleted ||
924         a.comment.published.localeCompare(b.comment.published)
925     );
926   } else if (sort == CommentSortType.Hot) {
927     tree.sort(
928       (a, b) =>
929         +a.comment.removed - +b.comment.removed ||
930         +a.comment.deleted - +b.comment.deleted ||
931         hotRankComment(b.comment) - hotRankComment(a.comment)
932     );
933   }
934
935   // Go through the children recursively
936   for (let node of tree) {
937     if (node.children) {
938       commentSort(node.children, sort);
939     }
940   }
941 }
942
943 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
944   commentSort(tree, convertCommentSortType(sort));
945 }
946
947 function convertCommentSortType(sort: SortType): CommentSortType {
948   if (
949     sort == SortType.TopAll ||
950     sort == SortType.TopDay ||
951     sort == SortType.TopWeek ||
952     sort == SortType.TopMonth ||
953     sort == SortType.TopYear
954   ) {
955     return CommentSortType.Top;
956   } else if (sort == SortType.New) {
957     return CommentSortType.New;
958   } else if (sort == SortType.Hot || sort == SortType.Active) {
959     return CommentSortType.Hot;
960   } else {
961     return CommentSortType.Hot;
962   }
963 }
964
965 export function postSort(
966   posts: Array<Post>,
967   sort: SortType,
968   communityType: boolean
969 ) {
970   // First, put removed and deleted comments at the bottom, then do your other sorts
971   if (
972     sort == SortType.TopAll ||
973     sort == SortType.TopDay ||
974     sort == SortType.TopWeek ||
975     sort == SortType.TopMonth ||
976     sort == SortType.TopYear
977   ) {
978     posts.sort(
979       (a, b) =>
980         +a.removed - +b.removed ||
981         +a.deleted - +b.deleted ||
982         (communityType && +b.stickied - +a.stickied) ||
983         b.score - a.score
984     );
985   } else if (sort == SortType.New) {
986     posts.sort(
987       (a, b) =>
988         +a.removed - +b.removed ||
989         +a.deleted - +b.deleted ||
990         (communityType && +b.stickied - +a.stickied) ||
991         b.published.localeCompare(a.published)
992     );
993   } else if (sort == SortType.Hot) {
994     posts.sort(
995       (a, b) =>
996         +a.removed - +b.removed ||
997         +a.deleted - +b.deleted ||
998         (communityType && +b.stickied - +a.stickied) ||
999         b.hot_rank - a.hot_rank
1000     );
1001   } else if (sort == SortType.Active) {
1002     posts.sort(
1003       (a, b) =>
1004         +a.removed - +b.removed ||
1005         +a.deleted - +b.deleted ||
1006         (communityType && +b.stickied - +a.stickied) ||
1007         b.hot_rank_active - a.hot_rank_active
1008     );
1009   }
1010 }
1011
1012 export const colorList: Array<string> = [
1013   hsl(0),
1014   hsl(100),
1015   hsl(150),
1016   hsl(200),
1017   hsl(250),
1018   hsl(300),
1019 ];
1020
1021 function hsl(num: number) {
1022   return `hsla(${num}, 35%, 50%, 1)`;
1023 }
1024
1025 function randomHsl() {
1026   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
1027 }
1028
1029 export function previewLines(
1030   text: string,
1031   maxChars: number = 300,
1032   maxLines: number = 1
1033 ): string {
1034   return (
1035     text
1036       .slice(0, maxChars)
1037       .split('\n')
1038       // Use lines * 2 because markdown requires 2 lines
1039       .slice(0, maxLines * 2)
1040       .join('\n') + '...'
1041   );
1042 }
1043
1044 export function hostname(url: string): string {
1045   let cUrl = new URL(url);
1046   return window.location.port
1047     ? `${cUrl.hostname}:${cUrl.port}`
1048     : `${cUrl.hostname}`;
1049 }
1050
1051 function canUseWebP() {
1052   // TODO pictshare might have a webp conversion bug, try disabling this
1053   return false;
1054
1055   // var elem = document.createElement('canvas');
1056   // if (!!(elem.getContext && elem.getContext('2d'))) {
1057   //   var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
1058   //   // was able or not to get WebP representation
1059   //   return (
1060   //     elem.toDataURL('image/webp').startsWith('data:image/' + testString)
1061   //   );
1062   // }
1063
1064   // // very old browser like IE 8, canvas not supported
1065   // return false;
1066 }
1067
1068 export function validTitle(title?: string): boolean {
1069   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1070   if (title === null || title.length < 3) return true;
1071
1072   const regex = new RegExp(/.*\S.*/, 'g');
1073
1074   return regex.test(title);
1075 }
1076
1077 export function siteBannerCss(banner: string): string {
1078   return ` \
1079     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1080     background-attachment: fixed; \
1081     background-position: top; \
1082     background-repeat: no-repeat; \
1083     background-size: 100% cover; \
1084
1085     width: 100%; \
1086     max-height: 100vh; \
1087     `;
1088 }