]> Untitled Git - lemmy.git/blob - ui/src/utils.ts
Adding Catalan to user pref dropdown, moment.js.
[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
13 import {
14   UserOperation,
15   Comment,
16   PrivateMessage,
17   User,
18   SortType,
19   ListingType,
20   SearchType,
21   WebSocketResponse,
22   WebSocketJsonResponse,
23   SearchForm,
24   SearchResponse,
25 } from './interfaces';
26 import { UserService, WebSocketService } from './services';
27
28 import Tribute from 'tributejs/src/Tribute.js';
29 import markdown_it from 'markdown-it';
30 import markdownitEmoji from 'markdown-it-emoji/light';
31 import markdown_it_container from 'markdown-it-container';
32 import twemoji from 'twemoji';
33 import emojiShortName from 'emoji-short-name';
34 import Toastify from 'toastify-js';
35
36 export const repoUrl = 'https://github.com/dessalines/lemmy';
37 export const markdownHelpUrl = 'https://commonmark.org/help/';
38 export const archiveUrl = 'https://archive.is';
39
40 export const postRefetchSeconds: number = 60 * 1000;
41 export const fetchLimit: number = 20;
42 export const mentionDropdownFetchLimit = 10;
43
44 export function randomStr() {
45   return Math.random()
46     .toString(36)
47     .replace(/[^a-z]+/g, '')
48     .substr(2, 10);
49 }
50
51 export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
52   let opStr: string = msg.op;
53   return {
54     op: UserOperation[opStr],
55     data: msg.data,
56   };
57 }
58
59 export const md = new markdown_it({
60   html: false,
61   linkify: true,
62   typographer: true,
63 })
64   .use(markdown_it_container, 'spoiler', {
65     validate: function(params: any) {
66       return params.trim().match(/^spoiler\s+(.*)$/);
67     },
68
69     render: function(tokens: any, idx: any) {
70       var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
71
72       if (tokens[idx].nesting === 1) {
73         // opening tag
74         return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
75       } else {
76         // closing tag
77         return '</details>\n';
78       }
79     },
80   })
81   .use(markdownitEmoji, {
82     defs: objectFlip(emojiShortName),
83   });
84
85 md.renderer.rules.emoji = function(token, idx) {
86   return twemoji.parse(token[idx].content);
87 };
88
89 export function hotRank(comment: Comment): number {
90   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
91
92   let date: Date = new Date(comment.published + 'Z'); // Add Z to convert from UTC date
93   let now: Date = new Date();
94   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
95
96   let rank =
97     (10000 * Math.log10(Math.max(1, 3 + comment.score))) /
98     Math.pow(hoursElapsed + 2, 1.8);
99
100   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
101
102   return rank;
103 }
104
105 export function mdToHtml(text: string) {
106   return { __html: md.render(text) };
107 }
108
109 export function getUnixTime(text: string): number {
110   return text ? new Date(text).getTime() / 1000 : undefined;
111 }
112
113 export function addTypeInfo<T>(
114   arr: Array<T>,
115   name: string
116 ): Array<{ type_: string; data: T }> {
117   return arr.map(e => {
118     return { type_: name, data: e };
119   });
120 }
121
122 export function canMod(
123   user: User,
124   modIds: Array<number>,
125   creator_id: number,
126   onSelf: boolean = false
127 ): boolean {
128   // You can do moderator actions only on the mods added after you.
129   if (user) {
130     let yourIndex = modIds.findIndex(id => id == user.id);
131     if (yourIndex == -1) {
132       return false;
133     } else {
134       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
135       modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
136       return !modIds.includes(creator_id);
137     }
138   } else {
139     return false;
140   }
141 }
142
143 export function isMod(modIds: Array<number>, creator_id: number): boolean {
144   return modIds.includes(creator_id);
145 }
146
147 var imageRegex = new RegExp(
148   `(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))`
149 );
150 var videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
151
152 export function isImage(url: string) {
153   return imageRegex.test(url);
154 }
155
156 export function isVideo(url: string) {
157   return videoRegex.test(url);
158 }
159
160 export function validURL(str: string) {
161   try {
162     return !!new URL(str);
163   } catch {
164     return false;
165   }
166 }
167
168 export function validEmail(email: string) {
169   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,}))$/;
170   return re.test(String(email).toLowerCase());
171 }
172
173 export function capitalizeFirstLetter(str: string): string {
174   return str.charAt(0).toUpperCase() + str.slice(1);
175 }
176
177 export function routeSortTypeToEnum(sort: string): SortType {
178   if (sort == 'new') {
179     return SortType.New;
180   } else if (sort == 'hot') {
181     return SortType.Hot;
182   } else if (sort == 'topday') {
183     return SortType.TopDay;
184   } else if (sort == 'topweek') {
185     return SortType.TopWeek;
186   } else if (sort == 'topmonth') {
187     return SortType.TopMonth;
188   } else if (sort == 'topyear') {
189     return SortType.TopYear;
190   } else if (sort == 'topall') {
191     return SortType.TopAll;
192   }
193 }
194
195 export function routeListingTypeToEnum(type: string): ListingType {
196   return ListingType[capitalizeFirstLetter(type)];
197 }
198
199 export function routeSearchTypeToEnum(type: string): SearchType {
200   return SearchType[capitalizeFirstLetter(type)];
201 }
202
203 export async function getPageTitle(url: string) {
204   let res = await fetch(`https://textance.herokuapp.com/title/${url}`);
205   let data = await res.text();
206   return data;
207 }
208
209 export function debounce(
210   func: any,
211   wait: number = 1000,
212   immediate: boolean = false
213 ) {
214   // 'private' variable for instance
215   // The returned function will be able to reference this due to closure.
216   // Each call to the returned function will share this common timer.
217   let timeout: any;
218
219   // Calling debounce returns a new anonymous function
220   return function() {
221     // reference the context and args for the setTimeout function
222     var context = this,
223       args = arguments;
224
225     // Should the function be called now? If immediate is true
226     //   and not already in a timeout then the answer is: Yes
227     var callNow = immediate && !timeout;
228
229     // This is the basic debounce behaviour where you can call this
230     //   function several times, but it will only execute once
231     //   [before or after imposing a delay].
232     //   Each time the returned function is called, the timer starts over.
233     clearTimeout(timeout);
234
235     // Set the new timeout
236     timeout = setTimeout(function() {
237       // Inside the timeout function, clear the timeout variable
238       // which will let the next execution run when in 'immediate' mode
239       timeout = null;
240
241       // Check if the function already ran with the immediate flag
242       if (!immediate) {
243         // Call the original function with apply
244         // apply lets you define the 'this' object as well as the arguments
245         //    (both captured before setTimeout)
246         func.apply(context, args);
247       }
248     }, wait);
249
250     // Immediate mode and no wait timer? Execute the function..
251     if (callNow) func.apply(context, args);
252   };
253 }
254
255 export const languages = [
256   { code: 'ca', name: 'Català' },
257   { code: 'en', name: 'English' },
258   { code: 'eo', name: 'Esperanto' },
259   { code: 'es', name: 'Español' },
260   { code: 'de', name: 'Deutsch' },
261   { code: 'zh', name: '中文' },
262   { code: 'fi', name: 'Suomi' },
263   { code: 'fr', name: 'Français' },
264   { code: 'sv', name: 'Svenska' },
265   { code: 'ru', name: 'Русский' },
266   { code: 'nl', name: 'Nederlands' },
267   { code: 'it', name: 'Italiano' },
268 ];
269
270 export function getLanguage(): string {
271   let user = UserService.Instance.user;
272   let lang = user && user.lang ? user.lang : 'browser';
273
274   if (lang == 'browser') {
275     return getBrowserLanguage();
276   } else {
277     return lang;
278   }
279 }
280
281 export function getBrowserLanguage(): string {
282   return navigator.language;
283 }
284
285 export function getMomentLanguage(): string {
286   let lang = getLanguage();
287   if (lang.startsWith('zh')) {
288     lang = 'zh-cn';
289   } else if (lang.startsWith('sv')) {
290     lang = 'sv';
291   } else if (lang.startsWith('fr')) {
292     lang = 'fr';
293   } else if (lang.startsWith('de')) {
294     lang = 'de';
295   } else if (lang.startsWith('ru')) {
296     lang = 'ru';
297   } else if (lang.startsWith('es')) {
298     lang = 'es';
299   } else if (lang.startsWith('eo')) {
300     lang = 'eo';
301   } else if (lang.startsWith('nl')) {
302     lang = 'nl';
303   } else if (lang.startsWith('it')) {
304     lang = 'it';
305   } else if (lang.startsWith('fi')) {
306     lang = 'fi';
307   } else if (lang.startsWith('ca')) {
308     lang = 'ca';
309   } else {
310     lang = 'en';
311   }
312   return lang;
313 }
314
315 export const themes = [
316   'litera',
317   'minty',
318   'solar',
319   'united',
320   'cyborg',
321   'darkly',
322   'journal',
323   'sketchy',
324   'vaporwave',
325   'vaporwave-dark',
326 ];
327
328 export function setTheme(theme: string = 'darkly') {
329   // unload all the other themes
330   for (var i = 0; i < themes.length; i++) {
331     let styleSheet = document.getElementById(themes[i]);
332     if (styleSheet) {
333       styleSheet.setAttribute('disabled', 'disabled');
334     }
335   }
336
337   // Load the theme dynamically
338   if (!document.getElementById(theme)) {
339     var head = document.getElementsByTagName('head')[0];
340     var link = document.createElement('link');
341     link.id = theme;
342     link.rel = 'stylesheet';
343     link.type = 'text/css';
344     link.href = `/static/assets/css/themes/${theme}.min.css`;
345     link.media = 'all';
346     head.appendChild(link);
347   }
348   document.getElementById(theme).removeAttribute('disabled');
349 }
350
351 export function objectFlip(obj: any) {
352   const ret = {};
353   Object.keys(obj).forEach(key => {
354     ret[obj[key]] = key;
355   });
356   return ret;
357 }
358
359 export function pictshareAvatarThumbnail(src: string): string {
360   // sample url: http://localhost:8535/pictshare/gs7xuu.jpg
361   let split = src.split('pictshare');
362   let out = `${split[0]}pictshare/96x96${split[1]}`;
363   return out;
364 }
365
366 export function showAvatars(): boolean {
367   return (
368     (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
369     !UserService.Instance.user
370   );
371 }
372
373 /// Converts to image thumbnail (only supports pictshare currently)
374 export function imageThumbnailer(url: string): string {
375   let split = url.split('pictshare');
376   if (split.length > 1) {
377     let out = `${split[0]}pictshare/140x140${split[1]}`;
378     return out;
379   } else {
380     return url;
381   }
382 }
383
384 export function isCommentType(item: Comment | PrivateMessage): item is Comment {
385   return (item as Comment).community_id !== undefined;
386 }
387
388 export function toast(text: string, background: string = 'success') {
389   let backgroundColor = `var(--${background})`;
390   Toastify({
391     text: text,
392     backgroundColor: backgroundColor,
393     gravity: 'bottom',
394     position: 'left',
395   }).showToast();
396 }
397
398 export function setupTribute(): Tribute {
399   return new Tribute({
400     collection: [
401       // Emojis
402       {
403         trigger: ':',
404         menuItemTemplate: (item: any) => {
405           let emoji = `:${item.original.key}:`;
406           return `${item.original.val} ${emoji}`;
407         },
408         selectTemplate: (item: any) => {
409           return `:${item.original.key}:`;
410         },
411         values: Object.entries(emojiShortName).map(e => {
412           return { key: e[1], val: e[0] };
413         }),
414         allowSpaces: false,
415         autocompleteMode: true,
416         menuItemLimit: mentionDropdownFetchLimit,
417       },
418       // Users
419       {
420         trigger: '@',
421         selectTemplate: (item: any) => {
422           return `[/u/${item.original.key}](/u/${item.original.key})`;
423         },
424         values: (text: string, cb: any) => {
425           userSearch(text, (users: any) => cb(users));
426         },
427         allowSpaces: false,
428         autocompleteMode: true,
429         menuItemLimit: mentionDropdownFetchLimit,
430       },
431
432       // Communities
433       {
434         trigger: '#',
435         selectTemplate: (item: any) => {
436           return `[/c/${item.original.key}](/c/${item.original.key})`;
437         },
438         values: (text: string, cb: any) => {
439           communitySearch(text, (communities: any) => cb(communities));
440         },
441         allowSpaces: false,
442         autocompleteMode: true,
443         menuItemLimit: mentionDropdownFetchLimit,
444       },
445     ],
446   });
447 }
448
449 function userSearch(text: string, cb: any) {
450   if (text) {
451     let form: SearchForm = {
452       q: text,
453       type_: SearchType[SearchType.Users],
454       sort: SortType[SortType.TopAll],
455       page: 1,
456       limit: mentionDropdownFetchLimit,
457     };
458
459     WebSocketService.Instance.search(form);
460
461     this.userSub = WebSocketService.Instance.subject.subscribe(
462       msg => {
463         let res = wsJsonToRes(msg);
464         if (res.op == UserOperation.Search) {
465           let data = res.data as SearchResponse;
466           let users = data.users.map(u => {
467             return { key: u.name };
468           });
469           cb(users);
470           this.userSub.unsubscribe();
471         }
472       },
473       err => console.error(err),
474       () => console.log('complete')
475     );
476   } else {
477     cb([]);
478   }
479 }
480
481 function communitySearch(text: string, cb: any) {
482   if (text) {
483     let form: SearchForm = {
484       q: text,
485       type_: SearchType[SearchType.Communities],
486       sort: SortType[SortType.TopAll],
487       page: 1,
488       limit: mentionDropdownFetchLimit,
489     };
490
491     WebSocketService.Instance.search(form);
492
493     this.communitySub = WebSocketService.Instance.subject.subscribe(
494       msg => {
495         let res = wsJsonToRes(msg);
496         if (res.op == UserOperation.Search) {
497           let data = res.data as SearchResponse;
498           let communities = data.communities.map(u => {
499             return { key: u.name };
500           });
501           cb(communities);
502           this.communitySub.unsubscribe();
503         }
504       },
505       err => console.error(err),
506       () => console.log('complete')
507     );
508   } else {
509     cb([]);
510   }
511 }