]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Show full scores on hover for posts and comments. Fixes #592
[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 as CommentNodeI,
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
408 export function pictshareImage(
409   hash: string,
410   thumbnail: boolean = false
411 ): string {
412   let root = `/pictshare`;
413
414   // Necessary for other servers / domains
415   if (hash.includes('pictshare')) {
416     let split = hash.split('/pictshare/');
417     root = `${split[0]}/pictshare`;
418     hash = split[1];
419   }
420
421   let out = `${root}/${thumbnail ? '192/' : ''}${hash}`;
422   return out;
423 }
424
425 export function isCommentType(item: Comment | PrivateMessage): item is Comment {
426   return (item as Comment).community_id !== undefined;
427 }
428
429 export function toast(text: string, background: string = 'success') {
430   let backgroundColor = `var(--${background})`;
431   Toastify({
432     text: text,
433     backgroundColor: backgroundColor,
434     gravity: 'bottom',
435     position: 'left',
436   }).showToast();
437 }
438
439 export function setupTribute(): Tribute {
440   return new Tribute({
441     collection: [
442       // Emojis
443       {
444         trigger: ':',
445         menuItemTemplate: (item: any) => {
446           let emoji = `:${item.original.key}:`;
447           return `${item.original.val} ${emoji}`;
448         },
449         selectTemplate: (item: any) => {
450           return `:${item.original.key}:`;
451         },
452         values: Object.entries(emojiShortName).map(e => {
453           return { key: e[1], val: e[0] };
454         }),
455         allowSpaces: false,
456         autocompleteMode: true,
457         menuItemLimit: mentionDropdownFetchLimit,
458         menuShowMinLength: 2,
459       },
460       // Users
461       {
462         trigger: '@',
463         selectTemplate: (item: any) => {
464           return `[/u/${item.original.key}](/u/${item.original.key})`;
465         },
466         values: (text: string, cb: any) => {
467           userSearch(text, (users: any) => cb(users));
468         },
469         allowSpaces: false,
470         autocompleteMode: true,
471         menuItemLimit: mentionDropdownFetchLimit,
472         menuShowMinLength: 2,
473       },
474
475       // Communities
476       {
477         trigger: '#',
478         selectTemplate: (item: any) => {
479           return `[/c/${item.original.key}](/c/${item.original.key})`;
480         },
481         values: (text: string, cb: any) => {
482           communitySearch(text, (communities: any) => cb(communities));
483         },
484         allowSpaces: false,
485         autocompleteMode: true,
486         menuItemLimit: mentionDropdownFetchLimit,
487         menuShowMinLength: 2,
488       },
489     ],
490   });
491 }
492
493 let tippyInstance = tippy('[data-tippy-content]');
494
495 export function setupTippy() {
496   tippyInstance.forEach(e => e.destroy());
497   tippyInstance = tippy('[data-tippy-content]', {
498     delay: [500, 0],
499     // Display on "long press"
500     touch: ['hold', 500],
501   });
502 }
503
504 function userSearch(text: string, cb: any) {
505   if (text) {
506     let form: SearchForm = {
507       q: text,
508       type_: SearchType[SearchType.Users],
509       sort: SortType[SortType.TopAll],
510       page: 1,
511       limit: mentionDropdownFetchLimit,
512     };
513
514     WebSocketService.Instance.search(form);
515
516     this.userSub = WebSocketService.Instance.subject.subscribe(
517       msg => {
518         let res = wsJsonToRes(msg);
519         if (res.op == UserOperation.Search) {
520           let data = res.data as SearchResponse;
521           let users = data.users.map(u => {
522             return { key: u.name };
523           });
524           cb(users);
525           this.userSub.unsubscribe();
526         }
527       },
528       err => console.error(err),
529       () => console.log('complete')
530     );
531   } else {
532     cb([]);
533   }
534 }
535
536 function communitySearch(text: string, cb: any) {
537   if (text) {
538     let form: SearchForm = {
539       q: text,
540       type_: SearchType[SearchType.Communities],
541       sort: SortType[SortType.TopAll],
542       page: 1,
543       limit: mentionDropdownFetchLimit,
544     };
545
546     WebSocketService.Instance.search(form);
547
548     this.communitySub = WebSocketService.Instance.subject.subscribe(
549       msg => {
550         let res = wsJsonToRes(msg);
551         if (res.op == UserOperation.Search) {
552           let data = res.data as SearchResponse;
553           let communities = data.communities.map(u => {
554             return { key: u.name };
555           });
556           cb(communities);
557           this.communitySub.unsubscribe();
558         }
559       },
560       err => console.error(err),
561       () => console.log('complete')
562     );
563   } else {
564     cb([]);
565   }
566 }
567
568 export function getListingTypeFromProps(props: any): ListingType {
569   return props.match.params.listing_type
570     ? routeListingTypeToEnum(props.match.params.listing_type)
571     : UserService.Instance.user
572     ? UserService.Instance.user.default_listing_type
573     : ListingType.All;
574 }
575
576 // TODO might need to add a user setting for this too
577 export function getDataTypeFromProps(props: any): DataType {
578   return props.match.params.data_type
579     ? routeDataTypeToEnum(props.match.params.data_type)
580     : DataType.Post;
581 }
582
583 export function getSortTypeFromProps(props: any): SortType {
584   return props.match.params.sort
585     ? routeSortTypeToEnum(props.match.params.sort)
586     : UserService.Instance.user
587     ? UserService.Instance.user.default_sort_type
588     : SortType.Hot;
589 }
590
591 export function getPageFromProps(props: any): number {
592   return props.match.params.page ? Number(props.match.params.page) : 1;
593 }
594
595 export function editCommentRes(
596   data: CommentResponse,
597   comments: Array<Comment>
598 ) {
599   let found = comments.find(c => c.id == data.comment.id);
600   if (found) {
601     found.content = data.comment.content;
602     found.updated = data.comment.updated;
603     found.removed = data.comment.removed;
604     found.deleted = data.comment.deleted;
605     found.upvotes = data.comment.upvotes;
606     found.downvotes = data.comment.downvotes;
607     found.score = data.comment.score;
608   }
609 }
610
611 export function saveCommentRes(
612   data: CommentResponse,
613   comments: Array<Comment>
614 ) {
615   let found = comments.find(c => c.id == data.comment.id);
616   if (found) {
617     found.saved = data.comment.saved;
618   }
619 }
620
621 export function createCommentLikeRes(
622   data: CommentResponse,
623   comments: Array<Comment>
624 ) {
625   let found: Comment = comments.find(c => c.id === data.comment.id);
626   if (found) {
627     found.score = data.comment.score;
628     found.upvotes = data.comment.upvotes;
629     found.downvotes = data.comment.downvotes;
630     if (data.comment.my_vote !== null) {
631       found.my_vote = data.comment.my_vote;
632     }
633   }
634 }
635
636 export function createPostLikeFindRes(data: PostResponse, posts: Array<Post>) {
637   let found = posts.find(c => c.id == data.post.id);
638   if (found) {
639     createPostLikeRes(data, found);
640   }
641 }
642
643 export function createPostLikeRes(data: PostResponse, post: Post) {
644   if (post) {
645     post.score = data.post.score;
646     post.upvotes = data.post.upvotes;
647     post.downvotes = data.post.downvotes;
648     if (data.post.my_vote !== null) {
649       post.my_vote = data.post.my_vote;
650     }
651   }
652 }
653
654 export function editPostFindRes(data: PostResponse, posts: Array<Post>) {
655   let found = posts.find(c => c.id == data.post.id);
656   if (found) {
657     editPostRes(data, found);
658   }
659 }
660
661 export function editPostRes(data: PostResponse, post: Post) {
662   if (post) {
663     post.url = data.post.url;
664     post.name = data.post.name;
665     post.nsfw = data.post.nsfw;
666   }
667 }
668
669 export function commentsToFlatNodes(
670   comments: Array<Comment>
671 ): Array<CommentNodeI> {
672   let nodes: Array<CommentNodeI> = [];
673   for (let comment of comments) {
674     nodes.push({ comment: comment });
675   }
676   return nodes;
677 }
678
679 export function commentSort(tree: Array<CommentNodeI>, sort: CommentSortType) {
680   // First, put removed and deleted comments at the bottom, then do your other sorts
681   if (sort == CommentSortType.Top) {
682     tree.sort(
683       (a, b) =>
684         +a.comment.removed - +b.comment.removed ||
685         +a.comment.deleted - +b.comment.deleted ||
686         b.comment.score - a.comment.score
687     );
688   } else if (sort == CommentSortType.New) {
689     tree.sort(
690       (a, b) =>
691         +a.comment.removed - +b.comment.removed ||
692         +a.comment.deleted - +b.comment.deleted ||
693         b.comment.published.localeCompare(a.comment.published)
694     );
695   } else if (sort == CommentSortType.Old) {
696     tree.sort(
697       (a, b) =>
698         +a.comment.removed - +b.comment.removed ||
699         +a.comment.deleted - +b.comment.deleted ||
700         a.comment.published.localeCompare(b.comment.published)
701     );
702   } else if (sort == CommentSortType.Hot) {
703     tree.sort(
704       (a, b) =>
705         +a.comment.removed - +b.comment.removed ||
706         +a.comment.deleted - +b.comment.deleted ||
707         hotRankComment(b.comment) - hotRankComment(a.comment)
708     );
709   }
710
711   // Go through the children recursively
712   for (let node of tree) {
713     if (node.children) {
714       commentSort(node.children, sort);
715     }
716   }
717 }
718
719 export function commentSortSortType(tree: Array<CommentNodeI>, sort: SortType) {
720   commentSort(tree, convertCommentSortType(sort));
721 }
722
723 function convertCommentSortType(sort: SortType): CommentSortType {
724   if (
725     sort == SortType.TopAll ||
726     sort == SortType.TopDay ||
727     sort == SortType.TopWeek ||
728     sort == SortType.TopMonth ||
729     sort == SortType.TopYear
730   ) {
731     return CommentSortType.Top;
732   } else if (sort == SortType.New) {
733     return CommentSortType.New;
734   } else if (sort == SortType.Hot) {
735     return CommentSortType.Hot;
736   } else {
737     return CommentSortType.Hot;
738   }
739 }
740
741 export function postSort(
742   posts: Array<Post>,
743   sort: SortType,
744   communityType: boolean
745 ) {
746   // First, put removed and deleted comments at the bottom, then do your other sorts
747   if (
748     sort == SortType.TopAll ||
749     sort == SortType.TopDay ||
750     sort == SortType.TopWeek ||
751     sort == SortType.TopMonth ||
752     sort == SortType.TopYear
753   ) {
754     posts.sort(
755       (a, b) =>
756         +a.removed - +b.removed ||
757         +a.deleted - +b.deleted ||
758         (communityType && +b.stickied - +a.stickied) ||
759         b.score - a.score
760     );
761   } else if (sort == SortType.New) {
762     posts.sort(
763       (a, b) =>
764         +a.removed - +b.removed ||
765         +a.deleted - +b.deleted ||
766         (communityType && +b.stickied - +a.stickied) ||
767         b.published.localeCompare(a.published)
768     );
769   } else if (sort == SortType.Hot) {
770     posts.sort(
771       (a, b) =>
772         +a.removed - +b.removed ||
773         +a.deleted - +b.deleted ||
774         (communityType && +b.stickied - +a.stickied) ||
775         hotRankPost(b) - hotRankPost(a)
776     );
777   }
778 }
779
780 export const colorList: Array<string> = [
781   hsl(0),
782   hsl(100),
783   hsl(150),
784   hsl(200),
785   hsl(250),
786   hsl(300),
787 ];
788
789 function hsl(num: number) {
790   return `hsla(${num}, 35%, 50%, 1)`;
791 }
792
793 function randomHsl() {
794   return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
795 }