filters.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. /**
  2. * This file is part of Threema Web.
  3. *
  4. * Threema Web is free software: you can redistribute it and/or modify it
  5. * under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or (at
  7. * your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful, but
  10. * WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  12. * General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. import Autolinker from 'autolinker';
  18. import {bufferToUrl, escapeRegExp, filter, hasValue, logAdapter} from './helpers';
  19. import {emojify, enlargeSingleEmoji} from './helpers/emoji';
  20. import {markify} from './markup_parser';
  21. import {MimeService} from './services/mime';
  22. import {NotificationService} from './services/notification';
  23. import {WebClientService} from './services/webclient';
  24. import {isContactReceiver} from './typeguards';
  25. angular.module('3ema.filters', [])
  26. /**
  27. * Escape HTML by replacing special characters with HTML entities.
  28. */
  29. .filter('escapeHtml', function() {
  30. const map = {
  31. '&': '&amp;',
  32. '<': '&lt;',
  33. '>': '&gt;',
  34. '"': '&quot;',
  35. "'": '&#039;',
  36. };
  37. return (text: string) => {
  38. if (text === undefined || text === null) {
  39. text = '';
  40. }
  41. return text.replace(/[&<>"']/g, (m) => map[m]);
  42. };
  43. })
  44. /**
  45. * Replace newline characters with a <br> tag.
  46. */
  47. .filter('nlToBr', function() {
  48. return (text, enabled: boolean) => {
  49. if (enabled || enabled === undefined) {
  50. text = text.replace(/\n/g, '<br>');
  51. }
  52. return text;
  53. };
  54. })
  55. /**
  56. * Replace a undefined/null or empty string with a placeholder
  57. */
  58. .filter('emptyToPlaceholder', function() {
  59. return (text, placeholder: string = '-') => {
  60. if (text === null || text === undefined || text.trim().length === 0) {
  61. return placeholder;
  62. }
  63. return text;
  64. };
  65. })
  66. /**
  67. * Convert links in text to <a> tags.
  68. */
  69. .filter('linkify', function() {
  70. const autolinker = new Autolinker({
  71. // Open links in new window
  72. newWindow: true,
  73. // Don't strip protocol prefix
  74. stripPrefix: false,
  75. // Don't strip trailing slashes
  76. stripTrailingSlash: false,
  77. // Don't truncate links
  78. truncate: 99999,
  79. // Add class name to linked links
  80. className: 'autolinked',
  81. // Link urls
  82. urls: true,
  83. // Link e-mails
  84. email: true,
  85. // Don't link phone numbers (doesn't work reliably)
  86. phone: false,
  87. // Don't link mentions
  88. mention: false,
  89. // Don't link hashtags
  90. hashtag: false,
  91. });
  92. return (text) => autolinker.link(text);
  93. })
  94. /**
  95. * Convert emoji unicode characters to images.
  96. */
  97. .filter('emojify', () => emojify)
  98. /**
  99. * Convert markdown elements to html elements
  100. */
  101. .filter('markify', () => markify)
  102. /**
  103. * Convert mention elements to html elements
  104. */
  105. .filter('mentionify', [
  106. 'WebClientService',
  107. '$translate',
  108. 'escapeHtmlFilter',
  109. function(webClientService: WebClientService, $translate: ng.translate.ITranslateService, escapeHtmlFilter) {
  110. return(text) => {
  111. if (text !== null && text.length > 10) {
  112. let result = text.match(/@\[([\*\@a-zA-Z0-9][\@a-zA-Z0-9]{7})\]/g);
  113. if (result !== null) {
  114. result = new Set(result);
  115. // Unique
  116. for (const possibleMention of result) {
  117. const identity = possibleMention.substr(2, 8);
  118. let mentionName;
  119. let cssClass;
  120. if (identity === '@@@@@@@@') {
  121. mentionName = $translate.instant('messenger.ALL');
  122. cssClass = 'all';
  123. } else if (identity === webClientService.me.id) {
  124. mentionName = webClientService.me.displayName;
  125. cssClass = 'me';
  126. } else {
  127. const contact = webClientService.contacts.get(possibleMention.substr(2, 8));
  128. if (contact !== null && contact !== undefined) {
  129. // Add identity to class for a simpler parsing
  130. cssClass = 'id ' + identity;
  131. mentionName = contact.displayName;
  132. }
  133. }
  134. if (mentionName !== undefined) {
  135. text = text.replace(
  136. new RegExp(escapeRegExp(possibleMention), 'g'),
  137. '<span class="mention ' + cssClass + '"'
  138. + ' text="@[' + identity + ']">' + escapeHtmlFilter(mentionName) + '</span>',
  139. );
  140. }
  141. }
  142. }
  143. }
  144. return text;
  145. };
  146. },
  147. ])
  148. /**
  149. * Reverse an array.
  150. */
  151. .filter('reverse', function() {
  152. return (list) => list.slice().reverse();
  153. })
  154. /**
  155. * Return whether receiver has corresponding data.
  156. */
  157. .filter('hasData', function() {
  158. return function(obj, receivers) {
  159. const valid = (receiver) => receivers.get(receiver.type).has(receiver.id);
  160. return filter(obj, valid);
  161. };
  162. })
  163. /**
  164. * Return whether item has a corresponding contact.
  165. */
  166. .filter('hasContact', function() {
  167. return function(obj, contacts) {
  168. const valid = (item) => contacts.has(item.id);
  169. return filter(obj, valid);
  170. };
  171. })
  172. /**
  173. * Filter for duration formatting.
  174. */
  175. .filter('duration', function() {
  176. return function(seconds) {
  177. const left = Math.floor(seconds / 60);
  178. const right = seconds % 60;
  179. const padLeft = left < 10 ? '0' : '';
  180. const padRight = right < 10 ? '0' : '';
  181. return padLeft + left + ':' + padRight + right;
  182. };
  183. })
  184. /**
  185. * Convert an ArrayBuffer to a data URL.
  186. *
  187. * Warning: Make sure that this is not called repeatedly on big data, or performance will decrease.
  188. */
  189. .filter('bufferToUrl', ['$sce', '$log', function($sce, $log) {
  190. const logTag = '[filters.bufferToUrl]';
  191. return function(buffer: ArrayBuffer, mimeType: string, trust: boolean = true) {
  192. if (!buffer) {
  193. $log.error(logTag, 'Could not apply bufferToUrl filter: buffer is', buffer);
  194. return '';
  195. }
  196. const uri = bufferToUrl(buffer, mimeType, logAdapter($log.warn, logTag));
  197. if (trust) {
  198. return $sce.trustAsResourceUrl(uri);
  199. } else {
  200. return uri;
  201. }
  202. };
  203. }])
  204. .filter('mapLink', function() {
  205. return function(location: threema.LocationInfo) {
  206. return 'https://www.openstreetmap.org/?mlat='
  207. + location.lat + '&mlon='
  208. + location.lon;
  209. };
  210. })
  211. /**
  212. * Convert message state to material icon class.
  213. */
  214. .filter('messageStateIcon', function() {
  215. return (message: threema.Message) => {
  216. if (!message) {
  217. return '';
  218. }
  219. if (!message.isOutbox) {
  220. switch (message.state) {
  221. case 'user-ack':
  222. return 'thumb_up';
  223. case 'user-dec':
  224. return 'thumb_down';
  225. default:
  226. return 'reply';
  227. }
  228. }
  229. switch (message.state) {
  230. case 'pending':
  231. case 'sending':
  232. return 'file_upload';
  233. case 'sent':
  234. return 'email';
  235. case 'delivered':
  236. return 'move_to_inbox';
  237. case 'read':
  238. return 'visibility';
  239. case 'send-failed':
  240. return 'report_problem';
  241. case 'user-ack':
  242. return 'thumb_up';
  243. case 'user-dec':
  244. return 'thumb_down';
  245. case 'timeout':
  246. return 'sync_problem';
  247. default:
  248. return '';
  249. }
  250. };
  251. })
  252. /**
  253. * Convert message state to title text.
  254. */
  255. .filter('messageStateTitleText', ['$translate', function($translate: ng.translate.ITranslateService) {
  256. return (message: threema.Message) => {
  257. if (!message) {
  258. return null;
  259. }
  260. if (!message.isOutbox) {
  261. switch (message.state) {
  262. case 'user-ack':
  263. return 'messageStates.WE_ACK';
  264. case 'user-dec':
  265. return 'messageStates.WE_DEC';
  266. default:
  267. return 'messageStates.UNKNOWN';
  268. }
  269. }
  270. switch (message.state) {
  271. case 'pending':
  272. return 'messageStates.PENDING';
  273. case 'sending':
  274. return 'messageStates.SENDING';
  275. case 'sent':
  276. return 'messageStates.SENT';
  277. case 'delivered':
  278. return 'messageStates.DELIVERED';
  279. case 'read':
  280. return 'messageStates.READ';
  281. case 'send-failed':
  282. return 'messageStates.FAILED';
  283. case 'user-ack':
  284. return 'messageStates.USER_ACK';
  285. case 'user-dec':
  286. return 'messageStates.USER_DEC';
  287. case 'timeout':
  288. return 'messageStates.TIMEOUT';
  289. default:
  290. return 'messageStates.UNKNOWN';
  291. }
  292. };
  293. }])
  294. .filter('fileSize', function() {
  295. return (size: number) => {
  296. if (!size) {
  297. return '';
  298. }
  299. const i = Math.floor( Math.log(size) / Math.log(1024) );
  300. const x = (size / Math.pow(1024, i)).toFixed(2);
  301. return (x + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]);
  302. };
  303. })
  304. /**
  305. * Return the MIME type label.
  306. */
  307. .filter('mimeTypeLabel', ['MimeService', function(mimeService: MimeService) {
  308. return (mimeType: string) => mimeService.getLabel(mimeType);
  309. }])
  310. /**
  311. * Return the MIME type icon URL.
  312. */
  313. .filter('mimeTypeIcon', ['MimeService', function(mimeService: MimeService) {
  314. return (mimeType: string) => mimeService.getIconUrl(mimeType);
  315. }])
  316. /**
  317. * Convert ID-Array to (Display-)Name-String, separated by ','
  318. */
  319. .filter('idsToNames', ['WebClientService', function(webClientService: WebClientService) {
  320. return(ids: string[]) => {
  321. const names: string[] = [];
  322. for (const id of ids) {
  323. const contactReceiver = webClientService.contacts.get(id);
  324. if (hasValue(contactReceiver)) {
  325. names.push(contactReceiver.displayName);
  326. } else {
  327. names.push('Unknown');
  328. }
  329. }
  330. return names.join(', ');
  331. };
  332. }])
  333. /**
  334. * Format a unix timestamp as a date.
  335. */
  336. .filter('unixToTimestring', ['$translate', function($translate) {
  337. function formatTime(date) {
  338. return ('00' + date.getHours()).slice(-2) + ':' +
  339. ('00' + date.getMinutes()).slice(-2);
  340. }
  341. function formatMonth(num) {
  342. switch (num) {
  343. case 0x0:
  344. return 'date.month_short.JAN';
  345. case 0x1:
  346. return 'date.month_short.FEB';
  347. case 0x2:
  348. return 'date.month_short.MAR';
  349. case 0x3:
  350. return 'date.month_short.APR';
  351. case 0x4:
  352. return 'date.month_short.MAY';
  353. case 0x5:
  354. return 'date.month_short.JUN';
  355. case 0x6:
  356. return 'date.month_short.JUL';
  357. case 0x7:
  358. return 'date.month_short.AUG';
  359. case 0x8:
  360. return 'date.month_short.SEP';
  361. case 0x9:
  362. return 'date.month_short.OCT';
  363. case 0xa:
  364. return 'date.month_short.NOV';
  365. case 0xb:
  366. return 'date.month_short.DEC';
  367. }
  368. }
  369. function isSameDay(date1, date2) {
  370. return date1.getFullYear() === date2.getFullYear()
  371. && date1.getMonth() === date2.getMonth()
  372. && date1.getDate() === date2.getDate();
  373. }
  374. return (timestamp: number, forceFull: boolean = false) => {
  375. const date = new Date(timestamp * 1000);
  376. const now = new Date();
  377. if (!forceFull && isSameDay(date, now)) {
  378. return formatTime(date);
  379. }
  380. const yesterday = new Date(now.getTime() - 1000 * 60 * 60 * 24);
  381. if (!forceFull && isSameDay(date, yesterday)) {
  382. return $translate.instant('date.YESTERDAY') + ', ' + formatTime(date);
  383. }
  384. let year = '';
  385. if (forceFull || date.getFullYear() !== now.getFullYear()) {
  386. year = ' ' + date.getFullYear();
  387. }
  388. return date.getDate() + '. '
  389. + $translate.instant(formatMonth(date.getMonth()))
  390. + year + ', '
  391. + formatTime(date);
  392. };
  393. }])
  394. /**
  395. * Mark data as trusted.
  396. */
  397. .filter('unsafeResUrl', ['$sce', function($sce: ng.ISCEService) {
  398. return $sce.trustAsResourceUrl;
  399. }])
  400. ;