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