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