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