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