Compare commits

..
750 Commits
Author SHA1 Message Date
or-else abd94d6858 refreshed screenshots 2020-06-23 17:39:49 +03:00
or-else 6ab2d8663e bump version and rebuild 2020-06-23 10:42:42 +03:00
or-else 2576fa2a87 bump version and rebuild 2020-06-17 14:22:58 +03:00
or-else d0627d5370 visual and translation fixes 2020-06-11 22:03:23 +03:00
or-else a192129ff9 dependencies updated, breaking change in react-intl fixed 2020-06-08 18:20:12 +03:00
or-else ee6b87c3a9 add actual translations 2020-06-03 15:06:21 +03:00
or-else 7fccd41ced spanish translation by @sanmiguel2019 added 2020-06-03 15:03:30 +03:00
or-else b95e3269ea mention Korean translation in README 2020-06-03 11:01:35 +03:00
or-else 38c0023e23 korean translation included into build 2020-06-02 19:02:40 +03:00
Gene 1efc9a90cb Merge pull request #53 from kimtaeyoung-98/master
Korean translation of en.json
2020-06-01 20:11:33 +03:00
kimtaeyoung-98 de04006afc en.json문서의 한국어 번역
Removed spaces.
2020-06-02 01:37:13 +09:00
kimtaeyoung-98 d484bff407 Korean translation of en.json
Korean translation of en.json
2020-06-01 19:04:08 +09:00
or-else 7cd5f786b2 rebuilt with sdk fix: always close the socket 2020-05-24 11:04:48 +03:00
or-else 65c94c9f12 a few more i18n fixes 2020-05-10 14:26:36 +03:00
or-else c3e5b1cabc fix i18n formatting issues 2020-05-10 14:02:52 +03:00
or-else ad97c5638f rebuilt 2020-05-10 12:54:20 +03:00
or-else c9d05642e2 fix issue with scrolling on image preview 2020-05-10 12:53:50 +03:00
or-else 3410128d6f rebuilt 2020-05-09 09:25:47 +03:00
or-else b7a8d4d09d mute in addition to blocking 2020-05-08 11:06:53 +03:00
or-else 919047cfde define and use max tag count 2020-05-01 17:19:18 +03:00
or-else fbaf98c117 use standard inband limit 2020-04-29 15:17:13 +03:00
or-else 5d96331ef5 bump version and rebuild 2020-04-26 11:20:03 +03:00
or-else 587d457712 bump version and rebuild 2020-04-22 11:50:56 +03:00
or-else 979b283928 fix for hidden group-manager control panel 2020-04-21 21:16:54 +03:00
or-else 7c70dea398 bump version and rebuild 2020-04-20 12:01:26 +03:00
or-else 3f04d0e23b more localizations 2020-04-18 12:26:45 +03:00
or-else d953f6ac3e bump version and rebuild 2020-04-17 14:36:44 +03:00
or-else e5e78755ae blocked chats display 2020-04-17 14:30:26 +03:00
or-else 52c1016b45 fix disappearing [you] badge in topic members 2020-04-15 15:55:40 +03:00
or-else c0a3ba6a92 make topic and user deletions "hard" 2020-04-15 09:45:49 +03:00
or-else 6be7b87173 small refactoring of enabled permissions in topic 2020-04-15 09:45:15 +03:00
or-else 35a63ff5ab fix alert dialog 2020-04-13 19:00:38 +03:00
or-else 375750a6ff bump version and rebuild 2020-04-13 16:13:34 +03:00
or-else ea8ac3c293 refactoring of account settings into multiple screens 2020-04-13 16:01:24 +03:00
or-else 5507ccd728 refactoring account settings into multiple screens 2020-04-13 10:35:33 +03:00
or-else b96c8c2516 bump version and rebuild 2020-04-03 14:22:36 +03:00
or-else e04240870d rebuilt 2020-04-02 10:15:54 +03:00
or-else 506cd00b27 chrome bug was fixed 2020-04-02 08:18:38 +03:00
or-else cdd40facec support for incognito mode 2020-04-01 21:13:32 +03:00
or-else 6222610836 rebuilt with different background 2020-03-26 14:27:24 +03:00
or-else 16b5bdf37d Merge branch 'devel' of https://github.com/tinode/webapp into devel 2020-03-20 15:08:23 +03:00
or-else 6e39fe9bef use different background image for messages panel 2020-03-20 15:08:11 +03:00
Gene 78c2f1aaa1 Merge pull request #46 from mzch/master
Fix 2 bugs in Linux environment
2020-03-14 18:25:37 +03:00
Koichi MATSUMOTO 947b166935 move postcss-cli to devDependencies
move postcss-cli to devDependencies
2020-03-14 22:20:15 +09:00
Koichi MATSUMOTO 337afcf075 fix lack of postcss-cli
fix lack of postcss-cli
2020-03-14 20:08:04 +09:00
Koichi MATSUMOTO 77e67bb714 fix typo
fix typo
2020-03-14 20:02:40 +09:00
or-else dbcac419de replaced UglifyJsPlugin with TerserPlugin 2020-03-14 09:39:52 +03:00
or-else 475e5eb3e7 dependencies updated 2020-03-14 09:09:27 +03:00
or-else 12f05e8570 bump version and rebuild 2020-03-08 16:28:45 +03:00
or-else 527a01bebb critical bug: login stalls after refresh 2020-02-27 19:54:32 +03:00
or-else ffe2ab7737 bump version and rebuild 2020-02-25 09:30:06 +03:00
or-else 2fd8346dc5 check Drafty for validity 2020-02-23 21:44:08 +03:00
or-else fd4cb58f0c more prominent "messaging disabled" note 2020-02-23 20:46:22 +03:00
or-else d2647d5f0c fix: incorrect redirect on failed sign up 2020-02-23 13:26:24 +03:00
or-else e27c106c33 pass himan UI language to tinode-sdk 2020-02-23 10:57:04 +03:00
or-else e7840b05a6 removed debug printing 2020-02-18 14:34:40 +03:00
or-else 3484c6549e rebuilt with SDK fix 2020-02-18 14:01:35 +03:00
or-else 30054aec04 formatting 2020-02-14 09:59:35 +03:00
or-else fae274062a correct link to web-app icon 2020-02-14 09:58:35 +03:00
or-else 3b77af2a10 adding missing web-app.svg icon 2020-02-14 09:58:03 +03:00
or-else a4b757fa95 bump version and rebuild 2020-02-08 12:23:51 +03:00
or-else 9d7e1d4f63 rebuilt with fixed note-read & translations 2020-02-05 10:18:59 +03:00
or-else 858e62f732 google translate missing chinese strings 2020-02-04 18:05:58 +03:00
or-else 50a775fc72 fix missing read notifications 2020-01-22 21:01:05 +03:00
or-else feaefdb952 update and restructure for clarity 2020-01-22 11:05:02 +03:00
or-else 755ad00619 merge next 2020-01-21 22:09:45 +03:00
or-else c6e440ca72 merge 2020-01-21 16:18:19 +03:00
or-else f34f786bc6 bump version and rebuild 2020-01-21 14:24:21 +03:00
aforge b2e7a4d63c Merge pull request #43 from tinode/retry-context-menu
Add Retry option in context menu for failed messages.
2020-01-20 22:35:58 -08:00
aforge f100385c79 Minor syntax change. 2020-01-20 22:34:39 -08:00
aforge 0081ed3716 Add Retry option in context menu for failed messages. 2020-01-20 21:25:22 -08:00
or-else 065115d2bf done 2020-01-18 13:42:19 +03:00
or-else ed6518df9c almost works 2020-01-18 11:36:57 +03:00
or-else f7a52d70d6 first pass at file attachment previews 2020-01-18 11:15:37 +03:00
or-else 72d978ed55 previewing image before sending works now 2020-01-14 18:07:56 +03:00
or-else 90889454a0 fix placeholder text positioning 2020-01-13 19:25:07 +03:00
or-else d1c90f8434 more refactoring of image preview 2020-01-13 18:20:09 +03:00
or-else 8622926a4c first pass at implementing image preview before sending 2020-01-13 10:20:12 +03:00
or-else 2de35424b5 updated FCM instructions 2020-01-13 10:19:36 +03:00
or-else d603953a77 bump version and rebuild 2020-01-11 14:21:16 +03:00
or-else d0e3e238b8 missing "content deleted" when all content is deleted 2020-01-10 12:25:46 +03:00
or-else 83dfb5e9b2 fix for incorrect "Not found" user name in messages 2020-01-09 20:54:25 +03:00
or-else 727d926bf6 dependencies updated 2020-01-09 20:53:45 +03:00
or-else fe9f4bb17e bump version and rebuild 2020-01-05 16:34:26 -05:00
or-else 1afd876d99 updated instructions for setting up firebase in web app 2019-12-30 15:42:41 -05:00
or-else 6fee1ce5ea firebase made a breaking change in 7.0.0 - need different config 2019-12-30 15:31:39 -05:00
or-else 7828c372e3 better exclusion of node modules cache 2019-12-29 20:48:17 -05:00
or-else e02047345c check in .gitignore 2019-12-29 20:46:56 -05:00
or-else db76ec3266 dependencies updated 2019-12-23 10:27:34 +03:00
or-else 06de1c1fb6 bump version and rebuild 2019-12-23 09:54:35 +03:00
or-else a662787b9c fix for different representation of deleted messages 2019-12-12 15:58:21 +03:00
or-else bc4c0811d1 indicators of deleted content work now 2019-12-06 10:41:21 +03:00
or-else 893cbdaa0c does not crash on deleted messages 2019-12-03 13:01:23 +03:00
or-else 44c5e30f4d all dependencies updated 2019-11-30 12:16:29 +03:00
or-else b81ec3e634 update dependencies and bump version 2019-11-30 11:21:10 +03:00
or-else b48e4e63d9 rebuilt 2019-11-20 11:49:06 +03:00
or-else dce1875de4 simplify handling of new message notifications 2019-11-20 11:44:15 +03:00
or-else 3392bf5ed7 make sure unread is > 0 before playing a sound 2019-11-20 11:40:36 +03:00
or-else 99de97b2e1 pres.act is not set when the message is sent by the current user 2019-11-20 11:18:52 +03:00
or-else d30b616b26 bump version and rebuild 2019-11-18 09:25:30 +03:00
or-else aa7cb55c8e better presentation of unnamed contacts 2019-11-13 20:18:21 +03:00
or-else 9e756c6772 bump version and rebuild 2019-11-12 15:54:12 +03:00
or-else 8ee50adf4d fix base64 preprocessing 2019-11-12 15:53:52 +03:00
or-else 4c28753e6a show different message when search returned no contacts 2019-11-08 17:43:41 +03:00
or-else 1cdb8b724c rebuilt 2019-11-06 11:28:30 +03:00
or-else 6a2efac566 and nother place where title length is enforced 2019-11-06 11:27:49 +03:00
or-else 8c1b926008 enforce maximum title length for topics and users 2019-11-06 11:25:07 +03:00
or-else c539e5f70d another Russian translation fix 2019-11-06 11:24:45 +03:00
or-else 154ec5000e rebuilt 2019-11-06 11:03:52 +03:00
or-else 80c5103ce0 UI fix for excessively long topic name 2019-11-06 10:33:21 +03:00
or-else 15c0ff2e75 more idiomatic Russian translation 2019-11-06 10:33:02 +03:00
or-else a404734794 bump version and rebuild 2019-11-03 09:44:28 +03:00
or-else f962c8e8a9 updated dependencies 2019-11-02 09:58:34 +03:00
or-else bb20937bc9 replace unpkg with jsdeliver as unpkg is too unreliable https://github.com/mjackson/unpkg/issues/229 2019-11-01 09:41:06 +03:00
or-else 3026e21b46 updated Missing Functionality section 2019-11-01 09:39:38 +03:00
or-else 92b9faa063 explicitly ignore "upd" presence updates 2019-11-01 09:39:05 +03:00
or-else a1894871aa updated for the next tindroidX package 2019-11-01 09:37:56 +03:00
or-else bd03a7164e fix translation to most common spelling 2019-10-25 08:24:53 +03:00
or-else 9891620188 added "mobile-web-app-capable" meta header 2019-10-23 19:31:59 +03:00
or-else afa0726bdf bump version and rebuild 2019-10-21 09:12:59 +03:00
or-else 1a927e5420 rebuilt with fixed url 2019-10-16 13:05:58 +03:00
Gene 5f4db76cbf Merge pull request #41 from tinode/fix-privacy-link
Fix broken privacy policy link.
2019-10-16 13:04:15 +03:00
aforge 4092324fea Fix broken privacy policy link. 2019-10-15 23:34:56 -07:00
or-else efc7377942 rebuilt with tinode-js fix 2019-10-14 17:01:30 +03:00
or-else 94f88855de rebuilt with tinode-js 2019-10-12 09:40:04 +03:00
or-else 73afafb605 rebuilt because of change in tinode-js: handling of {pres what=term}, tinode/chat#278 2019-10-12 09:00:18 +03:00
or-else fe556a0707 mark tinode.dev.js and tinode.prod.js as binary 2019-10-10 11:59:20 +03:00
or-else 79d0c6dd14 attempted fix for tinode/chat#278 2019-10-10 11:57:23 +03:00
or-else 8e9349b58b split tinode-sdk into a separate file 2019-10-03 18:15:29 +03:00
or-else 22710adbe3 add spinner to login action, small build changes 2019-10-03 16:48:47 +03:00
or-else b8a1b39bba bump version and rebuild 2019-10-02 10:44:59 +03:00
or-else ef341cb94e destructive menu action wrapped in an alert 2019-09-27 16:55:19 +03:00
or-else cda001e5c6 fix: new group topic is immediately unsubscribed 2019-09-26 19:25:18 +03:00
or-else 254c4768eb badges were broken 2019-09-25 14:36:19 +03:00
or-else 8f0125587f add firefox scrollbar styling 2019-09-24 20:50:19 +03:00
or-else 1aaf6f8fab rebuilt after fixing big in deleting messages 2019-09-24 20:04:52 +03:00
or-else 1d56d55487 adding alert component 2019-09-24 19:40:36 +03:00
or-else 205ca95217 bug fix: archive view does not render 2019-09-24 19:26:59 +03:00
or-else f9a29ed3ef add alert for destructable actions 2019-09-24 17:12:38 +03:00
or-else f3420dc66f upgraded react-intl to 3.3 2019-09-23 15:28:22 +03:00
or-else 6d581c8e7c rename chinese translation from zh-CN to zh 2019-09-23 14:15:57 +03:00
or-else 10bb206d51 experimenting with code splitting 2019-09-22 21:32:32 +03:00
or-else a2747b6e33 clarify use of explicit UI human language 2019-09-22 21:32:06 +03:00
or-else b3b0ae59ab updated dependencies 2019-09-22 21:31:33 +03:00
or-else d99434000f clarify group member editing UI (disallow deletes for S only) 2019-09-22 21:30:50 +03:00
or-else 2a164ddf49 links for blocking/reporting users and topics 2019-09-22 21:19:04 +03:00
or-else 12a0660e5b shorthand replacements for React.Fragment and <span> 2019-09-22 21:18:31 +03:00
or-else 2502460143 translations added, minor visual tweaks, version bumped 2019-09-20 14:37:29 +03:00
or-else c31631b69f ToS added, small bug in CSS fixed 2019-09-19 12:57:42 +03:00
or-else 711cf7c690 fix: crash on leaving the topic 2019-09-04 20:12:15 +03:00
or-else 3669ed7645 bump version and rebuild 2019-09-03 14:55:07 +03:00
or-else 41ebf3c705 fix: #37; correct case for auto* in LoginView 2019-09-02 18:49:31 +03:00
or-else 6f359be64d make webpack path universal / vs \\ 2019-08-29 20:03:57 +03:00
or-else d472a321de no autocorrection for username 2019-08-26 12:26:10 +03:00
or-else eda9f0f7f9 fix: exception in send-message.jsx on Enter 2019-08-24 18:04:14 +03:00
or-else d9c729d9ab update dependencies and rebuild 2019-08-24 14:31:55 +03:00
or-else 78b0909f02 add open graph headers 2019-08-24 14:13:33 +03:00
or-else 61a9b6c56f minify logo.svg and bump version 2019-08-22 11:55:23 +03:00
or-else a6bc2a1f8f migrate the app from api.. to web.. 2019-08-22 10:46:14 +03:00
or-else 503c9128d4 fix: wrong bubble tip color in Safari 2019-08-21 17:12:47 +03:00
or-else 1b4bba051e fix blob: URLS, corractly calculate image preview viwport size 2019-08-17 20:07:15 +03:00
or-else c14fb857bc fix for possible xss exploits 2019-08-17 16:56:15 +03:00
or-else be528ff282 bum version and rebuild 2019-08-15 11:24:49 +03:00
or-else fb9461834a refactored getDerivedStateFromProps in in-place-edit 2019-08-15 10:49:24 +03:00
or-else 0217d84721 refactoring due to facebook/react/pull/15047 2019-08-15 10:30:29 +03:00
or-else f1744c3f69 getDerivedStateFromProps refactored in contacts-view and chips-input, #35 2019-08-14 20:24:07 +03:00
or-else a79373c8cd replace getDerivedStateFromProps with componentDidUpdate 2019-08-12 11:51:44 +03:00
or-else d2975f0c20 fix avatar not being updated while edited 2019-08-06 17:18:28 +03:00
or-else d782fb8eab bump version and rebuild 2019-08-02 09:06:46 +03:00
or-else bdf152e5d6 fix: unable to delete profile picture 2019-08-01 09:53:41 +03:00
or-else 40edad1c99 fixed: unable to remove topic avatar 2019-08-01 09:49:26 +03:00
or-else 7239aae8aa fix typo, bump versin and rebuild 2019-07-26 11:48:19 +03:00
or-else 81c789b713 push notification instructions updated 2019-07-20 12:12:26 +03:00
or-else 744da99f30 don't play new message sound for achived topics 2019-07-18 13:35:39 +03:00
or-else 112ce08fde updated dependencies and rebuilt 2019-07-17 13:51:59 +03:00
or-else 3fd01648a8 bump version and rebuild 2019-07-08 17:29:00 +03:00
or-else 619ec15920 update one translation 2019-06-26 16:08:54 -07:00
or-else a6a097b15f translations 2019-06-20 20:26:43 -07:00
or-else 450a2f6975 translation tweaks 2019-06-16 12:09:47 -07:00
or-else e006043290 rebuilt with possible fix for https://github.com/tinode/chat/issues/242 2019-06-15 09:46:39 -07:00
or-else 34db50280e rebuild 2019-06-13 09:41:31 -07:00
or-else 6268fb68f8 minor fixes 2019-06-12 18:27:24 -07:00
or-else 9a310a0438 handle credential confirmation from EditAccountView 2019-06-12 11:03:21 -07:00
or-else 821ab6a16f dependencies updated 2019-06-11 17:01:11 -07:00
or-else f9e78bf280 credential management 2019-06-09 19:57:41 -07:00
or-else 83488ca878 rebuild 2019-06-07 16:27:26 -07:00
or-else 53d748a7bd deleting contacts 2019-06-07 16:26:42 -07:00
or-else 6cb2b77f69 rebuild with updated sdk 2019-05-29 09:09:42 -07:00
or-else d133287e7e small translation tweaks, rebuilt 2019-05-29 08:57:42 -07:00
Gene 3ef14a6fe6 Merge pull request #32 from linsui/patch-1
Create zh-CN.json
2019-05-29 07:49:09 -07:00
linsui f84096488a Create zh-CN.json 2019-05-29 12:02:42 +00:00
or-else 884a822408 fix for broken UI when too many users are online in a topic 2019-05-21 10:29:10 +03:00
or-else fe000cec33 bump version and rebuild 2019-05-15 09:39:39 +03:00
or-else 3202dcb1b0 rebuid 2019-05-13 19:44:07 +03:00
or-else eb20ebff6e big fix: crash in updating tags due to missing arrayEqual 2019-05-13 19:43:26 +03:00
or-else a0e8aa5c54 add bold-italic font variant, rebuild 2019-05-11 19:23:31 +03:00
or-else 00e2642aa9 new section in readme 2019-04-23 20:09:27 +03:00
or-else 0396a617c8 bump version and rebuild 2019-04-14 18:10:31 +03:00
or-else 9a55e4b72e make letter tile background identical to ios & android 2019-04-13 10:27:01 +03:00
or-else 60a88bc3d5 bump version and rebuild 2019-04-09 16:11:17 +03:00
or-else e73c41f3c6 request messages when subscribing to a new topic 2019-04-08 16:11:43 +03:00
or-else 4b561de04c rebuild -rc2 2019-04-07 10:42:46 +03:00
or-else 35ec5b98cf better display of reconnect timer 2019-04-07 10:42:06 +03:00
or-else 4bd0052d9f add icon indicators for muted and blocked contacts 2019-04-07 09:28:54 +03:00
or-else 545b0c79cb bump version and rebuild 2019-03-30 11:44:52 +03:00
or-else c66bf89883 add missing import 2019-03-28 19:51:06 +03:00
or-else 60fa6d7806 add support for tags with special chars 2019-03-26 21:50:44 +03:00
or-else 194306ac4f add missing import 2019-03-25 18:04:23 +03:00
or-else f29305b0af bump version and rebuild 2019-03-21 09:58:29 +03:00
or-else 1ac74dbe36 update dependencies and rebuild 2019-03-14 15:43:51 +03:00
or-else c76953294c rebuild with the bug fix from tinode-js 2019-03-14 11:58:04 +03:00
or-else 1458184fc2 update versions and rebuild 2019-03-11 10:12:05 +03:00
or-else 14b63f4627 rebuild 2019-03-05 15:00:42 +03:00
or-else 9492455bc3 rebuild 2019-03-03 19:53:55 +03:00
or-else 32ad99128e more conststent check for peer restrictions 2019-03-03 19:37:16 +03:00
or-else 64f1fd4380 fix false "peer messaging disabled" 2019-03-03 15:00:56 +03:00
or-else 6386fc7a46 rebuild 2019-03-02 16:45:18 +03:00
or-else 29425b48fc add warning when peer's messaging is disabled 2019-03-02 15:52:58 +03:00
or-else a9ba11a0ff buf fix: missing fnd result after viewing account info 2019-03-01 13:26:10 +03:00
or-else d13b24263e prevent premature subscription 2019-02-28 15:28:54 +03:00
or-else 0f13f2edb9 rebuild 2019-02-28 13:57:29 +03:00
or-else a042db27e8 don't send {not} for own messages; explicit defacs in p2p {sub} 2019-02-28 13:56:35 +03:00
or-else ff619da2b7 force sane permissions for user-initiated subscriptions 2019-02-27 16:52:47 +03:00
or-else 93e6d388b6 fixes for inviting/blocking users 2019-02-27 15:22:35 +03:00
or-else 173d153b82 remove banned topic from view 2019-02-26 16:48:13 +03:00
or-else 0ddb3a4f75 rename readOnly -> !isReader etc 2019-02-26 16:25:30 +03:00
or-else 9193b9be19 revent premature subscription on reload 2019-02-24 19:45:02 +03:00
or-else 79970cf558 getting rid of UNSAFE_componentWillReceiveProps 2019-02-24 19:30:11 +03:00
or-else 5d17ef7039 add new chat acceptance handlers 2019-02-24 15:14:02 +03:00
or-else 7e0aa5b93b more actions without subscribing first 2019-02-24 11:08:52 +03:00
or-else fd32413ba1 more work on confirming topic subscriptions, update permissions without subscribing 2019-02-21 22:05:40 +03:00
or-else 22b277a4ad add invitation.jsx widget 2019-02-18 19:10:16 +03:00
or-else da65e5663f added actions for chat invitation. bubble shadows 2019-02-18 19:09:39 +03:00
or-else 76d5ee1ed8 bump version and rebuild 2019-02-15 13:08:01 +03:00
or-else e7d68b5052 a possible fix for FF not focusing on a tab after notification click 2019-02-14 18:16:17 +03:00
or-else 1a17b01f0c add skipWaiting 2019-02-13 15:28:26 +03:00
or-else d7fcd8c761 bump version and rebuild 2019-02-11 16:13:11 +03:00
or-else 5e0cf3a2d2 0.15.13-rc1 2019-02-11 16:12:06 +03:00
or-else 636e38a6d7 rebuild 2019-02-11 16:09:45 +03:00
or-else 17d7e94138 change const -> let 2019-02-09 17:43:57 +03:00
or-else 49af44d4ca upkg.com is supposedly fixed now, rolling back the workaround 2019-02-09 10:53:46 +03:00
or-else 588b157005 update react dependencies to 16.8.1 2019-02-09 10:53:10 +03:00
or-else b5f926ef96 update dependencies due to a security issue in lodash 2019-02-09 10:50:22 +03:00
or-else 2e110a7a78 make sure the push token is sent to the server on app reload 2019-02-08 12:43:44 +03:00
or-else f710e5c76c mark generated css as binary to exclude from diff 2019-02-07 10:58:18 +03:00
or-else bedcc9a923 rebuild and mark generated .js and .js.map files as binary 2019-02-07 10:55:32 +03:00
or-else a554c0a9e1 minor fixes in chip-input 2019-02-06 20:20:51 +03:00
or-else ca2b3624e5 rebuild 2019-02-06 20:09:28 +03:00
or-else 87947b3de6 archiving works, refactored UNSAFE_componentWillReceiveProps in chips-input 2019-02-06 20:08:50 +03:00
or-else 160e2574d4 correct accoount editing for incremental updates 2019-02-04 13:05:03 +03:00
or-else a534387593 do inscremental updates instead of full updates 2019-02-04 12:46:17 +03:00
or-else 004dec20d3 automatic un-archiving on new message 2019-02-03 19:11:35 +03:00
or-else 68c0faf527 propagate {info} message to "me" topic 2019-02-03 18:29:59 +03:00
or-else 6bd64b2863 cleal localStorage on logout 2019-02-03 17:18:44 +03:00
or-else fc96d200ed archiving kind of works but buggy 2019-02-03 12:48:47 +03:00
or-else 7e30c2acc6 temporary fox for broken unpkg unpkg/unpkg.com#176 2019-02-03 09:48:52 +03:00
or-else 541ead2136 implementing chat archiving (does not work yet) 2019-02-02 21:31:58 +03:00
or-else 6388fedd70 deep-copy update private and public fields 2019-02-01 18:37:07 +03:00
or-else f34c342db3 rebuild 2019-02-01 12:14:22 +03:00
or-else 1883d35779 Merge branch 'devel' of https://github.com/tinode/webapp into archiving 2019-02-01 12:13:38 +03:00
or-else 15793cad26 add "archive" menu" 2019-02-01 11:50:22 +03:00
Gene 5e3d5a4be2 Merge pull request #28 from maksfreetimer/patch-2
service-worker: error on "fetch" event
2019-02-01 10:43:17 +03:00
Max 5f1c1f9f2e service-worker: error on "fetch" event
Chrome 70.0.3538.110
Sometimes i get this error: `Uncaught (in promise) TypeError: Failed to execute 'fetch' on 'ServiceWorkerGlobalScope': 'only-if-cached' can be set only with 'same-origin' mode
2019-02-01 10:32:39 +03:00
or-else a4af2a53ac pubm versions and rebuild 2019-01-29 14:22:42 +03:00
or-else 9bbddc08e6 make broken image not clickable 2019-01-25 17:03:50 +03:00
or-else b0be1fd012 show sock broken_image when the image is invalid 2019-01-25 16:49:24 +03:00
or-else 21303346b1 0.15.12-rc3 2019-01-25 14:24:51 +03:00
or-else bd7531e5b7 fix for tinode/chat#212 2019-01-25 14:24:43 +03:00
or-else a0184ac002 add cosmetic space between words 2019-01-22 13:40:09 +03:00
or-else 4d438ccab4 use locale for dates too 2019-01-14 12:42:26 +03:00
or-else 48e6144568 bum version and rebuild 2019-01-13 14:47:42 +03:00
or-else e028503c9e add "broken_image.png" as fallback 2019-01-13 14:46:46 +03:00
or-else fcb3d78cc4 rebuild 2019-01-06 14:06:46 -08:00
or-else b976a3caa8 0.15.11 2019-01-06 14:02:45 -08:00
or-else cc6a4cd125 rebuild 2018-12-27 16:10:40 +03:00
or-else 6d7f98c55b 0.15.11-rc4 2018-12-27 16:08:31 +03:00
or-else d80ddcabea pass Enter event otherwise crash 2018-12-25 11:08:57 +03:00
or-else 4c44b61a9d bump version of a dependency 2018-12-23 14:49:26 +03:00
or-else 9eb7bebe80 bump version and rebuild 2018-12-17 12:08:27 +03:00
or-else 1f634f5877 remove unnecessary check 2018-12-17 12:06:33 +03:00
or-else f78d673e02 add "reconnect now" option 2018-12-17 12:02:47 +03:00
or-else a5d405b083 add handling og onAutoreconnectIteration 2018-12-17 10:05:37 +03:00
or-else 405ce756a2 allow avatar removal 2018-12-15 14:24:05 +03:00
or-else 31190ec826 rebuild 2018-12-15 12:59:51 +03:00
or-else 9b3c714390 js2016 changes 2018-12-15 12:11:22 +03:00
or-else 2e76b122a2 keep focus on message editing firld whenever possible 2018-12-15 12:05:03 +03:00
or-else 14d75223e2 remove debug logging 2018-12-13 22:22:32 +03:00
or-else bc3e82bcbf bug fixes: crash in editing group member permissions, fnd not working sometimes, var -> let, const 2018-12-13 15:42:54 +03:00
or-else 10d88da558 bump version and rebuild 2018-12-05 13:58:19 +03:00
or-else 4dde45b6be 0.15.10 2018-12-05 13:54:44 +03:00
or-else 00eeb2503a css fix 2018-12-01 15:51:21 +03:00
or-else 4f70de3aa9 make topic title required 2018-12-01 11:34:42 +03:00
or-else 486cf2e355 rebuild 2018-11-30 21:37:11 +03:00
or-else 21841c0022 add missing import 2018-11-30 21:36:02 +03:00
or-else d1f7f02fa0 correctlu display messages from missing users 2018-11-24 18:23:29 +03:00
or-else fbbab3e7d6 fixes in settings view and message deletion 2018-11-21 11:50:04 +03:00
or-else 7248b5c398 keeping scroll position on vertical resize works now 2018-11-19 16:05:49 +03:00
or-else 96de18ea7b 0.15.10-rc1 2018-11-17 21:34:20 +03:00
or-else 198bd9a64c keep message view in the same scroll position on vertical resize 2018-11-17 21:34:13 +03:00
or-else 9324faa32f rebuild 2018-11-17 12:39:44 +03:00
or-else 769c25170f bug fix, add maps to package 2018-11-13 12:32:46 +03:00
or-else 9e073356b2 remove unused code for form name 2018-11-11 21:25:00 +03:00
or-else 14ff124674 strip formatting from button text 2018-11-11 16:59:39 +03:00
or-else f959aa24cc new drafty formatting seems to work as expected 2018-11-09 14:59:48 +03:00
or-else 7d058df54d update screenshots 2018-11-04 19:03:18 +03:00
or-else c72819de38 forms seem to work now 2018-11-04 17:27:25 +03:00
or-else bad6232b9a more formatting of bot forms 2018-11-03 18:53:14 +03:00
or-else d0e4a52ba9 add source maps, forms rendered ok now 2018-11-03 16:29:57 +03:00
or-else 2b9883c254 remove unneeded param 2018-11-01 17:45:31 +03:00
or-else 00e48b7c93 add "update is available" check 2018-11-01 17:39:37 +03:00
or-else 20cf802d3d rebuild after merge 2018-11-01 08:48:59 +03:00
or-else b07a739619 merge 2018-11-01 08:46:41 +03:00
or-else 3a09df6c14 edited for clarity 2018-11-01 08:31:02 +03:00
or-else aa1b55c182 clarification 2018-10-31 22:09:52 +03:00
or-else e8dba6bd5e externalize dependencies 2018-10-31 22:00:18 +03:00
or-else c76a7dcb6b splitting out libraries 2018-10-31 19:44:07 +03:00
or-else e7f3b424e4 add <noscript> message 2018-10-31 12:12:10 +03:00
or-else 0198f446d4 service worker fetch event for Add To Home Screen 2018-10-30 22:31:10 +03:00
or-else c2f261b143 cosmetic fixes 2018-10-30 16:11:35 +03:00
or-else 8c4e0e9068 all components are translated now 2018-10-30 15:56:36 +03:00
or-else 954b0079a4 even more translations and fixes 2018-10-30 11:44:50 +03:00
or-else c8506eb143 more corrections and translations 2018-10-29 19:07:30 +03:00
or-else a48be912b4 bump version and rebuild 2018-10-29 13:37:35 +03:00
or-else 639755ea2b more components updated/translated 2018-10-28 14:27:59 +03:00
or-else dc838776ec more components updated/translated 2018-10-28 14:18:22 +03:00
or-else d7fb701983 more components are functional now 2018-10-27 20:01:20 +03:00
or-else 92d4ba8a86 EditAccountView translated 2018-10-27 12:42:32 +03:00
or-else 48d5732c16 more components updated and translated 2018-10-27 11:55:32 +03:00
or-else 71bbfb4fdd more fixes 2018-10-26 16:36:30 +03:00
or-else 782f2d1fea adding back missing and changed dependencies 2018-10-26 15:29:33 +03:00
or-else 25021a866d things seem to work 2018-10-26 14:45:50 +03:00
or-else 3e3dbd7fdb translation instructions 2018-10-26 08:55:37 +03:00
or-else 53d545e114 add links to translations 2018-10-25 18:58:46 +03:00
or-else 00a54ac0ee more fixes 2018-10-25 18:57:13 +03:00
or-else cbc89617dc change how version is extracted 2018-10-25 14:56:51 +03:00
or-else e139c1371e login page is displayed now 2018-10-25 14:02:53 +03:00
or-else 358f9eb970 webpack works now 2018-10-25 11:19:43 +03:00
or-else 765a6e5d84 add config file 2018-10-24 18:09:49 +03:00
or-else d4ee2fb7bc webapp.jsx is no longer needed 2018-10-24 18:09:05 +03:00
or-else 5958dec09b move config params into a separate file 2018-10-24 18:08:47 +03:00
or-else 99dfb0afeb sort components into widgets and views 2018-10-24 18:04:16 +03:00
or-else 5367c71784 add config files 2018-10-24 17:14:56 +03:00
or-else 7dead5b14d switching to webpack 2018-10-24 17:14:07 +03:00
or-else 2bb60ea7a2 add imports 2018-10-23 22:34:11 +03:00
or-else 4b7d76c150 split the code into components 2018-10-23 21:31:28 +03:00
or-else 8b02f39557 initial pass at internationalization 2018-10-23 16:39:01 +03:00
or-else a2f075f4fb first pass at localization 2018-10-23 14:09:39 +03:00
or-else 8a70f37446 more consistent use of single ' vs double '' quotes 2018-10-23 11:44:23 +03:00
or-else 47abebd1f2 bump version, update dependencies and rebuild 2018-10-22 09:30:56 +03:00
or-else 2e02f8a1c8 cosmetic changes, allow aditing of user"s default permissions 2018-10-21 16:36:01 +03:00
or-else 49c0fbfd78 fix brokn download link 2018-10-20 12:01:25 +03:00
or-else 37bad29350 upgrade, bump version, and rebuild 2018-10-16 10:43:37 +03:00
or-else 58c29a8248 add missing badge image 2018-10-12 10:27:19 +03:00
or-else cbc3875288 bump version and rebuild 2018-10-11 15:56:19 +03:00
or-else e43e3ffcbc ensure correct sorting even in presence of undefined values 2018-10-11 12:51:55 +03:00
or-else 57ca428a65 add bw badge for mobile pushes 2018-10-11 11:13:08 +03:00
or-else b2ffb626da start groupping notifications + notification click works now 2018-10-10 17:21:57 +03:00
or-else a1cb605f37 update versions and rebuild 2018-10-08 15:48:48 +03:00
or-else c7b2a30f24 crash on error 2018-10-06 13:50:01 +03:00
or-else da24e28a92 start handling MESSAGE_STATUS_FAILED 2018-10-06 12:44:24 +03:00
or-else c52b661c87 check if topic has unsubscribed while processing pending messages 2018-10-06 11:49:34 +03:00
or-else ca212fb038 try re-sending pending messages on reconnect 2018-10-06 11:47:41 +03:00
or-else 44039f9a82 add android asset links verification file 2018-10-04 17:23:16 +03:00
or-else bcee90ef06 correctly reset Tinode object on change in settings 2018-10-04 17:22:37 +03:00
or-else e712bff7b3 correctly re-initialize tinode after logout 2018-10-03 12:48:43 +03:00
or-else 57fa378f4d apply es6 -> es5 transform 2018-10-02 14:17:09 +03:00
or-else 6cbf536fa8 bump version and rebuild 2018-10-01 20:02:00 +03:00
or-else 13f492285e password reset works now 2018-10-01 19:31:50 +03:00
or-else 60968b84c6 cosmetic bugs 2018-09-30 21:43:10 +03:00
or-else 114bfc409f add password entry form 2018-09-30 21:28:28 +03:00
or-else 871a1e5b5b adding ability to reset the password 2018-09-30 14:22:02 +03:00
or-else 7a3212cf36 rebuild with the new firebase 2018-09-29 10:38:20 +03:00
or-else 82dda02b12 update firebase 2018-09-29 10:37:11 +03:00
or-else 76620cc9b5 final fixes for logout crash, bump version and rebuild 2018-09-29 10:10:54 +03:00
or-else dba9500825 reset title and favicon on logout 2018-09-27 20:26:14 +03:00
or-else 62cac29071 dont't crash on logout 2018-09-27 20:10:26 +03:00
or-else 07e095aa9a update dependencies and bump version 2018-09-24 17:15:48 +03:00
or-else c8a47efde2 move firebase credentials to a separate file 2018-09-24 12:37:35 +03:00
or-else 3ad8274ad8 fix for #19 2018-09-23 19:11:03 +03:00
or-else 64219b9343 0.15.6 release 2018-09-23 10:30:33 +03:00
or-else a6fe3e59d4 remove unnecessary css styling 2018-09-23 09:36:23 +03:00
or-else 0231de6086 bump version and regenerate files 2018-09-22 09:55:14 +03:00
or-else 22a2954eb8 0.15.6-rc5 2018-09-22 09:47:06 +03:00
or-else a884f6fea2 add missing comma 2018-09-21 16:49:54 +03:00
or-else 6ac0ad6fbf add missing service worker to package 2018-09-21 16:41:43 +03:00
or-else 15393198be ignore click for the current topic in found contacts 2018-09-21 13:09:59 +03:00
or-else c56c91afce simplify SearchContacts component 2018-09-21 09:56:44 +03:00
or-else 4a66c1ee73 regenerated files 2018-09-20 16:44:43 +03:00
or-else b9bdeeae19 finished up webpush, rearranged settings 2018-09-20 16:42:46 +03:00
or-else c14a2044cd push notifications work now 2018-09-19 17:52:29 +03:00
or-else 72aef53c7e use explicit fb version as in service worker 2018-09-19 17:42:50 +03:00
or-else c567a2678a push notifications seem to work in FF and Chrome 2018-09-19 17:38:30 +03:00
or-else 242b30cee0 adding firebase messaging 2018-09-19 11:57:57 +03:00
or-else 6c9baf3cba merged from devel 2018-09-18 19:37:36 +03:00
or-else 534a2fded7 simplified version reporting, bump version 2018-09-18 19:35:41 +03:00
or-else 2fc6bebacc incorrect type of "private" in new topics 2018-09-18 16:47:46 +03:00
or-else e761c5e0f6 simplify version reporting 2018-09-18 09:57:10 +03:00
or-else 1a00f4b844 bump version 2018-09-18 09:29:40 +03:00
or-else ab7a049512 rebuild generated files 2018-09-17 19:12:12 +03:00
or-else bffa6eb6ce add missing react-dom requirement 2018-09-17 19:11:08 +03:00
or-else cefde649ba add missing react-dom requirement 2018-09-17 19:10:51 +03:00
or-else 89c41818f0 Merge branch 'devel' into push 2018-09-17 16:29:21 +03:00
or-else f53cef6262 rename the app 2018-09-17 10:03:46 +03:00
or-else 3064c0ec2a start adding push notifications 2018-09-16 20:20:22 +03:00
or-else 1e268b3eae replace buggy tinify with uglify-es 2018-09-16 15:04:20 +03:00
or-else 7228053261 remove demo login and password hints from the login screen 2018-09-16 10:15:16 +03:00
or-else d6d9340304 rebuilding again without the prepublish 2018-09-14 16:21:17 +03:00
or-else 2bf4a0fe1f mention the app size 2018-09-14 16:18:42 +03:00
or-else f9725cb06a package TinoeWeb as npm module 2018-09-14 16:13:41 +03:00
or-else 3c20bcf6f2 fixes for various UI bugs 2018-09-13 17:10:11 +03:00
or-else 03db847d48 last fix for the attachments, bump version 2018-09-12 17:19:11 +03:00
or-else 8b71444341 fix class name 2018-09-11 20:47:05 +03:00
or-else ce20a86072 fix react-js warnings 2018-09-11 20:44:21 +03:00
or-else c768755ae4 fix for issues/17 2018-09-11 16:55:30 +03:00
or-else ef3d799411 merge chat list and found contacts 2018-09-11 15:42:05 +03:00
or-else 0ba4b85a91 Merge branch 'master' of https://github.com/tinode/webapp into repackage 2018-09-11 15:39:57 +03:00
Gene ad016b2a8e Merge pull request #18 from maksfreetimer/patch-1
GroupManager methods fix
2018-09-11 15:39:30 +03:00
Max 18c7a82ce6 GroupManager methods fix
"selectedContacts" method seems to be called statically, otherwise "handleContactSelected" & "handleMemberRemoved" methods throw an exception
2018-09-11 15:04:41 +03:00
or-else dd9fd8e345 bug due to a typo 2018-09-09 11:46:57 +03:00
or-else 1318f399e6 typo in comment 2018-09-09 10:40:05 +03:00
or-else 6ed768cacc refactoring completed 2018-09-08 21:17:18 +03:00
or-else 7d01356be7 moving contact management to <TinodeWeb /> 2018-09-08 19:51:50 +03:00
or-else 72e51b65a2 remove debug logging 2018-09-07 09:54:10 +03:00
or-else b282f789fb replacing global Tinode object with TindeWeb's state object 2018-09-06 20:55:39 +03:00
or-else a578e27c82 refactor context menu 2018-09-06 12:00:01 +03:00
or-else e23b3172c9 start tracking generated files 2018-09-05 18:58:12 +03:00
or-else 5e0713fb3e partially updated Tinode from a global object to a state one 2018-09-05 18:56:01 +03:00
or-else aafa0f74d5 trying to package the webapp 2018-09-04 19:28:46 +03:00
or-else 40000cfa44 try to make this work for both devel and prod 2018-09-04 16:37:55 +03:00
or-else 3865eb8928 Merge branch 'devel' into repackage 2018-09-04 16:27:17 +03:00
or-else 7bf26a55be desktop -> notification alearts 2018-09-04 16:26:53 +03:00
or-else 15e5dacc51 adding desktop alerts, React and js updates 2018-09-04 16:20:34 +03:00
or-else 2de87accf5 start using onAllMessagesReceived 2018-09-04 12:44:52 +03:00
or-else 78764319c0 0.15.5 2018-09-03 12:20:28 +03:00
or-else cf86af1284 change prompt wording on validation code 2018-09-02 14:32:28 +03:00
or-else 22c86747a1 update version 2018-09-01 14:25:36 +03:00
or-else 39b18ff6dd generated files updated 2018-08-28 09:35:41 +03:00
or-else f833ec6960 correct error reporting on credential validation 2018-08-28 09:32:14 +03:00
or-else cb4a0655a3 bump version 2018-08-27 14:33:45 +03:00
or-else 8354d921f3 bump version 2018-08-25 16:49:35 +03:00
or-else d178803d86 rebuilt derived files 2018-08-25 16:48:40 +03:00
or-else 4aadff8ab3 message sounds, fix in searching for contacts 2018-08-25 16:43:27 +03:00
or-else cf849d6412 rearranging files 2018-08-24 16:00:07 +03:00
or-else 5c4429858b adding explicit dependencies on tinode-sdk 2018-08-24 14:25:55 +03:00
or-else 996dad96c1 minified 2018-08-21 14:58:40 +03:00
or-else 55c08373d0 making credential verification work better 2018-08-21 14:58:09 +03:00
or-else aca9286d63 if a png image is too big even after scaling, convert it to jpeg 2018-08-21 11:53:03 +03:00
or-else c7325e3c25 change var to const where appropriate 2018-08-21 10:08:25 +03:00
or-else 61b3bcbb4f corrected start_url 2018-08-21 09:33:22 +03:00
or-else 758b5b54cf changes to message deletion and inband image size 2018-08-20 18:51:37 +03:00
or-else 94c8bab4ec getting rid of componentWillMount per React 16 specs 2018-08-19 19:44:14 +03:00
or-else 7ac3041493 bump version and rebuild 2018-08-19 19:30:28 +03:00
or-else 4be41e47f4 starting to get rid of componentWillMount per React spec 2018-08-19 11:22:43 +03:00
or-else f782bc11cc simplifies InfoView parameters 2018-08-19 10:58:22 +03:00
or-else be8d64e29e generated files updated 2018-08-18 09:16:39 +03:00
or-else e22a4de007 remove unnecessary state update 2018-08-18 09:03:42 +03:00
or-else fde40f4f51 build and bump version 2018-08-17 16:07:03 +03:00
or-else e2b19d72bc add css minification 2018-08-13 11:36:50 +03:00
or-else a0eac497c6 minor npm packege changes 2018-08-12 16:13:52 +03:00
or-else f2ef14c239 setting up offline babel transpiling and nmp-ifying 2018-08-12 11:03:26 +03:00
or-else 2b8514b495 fix for visual breakage (https://github.com/minimaxir/big-list-of-naughty-strings) 2018-08-10 16:42:41 +03:00
or-else e9e8d9881f make the webapp work with browserify-processed tinode.js (symlinked tinode.dev.js) 2018-08-10 15:16:40 +03:00
or-else 3975401205 fix canonical url and application-url 2018-08-09 16:56:53 +03:00
or-else 6da0952e5d adding package.json 2018-08-09 10:48:51 +03:00
or-else 45acee4617 fix for a broken "send" button 2018-08-08 21:07:18 +03:00
or-else ff5096cd47 multiple simultaneous uploads and downloads work now 2018-08-08 15:59:26 +03:00
or-else 4cd97e7ff8 refactoring upload progress 2018-08-07 20:55:55 +03:00
or-else df86411cb7 rearranging components, splitting js/babel from html 2018-08-07 12:15:10 +03:00
or-else 905501f22f use delayed message sending 2018-08-06 08:43:30 +03:00
or-else c48e5f1dbf don't print entire message 2018-07-30 21:23:43 +03:00
or-else 0a956f0234 don't crash on malformed attachments 2018-07-30 20:50:48 +03:00
or-else ae73764173 fix: exception while logging out 2018-07-29 19:14:41 +03:00
or-else 2f8e5c4b33 conversion to react 16 seems to be successful 2018-07-27 19:27:26 +03:00
or-else a542afd194 updating reactjs to version 16 2018-07-27 18:13:05 +03:00
or-else a8c9347591 remove debug output 2018-07-14 14:15:47 +03:00
or-else dc46f8f166 bug fix: incorrect handling of avatar pic in InfoView 2018-06-20 12:19:44 +03:00
or-else 6eb3551665 add attachment's URLs to message header 2018-06-15 14:34:06 +03:00
or-else 3829b26cc8 fix an issue with incorrect scaling of png images 2018-06-11 11:31:18 +03:00
or-else 973b71eb80 uploading and downloading works ok now 2018-06-10 18:55:18 +03:00
or-else 1b7fd6a00b file uploads mostly work now 2018-06-09 21:14:12 +03:00
or-else daa1ee3e0c send credentials only if download url is relative 2018-06-09 19:05:26 +03:00
or-else 35eb23e3af uploading works with some minor UI glitches 2018-06-09 11:13:04 +03:00
or-else 0c16a5c991 uploading works, downloading still does not, UI broken 2018-06-08 22:15:31 +03:00
or-else 926dd43ce6 fixed image preview, split max inband size from total max attachment size 2018-06-06 17:47:48 +03:00
or-else 499bf1ee14 separate download url from preview url 2018-06-06 16:41:16 +03:00
or-else 9c507ba631 update Drafty for external attachmanes, update UI 2018-06-06 16:11:50 +03:00
or-else 2f307e2803 making file upload widget more consistent visually 2018-06-06 10:15:39 +03:00
or-else 2c21a34d07 adding large file uploads 2018-05-31 21:41:06 -07:00
or-else 2e3ecb1c4e support copy-pasting images and file attachments 2018-05-23 13:29:31 -07:00
or-else e21bf82de1 inline documentation cleanup 2018-05-22 18:54:04 -07:00
or-else d837d8950c start treating .Private as a dictionary instead of string 2018-05-19 16:51:16 -07:00
or-else b17e0d45ae updated logos (completely transparent background 2018-05-18 15:40:15 -07:00
or-else f5af1ff193 updated search, better typing indicator 2018-05-18 14:12:50 -07:00
or-else e77513d44b more meta tags and "keep me loggen in" on account creation 2018-05-17 09:07:26 -07:00
or-else 556e249b78 bug fix: incorrect handling of empty hashpath 2018-05-16 19:58:39 -07:00
or-else ce0e83070a don't send read notifications if tab or windows is invisible 2018-05-16 17:26:04 -07:00
or-else d0747b9f7b bug: topic may not be ready yet 2018-05-16 11:55:48 -07:00
or-else eb3ccf7b1e bug fix: crashes when switching topics with open <InfoView> 2018-05-16 11:36:54 -07:00
or-else 2e619d3fb8 show unread threads in title, bug fix 2018-05-16 10:17:00 -07:00
or-else 9d128e505f switch account tags from "me" to "fnd" 2018-05-15 10:32:32 -07:00
or-else 77479a3e2d graphic assets updated 2018-05-14 20:27:13 -07:00
or-else 353bd1d5fe bumped version to 0.15 due to incompatible changes in in FND 2018-05-14 13:05:37 -07:00
or-else ed11dd0d22 catch and report the error instead of throwing an exception 2018-05-10 17:08:44 -07:00
or-else 189fc0e3f2 new search works now 2018-05-09 19:00:15 -07:00
or-else d5c7464570 css tweaks 2018-05-05 10:22:58 -07:00
or-else ce4c803b97 handling of very long strings 2018-05-04 13:22:20 -07:00
or-else c6dbe3830e bug fix: callback to a stale ContactsView 2018-05-03 13:35:33 -07:00
or-else 09a1b85e86 bug fixes in creating topic with tags 2018-05-03 10:52:54 -07:00
or-else 82cc9ebb09 better handling of unchanged tags 2018-05-02 11:22:07 -07:00
or-else 820a4081a1 fix message scrolling and no-read-access display 2018-04-27 20:14:44 -07:00
or-else 09eb4bdde8 correct redirect after failed login 2018-04-27 10:52:43 -07:00
or-else c9c6bf1cef correctly display an invalid topic 2018-04-26 21:45:29 -07:00
or-else ff67a6d3d2 yet even more bug fixes in navigation 2018-04-26 14:13:18 -07:00
or-else c110c4bb5e active topic is not longer disabled on startup 2018-04-26 11:23:35 -07:00
or-else e2be203088 still debugging hash navigation 2018-04-25 21:22:44 -07:00
or-else 316de3e875 start actually using "keep-logged-in" 2018-04-25 10:57:07 -07:00
or-else 22c92043a1 another small fix for navigation 2018-04-25 09:39:19 -07:00
or-else cd4388d574 more debugging of hash navigation 2018-04-24 20:32:43 -07:00
or-else 9449606adc navigation mostly works but still buggy 2018-04-24 14:29:15 -07:00
or-else 4101ae21d1 Merge branch 'master' of https://github.com/tinode/example-react-js into devel 2018-04-23 21:03:20 -07:00
or-else 15ffade573 a few more navigation fixes 2018-04-23 20:44:24 -07:00
or-else 8148d42ba7 navigation by hash works partially 2018-04-23 19:48:57 -07:00
or-else 0f08e63b6e adding routing to hashchange handler 2018-04-23 12:41:02 -07:00
or-else 39b5ce8210 bug fix: remve &tab= on cancel 2018-04-22 16:26:26 -07:00
or-else a364aa7dea hash reflects the app state now 2018-04-22 16:21:17 -07:00
or-else e5fc770369 more work on #hash navigation 2018-04-22 14:24:26 -07:00
Gene da05994d0f Adding Apache 2.0 license 2018-04-21 21:46:21 -07:00
or-else 18ef1e88fd making the app navigatable by #hash 2018-04-21 18:23:56 -07:00
or-else 7e5f7dd30b adding local storage for auth token 2018-04-21 13:29:49 -07:00
or-else decad8f65c sort conversation by the last message timestamp 2018-04-19 19:16:59 -07:00
or-else 6edaf16db8 start reporting an error on invalid topic id 2018-04-15 14:06:03 -07:00
or-else cd8f5a1b94 remove badges from contact list 2018-04-15 12:55:10 -07:00
or-else 586b0f152b bug fixes in InfoView 2018-04-13 21:20:30 -07:00
or-else d06ca5cdbd bug fixes around acs handling 2018-04-11 17:42:26 -07:00
or-else ae1f08e506 don't send permissions update if permissions have not changed 2018-03-30 20:28:34 -07:00
or-else 53ef0f83d6 bug fix: wrong online status of a new topic 2018-03-30 10:55:37 -07:00
or-else efcf2680ec check for changes, don't send if unchanged 2018-03-28 19:19:18 -07:00
or-else ba4f976e3b cancel tag editor on escape key 2018-03-28 16:39:22 -07:00
or-else 757b23356c topic tags work as expected now 2018-03-28 16:29:45 -07:00
or-else fa90ff5bef make extra parameters collapsible to make more space for tags 2018-03-28 12:33:31 -07:00
or-else 60262291ce tags can now be added to group topics 2018-03-28 11:31:51 -07:00
or-else 515e27b489 refactoting tag manager 2018-03-28 11:04:45 -07:00
or-else ed268053f3 bug fix: in some cases messges never marked as read 2018-03-26 19:46:13 -07:00
or-else f68d4d3e24 bug fix in showing online group subscribers 2018-03-26 11:03:34 -07:00
or-else 00dd18302f bug fix with unread count, css & html fixes 2018-03-25 12:45:01 -07:00
or-else 3cba22ae22 disable notification sound for now 2018-03-24 18:45:56 -07:00
or-else ac89af9850 play sound on new message 2018-03-24 11:21:44 -07:00
or-else 83e17861bd allow the same file to be attached, bug in login 2018-03-23 19:36:59 -07:00
or-else 67c5cbf77f clear error on panel switch 2018-03-23 16:50:04 -07:00
or-else 1e69fabe2c better validation of input 2018-03-20 17:38:48 -07:00
or-else 9283c4ede2 minor corrections 2018-03-17 18:57:10 -07:00
or-else 95b9f1a1fd renaming type -> meth 2018-03-16 18:49:01 -07:00
or-else 99bab6cae9 added validation screen 2018-03-15 18:52:31 -07:00
or-else 57490cbe92 adding verification view 2018-03-14 20:22:04 -07:00
or-else 6d8b49c36c added autocomplete= to login, tag length enforcement 2018-03-09 11:49:19 -08:00
or-else b7a4efa6b6 tag management works now 2018-03-04 18:11:56 -08:00
or-else dac21cf807 fix favicon display 2018-03-03 18:59:28 -08:00
or-else bacaadfd86 added UI for tag handling 2018-03-03 18:24:13 -08:00
or-else ea55bde395 adding tag processing 2018-03-03 16:27:10 -08:00
or-else b277b020f7 more debug output 2018-02-28 14:24:09 -08:00
or-else 1415f21c51 bug fix in display of online topic subscribers 2018-02-12 15:04:33 -08:00
or-else 576d81a7ec adding back accidentally deleted link to manifest.json 2018-02-05 10:41:27 -08:00
or-else 0e5fb5b468 more icons 2018-02-05 10:38:09 -08:00
or-else 8daf217795 adding icons and actual manifest.json 2018-02-05 10:19:57 -08:00
or-else 29a55c1e37 adding manifest.json and related 2018-02-05 10:18:53 -08:00
or-else 964a423879 removed logging and renamed a few methods for clarity 2018-02-04 18:51:38 -08:00
or-else addd27108f making topic sach work 2018-02-04 18:14:29 -08:00
or-else dc91e9d7b7 report an error on failed invite 2018-01-22 17:21:51 -08:00
or-else c80e09d956 remove debug console logging 2018-01-20 17:33:58 -08:00
or-else 6a2681ab56 show users currently online in a topic 2018-01-20 17:24:34 -08:00
or-else e5c98ba2d1 bug fix for an "read" status not being sent in case of deleted messages 2018-01-09 15:13:57 -08:00
or-else 1b9b7a2671 bump app version 2017-12-14 11:18:41 -08:00
or-else 1a3362f1cd fix online status 2017-12-05 22:23:18 -08:00
or-else 9009667c5a bug fix: correct handling of hard-delete 2017-12-03 14:43:54 -08:00
or-else a59023575b bug fix in topic member menu 2017-11-25 19:02:36 -08:00
or-else b2b1ca2934 kind of works but buggy 2017-11-25 14:24:18 -08:00
or-else afd1bbb58a making new delete scheme work 2017-11-24 20:23:49 -08:00
or-else de655a105a bug fix 2017-10-25 14:00:21 -07:00
or-else 2c1c0b2544 fix for very long file names 2017-10-22 17:37:43 -07:00
or-else ccf5318e4c mention than deleting messages works now 2017-10-13 16:42:46 -07:00
or-else 7322abfd55 messages can be deleted one by one now 2017-10-13 13:04:54 -07:00
or-else 09518bbac2 adding message deletion - incomplete yet 2017-10-13 10:23:19 -07:00
or-else b6a831ec91 big fix in error handling 2017-10-01 16:08:02 -07:00
or-else 378cd622c4 dropping support for .ico because they are not well supported on android 2017-09-30 22:03:05 -07:00
or-else 939e3e59cf attach some images without converting them first 2017-09-30 16:47:02 -07:00
or-else 22e82b1c7f corrected inline image width on mobile 2017-09-26 14:54:00 -07:00
or-else e495917356 simplified handling of inline images 2017-09-26 14:43:46 -07:00
or-else cf4b58cab2 added missing screenshot 2017-09-25 21:01:36 -07:00
or-else 0a96014484 inline images work now 2017-09-25 21:00:35 -07:00
or-else 3e8fd9d6e6 file attachments work now 2017-09-24 18:21:14 -07:00
or-else a06d29863d add UI elements for attaching images and other docs 2017-09-23 13:26:58 -07:00
or-else de24e23127 bug fix: exception in changing permissions 2017-09-21 19:49:21 -07:00
or-else 9417e1aa49 bug fix: correct display of online status in MessagesView 2017-09-21 19:38:00 -07:00
or-else 1f8d26a215 bug fix: send private value in topic creation 2017-09-21 18:23:26 -07:00
or-else e4a0b60f1e better indication of write-only topics 2017-09-21 09:41:24 -07:00
or-else 19612e4236 add blob generation from base64 to be able to deal with large images 2017-09-20 17:13:13 -07:00
or-else af1e9c609b forgot to add the new icon 2017-09-20 15:27:37 -07:00
or-else 5d18c71475 add green dot to shortcut icon to indicate unread messages 2017-09-20 15:01:19 -07:00
or-else 20fc9ae343 scroll only on material changes 2017-09-20 12:00:54 -07:00
or-else 2e8b9145b7 better handling of lost/restored connection 2017-09-19 20:15:40 -07:00
or-else 859dec8410 removed dead code 2017-09-17 20:57:19 -07:00
or-else 8f13d2c856 better error handling and reporting 2017-09-17 18:36:01 -07:00
or-else d2a226852b fixed drafty's handling of undefined values 2017-09-15 18:04:02 -07:00
or-else 9c7a05f616 bug fix: lost topic parameters after reconnect 2017-09-14 19:26:45 -07:00
or-else a6e59936e2 typing indicators work now 2017-09-14 15:36:34 -07:00
or-else e3d95eafe1 bug fixes 2017-09-14 11:43:27 -07:00
or-else e30568ecd5 correctly propagate update to all components 2017-09-14 11:09:25 -07:00
or-else 9ac8ea8f93 adding borders to screenshots 2017-09-13 14:06:31 -07:00
or-else 1bac018961 adding borders to screenshots 2017-09-13 14:05:54 -07:00
or-else 1aba91d6f3 adding borders to screenshots 2017-09-13 14:03:20 -07:00
or-else fd750558ac adding borders to screenshots 2017-09-13 14:00:19 -07:00
or-else f2cb7d1e8b updated screenshots 2017-09-13 13:36:07 -07:00
or-else 7870fb06a2 better use of local cache 2017-09-13 12:17:24 -07:00
or-else 9adff7572e message pagination 2017-09-12 18:21:33 -07:00
or-else d50da89403 updated screenshot 2017-09-07 19:21:08 -07:00
or-else 8ddfb93f7a topic invites work correctly now, UI fixes 2017-09-07 19:10:50 -07:00
or-else b718aa9513 more context menu fixes and features 2017-09-05 22:04:14 -07:00
or-else 7782021c12 refactoring context menus 2017-09-03 21:52:59 -07:00
or-else 73b2a85eea cleaning up drafty and UI fixes in permissions editing 2017-09-02 15:57:03 -07:00
or-else d34f51a9d0 better formatted example 2017-08-24 20:16:53 -07:00
or-else 0a2abc080f a lot of refactoring of drafty to male it usable as a universal media container 2017-08-24 12:35:14 -07:00
or-else 9d1786ab67 better error handling 2017-08-21 19:53:41 -07:00
or-else fb6b4899cd rich formatting works 2017-08-21 19:45:48 -07:00
or-else 0c7549819a adding rich formatting to messags 2017-08-19 20:40:25 -07:00
or-else fd8c58e837 bug fix 2017-08-18 11:20:20 -07:00
or-else 5c74be339a added a couple of supporting methods and made representation a bit more compact 2017-08-18 11:01:51 -07:00
or-else c88c821002 added samples 2017-08-17 13:20:20 -07:00
or-else f20f83b936 urls, mentions, and hashtags are extracted and formatted correctly 2017-08-17 13:07:32 -07:00
or-else 88cc3145c4 parser and formatter work as expected 2017-08-16 17:53:39 -07:00
or-else 5d1bf5f624 first pass at defining rich text formatting 2017-08-15 20:54:59 -07:00
or-else 9fa998709d small tweaks to the logo 2017-08-10 22:35:20 +03:00
or-else 0f90626a49 make permissions editor UI clearer 2017-08-09 10:30:59 +03:00
or-else 40108d5201 incorrect value used for user2 2017-08-08 22:13:29 +03:00
or-else 0dde03cf6f more refactoring of permissions editor. still does not work 2017-08-08 18:33:30 +03:00
or-else 83fe8ea02b refactoring permissions editor 2017-08-08 11:16:50 +03:00
or-else 17876fc4b0 bug fix: an extra copy of a p2p topic was sometimes created 2017-08-07 21:14:42 +03:00
or-else eca16c8474 making default permissions work as expected 2017-08-07 17:12:43 +03:00
or-else ff0dfed2e8 change auto-login order: try token first 2017-08-07 10:06:03 +03:00
Gene db8e718002 Merge pull request #6 from riandyrn/attempt-generalized-number-of-tags-sent-to-server
[Attempt to Generalize Number of Tags Sent to Server]
2017-08-05 13:05:14 +03:00
Riandy Rahman Nugraha f49f0424d5 generalized to_tag() into to_tags() 2017-08-05 16:59:56 +07:00
or-else 998ddfd54b changed new code for style and made it usable for other non-email tags 2017-08-04 14:47:18 +03:00
Gene 53e67de669 Merge pull request #5 from riandyrn/riandy-branch
[Attempt to Make New User Discoverable]
2017-08-04 14:37:36 +03:00
Riandy Rahman Nugraha 897bb0719a remove tinode.js from example app —> tinode.js already has its own repo 2017-08-04 09:11:29 +07:00
Riandy Rahman Nugraha d051c84737 explicitly set defacs for newly created account to “JRWPS”; add email as tags in newly created account, so it could be discovered by others; 2017-08-03 09:15:31 +07:00
or-else 578df160d1 cosmetic change 2017-07-23 18:23:35 +03:00
or-else ac335a7a5c newer screenshot 2017-07-15 18:22:53 +03:00
or-else 5868915f2c permissions editing works now 2017-07-15 12:35:53 +03:00
or-else 1054c81de3 Merge branch 'devel' 2017-07-10 20:34:40 +03:00
or-else 79756602a7 topic member management complete 2017-07-10 19:29:35 +03:00
or-else 8d5f446ccd member management is complete now 2017-07-09 11:22:27 +03:00
or-else b689d08e71 subscriber management is somewhat functional 2017-07-06 16:15:56 +03:00
or-else 58ae90b574 added mateerial chips widget 2017-07-04 19:23:07 +03:00
or-else e79444e5f7 lets of bug fixes 2017-07-02 16:16:45 +03:00
or-else 45eb91765b more refactoring of topic and account editing 2017-06-29 14:02:16 +03:00
or-else 933ede1220 separated contact list into a component 2017-06-27 20:58:27 +03:00
or-else 506bd42201 documentation update 2017-06-25 20:40:17 +03:00
or-else b3ef0a856b name and version 2017-06-25 15:33:20 +03:00
or-else 732c17ad76 make mobile screenshots bigger 2017-06-25 15:32:15 +03:00
or-else 8ee22694f1 fit three images in a row 2017-06-25 15:29:25 +03:00
or-else e3909c96bd bug fixes and visual clean up 2017-06-25 15:26:19 +03:00
or-else ac97f1d61d added topic and account editing 2017-06-24 21:03:37 +03:00
or-else 61fbc69356 refactored contect menu and updated readme 2017-06-22 21:04:12 +03:00
or-else 67cd659712 better handling of avatars 2017-06-16 17:44:17 -07:00
or-else 895a81799f clearing messages works now 2017-06-15 21:29:07 -07:00
or-else 097bf7d193 make avatar circular rather than oval 2017-06-13 18:45:11 -07:00
or-else 40cccfdfa9 added error handling for hello() 2017-05-24 18:23:26 -07:00
Gene S 71fc855940 better handling of runtime server address changes 2017-04-25 11:49:12 -07:00
Gene S 9fc05d97d9 making host selectable from the login screen 2017-04-20 17:50:23 -07:00
Gene S 0a3dfa8d9a change server address to public 2017-04-19 17:05:12 -07:00
Gene S 43016e389a refactoring access mode to support server changes 2017-04-18 17:41:39 -07:00
Gene S 8c4284a435 Merge branch 'master' of https://github.com/tinode/example-react-js 2016-09-09 18:14:55 -07:00
Gene S 3f13c56fb9 merging changes 2016-09-09 18:14:52 -07:00
Or 73e2be8ac8 Merge pull request #1 from npmcdn-to-unpkg-bot/npmcdn-to-unpkg
Replace npmcdn.com with unpkg.com
2016-09-08 12:27:02 -07:00
npmcdn-to-unpkg-bot 86f706dee7 Replace npmcdn.com with unpkg.com 2016-09-08 07:29:29 +01:00
Gene S 1372a68ecf a few fixes around contacts discovery 2016-09-06 19:24:58 -07:00
Gene S dc37ab3d7a updated screenshot 2016-09-02 10:52:49 -07:00
Gene S e9de7d89fd contact discovery works now 2016-08-29 18:04:52 -07:00
Gene S 2e4b165b42 updated reactjs to the latest version 2016-08-29 14:54:50 -07:00
Gene S d03b340985 updated reactjs to the latest version 2016-08-29 14:54:28 -07:00
or-else 90d579bbcb incompatible protocol changes in authentication, version bumped to 0.8 2016-08-28 17:12:16 -07:00
Gene S 8067985edd bugs: AccessMode was broken, muting topics is now possible, but un-muting is still broken 2016-08-18 09:58:23 -07:00
Gene S 2d5ed1bb83 bug: removing deleted topic from view 2016-08-17 11:06:20 -07:00
Gene S 85bcc107d4 updated reactjs to the latest version 2016-06-15 15:40:58 -07:00
Gene S 3d4285d2dc introduced a handshake packet and bumped protocol version to 0.7, a few bugs fixed. long polling is broken 2016-06-15 15:28:22 -07:00
Gene S c7282f8c04 small bug fix in logging and clearer isplay of versions 2016-06-07 19:00:51 -07:00
Gene S e06612f5b3 autoreconnect with backoff and bug fixes 2016-03-16 19:16:31 -07:00
Gene S e28655a64c finished topic creation/deletion 2016-03-10 18:54:26 -08:00
Gene S 1db21526e1 fixes around deleteing/unsubscricing p2p topics 2016-03-09 17:26:55 -08:00
Gene S d709739dc0 menu positioning fixes 2016-03-04 18:31:15 -08:00
Gene S d9a1e2fd0c bug fixes 2016-03-04 18:12:06 -08:00
Gene S ac86e415e9 topic creation and context menu fixes 2016-03-03 18:03:11 -08:00
Gene S 5b89abb646 user search (incomplete) 2016-02-29 18:49:39 -08:00
Gene S ed49b8b10f user discovery 2016-02-29 09:32:38 -08:00
Gene S 58d02cf4bc added browser styling 2016-02-23 11:11:51 -08:00
Gene S 0ed884f5ad reduced delay fore sending read confirmation 2016-02-19 18:49:13 -08:00
Gene S e278ebda5d bug fixes 2016-02-18 19:30:21 -08:00
Gene S 103184c8da Merge branch 'master' of https://github.com/tinode/example-react-js 2016-02-17 19:06:45 -08:00
Gene S 3159ddb0a6 refactored message background colos 2016-02-17 19:06:42 -08:00
Gene S fc7369f586 refactored message background colos 2016-02-17 19:05:43 -08:00
or-else 11c78eb85e merge conflict + update react version to 14.7 2016-02-14 12:10:29 -08:00
or-else 5449728cc6 Changed public: vcard: <data> to public: <data> 2016-02-14 12:03:25 -08:00
Gene S 7d8e58fb4d changed host name 2016-02-12 18:22:29 -08:00
Gene S 1d46fd0279 refactoring of {sub} {get} and {set} for clarity 2016-02-12 15:52:05 -08:00
Gene S cc99b267bb swapping host name 2016-02-01 09:52:22 -08:00
Gene S cf38f55291 swapping host name 2016-02-01 09:50:18 -08:00
or-else 1483740189 making the app adaptive - now works on mobile 2016-01-31 20:04:37 -08:00
or-else f5e91a6366 update readme 2016-01-29 20:44:42 -08:00
or-else 0e539c0a98 change host from local to remote 2016-01-29 20:16:17 -08:00
Gene S d1265c21ee adding avatar support 2016-01-29 19:23:02 -08:00
Gene S 9a855a7288 removing unused icons 2016-01-27 10:50:55 -08:00
Gene S d331367223 a few small fixes 2016-01-27 10:39:24 -08:00
Gene S 156f1f50bc visual improvements and fixes 2016-01-26 19:16:39 -08:00
Gene S 3acd66e5b3 removed bootstrap dependencies 2016-01-26 17:27:29 -08:00
or-else 876a7c1b1f removed bootstrap 2016-01-24 22:23:28 -08:00
or-else 444d7c006d lots of refactoring to remove bootstrap, still unfinished 2016-01-24 22:13:50 -08:00
Gene S 10353c9a1d refactoring message view (incomplete) 2016-01-22 18:00:56 -08:00
Gene S 22e2be2dc8 online presence 2016-01-20 19:07:36 -08:00
Gene S d3e2436527 Merge branch 'master' of https://github.com/tinode/example-react-js 2016-01-20 10:24:30 -08:00
Gene S 984f255d8a debigging 2016-01-20 10:24:24 -08:00
or-else 3f508da8e6 lots of refactoring to support read/received counts 2016-01-18 21:38:53 -08:00
or-else 295ffd640b lots of refactoring to support read/received counts 2016-01-17 15:59:07 -08:00
Gene S b344f9d7e4 Merge branch 'master' of https://github.com/tinode/example-react-js 2016-01-15 16:46:50 -08:00
Gene S ca49c83073 account creation works now 2016-01-15 16:46:24 -08:00
or-else d28d74a77a lass padding in contact list 2016-01-14 20:51:27 -08:00
Gene S 1b52fde005 README update 2016-01-14 19:10:14 -08:00
Gene S 31d5320922 bug fixes and typing notifications 2016-01-14 17:12:03 -08:00
Gene S e871dad863 now fetching and rendering messages 2016-01-13 17:58:41 -08:00
Gene S eee2db628a fetching messages from the server 2016-01-12 18:57:34 -08:00
or-else 4c759913c7 making contact list scrollable 2016-01-11 22:01:28 -08:00
or-else 4442cbae9d making contacts scrollable 2016-01-10 19:46:50 -08:00
or-else 6d0d75ab1d making contacts scrollable 2016-01-10 19:33:19 -08:00
or-else 3f2746cdb7 contacts loaded and shown 2016-01-10 16:01:44 -08:00
or-else d28c696190 continue to hook up server 2016-01-10 14:07:25 -08:00
or-else eba7510be8 adding subscription (incomplete) 2016-01-09 21:59:36 -08:00
Gene S d1442bfc93 hooked up to the server and added login error handling 2016-01-08 18:26:08 -08:00
Gene S 743d941204 hooked up the Tinode bindings, it calls the server now 2016-01-07 18:18:56 -08:00
Gene S f82bd966e0 made demo available online 2016-01-06 10:54:14 -08:00
Gene S aedc9854c1 made demo available online 2016-01-06 10:53:14 -08:00
Gene S 87c5b5bc6b graphic assets 2016-01-05 18:48:40 -08:00
Gene S 9f50eff10a moved all logic to AppView, added Sign Up view 2016-01-05 18:47:37 -08:00
Gene S 3b56e7ca2f moved files around for clarity 2016-01-05 11:07:55 -08:00
Gene S f64c8082ff daily commit 2016-01-04 19:12:31 -08:00
or-else 20be763dc1 daily commit 2016-01-03 20:49:32 -08:00
or-else 391a956af8 daily commit 2016-01-02 20:16:30 -08:00
or-else 2121a6a4f2 daily commit 2016-01-01 21:36:34 -08:00
Gene Sokolov dc8f82961a daily commit 2016-01-01 20:48:38 -08:00
Gene S d9015804c8 current status 2015-12-31 15:10:26 -08:00
Gene S 135bec17a8 Merge branch 'master' of https://github.com/tinode/react-js 2015-12-31 15:08:24 -08:00
Gene S 35624786ab Initial commit 2015-12-31 15:07:38 -08:00
Gene S 0688a360c7 initial pass at a react.js chat 2015-12-31 15:03:22 -08:00
111 changed files with 39779 additions and 139 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"comments": false,
"plugins": [
["react-intl", {
"messagesDir": "./src/i18n/"
}]
]
}
+8
View File
@@ -0,0 +1,8 @@
index.dev.js binary
index.dev.js.map binary
index.prod.js binary
index.prod.js.map binary
base.min.css binary
tinode.dev.js binary
tinode.prod.js binary
package-lock.json binary
+3 -138
View File
@@ -1,138 +1,3 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store
node_modules
src/i18n/src/
+25
View File
@@ -0,0 +1,25 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroidx",
"sha256_cert_fingerprints":
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "web",
"site": "https://web.tinode.co"
}
},
{
"relation": ["delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "co.tinode.tindroidx",
"sha256_cert_fingerprints":
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
}
}]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+77 -1
View File
@@ -1,2 +1,78 @@
# im_webapp
# Single-page chat with Tinode
Single-page web chat application for [Tinode](https://github.com/tinode/chat/). The app is built on
[React](https://reactjs.org/). The [Tinode javascript SDK](https://github.com/tinode/tinode-js/) has no external dependencies.
Overall it's a lot like open source WhatsApp or Telegram web apps.
Although the app is generally usable, keep in mind that this is work in progress. Some bugs probably exist, some features are missing. The app was tested in the latest Chrome & Firefox only. NPM package is [available](https://www.npmjs.com/package/tinode-webapp).
Try possibly newer or older version live at https://web.tinode.co/.
<a href="https://web.tinode.co/"><img src="web-app.svg" height=36></a>
For demo access and other instructions [see here](https://github.com/tinode/chat/#demosandbox).
## Installing and running
This is NOT a standalone app, this is just a frontend, a client. It requires a [backend](https://github.com/tinode/chat/). See [installation instructions](https://github.com/tinode/chat/blob/master/INSTALL.md).
## Getting support
* Read [client-side](http://tinode.github.io/js-api/) and [server-side](https://github.com/tinode/chat/blob/master/docs/API.md) API documentation.
* For support, general questions, discussions post to [https://groups.google.com/d/forum/tinode](https://groups.google.com/d/forum/tinode).
* For bugs and feature requests [open an issue](https://github.com/tinode/webchat/issues/new).
## Internationalization
The app is fully internationalized using [React-Intl](https://github.com/formatjs/react-intl). The UI language is selected automatically from the language specified by the browser. A specific language can be forced by adding `#?hl=XX` parameter to the URL when starting the app, e.g. https://web.tinode.co/#?hl=ru.
As of the time of this writing the following translations exist:
* English
* Chinese (simplified)
* Korean
* Russian
* Spanish
More translations are welcome. Send a pull request with a json file with translated strings. Take a look at [English](/src/i18n/en.json), [Russian](/src/i18n/ru.json), or [Simplified Chinese](/src/i18n/zh.json) translations for guidance.
## Not done yet
* End-to-End encryption.
* Emoji support is weak.
* Mentions, hashtags.
* Replying or forwarding messages.
* Previews not generated for videos, audio, links or docs.
## Other
* Demo avatars and some other graphics are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
* Background patterns from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
* [Fonts](https://fonts.google.com/) and [icons](https://google.github.io/material-design-icons/#icon-font-for-the-web) from Google under [license](https://developers.google.com/terms) which permits commercial and non-commercial use.
* Push notifications built with https://firebase.google.com/.
## Push notifications
If you want to use the app with your own server and want web push notification to work you have to set them up:
* Register at https://firebase.google.com/, set up the project if you have not done so already.
* Follow instructions to create a web application https://support.google.com/firebase/answer/9326094 in your project.
* Follow instructions at https://support.google.com/firebase/answer/7015592 to get a Firebase configuration object ("Firebase SDK snippet").
* Locate `firebase-init.js` in the root folder of your copy of TinodeWeb app. Copy-paste the following keys from the configuration object to `firebase-init.js`: `apiKey`, `messagingSenderId`, `projectId`, `appId` (you may copy all keys).
* Copy `Web Push certificate` (Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates) to `messagingVapidKey` field in `firebase-init.js`.
* Double check that `firebase-init.js` contains the following keys: `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. The file may contain other optional keys.
* Copy Google-provided server key to `tinode.conf`, see details [here](https://github.com/tinode/chat/blob/master/docs/faq.md#q-how-to-setup-fcm-push-notifications).
## Responsive design
### Desktop screenshot
<p align="center">
<img src="web-desktop-2.png" alt="Desktop web: full app" width=810 />
</p>
### Mobile screenshots
<p align="center">
<img src="web-mob-contacts-1.png" alt="Mobile web: contacts" width=250 /> <img src="web-mob-chat-1.png" alt="Mobile web: chat" width=250 /> <img src="web-mob-info-1.png" alt="Mobile web: topic info" width=250 /> <img src="web-mob-new-chat-1.png" alt="Mobile web: start new chat" width=250 /> <img src="web-mob-acc-settings.png" alt="Mobile web: account settings" width=250 />
</p>
BIN
View File
Binary file not shown.
+2012
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
// Get these values for your project from the https://console.firebase.google.com/
const FIREBASE_INIT = {
// Set up a web application in console then get the config object.
// The following keys are copied from the object:
apiKey: "AIzaSyD6X4ULR-RUsobvs1zZ2bHdJuPz39q2tbQ",
messagingSenderId: "114126160546",
projectId: "tinode-1000",
appId: "1:114126160546:web:aca6ea2981feb81fb44dfb",
// Project Settings -> Cloud Messaging -> Web configuration -> Web Push certificates.
// This value IS NOT included in the generated js config you get from
// the firebase console. It needs to be added separately.
messagingVapidKey: "BOgQVPOMzIMXUpsYGpbVkZoEBc0ifKY_f2kSU5DNDGYI6i6CoKqqxDd7w7PJ3FaGRBgVGJffldETumOx831jl58"
};
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="192" height="192"><defs><filter id="f" color-interpolation-filters="sRGB"><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-1" result="offset"/><feComposite in="SourceGraphic" in2="offset" result="fbSourceGraphic"/><feColorMatrix result="fbSourceGraphicAlpha" in="fbSourceGraphic" values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"/><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in2="fbSourceGraphic" in="flood" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-3" result="offset"/><feComposite in2="offset" in="fbSourceGraphic" result="composite2"/></filter><filter id="d" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#FFF" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="b" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="c" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><linearGradient id="a"><stop offset="0" stop-opacity=".251"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="102.814" y1="97.076" x2="219.019" y2="170.481" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.29306 0 0 1.00596 -43.959 .807)"/></defs><g fill-rule="evenodd"><path d="M14.102 64A88 87.925 0 008 95.924c-.012 44.455 18.485 70.138 1.863 79.822-12.733 7.419 43.153 8.258 70.137 8.248V64H14.102z" fill="#fc5d3e" filter="url(#b)"/><path d="M80 64v119.994c8.24-.003 14.055-.08 14.055-.08L96 183.85a88 87.925 0 0088-87.926A88 87.925 0 00177.99 64H80z" fill="#45ae49" filter="url(#c)"/><path d="M96 8A88 87.925 0 009.52 80h172.978A88 87.925 0 0096 8z" fill="#2d526b" filter="url(#d)"/></g><path d="M88.002 40.975l-8.6 112.808 39.791 26.94c34.169-9.369 59.381-38.298 63.971-73.4z" fill="url(#e)" fill-rule="evenodd" opacity=".5"/><text y="153.195" x="53.125" style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" font-weight="700" font-size="160" font-family="Arial" letter-spacing="0" word-spacing="0"><tspan style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" y="153.195" x="53.125" fill="#fff">t</tspan></text></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+53
View File
@@ -0,0 +1,53 @@
<!-- DEVELOPMENT version of the web app. -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tinode</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web, version for developers" />
<meta name="description" content="Tinode instant messenger, development version" />
<meta name="application-url" content="https://web.tinode.co/">
<!-- Browser styling for Chrome, FF, Opera -->
<meta name="theme-color" content="#3949AB" />
<!-- Ask Google not to offer page translation -->
<meta name="google" value="notranslate" />
<!-- Open Graph description -->
<meta name="og:url" content="https://web.tinode.co/">
<meta name="og:title" content="Tinode Web Development">
<meta name="og:image" content="img/og-logo.jpeg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://web.tinode.co/" />
<!-- Google's Roboto font -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<!-- Google's material design icons -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<!-- Styles for local elements -->
<link rel="stylesheet" href="css/base.css" />
<!-- ReactJS -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16/umd/react.development.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16/umd/react-dom.development.js"></script>
<!-- React-Intl for translations -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3/dist/react-intl.js"></script>
<!-- Firebase for push notifications -->
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.
</div>
</div></noscript>
<div id="mountPoint"></div>
<script src="firebase-init.js"></script>
<script src="umd/tinode.dev.js"></script>
<script src="umd/index.dev.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<title>Tinode</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="Tinode Web" />
<meta name="description" content="Tinode instant messaging in a browser" />
<meta name="application-url" content="https://web.tinode.co/">
<meta name="theme-color" content="#3949AB" />
<meta name="google" value="notranslate" />
<meta name="format-detection" content="telephone=no" />
<meta name="og:url" content="https://web.tinode.co/">
<meta name="og:title" content="Tinode Web">
<meta name="og:image" content="img/og-logo.jpeg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<link rel="manifest" href="manifest.json" />
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://web.tinode.co/" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="css/base.min.css" />
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react@16.13.1/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/react-intl@3.12.1/dist/react-intl.min.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
then <a href="">try again</a>.</div></div></noscript>
<div id="mountPoint"></div>
<script src="firebase-init.js"></script>
<script src="umd/tinode.prod.js"></script>
<script src="umd/index.prod.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"short_name": "Tinode chat",
"name": "Tinode Web chat",
"icons": [
{
"src":"img/logo96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src":"img/logo192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "img/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512"
}
],
"start_url": "/",
"background_color": "silver",
"theme_color": "#3949AB",
"display": "standalone",
"gcm_sender_id": "103953800507"
}
+9344
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.16.6",
"repository": {
"type": "git",
"url": "https://github.com/tinode/example-react-js.git"
},
"scripts": {
"build": "npm run vers && npm run build:prod && npm run build:dev && npm run build:css && npm run build:i18n",
"build:css": "postcss css/base.css > css/base.min.css",
"build:dev": "webpack --mode development",
"build:prod": "webpack --mode production",
"build:i18n": "node scripts/flatten-messages.js",
"vers": "node scripts/gen-version.js"
},
"browserslist": "> 0.5%, not IE 11",
"files": [
"src/index.js",
"src/config.js",
"src/views/*.jsx",
"src/widgets/*.jsx",
"src/lib/*.js",
"src/i18n/*.json",
"umd/index.prod.js",
"umd/index.prod.js.map",
"umd/index.dev.js",
"umd/index.dev.js.map",
"umd/tinode.prod.js",
"umd/tinode.dev.js",
"audio/msg.mp3",
"css/base.css",
"css/base.min.css",
"img/bg_messages.png",
"img/logo.svg",
"img/logo32x32.png",
"img/logo32x32a.png",
"img/logo96.png",
"img/logo192.png",
"img/badge96.png",
"img/broken_image.png",
"img/og-logo.jpeg",
"index-dev.html",
"index.html",
"manifest.json",
"service-worker.js",
"firebase-init.js",
"web-desktop-2.png",
"web-mob-chat-1.png",
"web-mob-contacts-1.png",
"web-mob-info-1.png",
"web-mob-new-chat-1.png"
],
"keywords": [
"chat",
"instant-messenger",
"message",
"messenger",
"react",
"web"
],
"main": "umd/index.prod.js",
"bugs": {
"url": "https://github.com/tinode/example-react-js/issues"
},
"homepage": "https://github.com/tinode/chat",
"email": "info@tinode.co",
"author": "Tinode Authors <info@tinode.co>",
"license": "Apache-2.0",
"dependencies": {
"firebase": "^7.14.6",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-intl": "^4.6.9",
"tinode-sdk": "^0.16.6"
},
"devDependencies": {
"@babel/core": "^7.10.2",
"@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.10.1",
"babel-loader": "^8.1.0",
"babel-plugin-react-intl": "^7.5.20",
"browserslist": "^4.12.0",
"copy-webpack-plugin": "^6.0.2",
"cssnano": "^4.1.10",
"postcss": "^7.0.32",
"postcss-cli": "^7.1.1",
"terser-webpack-plugin": "^3.0.3",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: [
require('cssnano')({
preset: 'default',
}),
],
};
+101
View File
@@ -0,0 +1,101 @@
// 1.Flattens messages extracted via the React Intl Babel plugin. An error will be
// thrown if there are messages with the same `id`. The result is a file containing
// `id: message` pairs in "en" locale.
//
// 2. Takes all translations and groups them into a single file.
var fs = require('fs');
var path = require('path');
var globSync = require('glob').sync;
var mkdirpSync = require('mkdirp').sync;
const DEFAULT_TRANSLATION = 'en.json';
const MESSAGES_PATTERN = './src/i18n/src/**/*.json';
const LANG_DIR = './src/i18n/';
const LANG_PATTERN = './src/i18n/*.json';
const FINAL_FILE = './src/messages.json';
const MISSING_MARKER = ':!![MISSING]!!:';
const OBSOLETE_MARKER = ':!![OBSOLETE]!!:';
// The react-intl-babel-plugin saves extracted messages
// into files, one fine per component. Rewrite them into
// a single file en.json.
let messages = globSync(MESSAGES_PATTERN)
.map((filename) => fs.readFileSync(filename, 'utf8'))
.map((file) => JSON.parse(file))
.reduce((collection, descriptors) => {
descriptors.forEach(({id, defaultMessage}) => {
if (!defaultMessage) {
return;
}
if (collection.hasOwnProperty(id)) {
// Message is already defined.
throw new Error(`Duplicate message id: ${id}`);
}
collection[id] = defaultMessage;
});
return collection;
}, {});
let filename = path.join(LANG_DIR, DEFAULT_TRANSLATION);
mkdirpSync(path.dirname(filename));
fs.writeFileSync(filename, JSON.stringify(messages, null, 2)+"\n");
// Find messages present in en.json but missing in translations and vice versa.
// Mark missing or obsolete messages as such in translation files.
globSync(LANG_PATTERN)
.map((filename) => {
const parts = filename.split('/');
if (parts[parts.length-1] != DEFAULT_TRANSLATION) {
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
// Find missing messages.
Object.entries(messages).forEach((ent) => {
if (!translated.hasOwnProperty(ent[0])) {
translated[ent[0]] = MISSING_MARKER + ent[1];
}
});
// Find no longer needed messages.
Object.entries(translated).forEach((ent) => {
if (!messages.hasOwnProperty(ent[0])) {
let val = translated[ent[0]];
if (val.indexOf(MISSING_MARKER) == 0) {
val = val.substring(0, MISSING_MARKER.length);
}
if (val.indexOf(OBSOLETE_MARKER) != 0) {
val = OBSOLETE_MARKER + val;
}
translated[ent[0]] = val;
}
});
fs.writeFileSync(filename, JSON.stringify(translated, null, 2)+"\n");
}
});
// Combine all translations into a single json {"lang_1": {...}, "lang_2: {...}"}
// while removing messages marked as missing or obsolete.
// Read all translations.
messages = globSync(LANG_PATTERN)
.map((filename) => {
const parts = filename.split('/');
const locale = parts[parts.length - 1].split('.json')[0];
const translated = JSON.parse(fs.readFileSync(filename, 'utf8'));
// Strip messages prefixed with MISSING or OBSOLETE markers.
for (let id in translated) {
if (translated[id].indexOf(OBSOLETE_MARKER) == 0 ||
translated[id].indexOf(MISSING_MARKER) == 0) {
delete translated[id];
}
}
return { [locale]: translated };
})
.reduce((collection, descriptors) => {
return { ...collection, ...descriptors };
}, {});
// Write file with all translations combined.
mkdirpSync(path.dirname(FINAL_FILE));
fs.writeFileSync(FINAL_FILE, JSON.stringify(messages, null, 2)+"\n");
+13
View File
@@ -0,0 +1,13 @@
var fs = require('fs');
var version = require('../package.json').version;
fs.writeFile('./src/version.js',
'// This is a generated file. Don\'t edit.\n\n'+
'export const PACKAGE_VERSION = "'+version+'";\n',
{ flag: 'w' },
function(err) {
if (err) {
return console.log(err);
}
}
);
+126
View File
@@ -0,0 +1,126 @@
// Must be located at the root.
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-app.js');
importScripts('https://cdn.jsdelivr.net/npm/firebase@7.14.6/firebase-messaging.js');
importScripts('firebase-init.js');
firebase.initializeApp(FIREBASE_INIT);
// Basic internationalization.
const i18n = {
'en': {
'new_message': "New message",
'new_chat': "New chat",
},
'es': {
'new_message': "Nuevo mensaje",
'new_chat': "Nueva conversación",
},
'ko': {
'new_message': "새로운 메시지",
'new_chat': "새로운 채팅",
},
'ru': {
'new_message': "Новое сообщение",
'new_chat': "Новый чат",
},
'zh': {
'new_message': "新讯息",
'new_chat': "新聊天",
}
};
self.i18nMessage = function(id) {
// Choose translations: given something like 'de-CH', try 'de-CH' then 'de' then 'en'.
const lang = i18n[self.locale] || i18n[self.baseLocale] || i18n['en'];
// Try id in the specified language, if missing try English, otherwise use id as the last resort.
return lang[id] || i18n['en'][id] || id;
}
// This method shows the push notifications.
firebase.messaging().setBackgroundMessageHandler((payload) => {
if (payload.data.silent == 'true') {
return;
}
const pushType = payload.data.what || 'msg';
const title = payload.data.title || self.i18nMessage(pushType == 'msg' ? 'new_message' : 'new_chat');
const options = {
body: payload.data.content || '',
icon: '/img/logo96.png',
badge: '/img/badge96.png',
tag: payload.data.topic || undefined,
data: {
topic: payload.data.topic
}
};
return self.registration.showNotification(title, options);
});
// Update service worker immediately for both the current client
// and all other active clients.
self.addEventListener('install', event => {
self.skipWaiting();
});
// This code handles a click on notification: takes
// the user to the browser tab with the chat or opens a new tab.
self.addEventListener('notificationclick', event => {
const notification = event.notification;
notification.close();
const urlHash = '#/' + notification.data.topic;
event.waitUntil(self.clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then((windowClients) => {
let anyClient = null;
for (let i = 0; i < windowClients.length; i++) {
const url = new URL(windowClients[i].url);
if (url.hash.includes(notification.data.topic)) {
// Found the Tinode tab with the right topic open.
return windowClients[i].focus();
} else {
// This will be the least recently used tab.
anyClient = windowClients[i];
}
}
// Found tab with Tinode on a different topic,
// navigate to the right topic.
if (anyClient) {
const url = new URL(anyClient.url);
url.hash = urlHash;
return anyClient.focus().then(thisClient => {
return thisClient.navigate(url);
});
}
// Did not find a Tinode browser tab. Open one.
const url = new URL(self.location.origin);
url.hash = urlHash;
return clients.openWindow(url);
}));
});
// This is needed for 'Add to Home Screen'.
self.addEventListener('fetch', event => {
event.respondWith(
// Try to find response in cache.
caches.match(event.request)
.then((resp) => {
// If response is found in cache, return it. Otherwise fetch.
return resp || fetch(event.request);
})
.catch((err) => {
// Something went wrong.
console.log("Service worker Fetch:", err);
})
);
});
// This code get the human language from the webapp for localization of strings.
self.addEventListener('message', event => {
const data = JSON.parse(event.data);
self.locale = data.locale || '';
self.baseLocale = self.locale.toLowerCase().split(/[-_]/)[0];
});
+84
View File
@@ -0,0 +1,84 @@
import { PACKAGE_VERSION } from './version.js';
// Name of this application, used in the User-Agent.
export const APP_NAME = 'TinodeWeb/' + (PACKAGE_VERSION || '0.16');
// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own
export const API_KEY = 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
// The array of possible hosts to connect to.
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};
// Default host name and port to connect to.
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
// Enable console logging.
export const LOGGING_ENABLED = true;
// Minimum time between two keypress notifications, milliseconds.
export const KEYPRESS_DELAY = 3*1000;
// Delay before sending a {note} for reciving a message, milliseconds.
export const RECEIVED_DELAY = 500;
// Delay before sending a read notification, milliseconds.
export const READ_DELAY = 1000;
// The shortest allowed tag length. Matches the value on the server.
export const MIN_TAG_LENGTH = 4;
// The maximum number of tags allowed. Matches the value on the server.
export const MAX_TAG_COUNT = 16;
// Access mode for P2P subscriptions initiated by the current user.
export const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';
// Access mode for new group topics created by the current user.
export const NEW_GRP_ACCESS_MODE = 'JRWPSAO';
// Access mode for no access.
export const NO_ACCESS_MODE = 'N';
// Mediaquery breakpoint between desktop and mobile, in px. Should match the value
// in @media (max-size: 640px) in base.css
export const MEDIA_BREAKPOINT = 640;
// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.
export const REM_SIZE = 13;
// Size of the avatar image: When an avatar image is uploaded, it's resized to
// a square of this size.
export const AVATAR_SIZE = 128;
// Size of the broken_image shown in MessagesView
export const BROKEN_IMAGE_SIZE = 32;
// Number of chat messages to fetch in one call.
export const MESSAGES_PAGE = 24;
// Maximum in-band (included directly into the message) attachment size which fits into
// a message of 256K in size, assuming base64 encoding and 1024 bytes of overhead.
// This is size of an object *before* base64 encoding is applied.
// Increase this limit to a greater value in production, if desired. Also increase
// max_message_size in server config.
// MAX_INBAND_ATTACHMENT_SIZE = base64DecodedLen(max_message_size) - 1024;
export const MAX_INBAND_ATTACHMENT_SIZE = 195584;
// Absolute maximum attachment size to be used with the server = 8MB. Increase to
// something like 100MB in production.
export const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;
// Maximum allowed linear dimension of an inline image in pixels. You may want
// to adjust it to 1600 or 2400 for production.
export const MAX_IMAGE_DIM = 768;
// Maximum number of online users to be shown in a topic title bar. Others will be
// hidden under "+X more"
export const MAX_ONLINE_IN_TOPIC = 4;
// Maximum length of user name, topic title, and private comment.
export const MAX_TITLE_LENGTH = 60;
// Link for "Contact Us".
export const LINK_CONTACT_US = 'email:info@tinode.co';
// Link to Privacy Policy.
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
// Link to Terms of Service.
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
+174
View File
@@ -0,0 +1,174 @@
{
"validate_credential_action": "confirm",
"title_tag_manager": "Tags (user discovery)",
"label_user_contacts": "Contacts:",
"button_add_another": "Add another",
"label_message_sound": "Message sound:",
"label_push_notifications": "Notification alerts:",
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
"label_incognito_mode": "Incognito mode:",
"delete_account": "Delete account",
"delete_account_arning": "Are you sure you want to delete your account? It cannot be undone.",
"label_password": "Password",
"password_unchanged_prompt": "Unchanged",
"button_logout": "Logout",
"button_delete_account": "Delete account",
"label_default_access_mode": "Default access mode:",
"blocked_contacts_link": "Blocked contacts ({count})",
"link_contact_us": "Contact Us",
"link_terms_of_service": "Terms of Service",
"link_privacy_policy": "Privacy Policy",
"label_sdk": "SDK:",
"label_server_address": "Server address:",
"archived_contacts": "Archived contacts ({count})",
"contacts_not_found": "You have no chats<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Full name, e.g. John Doe",
"email_prompt": "Email, e.g. jdoe@example.com",
"button_sign_up": "Sign up",
"label_your_name": "Your name",
"label_user_id": "ID:",
"button_edit": "Edit",
"requested_permissions": "Requested",
"granted_permissions": "Granted",
"menu_item_edit_permissions": "Edit permissions",
"label_other_user": "Other",
"action_clear_messages": "Clear Messages",
"clear_messages_warning": "Are you sure you want to clear all messages? It cannot be undone.",
"action_delete_messages": "Clear Messages for All",
"delete_messages_warning": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
"action_leave_chat": "Leave Conversation",
"leave_chat_warning": "Are you sure you want to leave this conversation?",
"action_block_contact": "Block Contact",
"block_contact_warning": "Are you sure you want to block this contact?",
"action_report_chat": "Report Conversation",
"report_chat_warning": "Are you sure you want to block and report this conversation?",
"title_info": "Info",
"label_topic_name": "Name",
"label_private": "Private comment",
"private_editing_placeholder": "Visible to you only",
"label_muting_topic": "Muted:",
"action_more": "More",
"label_your_permissions": "Your permissions:",
"label_permissions": "Permissions:",
"label_you": "You:",
"label_default_access": "Default access mode:",
"label_group_members": "Group members:",
"button_add_members": "Add members",
"group_has_no_members": "No members",
"login_prompt": "Login",
"password_prompt": "Password",
"stay_logged_in": "Stay logged in",
"forgot_password_link": "Forgot password?",
"button_sign_in": "Sign in",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "online now",
"last_seen_timestamp": "Last seen",
"title_not_found": "Not found",
"unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.",
"enable_peers_messaging": "Enable",
"search_for_contacts": "Use search to find contacts",
"search_no_results": "Search returned no results",
"tabtitle_find_user": "find",
"tabtitle_new_group": "new group",
"tabtitle_group_by_id": "by id",
"new_password_placeholder": "Enter new password",
"label_reset_password": "Send a password reset email:",
"credential_email_prompt": "Your registration email",
"button_reset": "Reset",
"button_send_request": "Send request",
"label_server_to_use": "Server to use:",
"label_wire_transport": "Wire transport:",
"button_update": "Update",
"sidepanel_title_login": "Sign In",
"sidepanel_title_register": "Create Account",
"sidepanel_title_settings": "Settings",
"sidepanel_title_account_settings": "Account Settings",
"sidepanel_title_acc_general": "General",
"sidepanel_title_acc_security": "Security",
"sidepanel_title_acc_notifications": "Notifications",
"sidepanel_title_acc_support": "Support",
"sidepanel_title_newtpk": "Start New Chat",
"sidepanel_title_cred": "Confirm Credentials",
"sidepanel_title_reset": "Reset Password",
"sidepanel_title_archive": "Archived Chats",
"sidepanel_title_blocked": "Blocked Chats",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
"reconnect_now": "Try now",
"push_init_failed": "Failed to initialize push notifications",
"invalid_security_token": "Invalid security token",
"no_connection": "No connection",
"code_doesnot_match": "Code does not match",
"update_available": "Update available.",
"reload_update": "Reload",
"phone_dative": "phone",
"email_dative": "email",
"enter_confirmation_code_prompt": "Enter confirmation code sent to you by {method}:",
"numeric_confirmation_code_prompt": "Numbers only",
"button_confirm": "Confirm",
"save_attachment": "save",
"deleted_content": "content deleted",
"invalid_content": "invalid content",
"user_not_found": "Not found",
"badge_you": "you",
"badge_owner": "owner",
"menu_item_info": "Info",
"menu_item_clear_messages": "Clear messages",
"menu_item_clear_messages_for_all": "Clear for All",
"menu_item_delete": "Delete",
"menu_item_delete_for_all": "Delete for All",
"menu_item_send_retry": "Retry",
"menu_item_mute": "Mute",
"menu_item_unmute": "Unmute",
"menu_item_delete_topic": "Delete",
"topic_delete_warning": "Are you sure you want to delete this conversation?",
"menu_item_unblock": "Unblock",
"menu_item_block": "Block",
"topic_block_warning": "Are you sure you want to block this conversation?",
"menu_item_member_delete": "Remove",
"menu_item_archive_topic": "Archive",
"action_cancel": "cancel",
"upload_finishing": "finishing...",
"no_contacts": "You have no contacts :-(",
"contacts_not_found_short": "No contacts match ''{query}''",
"title_group_members": "Group Members",
"title_all_contacts": "All Contacts",
"button_ok": "OK",
"button_cancel": "Cancel",
"more_online_members": "+{overflow} more",
"download_action": "download",
"label_file_name": "File name:",
"label_content_type": "Content type:",
"label_size": "Size:",
"chat_invitation": "You are invited to start a new chat. What would you like to do?",
"chat_invitation_accept": "Accept",
"chat_invitation_ignore": "Ignore",
"chat_invitation_block": "Block",
"error_invalid_id": "Invalid ID",
"group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group",
"button_create": "Create",
"permission_join": "Join ({val})",
"permission_read": "Read ({val})",
"permission_write": "Write ({val})",
"permission_pres": "Get notified ({val})",
"permission_admin": "Approve ({val})",
"permission_share": "Share ({val})",
"permission_delete": "Delete ({val})",
"permission_owner": "Owner ({val})",
"title_permissions": "Permissions",
"message_sending": "sending...",
"message_sending_failed": "failed",
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
"messaging_disabled_prompt": "Messaging disabled",
"new_message_prompt": "New message",
"image_caption_prompt": "Image caption",
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
"cannot_initiate_file_upload": "Cannot initiate file upload.",
"tags_not_found": "No tags defined. Add some.",
"tags_editor_no_tags": "Add some tags",
"title_manage_tags": "Manage"
}
+174
View File
@@ -0,0 +1,174 @@
{
"validate_credential_action": "confirmar",
"title_tag_manager": "Etiquetas (descubrimiento de usuarios)",
"label_user_contacts": "Contactos:",
"button_add_another": "Añadir contacto",
"label_message_sound": "Sonido de mensaje:",
"label_push_notifications": "Alertas de notificaciones:",
"label_push_notifications_disabled": "Alertas de notificaciones (requiere HTTPS):",
"label_incognito_mode": "Modo incógnito:",
"delete_account": "Eliminar cuenta",
"delete_account_arning": "¿Estás seguro de que deseas eliminar permanentemente tu cuenta? Esta acción es irreversible.",
"label_password": "Contraseña",
"password_unchanged_prompt": "Sin cambios",
"button_logout": "Cerrar sesión",
"button_delete_account": "Eliminar cuenta",
"label_default_access_mode": "Modo de acceso predeterminado:",
"blocked_contacts_link": "Contactos bloqueados ({count})",
"link_contact_us": "Contáctanos",
"link_terms_of_service": "Términos de uso",
"link_privacy_policy": "Política de privacidad",
"label_sdk": "SDK:",
"label_server_address": "Dirección del servidor:",
"archived_contacts": "Contactos archivados ({count})",
"contacts_not_found": "No tienes chats<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Nombre completo, p.ej. Juan González Hernández",
"email_prompt": "Correo electrónico, p.ej. juan@example.com",
"button_sign_up": "Regístrate",
"label_your_name": "Tu nombre",
"label_user_id": "ID:",
"button_edit": "Editar",
"requested_permissions": "Solicitados",
"granted_permissions": "Otorgados",
"menu_item_edit_permissions": "Editar permisos",
"label_other_user": "Otros",
"action_clear_messages": "Borrar mensajes",
"clear_messages_warning": "¿Estás seguro de que quieres eliminar todos los mensajes? Esta acción es irreversible.",
"action_delete_messages": "Borrar mensajes para todos",
"delete_messages_warning": "¿Estás seguro de que quieres eliminar todos los mensajes para todos? Esta acción es irreversible.",
"action_leave_chat": "Dejar conversación",
"leave_chat_warning": "¿Estás seguro de que quieres dejar esta conversación?",
"action_block_contact": "Bloquear contacto",
"block_contact_warning": "¿Estás seguro de que quieres bloquear a este contacto?",
"action_report_chat": "Reportar conversación",
"report_chat_warning": "¿Estás seguro de que quieres bloquear y reportar a esta conversación?",
"title_info": "Información",
"label_topic_name": "Nombre del tema",
"label_private": "Comentario privado",
"private_editing_placeholder": "Sólo visible para tí",
"label_muting_topic": "Silenciado:",
"action_more": "Más",
"label_your_permissions": "Tus permisoss:",
"label_permissions": "Permisos:",
"label_you": "Tú:",
"label_default_access": "Modo de acceso predeterminado:",
"label_group_members": "Miembros del grupo:",
"button_add_members": "Añadir miembros",
"group_has_no_members": "No hay miembros",
"login_prompt": "Nombre de usuario",
"password_prompt": "Contraseña",
"stay_logged_in": "Permanecer conectado",
"forgot_password_link": "¿Olvidaste tu contraseña?",
"button_sign_in": "Entrar",
"label_client": "Cliente:",
"label_server": "Servidor:",
"online_now": "en línea",
"last_seen_timestamp": "Últ. vez",
"title_not_found": "No encontrado",
"unnamed_topic": "Sin nombre",
"messages_not_readable": "sin acceso a mensajes",
"peers_messaging_disabled": "La mensajería Peer está deshabilitada.",
"enable_peers_messaging": "Habilitar",
"search_for_contacts": "Usa la búsqueda para encontrar contactos",
"search_no_results": "La búsqueda no arrojó resultados",
"tabtitle_find_user": "encontrar",
"tabtitle_new_group": "nuevo grupo",
"tabtitle_group_by_id": "por ID",
"new_password_placeholder": "Introduzca una nueva contraseña",
"label_reset_password": "Enviar un correo electrónico de restablecimiento de contraseña:",
"credential_email_prompt": "Tu correo electrónico de registro",
"button_reset": "Restablecer",
"button_send_request": "Enviar petición",
"label_server_to_use": "Servidor para usar:",
"label_wire_transport": "Transporte de alambre:",
"button_update": "Actualizar",
"sidepanel_title_login": "Iniciar sesión",
"sidepanel_title_register": "Crear cuenta",
"sidepanel_title_settings": "Ajustes",
"sidepanel_title_account_settings": "Ajustes de la cuenta",
"sidepanel_title_acc_general": "General",
"sidepanel_title_acc_security": "Seguridad",
"sidepanel_title_acc_notifications": "Notificaciones",
"sidepanel_title_acc_support": "Soporte",
"sidepanel_title_newtpk": "Iniciar un nuevo chat",
"sidepanel_title_cred": "Confirmar credenciales",
"sidepanel_title_reset": "Restablecer contraseña",
"sidepanel_title_archive": "Chats archivados",
"sidepanel_title_blocked": "Chats bloqueados",
"update_available": "Actualización disponible.",
"reload_update": "Recargar",
"reconnect_countdown": "Desconectado. Reconectando en {seconds}…",
"reconnect_now": "Reintentar",
"push_init_failed": "Error al inicializar las notificaciones push",
"invalid_security_token": "Token de seguridad inválido",
"no_connection": "Sin conexión",
"code_doesnot_match": "El código no coincide",
"phone_dative": "teléfono",
"email_dative": "correo electrónico",
"enter_confirmation_code_prompt": "Intruduzca el código de confirmación enviado a tu {method}:",
"numeric_confirmation_code_prompt": "Sólo números",
"button_confirm": "Confirmar",
"save_attachment": "guardar",
"deleted_content": "este mensaje fue eliminado",
"invalid_content": "contenido inválido",
"user_not_found": "Usuario no encontrado",
"badge_you": "tú",
"badge_owner": "propietario",
"menu_item_info": "Información",
"menu_item_clear_messages": "Borrar mensajes",
"menu_item_clear_messages_for_all": "Borrar para todos",
"menu_item_delete": "Eliminar",
"menu_item_delete_for_all": "Eliminar para todos",
"menu_item_send_retry": "Inténtalo de nuevo",
"menu_item_mute": "Silenciar",
"menu_item_unmute": "Anular el silencio",
"menu_item_delete_topic": "Eliminar",
"topic_delete_warning": "¿Estás seguro de que quieres eliminar esta conversación?",
"menu_item_unblock": "Desbloquear",
"menu_item_block": "Bloquear",
"topic_block_warning": "¿Estás seguro de que quieres bloquear esta conversación",
"menu_item_member_delete": "Eliminar",
"menu_item_archive_topic": "Archivar",
"action_cancel": "cancelar",
"upload_finishing": "terminando...",
"no_contacts": "No tienes contactos :-(",
"contacts_not_found_short": "Ningún contacto coincide con ''{query}''",
"title_group_members": "Miembros del grupo",
"title_all_contacts": "Todos los contactos",
"button_ok": "OK",
"button_cancel": "Cancelar",
"more_online_members": "+{overflow} más",
"download_action": "descargar",
"label_file_name": "Nombre del archivo:",
"label_content_type": "Tipo de contenido:",
"label_size": "Tamaño:",
"chat_invitation": "Estás invitado a participar en un nuevo chat. ¿Qué te gustaría hacer?",
"chat_invitation_accept": "Aceptar",
"chat_invitation_ignore": "Ignorar",
"chat_invitation_block": "Bloquear",
"error_invalid_id": "ID inválido",
"group_user_id_prompt": "ID del grupo o usuario",
"button_subscribe": "Suscribirse",
"topic_name_editing_placeholder": "Nombre del grupo",
"button_create": "Crear",
"permission_join": "Unirse ({val})",
"permission_read": "Leer ({val})",
"permission_write": "Escribir ({val})",
"permission_pres": "Ser notificado ({val})",
"permission_admin": "Approbar ({val})",
"permission_share": "Compartir ({val})",
"permission_delete": "Eliminar ({val})",
"permission_owner": "Propietario ({val})",
"title_permissions": "Permisos",
"message_sending": "enviando...",
"message_sending_failed": "no se pudo enviar el mensaje",
"search_placeholder": "Ej. email:alice@example.com, tel:17025550003...",
"messaging_disabled_prompt": "El envío de mensajes está deshabilitado",
"new_message_prompt": "Nuevo mensaje",
"image_caption_prompt": "Añade un comentario",
"file_attachment_too_large": "El tamaño del archivo {size} excede el límite de {limit}.",
"cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.",
"tags_not_found": "No hay etiquetas definidas. Añade unas.",
"tags_editor_no_tags": "Añadir etiquetas",
"title_manage_tags": "Gestionar"
}
+177
View File
@@ -0,0 +1,177 @@
{
"validate_credential_action": "확인",
"title_tag_manager": "태그(사용자검색)",
"label_user_contacts": "연락처: ",
"button_add_another": "다른항목추가",
"label_message_sound": "메시지소리: ",
"label_push_notifications": "통지경보: ",
"label_push_notifications_disabled": "통지경보(HTTPS필요): ",
"label_incognito_mode": "시크릿모드: ",
"delete_account": "계정삭제",
"delete_account_arning": "계정을삭제하시겠습니까?실행취소할수없습니다.",
"label_password": "비밀번호",
"password_unchanged_prompt": "변경되지않음",
"button_logout": "로그아웃",
"button_delete_account": "계정삭제",
"label_default_access_mode": "기본액세스모드: ",
"blocked_contacts_link": "차단된연락처({수})",
"link_contact_us": "문의처",
"link_terms_of_service": "서비스약관",
"link_privacy_policy": "개인정보보호정책",
"label_sdk": "SDK:",
"label_server_address": "서버주소: ",
"archived_contacts": "보관된연락처({수})",
"contacts_not_found": "채팅이없습니다<br/>¯∖_(ツ)_/¯",
"full_name_prompt": "전체이름(예:홍길동)",
"email_prompt": "이메일(예:hong@example.com)",
"button_sign_up": "가입",
"label_your_name": "이름",
"label_user_id": "ID:",
"button_edit": "편집",
"requested_permissions": "요청",
"grant_permissions": ":!![OBSOLETE]!!:승인",
"menu_item_edit_permissions": "편집권한",
"label_other_user": "기타",
"action_clear_messages": "메시지지우기",
"clear_messages_warning": "모든메시지를지우시겠습니까?실행취소할수없습니다.",
"action_delete_messages": "모든메시지지우기",
"delete_messages_warning": "모든사람의모든메시지를삭제하시겠습니까?실행취소할수없습니다.",
"action_leave_chat": "대화나누기",
"leave_chat_warning": "이대화를나가시겠습니까?",
"action_block_contact": "연락차단",
"block_contact_warning": "이연락처를차단하시겠습니까?",
"action_report_chat": "대화기록",
"report_chat_warning": "이대화를차단하고기록하시겠습니까?",
"title_info": "정보",
"label_topic_name": "이름",
"label_private": "개인코멘트",
"private_editing_placeholder": "나만볼수있습니다",
"label_muting_topic": "음소거: ",
"action_more": "더보기",
"label_your_permissions": "권한: ",
"label_permissions": "권한: ",
"label_you": "당신: ",
"label_default_access": "기본액세스모드: ",
"label_group_members": "그룹회원: ",
"button_add_members": "회원추가",
"group_has_no_members": "회원없음",
"login_prompt": "로그인",
"password_prompt": "비밀번호",
"stay_logged_in": "로그인상태유지",
"forgot_password_link": "비밀번호를잊으셨습니까?",
"button_sign_in": "로그인",
"label_client": "클라이언트: ",
"label_server": "서버: ",
"online_now": "현재접속중",
"last_seen_timestamp": "마지막으로본",
"title_not_found": "찾을수없음",
"unnamed_topic": "이름없는",
"message_not_readable": ":!![OBSOLETE]!!:메시지에접근할수없음",
"peers_messaging_disabled": "동료의메시지가비활성화되었습니다.",
"enable_peers_messaging": "활성화",
"search_for_contacts": "검색을사용하여연락처찾기",
"search_no_results": "검색결과가없습니다",
"tabtitle_find_user": "찾기",
"tabtitle_new_group": "새그룹",
"tabtitle_group_by_id": "id로",
"new_password_placeholder": "새비밀번호입력",
"label_reset_password": "비밀번호재설정이메일보내기: ",
"credential_email_prompt": "등록이메일",
"button_reset": "재설정",
"button_send_request": "요청보내기",
"label_server_to_use": "사용할서버:",
"label_wire_transport": "와이어수송:",
"button_update": "업데이트",
"sidepanel_title_login": "로그인",
"sidepanel_title_register": "계정만들기",
"sidepanel_title_settings": "설정",
"sidepanel_title_account_settings": "계정설정",
"sidepanel_title_acc_general": "일반",
"sidepanel_title_acc_security": "보안",
"sidepanel_title_acc_notifications": "알림",
"sidepanel_title_acc_support": "지원",
"sidepanel_title_newtpk": "새채팅시작",
"sidepanel_title_cred": "자격증명확인",
"sidepanel_title_reset": "비밀번호재설정",
"sidepanel_title_archive": "보관된채팅",
"sidepanel_title_blocked": "차단된채팅",
"update_available": "업데이트가능.",
"reload_update": "재로드",
"reconnect_countdown": "연결이끊어졌습니다.다시연결하는중{초}…",
"reconnect_now": "지금시도",
"push_init_failed": "푸시알림을초기화하지못했습니다",
"invalid_security_token": "유효하지않은보안토큰",
"no_connection": "연결없음",
"code_doesnot_match": "코드가일치하지않습니다",
"phone_dative": "전화",
"email_dative": "이메일",
"enter_confirmation_code_prompt": "{방법}으로보낸확인코드를입력하십시오:",
"numeric_confirmation_code_prompt": "숫자만",
"button_confirm": "확인",
"save_attachment": "저장",
"delete_content": ":!![OBSOLETE]!!:컨텐츠삭제",
"invalid_content": "잘못된내용",
"user_not_found": "찾을수없음",
"badge_you": "당신",
"badge_owner": "소유자",
"menu_item_info": "정보",
"menu_item_clear_messages": "메시지지우기",
"menu_item_clear_messages_for_all": "모두지우기",
"menu_item_delete": "삭제",
"menu_item_delete_for_all": "모두삭제",
"menu_item_send_retry": "다시시도",
"menu_item_mute": "음소거",
"menu_item_unmute": "음소거해제",
"menu_item_delete_topic": "삭제",
"topic_delete_warning": "이대화를삭제하시겠습니까?",
"menu_item_unblock": "차단해제",
"menu_item_block": "차단",
"topic_block_warning": "이대화를차단하시겠습니까?",
"menu_item_member_delete": "제거",
"menu_item_archive_topic": "보관",
"action_cancel": "취소",
"upload_finishing": "마무리...",
"no_contacts": "연락처가없습니다 (._.)",
"contacts_not_found_short": "'{문의}'와일치하는연락처가없습니다.",
"title_group_members": "그룹구성원",
"title_all_contacts": "모든연락처",
"button_ok": "OK",
"button_cancel": "취소",
"more_online_members": "+{넘침}더보기",
"download_action": "다운로드",
"label_file_name": "파일이름: ",
"label_content_type": "컨텐츠유형: ",
"label_size": "크기: ",
"chat_invitation": "새로운대화를시작하도록초대되었습니다.무엇을하시겠습니까?",
"chat_invitation_accept": "수락",
"chat_invitation_ignore": "무시",
"chat_invitation_block": "차단",
"error_invalid_id": "잘못된ID",
"group_user_id_prompt": "그룹또는사용자ID",
"button_subscribe": "구독",
"topic_name_editing_placeholder": "그룹의자유형이름",
"button_create": "작성",
"permission_join": "참여({val})",
"permission_read": "읽기({val})",
"permission_write": "쓰기({val})",
"permission_pres": "알림받기({val})",
"permission_admin": "승인({val})",
"permission_share": "공유({val})",
"permission_delete": "삭제({val})",
"permission_owner": "소유자({val})",
"title_permissions": "권한",
"message_sending": "보내기...",
"message_sending_failed": "실패",
"search_placeholder": "email:alice@example.com,tel:17025550003...와같은목록",
"messaging_disabled_prompt": "메시지비활성화",
"new_message_prompt": "새메시지",
"image_caption_prompt": "이미지설명",
"file_attachment_too_large": "파일크기{크기}이(가){제한}제한을초과합니다.",
"cannot_initiate_file_upload": "파일업로드를시작할수없습니다.",
"tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.",
"tags_editor_no_tags": "일부태그추가",
"title_manage_tags": "관리",
"granted_permissions": ":!![MISSING]!!:Granted",
"messages_not_readable": ":!![MISSING]!!:no access to messages",
"deleted_content": ":!![MISSING]!!:content deleted"
}
+174
View File
@@ -0,0 +1,174 @@
{
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Полное имя, напр. Иван Петров",
"email_prompt": "Email, напр. ivan@example.com",
"button_sign_up": "Создать аккаунт",
"label_your_name": "Ваше имя",
"label_password": "Пароль",
"password_unchanged_prompt": "Не изменен",
"label_user_id": "ID:",
"label_default_access_mode": "Доступ по умолчанию:",
"label_message_sound": "Звук нового сообщения:",
"label_push_notifications": "Уведомления:",
"label_push_notifications_disabled": "Уведомления (требуют HTTPS):",
"title_tag_manager": "Теги для поиска",
"button_logout": "Выйти",
"login_prompt": "Логин",
"password_prompt": "Пароль",
"stay_logged_in": "Запомнить",
"forgot_password_link": "Напомнить пароль",
"button_sign_in": "Войти",
"label_client": "Клиент:",
"label_server": "Сервер:",
"online_now": "онлайн",
"last_seen_timestamp": "Был активен",
"title_not_found": "Не найден",
"unnamed_topic": "Без названия",
"messages_not_readable": "нет доступа к сообщениям",
"tabtitle_find_user": "найти",
"tabtitle_new_group": "создать",
"tabtitle_group_by_id": "по id",
"label_server_to_use": "Использовать сервер:",
"label_wire_transport": "Соединение:",
"button_update": "Применить",
"sidepanel_title_login": "Авторизация",
"sidepanel_title_register": "Зарегистрироваться",
"sidepanel_title_settings": "Настройки",
"sidepanel_title_account_settings": "Настройки аккаунта",
"sidepanel_title_newtpk": "Новый чат",
"sidepanel_title_cred": "Подтвердить",
"sidepanel_title_reset": "Сменить пароль",
"tags_not_found": "Тегов нет. Добавьте",
"tags_editor_no_tags": "Добавьте теги",
"title_manage_tags": "Редактировать",
"message_sending": "в пути...",
"message_sending_failed": "ошибка",
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "Отправка недоступна",
"new_message_prompt": "Новое сообщение",
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
"cannot_initiate_file_upload": "Ошибка загрузки файла.",
"search_for_contacts": "Поиск контактов",
"enter_confirmation_code_prompt": "Код подтверждения, полученный по {method}:",
"numeric_confirmation_code_prompt": "Только цифры",
"button_confirm": "Подтвердить",
"button_ok": "OK",
"button_cancel": "Отменить",
"invalid_content": "сообщение не читается",
"label_file_name": "Имя файла:",
"label_content_type": "Тип:",
"label_size": "Размер:",
"phone_dative": "телефону",
"email_dative": "емейлу",
"title_group_members": "Участники",
"download_action": "скачать",
"permission_join": "Подписываться ({val})",
"permission_read": "Читать ({val})",
"permission_write": "Писать ({val})",
"permission_pres": "Уведомлять ({val})",
"permission_admin": "Подтверждать ({val})",
"permission_share": "Приглашать ({val})",
"permission_delete": "Удалять ({val})",
"permission_owner": "Владелец ({val})",
"title_permissions": "Права доступа",
"requested_permissions": "Требуются",
"granted_permissions": "Получены",
"menu_item_edit_permissions": "Права доступа",
"label_other_user": "Второй",
"label_topic_name": "Название",
"label_private": "Комментарий",
"private_editing_placeholder": "Виден только вам",
"label_muting_topic": "Без уведомлений",
"action_more": "Ещё",
"label_your_permissions": "Ваши права доступа:",
"label_permissions": "Права доступа:",
"label_you": "Вы:",
"label_default_access": "Права по умолчанию:",
"label_group_members": "Участники чата:",
"button_add_members": "Добавить",
"group_has_no_members": "Нет участников",
"action_leave_chat": "Уйти из чата",
"menu_item_info": "Информация",
"menu_item_clear_messages": "Удалить сообщения",
"menu_item_clear_messages_for_all": "Удалить для всех",
"menu_item_delete": "Удалить",
"menu_item_delete_for_all": "Удалить для всех",
"menu_item_send_retry": "Отправить заново",
"menu_item_mute": "Не уведомлять",
"menu_item_unmute": "Уведомлять",
"menu_item_delete_topic": "Удалить чат",
"menu_item_unblock": "Разблокировать",
"menu_item_block": "Заблокировать",
"menu_item_member_delete": "Отписать",
"title_info": "Подробности",
"new_password_placeholder": "Введите новый пароль",
"label_reset_password": "Отправить емейл для смены пароля:",
"credential_email_prompt": "Регистрационный емейл",
"button_reset": "Изменить",
"button_send_request": "Отправить",
"action_cancel": "отменить",
"upload_finishing": "завершение...",
"no_contacts": "Ничего нет :-(",
"contacts_not_found_short": "Нет контактов для запроса ''{query}''",
"title_all_contacts": "Все контакты",
"error_invalid_id": "Неверный ID",
"group_user_id_prompt": "ID чата или пользователя",
"button_subscribe": "Подписаться",
"topic_name_editing_placeholder": "Название чата",
"button_create": "Создать",
"badge_you": "вы",
"badge_owner": "влад.",
"update_available": "Есть новая версия приложения.",
"reload_update": "Обновить",
"user_not_found": "Не найден",
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
"reconnect_now": "Подключить сейчас.",
"save_attachment": "сохранить",
"menu_item_archive_topic": "В архив",
"archived_contacts": "Чаты в архиве ({count})",
"sidepanel_title_archive": "Архив чатов",
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
"chat_invitation_accept": "Принять",
"chat_invitation_ignore": "Игнорировать",
"chat_invitation_block": "Заблокировать",
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
"enable_peers_messaging": "Разблокировать.",
"more_online_members": "+еще {overflow}",
"label_user_contacts": "Конакты:",
"validate_credential_action": "подтвердить",
"link_contact_us": "Связаться с нами",
"link_terms_of_service": "Условия сервиса",
"link_privacy_policy": "Политика конфиденциальности",
"action_delete_messages": "Удалить сообщения",
"action_block_contact": "Заблокировать контакт",
"action_report_chat": "Сообщить о нарушении",
"delete_messages_warning": "Вы действительно хотите удалить все сообщения?",
"leave_chat_warning": "Вы действительно хотите покинуть этот чат?",
"block_contact_warning": "Вы действительно заблокировать этот контакт?",
"report_chat_warning": "Вы действительно хотите сообщить о нарушении и заблокировать этот чат?",
"action_clear_messages": "Удалить сообщения",
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить.",
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
"search_no_results": "Ничего не найдено",
"deleted_content": "удалено",
"image_caption_prompt": "Подпись к фото",
"label_incognito_mode": "Режим инкогнито:",
"delete_account": "Удалить аккаунт",
"delete_account_arning": "Вы уверены, что ходите удалить свой аккаунт? Его невозможно будет восстановить.",
"button_delete_account": "Удалить аккаунт",
"sidepanel_title_acc_security": "Безопасность",
"sidepanel_title_acc_notifications": "Уведомления",
"sidepanel_title_acc_support": "Поддержка",
"label_sdk": "SDK:",
"label_server_address": "Адрес сервера:",
"button_edit": "Редактировать",
"button_add_another": "Добавить",
"sidepanel_title_acc_general": "Общие настройки",
"blocked_contacts_link": "Заблокированные ({count})",
"sidepanel_title_blocked": "Заблокированные чаты",
"push_init_failed": "Ошибка инициализации пуш уведомлений",
"invalid_security_token": "Токен некорректен",
"no_connection": "Нет связи",
"code_doesnot_match": "Код не совпадает"
}
+174
View File
@@ -0,0 +1,174 @@
{
"archived_contacts": "已归档联系人 ({count})",
"contacts_not_found": "你尚无会话<br />¯∖_(ツ)_/¯",
"full_name_prompt": "全名,例如张伟",
"email_prompt": "电子邮件,例如 zhang@example.com",
"button_sign_up": "注册",
"label_your_name": "你的姓名",
"label_password": "密码",
"password_unchanged_prompt": "未改变",
"label_user_id": "地址:",
"label_default_access_mode": "蓦然访问模式:",
"label_message_sound": "消息提示音:",
"label_push_notifications": "通知提醒:",
"label_push_notifications_disabled": "通知提醒(需要 HTTPS):",
"title_tag_manager": "标签(用户发现)",
"button_logout": "登出",
"requested_permissions": "已请求",
"granted_permissions": "已授予",
"menu_item_edit_permissions": "编辑权限",
"label_other_user": "其他",
"title_info": "信息",
"label_topic_name": "名称",
"label_private": "私人评论",
"private_editing_placeholder": "仅自己可见",
"label_muting_topic": "已静音:",
"action_more": "更多",
"label_your_permissions": "你的权限:",
"label_permissions": "权限:",
"label_you": "你:",
"label_default_access": "默认权限模式:",
"label_group_members": "群组成员:",
"button_add_members": "添加成员",
"group_has_no_members": "无成员",
"action_leave_chat": "离开",
"login_prompt": "登录",
"password_prompt": "密码",
"stay_logged_in": "保持登录",
"forgot_password_link": "忘记密码?",
"button_sign_in": "登录",
"label_client": "客户端:",
"label_server": "服务器:",
"online_now": "在线",
"last_seen_timestamp": "最后可见",
"title_not_found": "无法找到",
"unnamed_topic": "未命名",
"messages_not_readable": "无消息访问权限",
"peers_messaging_disabled": "成员间消息已禁用。",
"enable_peers_messaging": "启用",
"tabtitle_find_user": "搜索",
"tabtitle_new_group": "新群组",
"tabtitle_group_by_id": "通过 id",
"search_for_contacts": "使用搜索寻找联系人",
"new_password_placeholder": "输入新密码",
"label_reset_password": "发送密码重置邮件:",
"credential_email_prompt": "你的注册邮箱",
"button_reset": "重置",
"button_send_request": "发送请求",
"label_server_to_use": "使用的服务器:",
"label_wire_transport": "线路传输:",
"button_update": "更新",
"sidepanel_title_login": "登录",
"sidepanel_title_register": "创建账户",
"sidepanel_title_settings": "设置",
"sidepanel_title_account_settings": "帐号设定",
"sidepanel_title_newtpk": "开始新会话",
"sidepanel_title_cred": "确认凭据",
"sidepanel_title_reset": "重置密码",
"sidepanel_title_archive": "已存档会话",
"update_available": "更新可用。",
"reload_update": "重新载入",
"reconnect_countdown": "连接已断开。{seconds} 秒后重新连接…",
"reconnect_now": "立即尝试",
"phone_dative": "电话",
"email_dative": "电子邮件",
"enter_confirmation_code_prompt": "输入通过{method}发送的验证码:",
"numeric_confirmation_code_prompt": "仅数字",
"button_confirm": "确认",
"save_attachment": "保存",
"invalid_content": "无效内容",
"user_not_found": "未找到",
"badge_you": "你",
"badge_owner": "所有者",
"menu_item_info": "信息",
"menu_item_clear_messages": "清空消息",
"menu_item_clear_messages_for_all": "全部清除",
"menu_item_delete": "删除",
"menu_item_delete_for_all": "全部删除",
"menu_item_mute": "静音",
"menu_item_unmute": "取消静音",
"menu_item_delete_topic": "删除",
"menu_item_unblock": "取消屏蔽",
"menu_item_block": "屏蔽",
"menu_item_member_delete": "移除",
"menu_item_archive_topic": "归档",
"action_cancel": "取消",
"upload_finishing": "正在结束...",
"no_contacts": "你尚无联系人 (._.)",
"contacts_not_found_short": "无联系人匹配''{query}''",
"title_group_members": "群组成员",
"title_all_contacts": "全部联系人",
"button_ok": "好",
"button_cancel": "取消",
"download_action": "下载",
"label_file_name": "文件名:",
"label_content_type": "内容类型:",
"label_size": "大小:",
"chat_invitation": "你受邀开始新会话。你想怎么做?",
"chat_invitation_accept": "接受",
"chat_invitation_ignore": "忽略",
"chat_invitation_block": "屏蔽",
"error_invalid_id": "无效 ID",
"group_user_id_prompt": "群组或用户 ID",
"button_subscribe": "订阅",
"topic_name_editing_placeholder": "群组自由格式名称",
"button_create": "创建",
"permission_join": "加入 ({val})",
"permission_read": "读取 ({val})",
"permission_write": "写入 ({val})",
"permission_pres": "获取通知 ({val})",
"permission_admin": "批准 ({val})",
"permission_share": "分享 ({val})",
"permission_delete": "删除 ({val})",
"permission_owner": "所有者 ({val})",
"title_permissions": "权限",
"message_sending": "正在发送...",
"message_sending_failed": "发送失败",
"search_placeholder": "列表如 email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "消息已禁用",
"new_message_prompt": "新消息",
"file_attachment_too_large": "文件大小 {size} 超过 {limit} 限制。",
"cannot_initiate_file_upload": "无法初始化文件上传。",
"tags_not_found": "尚未定义标签。添加一些。",
"tags_editor_no_tags": "添加一些标签",
"title_manage_tags": "管理标签",
"more_online_members": "还有{overflow}个",
"label_user_contacts": "往来:",
"validate_credential_action": "确认",
"link_contact_us": "联系我们",
"link_terms_of_service": "条款和条件",
"link_privacy_policy": "隐私政策",
"action_delete_messages": "删除所有帖子",
"action_block_contact": "屏蔽联系人",
"delete_messages_warning": "您确定要删除所有消息吗?无法撤消。",
"leave_chat_warning": "您确定要退出此对话吗?",
"block_contact_warning": "您确定要阻止此联系人吗?",
"action_report_chat": "检举垃圾邮件",
"report_chat_warning": "您确定要停止并报告此对话吗?",
"action_clear_messages": "删除讯息",
"clear_messages_warning": "您确定要清除所有消息吗?无法撤消。",
"topic_delete_warning": "您确定要删除此对话吗?",
"topic_block_warning": "您确定要阻止此对话吗?",
"search_no_results": "搜索返回任何结果",
"deleted_content": "内容已删除",
"image_caption_prompt": "图片标题",
"menu_item_send_retry": "重试",
"label_incognito_mode": "无痕模式:",
"delete_account": "删除帐户",
"delete_account_arning": "您确定要删除您的帐户吗?无法撤消。",
"button_delete_account": "删除帐户",
"sidepanel_title_acc_security": "安全",
"sidepanel_title_acc_notifications": "通知",
"sidepanel_title_acc_support": "支持",
"label_sdk": "开发包:",
"label_server_address": "服务器地址:",
"button_edit": "编辑",
"button_add_another": "加上另一个",
"sidepanel_title_acc_general": "常用设定",
"blocked_contacts_link": "封锁的联络人 ({count})",
"sidepanel_title_blocked": "被阻止的聊天",
"push_init_failed": "初始化推送通知失败",
"invalid_security_token": "无效的安全令牌",
"no_connection": "无连接",
"code_doesnot_match": "代码不匹配"
}
+36
View File
@@ -0,0 +1,36 @@
// Put all packages together.
// Used to generate umd/index.prod.js
import React from 'react';
import ReactDOM from 'react-dom';
import { IntlProvider } from 'react-intl';
import allMessages from './messages.json';
import TinodeWeb from './views/tinode-web.jsx';
import HashNavigation from './lib/navigation.js';
// Detect human language to use in the UI:
// Check parameters from URL hash #?hl=ru, then browser, then use 'en' as a fallback.
const { params } = HashNavigation.parseUrlHash(window.location.hash);
const language = (params && params.hl) ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator.userLanguage ||
'en';
// Get the base language 'en' from a more specific 'en_GB' or 'en-US' as a partial fallback.
const baseLanguage = language.toLowerCase().split(/[-_]/)[0];
// Try the full locale first, then the locale without the region code, fallback to 'en'.
const messages =
allMessages[language] ||
allMessages[baseLanguage] ||
allMessages.en;
ReactDOM.render(
<IntlProvider locale={language} messages={messages} textComponent={React.Fragment}>
<TinodeWeb />
</IntlProvider>,
document.getElementById('mountPoint')
);
+219
View File
@@ -0,0 +1,219 @@
// File and image helper functions.
import { MAX_INBAND_ATTACHMENT_SIZE, MAX_IMAGE_DIM } from '../config.js';
// Supported image MIME types and corresponding file extensions.
export const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];
export const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];
// Make a data URL from public.photo
export function makeImageUrl(photo) {
return (photo && photo.type && photo.data) ?
'data:image/' + photo.type + ';base64,' + photo.data : null;
}
// Calculate linear dimensions for scaling image down to fit under a certain size.
// Returns an object which contains destination sizes, source sizes, and offsets
// into source (when making square images).
export function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) {
// Sanitize input
width = width | 0;
height = height | 0;
maxWidth = maxWidth | 0;
maxHeight = maxHeight | 0;
if (width <= 0 || height <= 0 || maxWidth <= 0 || maxHeight <= 0) {
return null;
}
if (forceSquare) {
maxWidth = maxHeight = Math.min(maxWidth, maxHeight);
}
let scale = Math.min(
Math.min(width, maxWidth) / width,
Math.min(height, maxHeight) / height
);
let size = {
dstWidth: (width * scale) | 0,
dstHeight: (height * scale) | 0,
};
if (forceSquare) {
// Also calculate parameters for making the image square.
size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight);
size.srcWidth = size.srcHeight = Math.min(width, height);
size.xoffset = ((width - size.srcWidth) / 2) | 0;
size.yoffset = ((height - size.srcWidth) / 2) | 0;
} else {
size.xoffset = size.yoffset = 0;
size.srcWidth = width;
size.srcHeight = height;
}
return size;
}
// Ensure file's extension matches mime content type
export function fileNameForMime(fname, mime) {
var idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);
var ext = MIME_EXTENSIONS[idx];
var at = fname.lastIndexOf('.');
if (at >= 0) {
fname = fname.substring(0, at);
}
return fname + '.' + ext;
}
// Convert uploaded image into a base64-encoded string possibly scaling
// linear dimensions or constraining to a square.
export function imageFileScaledToBase64(file, width, height, forceSquare, onSuccess, onError) {
var img = new Image();
img.crossOrigin = 'Anonymous';
img.onerror = function(err) {
onError("Image format unrecognized");
}
img.onload = function() {
var dim = fitImageSize(this.width, this.height, width, height, forceSquare);
if (!dim) {
onError("Invalid image");
return;
}
var canvas = document.createElement('canvas');
canvas.width = dim.dstWidth;
canvas.height = dim.dstHeight;
var ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,
0, 0, dim.dstWidth, dim.dstHeight);
var mime = (this.width != dim.dstWidth ||
this.height != dim.dstHeight ||
SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) ? 'image/jpeg' : file.type;
var imageBits = canvas.toDataURL(mime);
var parts = imageBits.split(',');
// Get actual image type: 'data:image/png;base64,'
mime = getMimeType(parts[0]);
if (!mime) {
onError("Unsupported image format");
return;
}
// Ensure the image is not too large
var quality = 0.78;
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
mime = 'image/jpeg';
}
if (mime == 'image/jpeg') {
// Reduce size of the jpeg by reducing image quality
while (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE && quality > 0.45) {
imageBits = canvas.toDataURL(mime, quality);
quality *= 0.84;
}
}
if (base64DecodedLen(imageBits.length) > MAX_INBAND_ATTACHMENT_SIZE) {
onError("The image size " + bytesToHumanSize(base64DecodedLen(imageBits.length)) +
" exceeds the " + bytesToHumanSize(MAX_INBAND_ATTACHMENT_SIZE) + " limit.", "err");
return;
}
canvas = null;
onSuccess(imageBits.split(',')[1], mime, dim.dstWidth, dim.dstHeight, fileNameForMime(file.name, mime));
};
img.src = URL.createObjectURL(file);
}
// Convert uploaded image file to base64-encoded string without scaling/converting the image
export function imageFileToBase64(file, onSuccess, onError) {
var reader = new FileReader();
reader.addEventListener('load', function() {
var parts = reader.result.split(',');
var mime = getMimeType(parts[0]);
if (!mime) {
onError("Failed to process image file");
return;
}
// Get image size.
var img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = function() {
onSuccess(parts[1], mime, this.width, this.height, fileNameForMime(file.name, mime));
}
img.onerror = function(err) {
onError("Image format not recognized");
}
img.src = URL.createObjectURL(file);
}, false);
reader.readAsDataURL(file);
}
export function fileToBase64(file, onSuccess, onError) {
var reader = new FileReader();
reader.addEventListener('load', function() {
onSuccess(file.type, reader.result.split(',')[1], file.name);
});
reader.readAsDataURL(file);
}
// File pasted from the clipboard. It's either an inline image or a file attachment.
// FIXME: handle large files out of band.
export function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {
var items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;
for (var i in items) {
var item = items[i];
if (item.kind === 'file') {
var file = item.getAsFile();
if (!file) {
console.log("Failed to get file object from pasted file item", item.kind, item.type);
continue;
}
if (file.type && file.type.split('/')[0] == 'image') {
// Handle inline image
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false, onImageSuccess, onError);
} else {
imageFileToBase64(file, onImageSuccess, onError);
}
} else {
// Handle file attachment
fileToBase64(file, onAttachmentSuccess, onError)
}
// Indicate that the pasted data contains a file.
return true;
}
}
// No file found.
return false;
}
// Get mime type from data URL header.
export function getMimeType(header) {
var mime = /^data:(image\/[-+a-z0-9.]+);base64/.exec(header);
return (mime && mime.length > 1) ? mime[1] : null;
}
// Given length of a binary object in bytes, calculate the length after
// base64 encoding.
export function base64EncodedLen(n) {
return Math.floor((n + 2) / 3) * 4;
}
// Given length of a base64-encoded object, calculate decoded size of the
// pbject in bytes.
export function base64DecodedLen(n) {
return Math.floor(n / 4) * 3;
}
// Re-encode string to standard base64 encoding with padding.
// The string may be base64-URL encoded without the padding.
export function base64ReEncode(str) {
if (str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
try {
str = btoa(atob(str));
} catch(err) {
console.log("Failed to base64 re-encode string.", err);
str = null;
}
}
return str;
}
+29
View File
@@ -0,0 +1,29 @@
import { KNOWN_HOSTS, DEFAULT_HOST } from '../config.js';
// Detect server address from the URL
export function detectServerAddress() {
let host = DEFAULT_HOST;
if (typeof window.location == 'object') {
if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') {
host = KNOWN_HOSTS.local;
} else if (window.location.hostname) {
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
}
}
return host;
}
// Detect if the page is served over HTTPS.
export function isSecureConnection() {
if (typeof window.location == 'object') {
return window.location.protocol == 'https:';
}
return false;
}
export function isLocalHost() {
if (typeof window.location == 'object') {
return window.location.hostname == 'localhost';
}
return false;
}
+26
View File
@@ -0,0 +1,26 @@
// Helper functions for storing values in localStorage.
// By default localStorage can store only strings, not objects or other types.
export default class LocalStorageUtil {
// Replace old object with the new one.
static setObject(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
// Get stored object.
static getObject(key) {
const value = localStorage.getItem(key);
return value && JSON.parse(value);
}
// Partially or wholly update stored object.
static updateObject(key, value) {
const oldVal = this.getObject(key);
this.setObject(key, Object.assign(oldVal || {}, value));
}
// Just a wrapper.
static removeItem(key) {
localStorage.removeItem(key);
}
}
+68
View File
@@ -0,0 +1,68 @@
// Utility class for hash navigation.
// Parse hash as in http://www.example.com/path#hash as if it were
// path and arguments.
export default class HashNavigation {
static parseUrlHash(hash) {
// Split path from args, path -> parts[0], args->path[1]
let parts = hash.split('?', 2);
let params = {};
let path = [];
if (parts[0]) {
path = parts[0].substr(1).split('/');
}
if (parts[1]) {
parts[1].split('&').forEach(function(part) {
let item = part.split('=');
if (item[0]) {
params[decodeURIComponent(item[0])] = decodeURIComponent(item[1]);
}
});
}
return {path: path, params: params};
}
static navigateTo(url) {
window.location.hash = url;
}
static composeUrlHash(path, params) {
var url = path.join('/');
var args = [];
for (var key in params) {
if (params.hasOwnProperty(key)) {
args.push(key + '=' + params[key]);
}
}
if (args.length > 0) {
url += '?' + args.join('&');
}
return url;
}
static addUrlParam(hash, key, value) {
var parsed = this.parseUrlHash(hash);
parsed.params[key] = value;
return this.composeUrlHash(parsed.path, parsed.params);
}
static removeUrlParam(hash, key) {
var parsed = this.parseUrlHash(hash);
delete parsed.params[key];
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlSidePanel(hash, sidepanel) {
var parsed = this.parseUrlHash(hash);
parsed.path[0] = sidepanel;
return this.composeUrlHash(parsed.path, parsed.params);
}
static setUrlTopic(hash, topic) {
var parsed = this.parseUrlHash(hash);
parsed.path[1] = topic;
// Close InfoView on topic change.
delete parsed.params.info;
return this.composeUrlHash(parsed.path, parsed.params);
}
}
+48
View File
@@ -0,0 +1,48 @@
// Short representation of time in the past.
export function shortDateFormat(then, locale) {
locale = locale || window.navigator.userLanguage || window.navigator.language;
const now = new Date();
if (then.getFullYear() == now.getFullYear()) {
if (then.getMonth() == now.getMonth() && then.getDate() == now.getDate()) {
return then.toLocaleTimeString(locale, {hour12: false, hour: '2-digit', minute: '2-digit'});
} else {
return then.toLocaleDateString(locale,
{hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
}
}
return then.toLocaleDateString(locale,
{hour12: false, year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'});
}
// Convert seconds to minutes:seconds, i.e. 156 sec -> 2:36.
export function secondsToTime(seconds) {
const min = Math.floor(seconds / 60);
let sec = seconds % 60;
sec = sec < 10 ? `0${sec}` : sec;
return `${min}:${sec}`;
}
// Convert a number of bytes to human-readable format.
export function bytesToHumanSize(bytes) {
if (!bytes || bytes == 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const bucket = Math.min(Math.floor(Math.log2(bytes) / 10) | 0, sizes.length-1);
const count = bytes / Math.pow(1024, bucket);
const round = bucket > 0 ? (count < 3 ? 2 : (count < 30 ? 1 : 0)) : 0;
return count.toFixed(round) + ' ' + sizes[bucket];
}
// Get 32 bit integer hash value for a string. Ideally it should produce the same value
// as Java's String#hash().
export function stringHash(value) {
let hash = 0;
value = '' + value;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) - hash) + value.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
return hash;
}
+142
View File
@@ -0,0 +1,142 @@
// Odds and ends
// Make shortcut icon appear with a green dot + show unread count in title.
export function updateFavicon(count) {
const oldIcon = document.getElementById('shortcut-icon');
const head = document.head || document.getElementsByTagName('head')[0];
const newIcon = document.createElement('link');
newIcon.type = 'image/png';
newIcon.id = 'shortcut-icon';
newIcon.rel = 'shortcut icon';
newIcon.href = 'img/logo32x32' + (count > 0 ? 'a' : '') + '.png';
if (oldIcon) {
head.removeChild(oldIcon);
}
head.appendChild(newIcon);
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
}
// Create VCard which represents topic 'public' info
export function vcard(fn, imageDataUrl) {
let card = null;
if ((fn && fn.trim()) || imageDataUrl) {
card = {};
if (fn) {
card.fn = fn.trim();
}
if (imageDataUrl) {
const dataStart = imageDataUrl.indexOf(',');
card.photo = dataStart >= 0 ? {
data: imageDataUrl.substring(dataStart+1),
type: 'jpg'
} : imageDataUrl;
}
}
return card;
}
// Deep-shallow compare two arrays: shallow compare each element.
export function arrayEqual(a, b) {
if (a === b) {
return true;
}
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
// Compare lengths first.
if (a.length != b.length) {
return false;
}
// Order of elements is ignored.
a.sort();
b.sort();
for (let i = 0, l = a.length; i < l; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// Checks (loosely) if the given string is a phone. If so, returns the phone number in a format
// as close to E.164 as possible.
export function asPhone(val) {
val = val.trim();
if (/^(?:\+?(\d{1,3}))?[- (.]*(\d{3})[- ).]*(\d{3})[- .]*(\d{2})[- .]*(\d{2})?$/.test(val)) {
return val.replace(/[- ().]*/, '');
}
return null;
}
// Checks (loosely) if the given string is an email. If so returns the email.
export function asEmail(val) {
val = val.trim();
if (/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(val)) {
return val;
}
return null;
}
// Checks if URL is a relative url, i.e. has no 'scheme://', including the case of missing scheme '//'.
// The scheme is expected to be RFC-compliant, e.g. [a-z][a-z0-9+.-]*
// example.html - ok
// https:example.com - not ok.
// http:/example.com - not ok.
// ' ↲ https://example.com' - not ok. (↲ means carriage return)
export function isUrlRelative(url) {
return !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
}
// Ensure URL does not present an XSS risk. Optional allowedSchemes may contain an array of
// strings with permitted URL schemes, such as ['ftp', 'ftps']; otherwise accept http and https only.
export function sanitizeUrl(url, allowedSchemes) {
if (!url) {
return null;
}
// Strip control characters and whitespace. They are not valid URL characters anyway.
url = url.replace(/[^\x20-\x7E]/gmi, '').trim();
// Relative URLs are safe.
if (!/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
return url;
}
// Blob URLs are safe.
if (/^blob:http/.test(url)) {
return url;
}
// Absolute URL. Accept only safe schemes, or no scheme.
const schemes = Array.isArray(allowedSchemes) ? allowedSchemes.join('|') : 'http|https';
const re = new RegExp('^((' + schemes + '):|//)', 'i');
if (!re.test(url)) {
return null;
}
return url;
}
// Ensure URL is suitable for <img src="url"> field: the URL must be a relative URL or
// have http:, https:, or data: scheme. In case of data: scheme, the URL must start with
// a 'data:image/XXXX;base64,'.
export function sanitizeImageUrl(url) {
if (!url) {
return null;
}
const sanitizedUrl = sanitizeUrl(url);
if (sanitizedUrl) {
return sanitizedUrl;
}
// Is this a data: URL of an image?
if (/data:image\/[a-z0-9.-]+;base64,/i.test(url.trim())) {
return url;
}
return null;
}
+869
View File
@@ -0,0 +1,869 @@
{
"en": {
"validate_credential_action": "confirm",
"title_tag_manager": "Tags (user discovery)",
"label_user_contacts": "Contacts:",
"button_add_another": "Add another",
"label_message_sound": "Message sound:",
"label_push_notifications": "Notification alerts:",
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
"label_incognito_mode": "Incognito mode:",
"delete_account": "Delete account",
"delete_account_arning": "Are you sure you want to delete your account? It cannot be undone.",
"label_password": "Password",
"password_unchanged_prompt": "Unchanged",
"button_logout": "Logout",
"button_delete_account": "Delete account",
"label_default_access_mode": "Default access mode:",
"blocked_contacts_link": "Blocked contacts ({count})",
"link_contact_us": "Contact Us",
"link_terms_of_service": "Terms of Service",
"link_privacy_policy": "Privacy Policy",
"label_sdk": "SDK:",
"label_server_address": "Server address:",
"archived_contacts": "Archived contacts ({count})",
"contacts_not_found": "You have no chats<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Full name, e.g. John Doe",
"email_prompt": "Email, e.g. jdoe@example.com",
"button_sign_up": "Sign up",
"label_your_name": "Your name",
"label_user_id": "ID:",
"button_edit": "Edit",
"requested_permissions": "Requested",
"granted_permissions": "Granted",
"menu_item_edit_permissions": "Edit permissions",
"label_other_user": "Other",
"action_clear_messages": "Clear Messages",
"clear_messages_warning": "Are you sure you want to clear all messages? It cannot be undone.",
"action_delete_messages": "Clear Messages for All",
"delete_messages_warning": "Are you sure you want to delete all messages for everyone? It cannot be undone.",
"action_leave_chat": "Leave Conversation",
"leave_chat_warning": "Are you sure you want to leave this conversation?",
"action_block_contact": "Block Contact",
"block_contact_warning": "Are you sure you want to block this contact?",
"action_report_chat": "Report Conversation",
"report_chat_warning": "Are you sure you want to block and report this conversation?",
"title_info": "Info",
"label_topic_name": "Name",
"label_private": "Private comment",
"private_editing_placeholder": "Visible to you only",
"label_muting_topic": "Muted:",
"action_more": "More",
"label_your_permissions": "Your permissions:",
"label_permissions": "Permissions:",
"label_you": "You:",
"label_default_access": "Default access mode:",
"label_group_members": "Group members:",
"button_add_members": "Add members",
"group_has_no_members": "No members",
"login_prompt": "Login",
"password_prompt": "Password",
"stay_logged_in": "Stay logged in",
"forgot_password_link": "Forgot password?",
"button_sign_in": "Sign in",
"label_client": "Client:",
"label_server": "Server:",
"online_now": "online now",
"last_seen_timestamp": "Last seen",
"title_not_found": "Not found",
"unnamed_topic": "Unnamed",
"messages_not_readable": "no access to messages",
"peers_messaging_disabled": "Peer's messaging is disabled.",
"enable_peers_messaging": "Enable",
"search_for_contacts": "Use search to find contacts",
"search_no_results": "Search returned no results",
"tabtitle_find_user": "find",
"tabtitle_new_group": "new group",
"tabtitle_group_by_id": "by id",
"new_password_placeholder": "Enter new password",
"label_reset_password": "Send a password reset email:",
"credential_email_prompt": "Your registration email",
"button_reset": "Reset",
"button_send_request": "Send request",
"label_server_to_use": "Server to use:",
"label_wire_transport": "Wire transport:",
"button_update": "Update",
"sidepanel_title_login": "Sign In",
"sidepanel_title_register": "Create Account",
"sidepanel_title_settings": "Settings",
"sidepanel_title_account_settings": "Account Settings",
"sidepanel_title_acc_general": "General",
"sidepanel_title_acc_security": "Security",
"sidepanel_title_acc_notifications": "Notifications",
"sidepanel_title_acc_support": "Support",
"sidepanel_title_newtpk": "Start New Chat",
"sidepanel_title_cred": "Confirm Credentials",
"sidepanel_title_reset": "Reset Password",
"sidepanel_title_archive": "Archived Chats",
"sidepanel_title_blocked": "Blocked Chats",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
"reconnect_now": "Try now",
"push_init_failed": "Failed to initialize push notifications",
"invalid_security_token": "Invalid security token",
"no_connection": "No connection",
"code_doesnot_match": "Code does not match",
"update_available": "Update available.",
"reload_update": "Reload",
"phone_dative": "phone",
"email_dative": "email",
"enter_confirmation_code_prompt": "Enter confirmation code sent to you by {method}:",
"numeric_confirmation_code_prompt": "Numbers only",
"button_confirm": "Confirm",
"save_attachment": "save",
"deleted_content": "content deleted",
"invalid_content": "invalid content",
"user_not_found": "Not found",
"badge_you": "you",
"badge_owner": "owner",
"menu_item_info": "Info",
"menu_item_clear_messages": "Clear messages",
"menu_item_clear_messages_for_all": "Clear for All",
"menu_item_delete": "Delete",
"menu_item_delete_for_all": "Delete for All",
"menu_item_send_retry": "Retry",
"menu_item_mute": "Mute",
"menu_item_unmute": "Unmute",
"menu_item_delete_topic": "Delete",
"topic_delete_warning": "Are you sure you want to delete this conversation?",
"menu_item_unblock": "Unblock",
"menu_item_block": "Block",
"topic_block_warning": "Are you sure you want to block this conversation?",
"menu_item_member_delete": "Remove",
"menu_item_archive_topic": "Archive",
"action_cancel": "cancel",
"upload_finishing": "finishing...",
"no_contacts": "You have no contacts :-(",
"contacts_not_found_short": "No contacts match ''{query}''",
"title_group_members": "Group Members",
"title_all_contacts": "All Contacts",
"button_ok": "OK",
"button_cancel": "Cancel",
"more_online_members": "+{overflow} more",
"download_action": "download",
"label_file_name": "File name:",
"label_content_type": "Content type:",
"label_size": "Size:",
"chat_invitation": "You are invited to start a new chat. What would you like to do?",
"chat_invitation_accept": "Accept",
"chat_invitation_ignore": "Ignore",
"chat_invitation_block": "Block",
"error_invalid_id": "Invalid ID",
"group_user_id_prompt": "Group or User ID",
"button_subscribe": "Subscribe",
"topic_name_editing_placeholder": "Freeform name of the group",
"button_create": "Create",
"permission_join": "Join ({val})",
"permission_read": "Read ({val})",
"permission_write": "Write ({val})",
"permission_pres": "Get notified ({val})",
"permission_admin": "Approve ({val})",
"permission_share": "Share ({val})",
"permission_delete": "Delete ({val})",
"permission_owner": "Owner ({val})",
"title_permissions": "Permissions",
"message_sending": "sending...",
"message_sending_failed": "failed",
"search_placeholder": "List like email:alice@example.com, tel:17025550003...",
"messaging_disabled_prompt": "Messaging disabled",
"new_message_prompt": "New message",
"image_caption_prompt": "Image caption",
"file_attachment_too_large": "The file size {size} exceeds the {limit} limit.",
"cannot_initiate_file_upload": "Cannot initiate file upload.",
"tags_not_found": "No tags defined. Add some.",
"tags_editor_no_tags": "Add some tags",
"title_manage_tags": "Manage"
},
"es": {
"validate_credential_action": "confirmar",
"title_tag_manager": "Etiquetas (descubrimiento de usuarios)",
"label_user_contacts": "Contactos:",
"button_add_another": "Añadir contacto",
"label_message_sound": "Sonido de mensaje:",
"label_push_notifications": "Alertas de notificaciones:",
"label_push_notifications_disabled": "Alertas de notificaciones (requiere HTTPS):",
"label_incognito_mode": "Modo incógnito:",
"delete_account": "Eliminar cuenta",
"delete_account_arning": "¿Estás seguro de que deseas eliminar permanentemente tu cuenta? Esta acción es irreversible.",
"label_password": "Contraseña",
"password_unchanged_prompt": "Sin cambios",
"button_logout": "Cerrar sesión",
"button_delete_account": "Eliminar cuenta",
"label_default_access_mode": "Modo de acceso predeterminado:",
"blocked_contacts_link": "Contactos bloqueados ({count})",
"link_contact_us": "Contáctanos",
"link_terms_of_service": "Términos de uso",
"link_privacy_policy": "Política de privacidad",
"label_sdk": "SDK:",
"label_server_address": "Dirección del servidor:",
"archived_contacts": "Contactos archivados ({count})",
"contacts_not_found": "No tienes chats<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Nombre completo, p.ej. Juan González Hernández",
"email_prompt": "Correo electrónico, p.ej. juan@example.com",
"button_sign_up": "Regístrate",
"label_your_name": "Tu nombre",
"label_user_id": "ID:",
"button_edit": "Editar",
"requested_permissions": "Solicitados",
"granted_permissions": "Otorgados",
"menu_item_edit_permissions": "Editar permisos",
"label_other_user": "Otros",
"action_clear_messages": "Borrar mensajes",
"clear_messages_warning": "¿Estás seguro de que quieres eliminar todos los mensajes? Esta acción es irreversible.",
"action_delete_messages": "Borrar mensajes para todos",
"delete_messages_warning": "¿Estás seguro de que quieres eliminar todos los mensajes para todos? Esta acción es irreversible.",
"action_leave_chat": "Dejar conversación",
"leave_chat_warning": "¿Estás seguro de que quieres dejar esta conversación?",
"action_block_contact": "Bloquear contacto",
"block_contact_warning": "¿Estás seguro de que quieres bloquear a este contacto?",
"action_report_chat": "Reportar conversación",
"report_chat_warning": "¿Estás seguro de que quieres bloquear y reportar a esta conversación?",
"title_info": "Información",
"label_topic_name": "Nombre del tema",
"label_private": "Comentario privado",
"private_editing_placeholder": "Sólo visible para tí",
"label_muting_topic": "Silenciado:",
"action_more": "Más",
"label_your_permissions": "Tus permisoss:",
"label_permissions": "Permisos:",
"label_you": "Tú:",
"label_default_access": "Modo de acceso predeterminado:",
"label_group_members": "Miembros del grupo:",
"button_add_members": "Añadir miembros",
"group_has_no_members": "No hay miembros",
"login_prompt": "Nombre de usuario",
"password_prompt": "Contraseña",
"stay_logged_in": "Permanecer conectado",
"forgot_password_link": "¿Olvidaste tu contraseña?",
"button_sign_in": "Entrar",
"label_client": "Cliente:",
"label_server": "Servidor:",
"online_now": "en línea",
"last_seen_timestamp": "Últ. vez",
"title_not_found": "No encontrado",
"unnamed_topic": "Sin nombre",
"messages_not_readable": "sin acceso a mensajes",
"peers_messaging_disabled": "La mensajería Peer está deshabilitada.",
"enable_peers_messaging": "Habilitar",
"search_for_contacts": "Usa la búsqueda para encontrar contactos",
"search_no_results": "La búsqueda no arrojó resultados",
"tabtitle_find_user": "encontrar",
"tabtitle_new_group": "nuevo grupo",
"tabtitle_group_by_id": "por ID",
"new_password_placeholder": "Introduzca una nueva contraseña",
"label_reset_password": "Enviar un correo electrónico de restablecimiento de contraseña:",
"credential_email_prompt": "Tu correo electrónico de registro",
"button_reset": "Restablecer",
"button_send_request": "Enviar petición",
"label_server_to_use": "Servidor para usar:",
"label_wire_transport": "Transporte de alambre:",
"button_update": "Actualizar",
"sidepanel_title_login": "Iniciar sesión",
"sidepanel_title_register": "Crear cuenta",
"sidepanel_title_settings": "Ajustes",
"sidepanel_title_account_settings": "Ajustes de la cuenta",
"sidepanel_title_acc_general": "General",
"sidepanel_title_acc_security": "Seguridad",
"sidepanel_title_acc_notifications": "Notificaciones",
"sidepanel_title_acc_support": "Soporte",
"sidepanel_title_newtpk": "Iniciar un nuevo chat",
"sidepanel_title_cred": "Confirmar credenciales",
"sidepanel_title_reset": "Restablecer contraseña",
"sidepanel_title_archive": "Chats archivados",
"sidepanel_title_blocked": "Chats bloqueados",
"update_available": "Actualización disponible.",
"reload_update": "Recargar",
"reconnect_countdown": "Desconectado. Reconectando en {seconds}…",
"reconnect_now": "Reintentar",
"push_init_failed": "Error al inicializar las notificaciones push",
"invalid_security_token": "Token de seguridad inválido",
"no_connection": "Sin conexión",
"code_doesnot_match": "El código no coincide",
"phone_dative": "teléfono",
"email_dative": "correo electrónico",
"enter_confirmation_code_prompt": "Intruduzca el código de confirmación enviado a tu {method}:",
"numeric_confirmation_code_prompt": "Sólo números",
"button_confirm": "Confirmar",
"save_attachment": "guardar",
"deleted_content": "este mensaje fue eliminado",
"invalid_content": "contenido inválido",
"user_not_found": "Usuario no encontrado",
"badge_you": "tú",
"badge_owner": "propietario",
"menu_item_info": "Información",
"menu_item_clear_messages": "Borrar mensajes",
"menu_item_clear_messages_for_all": "Borrar para todos",
"menu_item_delete": "Eliminar",
"menu_item_delete_for_all": "Eliminar para todos",
"menu_item_send_retry": "Inténtalo de nuevo",
"menu_item_mute": "Silenciar",
"menu_item_unmute": "Anular el silencio",
"menu_item_delete_topic": "Eliminar",
"topic_delete_warning": "¿Estás seguro de que quieres eliminar esta conversación?",
"menu_item_unblock": "Desbloquear",
"menu_item_block": "Bloquear",
"topic_block_warning": "¿Estás seguro de que quieres bloquear esta conversación",
"menu_item_member_delete": "Eliminar",
"menu_item_archive_topic": "Archivar",
"action_cancel": "cancelar",
"upload_finishing": "terminando...",
"no_contacts": "No tienes contactos :-(",
"contacts_not_found_short": "Ningún contacto coincide con ''{query}''",
"title_group_members": "Miembros del grupo",
"title_all_contacts": "Todos los contactos",
"button_ok": "OK",
"button_cancel": "Cancelar",
"more_online_members": "+{overflow} más",
"download_action": "descargar",
"label_file_name": "Nombre del archivo:",
"label_content_type": "Tipo de contenido:",
"label_size": "Tamaño:",
"chat_invitation": "Estás invitado a participar en un nuevo chat. ¿Qué te gustaría hacer?",
"chat_invitation_accept": "Aceptar",
"chat_invitation_ignore": "Ignorar",
"chat_invitation_block": "Bloquear",
"error_invalid_id": "ID inválido",
"group_user_id_prompt": "ID del grupo o usuario",
"button_subscribe": "Suscribirse",
"topic_name_editing_placeholder": "Nombre del grupo",
"button_create": "Crear",
"permission_join": "Unirse ({val})",
"permission_read": "Leer ({val})",
"permission_write": "Escribir ({val})",
"permission_pres": "Ser notificado ({val})",
"permission_admin": "Approbar ({val})",
"permission_share": "Compartir ({val})",
"permission_delete": "Eliminar ({val})",
"permission_owner": "Propietario ({val})",
"title_permissions": "Permisos",
"message_sending": "enviando...",
"message_sending_failed": "no se pudo enviar el mensaje",
"search_placeholder": "Ej. email:alice@example.com, tel:17025550003...",
"messaging_disabled_prompt": "El envío de mensajes está deshabilitado",
"new_message_prompt": "Nuevo mensaje",
"image_caption_prompt": "Añade un comentario",
"file_attachment_too_large": "El tamaño del archivo {size} excede el límite de {limit}.",
"cannot_initiate_file_upload": "No se pudo iniciar la carga del archivo.",
"tags_not_found": "No hay etiquetas definidas. Añade unas.",
"tags_editor_no_tags": "Añadir etiquetas",
"title_manage_tags": "Gestionar"
},
"ko": {
"validate_credential_action": "확인",
"title_tag_manager": "태그(사용자검색)",
"label_user_contacts": "연락처: ",
"button_add_another": "다른항목추가",
"label_message_sound": "메시지소리: ",
"label_push_notifications": "통지경보: ",
"label_push_notifications_disabled": "통지경보(HTTPS필요): ",
"label_incognito_mode": "시크릿모드: ",
"delete_account": "계정삭제",
"delete_account_arning": "계정을삭제하시겠습니까?실행취소할수없습니다.",
"label_password": "비밀번호",
"password_unchanged_prompt": "변경되지않음",
"button_logout": "로그아웃",
"button_delete_account": "계정삭제",
"label_default_access_mode": "기본액세스모드: ",
"blocked_contacts_link": "차단된연락처({수})",
"link_contact_us": "문의처",
"link_terms_of_service": "서비스약관",
"link_privacy_policy": "개인정보보호정책",
"label_sdk": "SDK:",
"label_server_address": "서버주소: ",
"archived_contacts": "보관된연락처({수})",
"contacts_not_found": "채팅이없습니다<br/>¯∖_(ツ)_/¯",
"full_name_prompt": "전체이름(예:홍길동)",
"email_prompt": "이메일(예:hong@example.com)",
"button_sign_up": "가입",
"label_your_name": "이름",
"label_user_id": "ID:",
"button_edit": "편집",
"requested_permissions": "요청",
"menu_item_edit_permissions": "편집권한",
"label_other_user": "기타",
"action_clear_messages": "메시지지우기",
"clear_messages_warning": "모든메시지를지우시겠습니까?실행취소할수없습니다.",
"action_delete_messages": "모든메시지지우기",
"delete_messages_warning": "모든사람의모든메시지를삭제하시겠습니까?실행취소할수없습니다.",
"action_leave_chat": "대화나누기",
"leave_chat_warning": "이대화를나가시겠습니까?",
"action_block_contact": "연락차단",
"block_contact_warning": "이연락처를차단하시겠습니까?",
"action_report_chat": "대화기록",
"report_chat_warning": "이대화를차단하고기록하시겠습니까?",
"title_info": "정보",
"label_topic_name": "이름",
"label_private": "개인코멘트",
"private_editing_placeholder": "나만볼수있습니다",
"label_muting_topic": "음소거: ",
"action_more": "더보기",
"label_your_permissions": "권한: ",
"label_permissions": "권한: ",
"label_you": "당신: ",
"label_default_access": "기본액세스모드: ",
"label_group_members": "그룹회원: ",
"button_add_members": "회원추가",
"group_has_no_members": "회원없음",
"login_prompt": "로그인",
"password_prompt": "비밀번호",
"stay_logged_in": "로그인상태유지",
"forgot_password_link": "비밀번호를잊으셨습니까?",
"button_sign_in": "로그인",
"label_client": "클라이언트: ",
"label_server": "서버: ",
"online_now": "현재접속중",
"last_seen_timestamp": "마지막으로본",
"title_not_found": "찾을수없음",
"unnamed_topic": "이름없는",
"peers_messaging_disabled": "동료의메시지가비활성화되었습니다.",
"enable_peers_messaging": "활성화",
"search_for_contacts": "검색을사용하여연락처찾기",
"search_no_results": "검색결과가없습니다",
"tabtitle_find_user": "찾기",
"tabtitle_new_group": "새그룹",
"tabtitle_group_by_id": "id로",
"new_password_placeholder": "새비밀번호입력",
"label_reset_password": "비밀번호재설정이메일보내기: ",
"credential_email_prompt": "등록이메일",
"button_reset": "재설정",
"button_send_request": "요청보내기",
"label_server_to_use": "사용할서버:",
"label_wire_transport": "와이어수송:",
"button_update": "업데이트",
"sidepanel_title_login": "로그인",
"sidepanel_title_register": "계정만들기",
"sidepanel_title_settings": "설정",
"sidepanel_title_account_settings": "계정설정",
"sidepanel_title_acc_general": "일반",
"sidepanel_title_acc_security": "보안",
"sidepanel_title_acc_notifications": "알림",
"sidepanel_title_acc_support": "지원",
"sidepanel_title_newtpk": "새채팅시작",
"sidepanel_title_cred": "자격증명확인",
"sidepanel_title_reset": "비밀번호재설정",
"sidepanel_title_archive": "보관된채팅",
"sidepanel_title_blocked": "차단된채팅",
"update_available": "업데이트가능.",
"reload_update": "재로드",
"reconnect_countdown": "연결이끊어졌습니다.다시연결하는중{초}…",
"reconnect_now": "지금시도",
"push_init_failed": "푸시알림을초기화하지못했습니다",
"invalid_security_token": "유효하지않은보안토큰",
"no_connection": "연결없음",
"code_doesnot_match": "코드가일치하지않습니다",
"phone_dative": "전화",
"email_dative": "이메일",
"enter_confirmation_code_prompt": "{방법}으로보낸확인코드를입력하십시오:",
"numeric_confirmation_code_prompt": "숫자만",
"button_confirm": "확인",
"save_attachment": "저장",
"invalid_content": "잘못된내용",
"user_not_found": "찾을수없음",
"badge_you": "당신",
"badge_owner": "소유자",
"menu_item_info": "정보",
"menu_item_clear_messages": "메시지지우기",
"menu_item_clear_messages_for_all": "모두지우기",
"menu_item_delete": "삭제",
"menu_item_delete_for_all": "모두삭제",
"menu_item_send_retry": "다시시도",
"menu_item_mute": "음소거",
"menu_item_unmute": "음소거해제",
"menu_item_delete_topic": "삭제",
"topic_delete_warning": "이대화를삭제하시겠습니까?",
"menu_item_unblock": "차단해제",
"menu_item_block": "차단",
"topic_block_warning": "이대화를차단하시겠습니까?",
"menu_item_member_delete": "제거",
"menu_item_archive_topic": "보관",
"action_cancel": "취소",
"upload_finishing": "마무리...",
"no_contacts": "연락처가없습니다 (._.)",
"contacts_not_found_short": "'{문의}'와일치하는연락처가없습니다.",
"title_group_members": "그룹구성원",
"title_all_contacts": "모든연락처",
"button_ok": "OK",
"button_cancel": "취소",
"more_online_members": "+{넘침}더보기",
"download_action": "다운로드",
"label_file_name": "파일이름: ",
"label_content_type": "컨텐츠유형: ",
"label_size": "크기: ",
"chat_invitation": "새로운대화를시작하도록초대되었습니다.무엇을하시겠습니까?",
"chat_invitation_accept": "수락",
"chat_invitation_ignore": "무시",
"chat_invitation_block": "차단",
"error_invalid_id": "잘못된ID",
"group_user_id_prompt": "그룹또는사용자ID",
"button_subscribe": "구독",
"topic_name_editing_placeholder": "그룹의자유형이름",
"button_create": "작성",
"permission_join": "참여({val})",
"permission_read": "읽기({val})",
"permission_write": "쓰기({val})",
"permission_pres": "알림받기({val})",
"permission_admin": "승인({val})",
"permission_share": "공유({val})",
"permission_delete": "삭제({val})",
"permission_owner": "소유자({val})",
"title_permissions": "권한",
"message_sending": "보내기...",
"message_sending_failed": "실패",
"search_placeholder": "email:alice@example.com,tel:17025550003...와같은목록",
"messaging_disabled_prompt": "메시지비활성화",
"new_message_prompt": "새메시지",
"image_caption_prompt": "이미지설명",
"file_attachment_too_large": "파일크기{크기}이(가){제한}제한을초과합니다.",
"cannot_initiate_file_upload": "파일업로드를시작할수없습니다.",
"tags_not_found": "태그가정의되지않았습니다.일부를추가하십시오.",
"tags_editor_no_tags": "일부태그추가",
"title_manage_tags": "관리"
},
"ru": {
"contacts_not_found": "Чатов нет<br />¯∖_(ツ)_/¯",
"full_name_prompt": "Полное имя, напр. Иван Петров",
"email_prompt": "Email, напр. ivan@example.com",
"button_sign_up": "Создать аккаунт",
"label_your_name": "Ваше имя",
"label_password": "Пароль",
"password_unchanged_prompt": "Не изменен",
"label_user_id": "ID:",
"label_default_access_mode": "Доступ по умолчанию:",
"label_message_sound": "Звук нового сообщения:",
"label_push_notifications": "Уведомления:",
"label_push_notifications_disabled": "Уведомления (требуют HTTPS):",
"title_tag_manager": "Теги для поиска",
"button_logout": "Выйти",
"login_prompt": "Логин",
"password_prompt": "Пароль",
"stay_logged_in": "Запомнить",
"forgot_password_link": "Напомнить пароль",
"button_sign_in": "Войти",
"label_client": "Клиент:",
"label_server": "Сервер:",
"online_now": "онлайн",
"last_seen_timestamp": "Был активен",
"title_not_found": "Не найден",
"unnamed_topic": "Без названия",
"messages_not_readable": "нет доступа к сообщениям",
"tabtitle_find_user": "найти",
"tabtitle_new_group": "создать",
"tabtitle_group_by_id": "по id",
"label_server_to_use": "Использовать сервер:",
"label_wire_transport": "Соединение:",
"button_update": "Применить",
"sidepanel_title_login": "Авторизация",
"sidepanel_title_register": "Зарегистрироваться",
"sidepanel_title_settings": "Настройки",
"sidepanel_title_account_settings": "Настройки аккаунта",
"sidepanel_title_newtpk": "Новый чат",
"sidepanel_title_cred": "Подтвердить",
"sidepanel_title_reset": "Сменить пароль",
"tags_not_found": "Тегов нет. Добавьте",
"tags_editor_no_tags": "Добавьте теги",
"title_manage_tags": "Редактировать",
"message_sending": "в пути...",
"message_sending_failed": "ошибка",
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "Отправка недоступна",
"new_message_prompt": "Новое сообщение",
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
"cannot_initiate_file_upload": "Ошибка загрузки файла.",
"search_for_contacts": "Поиск контактов",
"enter_confirmation_code_prompt": "Код подтверждения, полученный по {method}:",
"numeric_confirmation_code_prompt": "Только цифры",
"button_confirm": "Подтвердить",
"button_ok": "OK",
"button_cancel": "Отменить",
"invalid_content": "сообщение не читается",
"label_file_name": "Имя файла:",
"label_content_type": "Тип:",
"label_size": "Размер:",
"phone_dative": "телефону",
"email_dative": "емейлу",
"title_group_members": "Участники",
"download_action": "скачать",
"permission_join": "Подписываться ({val})",
"permission_read": "Читать ({val})",
"permission_write": "Писать ({val})",
"permission_pres": "Уведомлять ({val})",
"permission_admin": "Подтверждать ({val})",
"permission_share": "Приглашать ({val})",
"permission_delete": "Удалять ({val})",
"permission_owner": "Владелец ({val})",
"title_permissions": "Права доступа",
"requested_permissions": "Требуются",
"granted_permissions": "Получены",
"menu_item_edit_permissions": "Права доступа",
"label_other_user": "Второй",
"label_topic_name": "Название",
"label_private": "Комментарий",
"private_editing_placeholder": "Виден только вам",
"label_muting_topic": "Без уведомлений",
"action_more": "Ещё",
"label_your_permissions": "Ваши права доступа:",
"label_permissions": "Права доступа:",
"label_you": "Вы:",
"label_default_access": "Права по умолчанию:",
"label_group_members": "Участники чата:",
"button_add_members": "Добавить",
"group_has_no_members": "Нет участников",
"action_leave_chat": "Уйти из чата",
"menu_item_info": "Информация",
"menu_item_clear_messages": "Удалить сообщения",
"menu_item_clear_messages_for_all": "Удалить для всех",
"menu_item_delete": "Удалить",
"menu_item_delete_for_all": "Удалить для всех",
"menu_item_send_retry": "Отправить заново",
"menu_item_mute": "Не уведомлять",
"menu_item_unmute": "Уведомлять",
"menu_item_delete_topic": "Удалить чат",
"menu_item_unblock": "Разблокировать",
"menu_item_block": "Заблокировать",
"menu_item_member_delete": "Отписать",
"title_info": "Подробности",
"new_password_placeholder": "Введите новый пароль",
"label_reset_password": "Отправить емейл для смены пароля:",
"credential_email_prompt": "Регистрационный емейл",
"button_reset": "Изменить",
"button_send_request": "Отправить",
"action_cancel": "отменить",
"upload_finishing": "завершение...",
"no_contacts": "Ничего нет :-(",
"contacts_not_found_short": "Нет контактов для запроса ''{query}''",
"title_all_contacts": "Все контакты",
"error_invalid_id": "Неверный ID",
"group_user_id_prompt": "ID чата или пользователя",
"button_subscribe": "Подписаться",
"topic_name_editing_placeholder": "Название чата",
"button_create": "Создать",
"badge_you": "вы",
"badge_owner": "влад.",
"update_available": "Есть новая версия приложения.",
"reload_update": "Обновить",
"user_not_found": "Не найден",
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
"reconnect_now": "Подключить сейчас.",
"save_attachment": "сохранить",
"menu_item_archive_topic": "В архив",
"archived_contacts": "Чаты в архиве ({count})",
"sidepanel_title_archive": "Архив чатов",
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
"chat_invitation_accept": "Принять",
"chat_invitation_ignore": "Игнорировать",
"chat_invitation_block": "Заблокировать",
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
"enable_peers_messaging": "Разблокировать.",
"more_online_members": "+еще {overflow}",
"label_user_contacts": "Конакты:",
"validate_credential_action": "подтвердить",
"link_contact_us": "Связаться с нами",
"link_terms_of_service": "Условия сервиса",
"link_privacy_policy": "Политика конфиденциальности",
"action_delete_messages": "Удалить сообщения",
"action_block_contact": "Заблокировать контакт",
"action_report_chat": "Сообщить о нарушении",
"delete_messages_warning": "Вы действительно хотите удалить все сообщения?",
"leave_chat_warning": "Вы действительно хотите покинуть этот чат?",
"block_contact_warning": "Вы действительно заблокировать этот контакт?",
"report_chat_warning": "Вы действительно хотите сообщить о нарушении и заблокировать этот чат?",
"action_clear_messages": "Удалить сообщения",
"clear_messages_warning": "Вы действительно хотите удалить все сообщения в чате? Их будет невозможно восстановить.",
"topic_delete_warning": "Вы действительно хотите удалить этот чат?",
"topic_block_warning": "Вы действительно хотите заблокировать этот чат?",
"search_no_results": "Ничего не найдено",
"deleted_content": "удалено",
"image_caption_prompt": "Подпись к фото",
"label_incognito_mode": "Режим инкогнито:",
"delete_account": "Удалить аккаунт",
"delete_account_arning": "Вы уверены, что ходите удалить свой аккаунт? Его невозможно будет восстановить.",
"button_delete_account": "Удалить аккаунт",
"sidepanel_title_acc_security": "Безопасность",
"sidepanel_title_acc_notifications": "Уведомления",
"sidepanel_title_acc_support": "Поддержка",
"label_sdk": "SDK:",
"label_server_address": "Адрес сервера:",
"button_edit": "Редактировать",
"button_add_another": "Добавить",
"sidepanel_title_acc_general": "Общие настройки",
"blocked_contacts_link": "Заблокированные ({count})",
"sidepanel_title_blocked": "Заблокированные чаты",
"push_init_failed": "Ошибка инициализации пуш уведомлений",
"invalid_security_token": "Токен некорректен",
"no_connection": "Нет связи",
"code_doesnot_match": "Код не совпадает"
},
"zh": {
"archived_contacts": "已归档联系人 ({count})",
"contacts_not_found": "你尚无会话<br />¯∖_(ツ)_/¯",
"full_name_prompt": "全名,例如张伟",
"email_prompt": "电子邮件,例如 zhang@example.com",
"button_sign_up": "注册",
"label_your_name": "你的姓名",
"label_password": "密码",
"password_unchanged_prompt": "未改变",
"label_user_id": "地址:",
"label_default_access_mode": "蓦然访问模式:",
"label_message_sound": "消息提示音:",
"label_push_notifications": "通知提醒:",
"label_push_notifications_disabled": "通知提醒(需要 HTTPS):",
"title_tag_manager": "标签(用户发现)",
"button_logout": "登出",
"requested_permissions": "已请求",
"granted_permissions": "已授予",
"menu_item_edit_permissions": "编辑权限",
"label_other_user": "其他",
"title_info": "信息",
"label_topic_name": "名称",
"label_private": "私人评论",
"private_editing_placeholder": "仅自己可见",
"label_muting_topic": "已静音:",
"action_more": "更多",
"label_your_permissions": "你的权限:",
"label_permissions": "权限:",
"label_you": "你:",
"label_default_access": "默认权限模式:",
"label_group_members": "群组成员:",
"button_add_members": "添加成员",
"group_has_no_members": "无成员",
"action_leave_chat": "离开",
"login_prompt": "登录",
"password_prompt": "密码",
"stay_logged_in": "保持登录",
"forgot_password_link": "忘记密码?",
"button_sign_in": "登录",
"label_client": "客户端:",
"label_server": "服务器:",
"online_now": "在线",
"last_seen_timestamp": "最后可见",
"title_not_found": "无法找到",
"unnamed_topic": "未命名",
"messages_not_readable": "无消息访问权限",
"peers_messaging_disabled": "成员间消息已禁用。",
"enable_peers_messaging": "启用",
"tabtitle_find_user": "搜索",
"tabtitle_new_group": "新群组",
"tabtitle_group_by_id": "通过 id",
"search_for_contacts": "使用搜索寻找联系人",
"new_password_placeholder": "输入新密码",
"label_reset_password": "发送密码重置邮件:",
"credential_email_prompt": "你的注册邮箱",
"button_reset": "重置",
"button_send_request": "发送请求",
"label_server_to_use": "使用的服务器:",
"label_wire_transport": "线路传输:",
"button_update": "更新",
"sidepanel_title_login": "登录",
"sidepanel_title_register": "创建账户",
"sidepanel_title_settings": "设置",
"sidepanel_title_account_settings": "帐号设定",
"sidepanel_title_newtpk": "开始新会话",
"sidepanel_title_cred": "确认凭据",
"sidepanel_title_reset": "重置密码",
"sidepanel_title_archive": "已存档会话",
"update_available": "更新可用。",
"reload_update": "重新载入",
"reconnect_countdown": "连接已断开。{seconds} 秒后重新连接…",
"reconnect_now": "立即尝试",
"phone_dative": "电话",
"email_dative": "电子邮件",
"enter_confirmation_code_prompt": "输入通过{method}发送的验证码:",
"numeric_confirmation_code_prompt": "仅数字",
"button_confirm": "确认",
"save_attachment": "保存",
"invalid_content": "无效内容",
"user_not_found": "未找到",
"badge_you": "你",
"badge_owner": "所有者",
"menu_item_info": "信息",
"menu_item_clear_messages": "清空消息",
"menu_item_clear_messages_for_all": "全部清除",
"menu_item_delete": "删除",
"menu_item_delete_for_all": "全部删除",
"menu_item_mute": "静音",
"menu_item_unmute": "取消静音",
"menu_item_delete_topic": "删除",
"menu_item_unblock": "取消屏蔽",
"menu_item_block": "屏蔽",
"menu_item_member_delete": "移除",
"menu_item_archive_topic": "归档",
"action_cancel": "取消",
"upload_finishing": "正在结束...",
"no_contacts": "你尚无联系人 (._.)",
"contacts_not_found_short": "无联系人匹配''{query}''",
"title_group_members": "群组成员",
"title_all_contacts": "全部联系人",
"button_ok": "好",
"button_cancel": "取消",
"download_action": "下载",
"label_file_name": "文件名:",
"label_content_type": "内容类型:",
"label_size": "大小:",
"chat_invitation": "你受邀开始新会话。你想怎么做?",
"chat_invitation_accept": "接受",
"chat_invitation_ignore": "忽略",
"chat_invitation_block": "屏蔽",
"error_invalid_id": "无效 ID",
"group_user_id_prompt": "群组或用户 ID",
"button_subscribe": "订阅",
"topic_name_editing_placeholder": "群组自由格式名称",
"button_create": "创建",
"permission_join": "加入 ({val})",
"permission_read": "读取 ({val})",
"permission_write": "写入 ({val})",
"permission_pres": "获取通知 ({val})",
"permission_admin": "批准 ({val})",
"permission_share": "分享 ({val})",
"permission_delete": "删除 ({val})",
"permission_owner": "所有者 ({val})",
"title_permissions": "权限",
"message_sending": "正在发送...",
"message_sending_failed": "发送失败",
"search_placeholder": "列表如 email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "消息已禁用",
"new_message_prompt": "新消息",
"file_attachment_too_large": "文件大小 {size} 超过 {limit} 限制。",
"cannot_initiate_file_upload": "无法初始化文件上传。",
"tags_not_found": "尚未定义标签。添加一些。",
"tags_editor_no_tags": "添加一些标签",
"title_manage_tags": "管理标签",
"more_online_members": "还有{overflow}个",
"label_user_contacts": "往来:",
"validate_credential_action": "确认",
"link_contact_us": "联系我们",
"link_terms_of_service": "条款和条件",
"link_privacy_policy": "隐私政策",
"action_delete_messages": "删除所有帖子",
"action_block_contact": "屏蔽联系人",
"delete_messages_warning": "您确定要删除所有消息吗?无法撤消。",
"leave_chat_warning": "您确定要退出此对话吗?",
"block_contact_warning": "您确定要阻止此联系人吗?",
"action_report_chat": "检举垃圾邮件",
"report_chat_warning": "您确定要停止并报告此对话吗?",
"action_clear_messages": "删除讯息",
"clear_messages_warning": "您确定要清除所有消息吗?无法撤消。",
"topic_delete_warning": "您确定要删除此对话吗?",
"topic_block_warning": "您确定要阻止此对话吗?",
"search_no_results": "搜索返回任何结果",
"deleted_content": "内容已删除",
"image_caption_prompt": "图片标题",
"menu_item_send_retry": "重试",
"label_incognito_mode": "无痕模式:",
"delete_account": "删除帐户",
"delete_account_arning": "您确定要删除您的帐户吗?无法撤消。",
"button_delete_account": "删除帐户",
"sidepanel_title_acc_security": "安全",
"sidepanel_title_acc_notifications": "通知",
"sidepanel_title_acc_support": "支持",
"label_sdk": "开发包:",
"label_server_address": "服务器地址:",
"button_edit": "编辑",
"button_add_another": "加上另一个",
"sidepanel_title_acc_general": "常用设定",
"blocked_contacts_link": "封锁的联络人 ({count})",
"sidepanel_title_blocked": "被阻止的聊天",
"push_init_failed": "初始化推送通知失败",
"invalid_security_token": "无效的安全令牌",
"no_connection": "无连接",
"code_doesnot_match": "代码不匹配"
}
}
+3
View File
@@ -0,0 +1,3 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.16.6";
+186
View File
@@ -0,0 +1,186 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import TagManager from '../widgets/tag-manager.jsx';
import { MAX_TITLE_LENGTH } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, asEmail, asPhone, vcard } from '../lib/utils.js';
export default class AccGeneralView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageUrl(me.public ? me.public.photo : null),
tags: me.tags(),
credentials: me.getCredentials() || [],
addCredActive: false,
addCredInvalid: false,
newCred: '',
previousOnTags: me.onTagsUpdated
};
this.tnNewTags = this.tnNewTags.bind(this);
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleCredChange = this.handleCredChange.bind(this);
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
this.handleCredEntered = this.handleCredEntered.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
}
componentDidMount() {
const me = this.props.tinode.getMeTopic();
me.onCredsUpdated = this.tnCredsUpdated;
me.onTagsUpdated = this.tnNewTags;
}
componentWillUnmount() {
const me = this.props.tinode.getMeTopic();
me.onTagsUpdated = this.state.previousOnTags;
me.onCredsUpdated = undefined;
}
tnNewTags(tags) {
this.setState({tags: tags});
}
tnCredsUpdated(creds) {
this.setState({credentials: creds || []});
}
handleFullNameUpdate(fn) {
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
if (fn) {
this.setState({fullName: fn});
this.props.onUpdateAccount(undefined, vcard(fn, null));
}
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onUpdateAccount(undefined, vcard(null, img || Tinode.DEL_CHAR));
}
handleCredChange(e) {
this.setState({newCred: e.target.value, addCredInvalid: false});
}
handleCredKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({newCred: '', addCredActive: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleCredEntered(e);
}
}
handleCredEntered(e) {
let value = this.state.newCred.trim();
if (!value) {
this.setState({addCredActive: false, addCredInvalid: false});
return;
}
let val = asPhone(value);
let method;
if (val) {
method = 'tel';
} else {
val = asEmail(value);
if (val) {
method = 'email';
}
}
if (method) {
this.props.onCredAdd(method, val);
this.setState({addCredActive: false, newCred: ''});
} else {
this.setState({addCredInvalid: true});
}
}
handleTagsUpdated(tags) {
// Check if tags have actually changed.
if (arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
return;
}
this.props.onUpdateTags(tags);
}
render() {
const credentials = [];
this.state.credentials.map((cred) => {
credentials.push(<div key={cred.meth + ":" + cred.val + ":" + cred.done}>{cred.meth}: <tt>{cred.val}</tt>
<span > {!cred.done ?
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredConfirm(cred.meth, cred.val);}}>
<FormattedMessage id="validate_credential_action" defaultMessage="confirm"
description="Validate credentail call to action" />
</a>
: null} <a href="#" onClick={(e) => {e.preventDefault(); this.props.onCredDelete(cred.meth, cred.val);}}><i
className="material-icons gray">delete_outline</i></a></span></div>);
});
return (
<div className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<label className="small"><FormattedMessage id="label_your_name" /></label>
<div><FormattedMessage id="full_name_prompt">{
(full_name_placeholder) => <InPlaceEdit
placeholder={full_name_placeholder}
value={this.state.fullName}
onFinished={this.handleFullNameUpdate} />
}</FormattedMessage></div>
</div>
<AvatarUpload
avatar={this.state.avatar}
uid={this.props.myUserId}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="hr" />
<FormattedMessage id="title_tag_manager" defaultMessage="Tags (user discovery)"
description="Section title for TagManager">{
(title_tag_manager) => <TagManager
title={title_tag_manager}
activated={false}
tags={this.state.tags}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
<div className="hr" />
<div className="panel-form-column">
<label className="small">
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
description="Label for user contacts" />
</label>
<div className="quoted">
{credentials}
{this.state.addCredActive ?
<input type="text" value={this.state.value}
className={this.state.addCredInvalid ? 'invalid' : null}
placeholder="Phone number or email" required="required" autoFocus
onChange={this.handleCredChange} onKeyDown={this.handleCredKeyDown} onBlur={this.handleCredEntered} />
: null}
<div>
<a href="#" onClick={(e) => {e.preventDefault(); this.setState({addCredActive: true});}}>
<i className="material-icons">add</i>
<FormattedMessage id="button_add_another" defaultMessage="Add another" description="Call to action [+ add another]" />
</a></div>
</div>
</div>
</div>
);
}
};
+61
View File
@@ -0,0 +1,61 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import CheckBox from '../widgets/checkbox.jsx';
export default class EditAccountView extends React.PureComponent {
constructor(props) {
super(props);
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
}
handleCheckboxClick(what, checked) {
if (what == 'sound') {
this.props.onToggleMessageSounds(checked);
} else if (what == 'alert') {
this.props.onTogglePushNotifications(checked);
} else if (what == 'incognito') {
this.props.onToggleIncognitoMode(checked);
}
}
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-row">
<label htmlFor="message-sound">
<FormattedMessage id="label_message_sound" defaultMessage="Message sound:"
description="Label for message sounds toggle" />
</label>
<CheckBox name="sound" id="message-sound"
checked={this.props.messageSounds} onChange={this.handleCheckboxClick} />
</div>
<div className="panel-form-row">
<label htmlFor="desktop-alerts">
{this.props.desktopAlertsEnabled ?
<FormattedMessage id="label_push_notifications" defaultMessage="Notification alerts:"
description="Label for push notifications switch" />
:
<FormattedMessage id="label_push_notifications_disabled"
defaultMessage="Notification alerts (requires HTTPS):"
description="Label for push notifications switch" />
}
</label>
<CheckBox name="alert" id="desktop-alerts"
checked={this.props.desktopAlerts}
onChange={this.props.desktopAlertsEnabled ? this.handleCheckboxClick : null} />
</div>
<div className="panel-form-row">
<label htmlFor="incognito-mode">
<FormattedMessage id="label_incognito_mode" defaultMessage="Incognito mode:"
description="Label for incognito mode toggle" />
</label>
<CheckBox name="incognito" id="incognito-mode"
checked={this.props.incognitoMode} onChange={this.handleCheckboxClick} />
</div>
</div>
);
}
};
+155
View File
@@ -0,0 +1,155 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
const messages = defineMessages({
delete_account: {
id: 'delete_account',
defaultMessage: 'Delete account',
description: 'Title for delete account warning'
},
delete_account_warning: {
id: 'delete_account_arning',
defaultMessage: 'Are you sure you want to delete your account? It cannot be undone.',
description: 'Warning message when deleting an account'
}
});
class AccSecurityView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
let blockedCount = 0;
me.contacts((c) => {
if (c.acs && !c.acs.isJoiner()) {
blockedCount ++;
}
});
const defacs = me.getDefaultAccess();
this.state = {
auth: defacs ? defacs.auth : null,
anon: defacs ? defacs.anon : null,
showPermissionEditorFor: undefined,
blockedCount: blockedCount,
};
this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
}
handlePasswordUpdate(pwd) {
this.setState({password: pwd});
this.props.onUpdateAccount(pwd);
}
handleLaunchPermissionsEditor(which) {
this.setState({
showPermissionEditorFor: which,
editedPermissions: this.state[which]
});
}
handleHidePermissionsEditor() {
this.setState({showPermissionEditorFor: undefined});
}
handlePermissionsChanged(perm) {
let defacs = {};
defacs[this.state.showPermissionEditorFor] = perm;
this.props.onUpdateAccount(undefined, undefined, defacs);
let newState = {showPermissionEditorFor: undefined};
newState[this.state.showPermissionEditorFor] = perm;
this.setState(newState);
}
handleDeleteAccount(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.delete_account), // title
formatMessage(messages.delete_account_warning), // content
(() => { this.props.onDeleteAccount(); }), // onConfirm
null, // use default text "OK"
true, // Show Reject button
null // use default text "Cancel"
);
}
render() {
return (
<React.Fragment>{this.state.showPermissionEditorFor ?
<PermissionsEditor
mode={this.state.editedPermissions}
skip="O"
onSubmit={this.handlePermissionsChanged}
onCancel={this.handleHidePermissionsEditor} />
:
<div className="scrollable-panel">
<div className="panel-form-column">
<label className="small">
<FormattedMessage id="label_password" defaultMessage="Password"
description="Label for password editing" />
</label>
<div>
<FormattedMessage id="password_unchanged_prompt" defaultMessage="Unchanged"
description="Message in editor while password is unchanged">{
(password_unchanged) => <InPlaceEdit
placeholder={password_unchanged}
type="password"
onFinished={this.handlePasswordUpdate} />
}</FormattedMessage>
</div>
</div>
<div className="hr" />
<div className="panel-form-column">
<a href="#" className="red flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
<i className="material-icons">exit_to_app</i> &nbsp;<FormattedMessage id="button_logout"
defaultMessage="Logout" description="Button [Logout]" />
</a>
<a href="#" className="red flat-button" onClick={(e) => {this.handleDeleteAccount(e);}}>
<i className="material-icons">delete</i> &nbsp;<FormattedMessage id="button_delete_account"
defaultMessage="Delete account" description="Button [Delete account]" />
</a>
</div>
<div className="hr" />
<div className="panel-form-column">
<div>
<label className="small">
<FormattedMessage id="label_default_access_mode" defaultMessage="Default access mode:"
description="Label for default access mode" />
</label>
</div>
<div className="quoted">
<div>Auth: <tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>{this.state.auth}</tt></div>
<div>Anon: <tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>{this.state.anon}</tt></div>
</div>
</div>
{this.state.blockedCount > 0 ?
<>
<div className="hr" />
<div className="panel-form-row">
<i className="material-icons">block</i>&nbsp;
<a href="#" className="gray" onClick={(e) => {e.preventDefault(); this.props.onShowBlocked();}}>
<FormattedMessage id="blocked_contacts_link" defaultMessage="Blocked contacts ({count})"
values={{ count: this.state.blockedCount }} description="Blocked contacts link" />
</a>
</div>
</>
: null }
</div>
}</React.Fragment>
);
}
};
export default injectIntl(AccSecurityView);
+49
View File
@@ -0,0 +1,49 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
export default class AccSupportView extends React.PureComponent {
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-column">
<a href={LINK_CONTACT_US} className="flat-button" target="_blank">
<i className="material-icons">email</i> &nbsp;<FormattedMessage id="link_contact_us"
defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</a>
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank">
<i className="material-icons">description</i> &nbsp;<FormattedMessage id="link_terms_of_service"
defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
</a>
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank">
<i className="material-icons">policy</i> &nbsp;<FormattedMessage id="link_privacy_policy"
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
</a>
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_client" /></label>
{APP_NAME}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_sdk" defaultMessage="SDK:" /></label>
{Tinode.getLibrary()}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_server" /></label>
{this.props.serverVersion}
</div>
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_server_address" defaultMessage="Server address:" /></label>
{this.props.serverAddress}
</div>
</div>
</div>
);
}
};
+105
View File
@@ -0,0 +1,105 @@
/* ContactsView holds all contacts-related stuff */
import React from 'react';
import { FormattedMessage, defineMessages } from 'react-intl';
import ContactList from '../widgets/contact-list.jsx';
import { updateFavicon } from '../lib/utils.js';
const messages = defineMessages({
archived_contacts_title: {
id: "archived_contacts",
defaultMessage: "Archived contacts ({count})",
description: "Label for archived chats"
}
});
export default class ContactsView extends React.Component {
constructor(props) {
super(props);
this.handleAction = this.handleAction.bind(this);
this.state = ContactsView.deriveStateFromProps(props);
}
static deriveStateFromProps(props) {
const contacts = [];
let unreadThreads = 0;
let archivedCount = 0;
props.chatList.map((c) => {
const blocked = c.acs && !c.acs.isJoiner();
// Show only blocked contacts only when props.blocked == true.
if (blocked && props.blocked) {
contacts.push(c);
}
if (blocked || props.blocked) {
return;
}
if (c.private && c.private.arch) {
if (props.archive) {
contacts.push(c);
} else {
archivedCount ++;
}
} else if (!props.archive) {
contacts.push(c);
unreadThreads += c.unread > 0 ? 1 : 0;
}
});
contacts.sort((a, b) => {
return (b.touched || 0) - (a.touched || 0);
});
if (archivedCount > 0) {
contacts.push({
action: 'archive',
title: messages.archived_contacts_title,
values: {count: archivedCount}
});
}
return {
contactList: contacts,
unreadThreads: unreadThreads
};
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.chatList != this.props.chatList ||
prevProps.archive != this.props.archive ||
prevProps.blocked != this.props.blocked) {
const newState = ContactsView.deriveStateFromProps(this.props);
this.setState(newState);
if (newState.unreadThreads != prevState.unreadThreads) {
updateFavicon(newState.unreadThreads);
}
}
}
handleAction(action_ignored) {
this.props.onShowArchive();
}
render() {
return (
<FormattedMessage id="contacts_not_found"
defaultMessage="You have no chats<br />¯∖_(ツ)_/¯"
description="HTML message shown in ContactList when no contacts are found">{
(no_contacts) => <ContactList
connected={this.props.connected}
contacts={this.state.contactList}
emptyListMessage={no_contacts}
topicSelected={this.props.topicSelected}
myUserId={this.props.myUserId}
showOnline={true}
showUnread={true}
onTopicSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
onAction={this.handleAction} />
}</FormattedMessage>
);
}
};
+126
View File
@@ -0,0 +1,126 @@
// Account registration.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
import LocalStorageUtil from '../lib/local-storage.js';
import { vcard } from '../lib/utils.js';
import { MAX_TITLE_LENGTH } from '../config.js';
export default class CreateAccountView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
login: '',
password: '',
email: '',
fn: '', // full/formatted name
imageDataUrl: null,
errorCleared: false,
saveToken: LocalStorageUtil.getObject('keep-logged-in')
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handleFnChange = this.handleFnChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(password) {
this.setState({password: password});
}
handleEmailChange(e) {
this.setState({email: e.target.value})
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handleImageChanged(img) {
this.setState({imageDataUrl: img});
}
handleToggleSaveToken() {
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
this.setState({errorCleared: false});
this.props.onCreateAccount(
this.state.login.trim(),
this.state.password.trim(),
vcard(this.state.fn.trim().substring(0, MAX_TITLE_LENGTH), this.state.imageDataUrl),
{'meth': 'email', 'val': this.state.email});
}
render() {
let submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form className="panel-form-column" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<FormattedMessage id="login_prompt">{
(login_prompt) => <input type="text" placeholder={login_prompt} autoComplete="user-name"
value={this.state.login} onChange={this.handleLoginChange} required autoFocus />
}</FormattedMessage>
<FormattedMessage id="password_prompt">{
(password_prompt) => <VisiblePassword placeholder={password_prompt} autoComplete="new-password"
value={this.state.password} onFinished={this.handlePasswordChange}
required={true} />
}</FormattedMessage>
</div>
<AvatarUpload
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="panel-form-row">
<FormattedMessage id="full_name_prompt" defaultMessage="Full name, e.g. John Doe"
description="Input placeholder for person's full name">{
(full_name_prompt) => <input type="text" placeholder={full_name_prompt} autoComplete="name"
value={this.state.fn} onChange={this.handleFnChange} required/>
}</FormattedMessage>
</div>
<div className="panel-form-row">
<FormattedMessage id="email_prompt" defaultMessage="Email, e.g. jdoe@example.com"
description="Input placeholder for email entry">{
(email_prompt) => <input type="email" placeholder={email_prompt} autoComplete="email"
value={this.state.email} onChange={this.handleEmailChange} required/>
}</FormattedMessage>
</div>
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
<FormattedMessage id="stay_logged_in">{
(stay_logged_in) => <label htmlFor="save-token">&nbsp;{stay_logged_in}</label>
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit">
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
description="Create account button [Sign Up]" />
</button>
</div>
</form>
);
}
};
+63
View File
@@ -0,0 +1,63 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
export default class EditAccountView extends React.Component {
constructor(props) {
super(props);
const me = this.props.tinode.getMeTopic();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageUrl(me.public ? me.public.photo : null)
};
}
render() {
return (
<div className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<label className="small">
<FormattedMessage id="label_your_name" defaultMessage="Your name"
description="Label for full name editing" />
</label>
<div className="large">{this.state.fullName}</div>
<div>
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="ID:"
description="Label for user address (ID)" /></label>&nbsp;
<tt>{this.props.myUserId}</tt>
</div>
</div>
<AvatarUpload
avatar={this.state.avatar}
readOnly={!this.state.owner}
uid={this.props.myUserId}
title={this.state.fullName} />
</div>
<div className="panel-form-row">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('general');}}>
<i className="material-icons">edit</i>&nbsp;
<FormattedMessage id="button_edit" defaultMessage="Edit" description="Call to action [Edit]" />
</a>
</div>
<div className="hr" />
<div className="panel-form-column">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('notif');}}>
<i className="material-icons">notifications</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_notifications" />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('security');}}>
<i className="material-icons">security</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_security" />
</a>
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.props.onBasicNavigate('support');}}>
<i className="material-icons">contact_support</i>&nbsp;<FormattedMessage id="sidepanel_title_acc_support" />
</a>
</div>
</div>
);
}
};
+766
View File
@@ -0,0 +1,766 @@
// InfoView: panel with topic/user info.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import AvatarUpload from '../widgets/avatar-upload.jsx';
import CheckBox from '../widgets/checkbox.jsx';
import ContactList from '../widgets/contact-list.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx';
import InPlaceEdit from '../widgets/in-place-edit.jsx';
import MenuCancel from '../widgets/menu-cancel.jsx';
import MoreButton from '../widgets/more-button.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import TagManager from '../widgets/tag-manager.jsx';
import { MAX_TITLE_LENGTH, NO_ACCESS_MODE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { arrayEqual, vcard } from '../lib/utils.js';
const messages = defineMessages({
requested: {
id: 'requested_permissions',
defaultMessage: 'Requested',
description: 'Title for permissions'
},
granted: {
id: 'granted_permissions',
defaultMessage: 'Granted',
description: 'Title for permissions'
},
edit_permissions: {
id: 'menu_item_edit_permissions',
defaultMessage: 'Edit permissions',
description: 'Menu item [Edit permissions]'
},
other_user: {
id: 'label_other_user',
defaultMessage: 'Other',
description: 'Label for the other user when the user is unnamed'
},
clear_messages: {
id: 'action_clear_messages',
defaultMessage: 'Clear Messages',
description: 'Flat button [Clear Messages] (soft-delete messages)'
},
clear_messages_warning: {
id: 'clear_messages_warning',
defaultMessage: 'Are you sure you want to clear all messages? It cannot be undone.',
description: 'Alert dialog warning when deleting all messages.'
},
delete_messages: {
id: 'action_delete_messages',
defaultMessage: 'Clear Messages for All',
description: 'Flat button [Clear for All] (hard-delete all messages)'
},
delete_messages_warning: {
id: 'delete_messages_warning',
defaultMessage: 'Are you sure you want to delete all messages for everyone? It cannot be undone.',
description: 'Alert dialog warning when hard-deleting all messages.'
},
leave_chat: {
id: 'action_leave_chat',
defaultMessage: 'Leave Conversation',
description: 'Flat button [Leave Conversation]'
},
leave_chat_warning: {
id: 'leave_chat_warning',
defaultMessage: 'Are you sure you want to leave this conversation?',
description: 'Alert dialog warning when unsubscribing from a chat.'
},
block_contact: {
id: 'action_block_contact',
defaultMessage: "Block Contact",
description: "Flat button [Block Contact]"
},
block_contact_warning: {
id: 'block_contact_warning',
defaultMessage: 'Are you sure you want to block this contact?',
description: 'Alert dialog warning when blocking a contact.'
},
report_chat: {
id: 'action_report_chat',
defaultMessage: 'Report Conversation',
description: 'Flat button [Report Group]'
},
report_chat_warning: {
id: 'report_chat_warning',
defaultMessage: 'Are you sure you want to block and report this conversation?',
description: 'Alert dialog warning when reporting a conversation for abuse'
},
});
class InfoView extends React.Component {
constructor(props) {
super(props);
this.state = {
topic: null,
owner: false,
admin: false,
sharer: false,
deleter: false,
muted: false,
address: null,
groupTopic: undefined,
fullName: undefined,
avatar: null,
private: null,
selectedContact: null,
access: null,
modeGiven: null,
modeWant: null,
modeGiven2: null, // P2P topic, the other user mode given
modeWant2: null, // P2P topic, the other user mode want
auth: null,
anon: null,
contactList: [],
tags: [],
showMemberPanel: false,
showPermissionEditorFor: undefined,
moreInfoExpanded: false,
previousMetaDesc: undefined,
previousSubsUpdated: undefined,
previousTagsUpdated: undefined
};
this.resetSubs = this.resetSubs.bind(this);
this.resetDesc = this.resetDesc.bind(this);
this.onMetaDesc = this.onMetaDesc.bind(this);
this.onSubsUpdated = this.onSubsUpdated.bind(this);
this.onTagsUpdated = this.onTagsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handlePrivateUpdate = this.handlePrivateUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleMuted = this.handleMuted.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handleShowAddMembers = this.handleShowAddMembers.bind(this);
this.handleHideAddMembers = this.handleHideAddMembers.bind(this);
this.handleMemberUpdateRequest = this.handleMemberUpdateRequest.bind(this);
this.handleDeleteMessages = this.handleDeleteMessages.bind(this);
this.handleLeave = this.handleLeave.bind(this);
this.handleBlock = this.handleBlock.bind(this);
this.handleReport = this.handleReport.bind(this);
this.handleMemberSelected = this.handleMemberSelected.bind(this);
this.handleMoreInfo = this.handleMoreInfo.bind(this);
this.handleTagsUpdated = this.handleTagsUpdated.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
}
// No need to separately handle component mount.
componentDidUpdate(props) {
const topic = this.props.tinode.getTopic(props.topic);
if (!topic) {
return;
}
if (this.onMetaDesc != topic.onMetaDesc) {
this.previousMetaDesc = topic.onMetaDesc;
topic.onMetaDesc = this.onMetaDesc;
this.previousSubsUpdated = topic.onSubsUpdated;
topic.onSubsUpdated = this.onSubsUpdated;
if (topic.getType() == 'grp') {
this.previousTagsUpdated = topic.onTagsUpdated;
topic.onTagsUpdated = this.onTagsUpdated;
} else {
this.previousTagsUpdated = undefined;
}
}
if (this.state.topic != props.topic) {
this.setState({topic: props.topic});
this.resetDesc(topic, props);
this.resetSubs(topic, props);
}
}
componentWillUnmount() {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.setState({topic: null});
topic.onMetaDesc = this.previousMetaDesc;
topic.onSubsUpdated = this.previousSubsUpdated;
topic.onTagsUpdated = this.previousTagsUpdated;
}
resetSubs(topic, props) {
const newState = {contactList: []};
if (topic.getType() == 'p2p') {
// Fetch the other party in the p2p conversation.
// Topic may not be ready yet, so check if user is found.
const user2 = topic.subscriber(props.topic);
if (user2) {
newState.modeGiven2 = user2.acs.getGiven();
newState.modeWant2 = user2.acs.getWant();
} else {
newState.modeGiven2 = NO_ACCESS_MODE;
newState.modeWant2 = NO_ACCESS_MODE;
}
} else {
topic.subscribers((sub) => {
newState.contactList.push(sub);
}, this);
}
this.setState(newState);
}
resetDesc(topic, props) {
const defacs = topic.getDefaultAccess() || {};
const acs = topic.getAccessMode();
this.setState({
owner: acs && acs.isOwner(),
admin: acs && acs.isAdmin(),
sharer: acs && acs.isSharer(),
deleter: acs && acs.isDeleter(),
muted: acs && acs.isMuted(),
fullName: topic.public ? topic.public.fn : undefined,
avatar: makeImageUrl(topic.public ? topic.public.photo : null),
private: topic.private ? topic.private.comment : null,
address: topic.name,
groupTopic: (topic.getType() == 'grp'),
showMemberPanel: false,
access: acs ? acs.getMode() : undefined,
modeGiven: acs ? acs.getGiven() : undefined,
modeWant: acs ? acs.getWant() : undefined,
auth: defacs.auth,
anon: defacs.anon
});
if (topic.getType() == 'grp' && acs && acs.isOwner()) {
// Requesting tags: owner is editing the topic.
topic.getMeta(topic.startMetaQuery().withTags().build());
}
}
onMetaDesc(desc) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetDesc(topic, this.props);
if (this.previousMetaDesc && this.previousMetaDesc != this.onMetaDesc) {
this.previousMetaDesc(desc);
}
}
onSubsUpdated(subs) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
this.resetSubs(topic, this.props);
if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
this.previousSubsUpdated(subs);
}
}
onTagsUpdated(tags) {
this.setState({tags: tags});
if (this.previousTagsUpdated && this.previousTagsUpdated != this.onTagsUpdated) {
this.previousTagsUpdated();
}
}
handleFullNameUpdate(fn) {
fn = fn.trim().substring(0, MAX_TITLE_LENGTH);
if (this.state.fullName !== fn) {
this.setState({fullName: fn});
this.props.onTopicDescUpdate(this.props.topic, vcard(fn, null), null);
}
}
handlePrivateUpdate(comment) {
comment = comment.trim().substring(0, MAX_TITLE_LENGTH);
if (this.state.private !== comment) {
this.setState({private: comment});
this.props.onTopicDescUpdate(this.props.topic, null, comment || Tinode.DEL_CHAR);
}
}
handleImageChanged(img) {
this.setState({avatar: img});
this.props.onTopicDescUpdate(this.props.topic, vcard(null, img || Tinode.DEL_CHAR), null);
}
handleMuted(ignored, checked) {
this.setState({muted: checked});
this.props.onChangePermissions(this.props.topic, checked ? '-P' : '+P');
}
handlePermissionsChanged(perm) {
switch (this.state.showPermissionEditorFor) {
case 'auth':
this.props.onTopicDescUpdate(this.props.topic, null, null, {auth: perm});
break;
case 'anon':
this.props.onTopicDescUpdate(this.props.topic, null, null, {anon: perm});
break;
case 'mode':
case 'want':
this.props.onChangePermissions(this.props.topic, perm);
break;
case 'given':
this.props.onChangePermissions(this.props.topic, perm, this.props.topic);
break;
case 'user':
this.props.onChangePermissions(this.props.topic, perm, this.state.userPermissionsEdited);
break;
}
this.setState({showPermissionEditorFor: undefined});
}
handleLaunchPermissionsEditor(which, uid) {
const {formatMessage} = this.props.intl;
let toEdit, toCompare, toSkip, titleEdit, titleCompare, userTitle, userAvatar;
switch (which) {
case 'mode':
toEdit = this.state.access;
break;
case 'want':
toEdit = this.state.modeWant;
toCompare = this.state.modeGiven;
toSkip = this.state.owner ? 'O' : // Don't allow owner to unset 'O' permission.
// Allow accepting any of 'ASDO' permissions but don't allow asking for them.
Tinode.AccessMode.encode(Tinode.AccessMode.diff('ASDO', this.state.modeGiven));
titleEdit = formatMessage(messages.requested);
titleCompare = formatMessage(messages.granted);
break;
case 'given':
toEdit = this.state.modeGiven2;
toCompare = this.state.modeWant2;
toSkip = this.state.groupTopic ? (this.state.owner ? '' : 'O') : 'ASDO';
titleEdit = formatMessage(messages.granted);
titleCompare = formatMessage(messages.requested);
break;
case 'auth':
toEdit = this.state.auth;
toSkip = 'O';
break;
case 'anon':
toEdit = this.state.anon;
toSkip = 'O';
break;
case 'user': {
let topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
var user = topic.subscriber(uid);
if (!user || !user.acs) {
return;
}
toEdit = user.acs.getGiven();
toCompare = user.acs.getWant();
toSkip = this.state.owner ? '' : 'O';
titleEdit = formatMessage(messages.granted);
titleCompare = formatMessage(messages.requested);
if (user.public) {
userTitle = user.public.fn;
userAvatar = user.public.photo;
}
break;
}
default:
console.log("Unknown permission editing mode '" + which + "'");
break;
}
this.setState({
showPermissionEditorFor: which,
userPermissionsEdited: uid,
userPermissionsTitle: userTitle,
userPermissionsAvatar: userAvatar,
editedPermissions: toEdit,
immutablePermissions: toCompare,
editedPermissionsTitle: titleEdit,
immutablePermissionsTitle: titleCompare,
editedPermissionsSkipped: toSkip,
});
}
handleHidePermissionsEditor() {
this.setState({showPermissionEditorFor: undefined});
}
handleShowAddMembers(e) {
e.preventDefault();
this.props.onInitFind();
this.setState({showMemberPanel: true});
}
handleHideAddMembers() {
this.setState({showMemberPanel: false});
}
handleMemberUpdateRequest(members, added, removed) {
this.props.onMemberUpdateRequest(this.props.topic, added, removed);
this.setState({showMemberPanel: false});
}
handleDeleteMessages(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages), // title
formatMessage(this.state.deleter ? messages.delete_messages_warning : messages.clear_messages_warning), // content
(() => { this.props.onDeleteMessages(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleLeave(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.leave_chat), // title
formatMessage(messages.leave_chat_warning), // content
(() => { this.props.onLeaveTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleBlock(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.block_contact), // title
formatMessage(messages.block_contact_warning), // content
(() => { this.props.onBlockTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleReport(e) {
e.preventDefault();
const {formatMessage} = this.props.intl;
this.props.onShowAlert(
formatMessage(messages.report_chat), // title
formatMessage(messages.report_chat_warning), // content
(() => { this.props.onReportTopic(this.props.topic); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
handleMemberSelected(uid) {
this.setState({selectedContact: uid});
}
handleMoreInfo(open) {
this.setState({moreInfoExpanded: open});
}
handleTagsUpdated(tags) {
if (!arrayEqual(this.state.tags.slice(0), tags.slice(0))) {
this.props.onTopicTagsUpdate(this.props.topic, tags);
}
}
handleContextMenu(params) {
const {formatMessage} = this.props.intl;
const instance = this;
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
}
const user = topic.subscriber(params.topicName);
if (!user || !user.acs) {
return;
}
const menuItems = [
{title: formatMessage(messages.edit_permissions), handler: function() {
instance.handleLaunchPermissionsEditor('user', params.topicName);
}},
'member_delete',
user.acs.isMuted() ? 'member_unmute' : 'member_mute',
user.acs.isJoiner() ? 'member_block' : 'member_unblock'
];
this.props.showContextMenu({
topicName: this.props.topic,
x: params.x,
y: params.y,
user: params.topicName}, menuItems);
}
render() {
const {formatMessage} = this.props.intl;
return (
<div id="info-view">
<div className="caption-panel" id="info-caption-panel">
<div className="panel-title" id="info-title">
<FormattedMessage id="title_info" defaultMessage="Info" description="Title for InfoView" />
</div>
<div>
<MenuCancel onCancel={this.props.onCancel} />
</div>
</div>
{this.props.displayMobile ?
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} /> : null}
{this.state.showMemberPanel ?
<GroupManager
members={this.state.contactList}
requiredMember={this.props.myUserId}
keepInitialMembers={!this.state.admin && !this.state.owner}
myUserId={this.props.myUserId}
contacts={this.props.searchableContacts}
onCancel={this.handleHideAddMembers}
onSubmit={this.handleMemberUpdateRequest} />
:
this.state.showPermissionEditorFor ?
<PermissionsEditor
mode={this.state.editedPermissions}
compare={this.state.immutablePermissions}
skip={this.state.editedPermissionsSkipped}
modeTitle={this.state.editedPermissionsTitle}
compareTitle={this.state.immutablePermissionsTitle}
userTitle={this.state.userPermissionsTitle}
item={this.state.userPermissionsEdited}
userAvatar={this.state.userPermissionsAvatar}
onSubmit={this.handlePermissionsChanged}
onCancel={this.handleHidePermissionsEditor}
/>
:
<div id="info-view-content" className="scrollable-panel">
<div className="panel-form-row">
<div className="panel-form-column">
<div><label className="small">
<FormattedMessage id="label_topic_name" defaultMessage="Name"
description="Label for editing topic name" />
</label></div>
<div><InPlaceEdit
placeholder={this.state.groupTopic ? "Group name" : <i>Unknown</i>}
readOnly={!this.state.owner}
value={this.state.fullName}
required={true}
onFinished={this.handleFullNameUpdate} /></div>
<div>
<label className="small">
<FormattedMessage id="label_private" defaultMessage="Private comment"
description="Label for editing 'private'" />
</label>
</div>
<div>
<FormattedMessage id="private_editing_placeholder"
defaultMessage="Visible to you only"
description="Placeholder for editing 'private'">{
(private_placeholder) => <InPlaceEdit
placeholder={private_placeholder}
value={this.state.private}
onFinished={this.handlePrivateUpdate} />
}</FormattedMessage>
</div>
</div>
<AvatarUpload
avatar={this.state.avatar}
readOnly={!this.state.owner}
uid={this.props.topic}
title={this.state.fullName}
onImageChanged={this.handleImageChanged}
onError={this.props.onError} />
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label>
<FormattedMessage id="label_muting_topic" defaultMessage="Muted:"
description="Label for Muting/unmuting the topic" />
</label>
<CheckBox name="P" checked={this.state.muted}
onChange={this.handleMuted} />
</div>
<FormattedMessage id="action_more" defaultMessage="More"
description="Action for showing more content">{
(more) => <MoreButton
title={more}
open={this.state.moreInfoExpanded}
onToggle={this.handleMoreInfo} />
}</FormattedMessage>
{this.state.moreInfoExpanded ?
<div className="panel-form-column">
<div className="panel-form-row">
<label><FormattedMessage id="label_user_id" /></label>
<tt>{this.state.address}</tt>
</div>
{this.state.groupTopic ?
<div className="panel-form-row">
<label>
<FormattedMessage id="label_your_permissions" defaultMessage="Your permissions:"
description="Label for current user permissions" />
</label>
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt>
</div>
:
<div>
<div>
<label className="small">
<FormattedMessage id="label_permissions" defaultMessage="Permissions:"
description="Section title" />
</label>
</div>
<div className="quoted">
<div>
<FormattedMessage id="label_you" defaultMessage="You:"
description="Label for the current user" /> &nbsp;<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'want')}>
{this.state.access}
</tt></div>
<div>{this.state.fullName ? this.state.fullName : formatMessage(messages.other_user)}:
&nbsp;<tt className="clickable" onClick={this.handleLaunchPermissionsEditor.bind(this, 'given')}>
{this.state.modeGiven2}
</tt>
</div>
</div>
</div>
}
{this.state.sharer && (this.state.auth || this.state.anon) ?
<div>
<div>
<label className="small">
<FormattedMessage id="label_default_access" defaultMessage="Default access mode:"
description="Section title" />
</label>
</div>
<div className="quoted">
<div>Auth: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'auth')}>
{this.state.auth}
</tt>
:
<tt>{this.state.auth}</tt>
}
</div>
<div>Anon: {this.state.admin ?
<tt className="clickable"
onClick={this.handleLaunchPermissionsEditor.bind(this, 'anon')}>
{this.state.anon}
</tt>
:
<tt>{this.state.anon}</tt>
}
</div>
</div>
</div>
:
null
}
</div>
:
null
}
</div>
<div className="hr" />
{this.state.owner ?
<>
<FormattedMessage id="title_tag_manager">{
(tags) => <TagManager
title={tags}
tags={this.state.tags}
activated={false}
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
<div className="hr" />
</>
:
null
}
<div className="panel-form-column">
<a href="#" className="flat-button" onClick={this.handleDeleteMessages}>
<i className="material-icons">delete_outline</i> &nbsp;{
formatMessage(this.state.deleter ? messages.delete_messages : messages.clear_messages)
}
</a>
<a href="#" className="red flat-button" onClick={this.handleLeave}>
<i className="material-icons">exit_to_app</i> &nbsp;{formatMessage(messages.leave_chat)}
</a>
{!this.state.groupTopic ?
<a href="#" className="red flat-button" onClick={this.handleBlock}>
<i className="material-icons">block</i> &nbsp;{formatMessage(messages.block_contact)}
</a>
:
null
}
{!this.state.owner ?
<a href="#" className="red flat-button" onClick={this.handleReport}>
<i className="material-icons">report</i> &nbsp;{formatMessage(messages.report_chat)}
</a>
:
null
}
</div>
{this.state.groupTopic ?
<>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_group_members" defaultMessage="Group members:"
description="Section title or label" />
</label>
</div>
<div className="panel-form-row">
{this.state.sharer ?
<a href="#" className="flat-button" onClick={this.handleShowAddMembers}>
<i className="material-icons">person_add</i> &nbsp;<FormattedMessage id="button_add_members"
defaultMessage="Add members" description="Flat button [Add members] (to topic)" />
</a>
: null}
</div>
<FormattedMessage id="group_has_no_members" defaultMessage="No members"
description="Shown in place of group members">{
(no_members) => <ContactList
contacts={this.state.contactList}
myUserId={this.props.myUserId}
emptyListMessage={no_members}
topicSelected={this.state.selectedContact}
showOnline={false}
showUnread={false}
showMode={true}
noScroll={true}
onTopicSelected={this.handleMemberSelected}
showContextMenu={this.state.admin ? this.handleContextMenu : false}
/>
}</FormattedMessage>
</div>
</>
:
null
}
</div>
}
</div>
);
}
};
export default injectIntl(InfoView);
+98
View File
@@ -0,0 +1,98 @@
// Login form.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import CheckBox from '../widgets/checkbox.jsx';
import VisiblePassword from '../widgets/visible-password.jsx';
import LocalStorageUtil from '../lib/local-storage.js';
export default class LoginView extends React.Component {
constructor(props) {
super(props);
this.state = {
login: props.login,
password: '',
hostName: props.serverAddress,
saveToken: LocalStorageUtil.getObject('keep-logged-in')
};
this.handleLoginChange = this.handleLoginChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleLoginChange(e) {
this.setState({login: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
handleToggleSaveToken() {
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
this.setState({saveToken: !this.state.saveToken});
}
handleSubmit(e) {
e.preventDefault();
this.props.onLogin(this.state.login.trim(), this.state.password.trim());
}
render() {
var submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form id="login-form" onSubmit={this.handleSubmit}>
<FormattedMessage id="login_prompt" defaultMessage="Login"
description="Login placeholder in LoginView">
{
(login_prompt) => <input type="text" id="inputLogin"
placeholder={login_prompt}
autoComplete="username"
autoCorrect="off"
autoCapitalize="none"
value={this.state.login}
onChange={this.handleLoginChange}
required autoFocus />
}
</FormattedMessage>
<FormattedMessage id="password_prompt" defaultMessage="Password"
description="Password placeholder in LoginView">
{
(password_prompt) => <VisiblePassword type="password" id="inputPassword"
placeholder={password_prompt}
autoComplete="current-password"
value={this.state.password}
onChange={this.handlePasswordChange}
required={true} />
}
</FormattedMessage>
<div className="panel-form-row">
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
onChange={this.handleToggleSaveToken} />
<label htmlFor="save-token">&nbsp;
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
description="Label for a checkbox" />
</label>
<a href="#reset">
<FormattedMessage id="forgot_password_link" defaultMessage="Forgot password?"
description="Link to Reset password form" />
</a>
</div>
<div className="dialog-buttons">
<button className={submitClasses} type="submit">
<FormattedMessage id="button_sign_in" defaultMessage="Sign in"
description="Button [Sign In]" />
</button>
</div>
</form>
);
}
};
/* END Login */
+25
View File
@@ -0,0 +1,25 @@
/* This is just a static page to display when no conversation is selected. */
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
import { APP_NAME } from '../config.js';
export default class LogoView extends React.PureComponent {
render() {
const version = APP_NAME + ' (' + Tinode.getLibrary() + ')';
return (
<div id="dummy-view">
<div>
<a href="https://github.com/tinode/chat/">
<img id="logo" alt="logo" src="img/logo.svg" />
<h2>Tinode Web</h2>
</a>
<p><FormattedMessage id="label_client" defaultMessage="Client:" /> {version}</p>
<p><FormattedMessage id="label_server" defaultMessage="Server:" /> {this.props.serverVersion} ({this.props.serverAddress})</p>
</div>
</div>
);
}
};
+887
View File
@@ -0,0 +1,887 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
const Drafty = Tinode.Drafty;
import ChatMessage from '../widgets/chat-message.jsx';
import DocPreview from '../widgets/doc-preview.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupSubs from '../widgets/group-subs.jsx';
import ImagePreview from '../widgets/image-preview.jsx';
import Invitation from '../widgets/invitation.jsx';
import LetterTile from '../widgets/letter-tile.jsx';
import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx';
import SendMessage from '../widgets/send-message.jsx';
import { DEFAULT_P2P_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE, MAX_EXTERN_ATTACHMENT_SIZE,
MAX_IMAGE_DIM, MAX_INBAND_ATTACHMENT_SIZE, READ_DELAY } from '../config.js';
import { SUPPORTED_IMAGE_FORMATS, filePasted, fileToBase64, imageFileToBase64,
imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
import { bytesToHumanSize, shortDateFormat } from '../lib/strformat.js';
// Run timer with this frequency (ms) for checking notification queue.
const NOTIFICATION_EXEC_INTERVAL = 300;
const messages = defineMessages({
online_now: {
id: 'online_now',
defaultMessage: 'online now',
description: 'Indicator that the user or topic is currently online',
},
last_seen: {
id: 'last_seen_timestamp',
defaultMessage: 'Last seen',
description: 'Label for the timestamp of when the user or topic was last online'
},
'not_found': {
id: 'title_not_found',
defaultMessage: 'Not found',
description: 'Title shown when topic is not found'
}
});
// Checks if the access permissions are granted but not yet accepted.
function isUnconfirmed(acs) {
if (acs) {
const ex = acs.getExcessive() || '';
return acs.isJoiner('given') && (ex.includes('R') || ex.includes('W'));
}
return false;
}
function isPeerRestricted(acs) {
if (acs) {
const ms = acs.getMissing() || '';
return acs.isJoiner('want') && (ms.includes('R') || ms.includes('W'));
}
return false;
}
class MessagesView extends React.Component {
constructor(props) {
super(props);
this.state = MessagesView.getDerivedStateFromProps(props, {});
this.leave = this.leave.bind(this);
this.sendImageAttachment = this.sendImageAttachment.bind(this);
this.sendFileAttachment = this.sendFileAttachment.bind(this);
this.sendKeyPress = this.sendKeyPress.bind(this);
this.handleScrollReference = this.handleScrollReference.bind(this);
this.handleScrollEvent = this.handleScrollEvent.bind(this);
this.handleDescChange = this.handleDescChange.bind(this);
this.handleSubsUpdated = this.handleSubsUpdated.bind(this);
this.handleNewMessage = this.handleNewMessage.bind(this);
this.handleAllMessagesReceived = this.handleAllMessagesReceived.bind(this);
this.handleInfoReceipt = this.handleInfoReceipt.bind(this);
this.handleImagePostview = this.handleImagePostview.bind(this);
this.handleClosePreview = this.handleClosePreview.bind(this);
this.handleFormResponse = this.handleFormResponse.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
this.handleEnablePeer = this.handleEnablePeer.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this);
this.postReadNotification = this.postReadNotification.bind(this);
this.clearNotificationQueue = this.clearNotificationQueue.bind(this);
this.readNotificationQueue = [];
this.readNotificationTimer = null;
}
componentDidMount() {
// this.propsChange(this.props, this.state);
if (this.messagesScroller) {
this.messagesScroller.addEventListener('scroll', this.handleScrollEvent);
}
}
componentWillUnmount() {
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
}
// Flush all notifications.
this.clearNotificationQueue();
}
// Scroll last message into view on component update e.g. on message received
// or vertical shrinking.
componentDidUpdate(prevProps, prevState) {
if (this.messagesScroller) {
if (prevState.topic != this.state.topic || prevState.messages.length != this.state.messages.length) {
// New message
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
} else if (prevProps.viewportHeight > this.props.viewportHeight) {
// Componet changed height.
this.messagesScroller.scrollTop += prevProps.viewportHeight - this.props.viewportHeight;
}
}
const topic = this.props.tinode.getTopic(this.state.topic);
if (this.state.topic != prevState.topic) {
if (prevState.topic && !Tinode.isNewGroupTopicName(prevState.topic)) {
this.leave(prevState.topic);
}
if (topic) {
topic.onData = this.handleNewMessage;
topic.onAllMessagesReceived = this.handleAllMessagesReceived;
topic.onInfo = this.handleInfoReceipt;
topic.onMetaDesc = this.handleDescChange;
topic.onSubsUpdated = this.handleSubsUpdated;
topic.onPres = this.handleSubsUpdated;
}
}
if (!this.props.applicationVisible) {
// If application is not visible, flush all unsent 'read' notifications.
this.clearNotificationQueue();
} else {
// Otherwise assume there are unread messages.
this.postReadNotification(0);
}
if (topic && !topic.isSubscribed() && this.props.ready &&
((this.state.topic != prevState.topic) || !prevProps.ready)) {
// Is this a new topic?
const newTopic = (this.props.newTopicParams && this.props.newTopicParams._topicName == this.props.topic);
// Don't request the tags. They are useless unless the user
// is the owner and is editing the topic.
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub();
if (this.state.isReader || newTopic) {
// If reading is either permitted or we don't know because it's a new topic. Ask for messages.
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
if (this.state.isReader) {
getQuery = getQuery.withLaterDel();
}
// And show "loading" spinner.
this.setState({ fetchingMessages: true });
}
const setQuery = newTopic ? this.props.newTopicParams : undefined;
topic.subscribe(getQuery.build(), setQuery)
.then((ctrl) => {
if (this.state.topic != ctrl.topic) {
this.setState({topic: ctrl.topic});
}
this.props.onNewTopicCreated(this.props.topic, ctrl.topic);
// If there are unsent messages, try sending them now.
topic.queuedMessages((pub) => {
if (!pub._sending && topic.isSubscribed()) {
topic.publishMessage(pub);
}
});
})
.catch((err) => {
console.log("Failed subscription to", this.state.topic);
this.props.onError(err.message, 'err');
const blankState = MessagesView.getDerivedStateFromProps({}, {});
blankState.title = this.props.intl.formatMessage(messages.not_found);
this.setState(blankState);
});
}
}
static getDerivedStateFromProps(nextProps, prevState) {
let nextState = {};
if (!nextProps.topic) {
// Default state: no topic.
nextState = {
messages: [],
onlineSubs: [],
topic: null,
title: '',
avatar: null,
docPreview: null,
imagePreview: null,
imagePostview: null,
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false,
peerMessagingDisabled: false
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
nextState = {
topic: nextProps.topic,
docPreview: null,
imagePreview: null,
imagePostview: null,
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false
};
if (topic) {
// Topic exists.
const msgs = [];
const subs = [];
if (nextProps.connected) {
topic.subscribers((sub) => {
if (sub.online && sub.user != nextProps.myUserId) {
subs.push(sub);
}
});
}
topic.messages(function(msg) {
if (!msg.deleted) {
msgs.push(msg);
}
});
Object.assign(nextState, {
messages: msgs,
onlineSubs: subs
});
if (topic.public) {
Object.assign(nextState, {
title: topic.public.fn,
avatar: makeImageUrl(topic.public.photo)
});
} else {
Object.assign(nextState, {
title: '',
avatar: null
});
}
const peer = topic.p2pPeerDesc();
if (peer) {
Object.assign(nextState, {
peerMessagingDisabled: isPeerRestricted(peer.acs)
});
} else if (prevState.peerMessagingDisabled) {
Object.assign(nextState, {
peerMessagingDisabled: false
});
}
} else {
// Invalid topic.
Object.assign(nextState, {
messages: [],
onlineSubs: [],
title: '',
avatar: null,
peerMessagingDisabled: false
});
}
}
if (nextProps.acs) {
if (nextProps.acs.isWriter() != prevState.isWriter) {
nextState.isWriter = !prevState.isWriter;
}
if (nextProps.acs.isReader() != prevState.isReader) {
nextState.isReader = !prevState.isReader;
}
if (!nextProps.acs.isReader('given') != prevState.readingBlocked) {
nextState.readingBlocked = !prevState.readingBlocked;
}
} else {
if (prevState.isWriter) {
nextState.isWriter = false;
}
if (prevState.isReader) {
nextState.isReader = false;
}
if (!prevState.readingBlocked) {
prevState.readingBlocked = true;
}
}
if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) {
nextState.unconfirmed = !prevState.unconformed;
}
// Clear subscribers online when there is no connection.
if (!nextProps.connected && prevState.onlineSubs && prevState.onlineSubs.length > 0) {
nextState.onlineSubs = [];
}
return nextState;
}
leave(oldTopicName) {
if (!oldTopicName) {
return;
}
let oldTopic = this.props.tinode.getTopic(oldTopicName);
if (oldTopic && oldTopic.isSubscribed()) {
oldTopic.leave(false)
.catch(() => { /* do nothing here */ })
.finally(() => {
// We don't care if the request succeeded or failed.
// The topic is dead regardless.
this.setState({fetchingMessages: false});
oldTopic.onData = undefined;
oldTopic.onAllMessagesReceived = undefined;
oldTopic.onInfo = undefined;
oldTopic.onMetaDesc = undefined;
oldTopic.onSubsUpdated = undefined;
oldTopic.onPres = undefined;
});
}
}
handleScrollReference(node) {
if (node) {
node.addEventListener('scroll', this.handleScrollEvent);
this.messagesScroller = node;
this.messagesScroller.scrollTop = this.messagesScroller.scrollHeight - this.state.scrollPosition;
}
}
// Get older messages
handleScrollEvent(event) {
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
if (event.target.scrollTop <= 0) {
this.setState((prevState, props) => {
const newState = {};
if (!prevState.fetchingMessages) {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
newState.fetchingMessages = true;
topic.getMessagesPage(MESSAGES_PAGE).catch((err) => {
this.setState({fetchingMessages: false});
this.props.onError(err.message, 'err');
});
}
}
return newState;
});
}
}
handleDescChange(desc) {
if (desc.public) {
this.setState({
title: desc.public.fn,
avatar: makeImageUrl(desc.public.photo)
});
} else {
this.setState({
title: '',
avatar: null
});
}
if (desc.acs) {
this.setState({
isWriter: desc.acs.isWriter(),
isReader: desc.acs.isReader(),
readingBlocked: !desc.acs.isReader('given'),
unconfirmed: isUnconfirmed(desc.acs),
});
}
}
postReadNotification(seq) {
// Ignore notifications if the app is invisible.
if (!this.props.applicationVisible) {
return;
}
// Set up the timer if it's not running already.
if (!this.readNotificationTimer) {
this.readNotificationTimer = setInterval(() => {
if (this.readNotificationQueue.length == 0) {
// Shut down the timer if the queue is empty.
clearInterval(this.readNotificationTimer);
this.readNotificationTimer = null;
return;
}
let seq = -1;
while (this.readNotificationQueue.length > 0) {
const n = this.readNotificationQueue[0];
if (n.topicName != this.state.topic) {
// Topic has changed. Drop the notification.
this.readNotificationQueue.shift();
continue;
}
const now = new Date();
if (n.sendAt <= now) {
// Remove expired notification from queue.
this.readNotificationQueue.shift();
seq = Math.max(seq, n.seq);
} else {
break;
}
}
// Send only one notification for the whole batch of messages.
if (seq >= 0) {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic) {
topic.noteRead(seq);
}
}
}, NOTIFICATION_EXEC_INTERVAL);
}
const now = new Date();
this.readNotificationQueue.push({
topicName: this.state.topic,
seq: seq,
sendAt: now.setMilliseconds(now.getMilliseconds() + READ_DELAY)
});
}
// Clear notification queue and timer.
clearNotificationQueue() {
this.readNotificationQueue = [];
if (this.readNotificationTimer) {
clearInterval(this.readNotificationTimer);
this.readNotificationTimer = null;
}
}
handleSubsUpdated() {
if (this.state.topic) {
const subs = [];
const topic = this.props.tinode.getTopic(this.state.topic);
topic.subscribers((sub) => {
if (sub.online && sub.user != this.props.myUserId) {
subs.push(sub);
}
});
const newState = {onlineSubs: subs};
const peer = topic.p2pPeerDesc();
if (peer) {
Object.assign(newState, {
peerMessagingDisabled: isPeerRestricted(peer.acs)
});
} else if (this.state.peerMessagingDisabled) {
Object.assign(newState, {
peerMessagingDisabled: false
});
}
this.setState(newState);
}
}
handleNewMessage(msg) {
// Regenerate messages list
const topic = this.props.tinode.getTopic(this.state.topic);
const newState = {messages: []};
topic.messages((m) => {
if (!m.deleted) {
newState.messages.push(m);
}
});
// msg could be null if one or more messages were deleted.
if (msg && !msg.deleted) {
// If the message is added to the end of the message list,
// scroll to the bottom.
if (topic.isNewMessage(msg.seq)) {
newState.scrollPosition = 0;
}
// Aknowledge messages except own messages. They are
// automatically assumed to be read and recived.
const status = topic.msgStatus(msg);
if (status >= Tinode.MESSAGE_STATUS_SENT && msg.from != this.props.myUserId) {
this.postReadNotification(msg.seq);
}
this.props.onData(msg);
}
this.setState(newState);
}
handleAllMessagesReceived(count) {
this.setState({fetchingMessages: false});
if (count > 0) {
// 0 means "latest".
this.postReadNotification(0);
}
}
handleInfoReceipt(info) {
switch (info.what) {
case 'kp': {
clearTimeout(this.keyPressTimer);
var instance = this;
this.keyPressTimer = setTimeout(function() {
instance.setState({typingIndicator: false});
}, KEYPRESS_DELAY + 1000);
if (!this.state.typingIndicator) {
this.setState({typingIndicator: true});
}
break;
}
case 'read':
case 'recv':
// Redraw due to changed recv/read status.
this.forceUpdate();
break;
default:
console.log("Other change in topic: ", info.what);
}
}
handleImagePostview(content) {
this.setState({ imagePostview: content });
}
handleClosePreview() {
this.setState({ imagePostview: null, imagePreview: null, docPreview: null });
}
handleFormResponse(action, text, data) {
if (action == 'pub') {
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
} else if (action == 'url') {
const url = new URL(data.ref);
const params = url.searchParams;
for (let key in data.resp) {
if (data.resp.hasOwnProperty(key)) {
params.set(key, data.resp[key]);
}
}
['name', 'seq'].map(function(key) {
if (data[key]) {
params.set(key, data[key]);
}
});
params.set('uid', this.props.myUserId);
url.search = params;
window.open(url, '_blank');
} else {
console.log("Unknown action in form", action);
}
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.state.topic, y: e.pageY, x: e.pageX });
}
handleShowContextMenuMessage(params, messageSpecificMenuItems) {
params.topicName = this.state.topic;
const menuItems = messageSpecificMenuItems || [];
menuItems.push('message_delete');
const topic = this.props.tinode.getTopic(params.topicName);
if (topic) {
const acs = topic.getAccessMode();
if (acs && acs.isDeleter()) {
menuItems.push('message_delete_hard');
}
}
this.props.showContextMenu(params, menuItems);
}
handleNewChatAcceptance(action) {
this.props.onNewChat(this.state.topic, action);
}
handleEnablePeer(e) {
e.preventDefault();
this.props.onChangePermissions(this.state.topic, DEFAULT_P2P_ACCESS_MODE, this.state.topic);
}
sendKeyPress() {
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic.isSubscribed()) {
topic.noteKeyPress();
}
}
sendFileAttachment(file) {
if (file.size > MAX_INBAND_ATTACHMENT_SIZE) {
// Too large to send inband - uploading out of band and sending as a link.
const uploader = this.props.tinode.getLargeFileHelper();
if (!uploader) {
this.props.onError(this.props.intl.formatMessage(messages.cannot_initiate_upload));
return;
}
const uploadCompletionPromise = uploader.upload(file);
const msg = Drafty.attachFile(null, file.type, null, file.name, file.size, uploadCompletionPromise);
// Pass data and the uploader to the TinodeWeb.
this.props.sendMessage(msg, uploadCompletionPromise, uploader);
} else {
// Small enough to send inband.
fileToBase64(file,
(mime, bits, fname) => {
this.props.sendMessage(Drafty.attachFile(null, mime, bits, fname));
},
this.props.onError
);
}
}
handleAttachFile(file) {
if (file.size > MAX_EXTERN_ATTACHMENT_SIZE) {
// Too large.
this.props.onError(this.props.intl.formatMessage(messages.file_attachment_too_large,
{size: bytesToHumanSize(file.size), limit: bytesToHumanSize(MAX_EXTERN_ATTACHMENT_SIZE)}), 'err');
} else {
this.setState({ docPreview: {
file: file,
filename: file.name,
size: file.size,
type: file.type
}});
}
}
sendImageAttachment(caption, mime, bits, width, height, fname) {
let msg = Drafty.insertImage(null, 0, mime, bits, width, height, fname);
if (caption) {
msg = Drafty.appendLineBreak(msg);
msg = Drafty.append(msg, Drafty.init(caption));
}
this.props.sendMessage(msg);
}
handleAttachImage(file) {
// Check if the uploaded file is indeed an image and if it isn't too large.
if (file.size > MAX_INBAND_ATTACHMENT_SIZE || SUPPORTED_IMAGE_FORMATS.indexOf(file.type) < 0) {
// Convert image for size or format.
imageFileScaledToBase64(file, MAX_IMAGE_DIM, MAX_IMAGE_DIM, false,
// Success
(bits, mime, width, height, fname) => {
this.setState({imagePreview: {
url: URL.createObjectURL(file),
bits: bits,
filename: fname,
width: width,
height: height,
size: bits.length,
type: mime
}});
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
} else {
// Image can be uploaded as is. No conversion is needed.
imageFileToBase64(file,
// Success
(bits, mime, width, height, fname) => {
this.setState({imagePreview: {
url: URL.createObjectURL(file),
bits: bits,
filename: fname,
width: width,
height: height,
size: bits.length,
type: mime
}});
},
// Failure
(err) => {
this.props.onError(err, 'err');
}
);
}
}
render() {
const {formatMessage} = this.props.intl;
let component;
if (this.props.hideSelf) {
component = null;
} else if (!this.state.topic) {
component = (
<LogoView
serverVersion={this.props.serverVersion}
serverAddress={this.props.serverAddress} />
);
} else {
let component2;
if (this.state.imagePreview) {
// Preview image before sending.
component2 = (
<ImagePreview
content={this.state.imagePreview}
onClose={this.handleClosePreview}
onSendMessage={this.sendImageAttachment} />
);
} else if (this.state.imagePostview) {
// Expand received image.
component2 = (
<ImagePreview
content={this.state.imagePostview}
onClose={this.handleClosePreview} />
);
} else if (this.state.docPreview) {
// Preview attachment before sending.
component2 = (
<DocPreview
content={this.state.docPreview}
onClose={this.handleClosePreview}
onSendMessage={this.sendFileAttachment} />
);
} else {
const topic = this.props.tinode.getTopic(this.state.topic);
const groupTopic = topic.getType() == 'grp';
let messageNodes = [];
let previousFrom = null;
let chatBoxClass = null;
for (let i=0; i<this.state.messages.length; i++) {
let msg = this.state.messages[i];
let nextFrom = null;
if (i + 1 < this.state.messages.length) {
nextFrom = this.state.messages[i+1].from
}
let sequence = 'single';
if (msg.from == previousFrom) {
if (msg.from == nextFrom) {
sequence = 'middle';
} else {
sequence = 'last';
}
} else if (msg.from == nextFrom) {
sequence = 'first';
}
previousFrom = msg.from;
const isReply = !(msg.from == this.props.myUserId);
const deliveryStatus = topic.msgStatus(msg);
let userName, userAvatar, userFrom;
if (groupTopic) {
const user = topic.userDesc(msg.from);
if (user && user.public) {
userName = user.public.fn;
userAvatar = makeImageUrl(user.public.photo);
}
userFrom = msg.from;
chatBoxClass='chat-box group';
} else {
chatBoxClass='chat-box';
}
messageNodes.push(
<ChatMessage
tinode={this.props.tinode}
content={msg.content} deleted={msg.hi}
mimeType={msg.head ? msg.head.mime : null}
timestamp={msg.ts} response={isReply} seq={msg.seq}
userFrom={userFrom} userName={userName} userAvatar={userAvatar}
sequence={sequence} received={deliveryStatus} uploader={msg._uploader}
viewportWidth={this.props.viewportWidth}
showContextMenu={this.handleShowContextMenuMessage}
onImagePreview={this.handleImagePostview}
onFormResponse={this.handleFormResponse}
onError={this.props.onError}
key={msg.seq} />
);
}
let lastSeen = null;
const cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
if (cont && Tinode.topicType(cont.topic) == 'p2p') {
if (cont.online) {
lastSeen = formatMessage(messages.online_now);
} else if (cont.seen) {
lastSeen = formatMessage(messages.last_seen) + ": " +
shortDateFormat(cont.seen.when, this.props.intl.locale);
// TODO: also handle user agent in c.seen.ua
}
}
const avatar = this.state.avatar || true;
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
component2 = (
<>
<div id="topic-caption-panel" className="caption-panel">
{this.props.displayMobile ?
<a href="#" id="hide-message-view" onClick={(e) => {e.preventDefault(); this.props.onHideMessagesView();}}>
<i className="material-icons">arrow_back</i>
</a>
:
null}
<div className="avatar-box">
<LetterTile
avatar={avatar}
topic={this.state.topic}
title={this.state.title} />
<span className={online} />
</div>
<div id="topic-title-group">
<div id="topic-title" className="panel-title">{
this.state.title ||
<i><FormattedMessage id="unnamed_topic" defaultMessage="Unnamed"
description="Title shown when the topic has no name" /></i>
}</div>
<div id="topic-last-seen">{lastSeen}</div>
</div>
{groupTopic ?
<GroupSubs
subscribers={this.state.onlineSubs} /> :
<div id="topic-users" />
}
<div>
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">more_vert</i>
</a>
</div>
</div>
{this.props.displayMobile ?
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
onClearError={this.props.onError} />
: null}
<LoadSpinner show={this.state.fetchingMessages} />
<div id="messages-container">
<div id="messages-panel" ref={this.handleScrollReference}>
<ul id="scroller" className={chatBoxClass}>
{messageNodes}
</ul>
</div>
{!this.state.isReader ?
<div id="write-only-background">
{this.state.readingBlocked ?
<div id="write-only-note">
<FormattedMessage id="messages_not_readable" defaultMessage="no access to messages"
description="Message shown in topic without the read access" />
</div>
: null }
</div>
: null }
</div>
{this.state.peerMessagingDisabled && !this.state.unconfirmed ?
<div id="peer-messaging-disabled-note">
<i className="material-icons secondary">block</i> <FormattedMessage
id="peers_messaging_disabled" defaultMessage="Peer's messaging is disabled."
description="Shown when the p2p peer's messaging is disabled" /> <a href="#"
onClick={this.handleEnablePeer}><FormattedMessage id="enable_peers_messaging"
defaultMessage="Enable" description="Call to action to enable peer's messaging" /></a>.
</div> : null}
{this.state.unconfirmed ?
<Invitation onAction={this.handleNewChatAcceptance} />
:
<SendMessage
disabled={!this.state.isWriter}
onSendMessage={this.props.sendMessage}
onKeyPress={this.sendKeyPress}
onAttachFile={this.handleAttachFile}
onAttachImage={this.handleAttachImage}
onError={this.props.onError} />}
</>
);
}
component = <div id="topic-view">{component2}</div>
}
return component;
}
};
export default injectIntl(MessagesView);
+126
View File
@@ -0,0 +1,126 @@
// Create new topic and invite users or send an invite.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import ContactList from '../widgets/contact-list.jsx';
import NewTopicById from '../widgets/new-topic-by-id.jsx';
import NewTopicGroup from '../widgets/new-topic-group.jsx';
import SearchContacts from '../widgets/search-contacts.jsx';
import HashNavigation from '../lib/navigation.js';
import { vcard } from '../lib/utils.js';
const messages = defineMessages({
search_for_contacts: {
id: "search_for_contacts",
defaultMessage: "Use search to find contacts",
description: "Text shown in contacts view when user entered no search query."
},
search_no_results: {
id: "search_no_results",
defaultMessage: "Search returned no results",
description: "Text shown in contacts view when query returned no results."
}
});
class NewTopicView extends React.Component {
constructor(props) {
super(props);
this.state = {
tabSelected: 'p2p',
searchQuery: null
};
this.handleTabClick = this.handleTabClick.bind(this);
this.handleSearchContacts = this.handleSearchContacts.bind(this);
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);
this.handleGroupByID = this.handleGroupByID.bind(this);
}
componentDidMount() {
this.props.onInitFind();
}
handleTabClick(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
this.setState({tabSelected: e.currentTarget.dataset.id});
}
handleSearchContacts(query) {
this.props.onSearchContacts(query);
this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});
}
handleContactSelected(sel) {
if (this.state.tabSelected === 'p2p') {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(sel, undefined);
}
}
handleNewGroupSubmit(name, dataUrl, priv, tags) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(undefined, vcard(name, dataUrl), priv, tags);
}
handleGroupByID(topicName) {
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'tab'));
this.props.onCreateTopic(topicName);
}
render() {
const {formatMessage} = this.props.intl;
const no_contacts_placeholder = formatMessage(this.state.searchQuery ?
messages.search_no_results : messages.search_for_contacts);
return (
<div className="flex-column">
<ul className="tabbar">
<li className={this.state.tabSelected === "p2p" ? "active" : null}>
<a href="#" data-id="p2p" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_find_user" defaultMessage="find"
description="Tab title Find" />
</a>
</li>
<li className={this.state.tabSelected === "grp" ? "active" : null}>
<a href="#" data-id="grp" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_new_group" defaultMessage="new group"
description="Tab title New Group" />
</a>
</li>
<li className={this.state.tabSelected === "byid" ? "active" : null}>
<a href="#" data-id="byid" onClick={this.handleTabClick}>
<FormattedMessage id="tabtitle_group_by_id" defaultMessage="by id"
description="Tab title Find topic by ID" />
</a>
</li>
</ul>
{this.state.tabSelected === 'grp' ?
<NewTopicGroup onSubmit={this.handleNewGroupSubmit} /> :
this.state.tabSelected === 'byid' ?
<NewTopicById
onSubmit={this.handleGroupByID}
onError={this.props.onError} /> :
<div className="flex-column">
<SearchContacts
type="p2p"
onSearchContacts={this.handleSearchContacts} />
<ContactList
contacts={this.props.searchResults}
myUserId={this.props.myUserId}
emptyListMessage={no_contacts_placeholder}
showOnline={false}
showUnread={false}
showContextMenu={false}
onTopicSelected={this.handleContactSelected} />
</div>}
</div>
);
}
};
export default injectIntl(NewTopicView);
+87
View File
@@ -0,0 +1,87 @@
// A password reset form.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import VisiblePassword from '../widgets/visible-password.jsx';
import HashNavigation from '../lib/navigation.js';
export default class PasswordResetView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
email: '',
password: ''
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this);
this.handlePasswordChange = this.handlePasswordChange.bind(this);
}
componentDidMount() {
let parsed = HashNavigation.parseUrlHash(window.location.hash);
this.setState({token: parsed.params.token, scheme: parsed.params.scheme});
}
handleSubmit(e) {
e.preventDefault();
if (this.state.token) {
this.props.onReset(this.state.scheme, this.state.password.trim(), this.state.token);
} else {
this.props.onRequest('email', this.state.email.trim());
}
}
handleEmailChange(e) {
this.setState({email: e.target.value});
}
handlePasswordChange(e) {
this.setState({password: e.target.value});
}
render() {
let reset = (this.state.token && this.state.scheme);
return (
<form id="password-reset-form" onSubmit={this.handleSubmit}>
{reset ?
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
description="Placeholder for entering new password">{
(placeholder) => <VisiblePassword
placeholder={placeholder}
autoComplete="new-password"
value={this.state.password}
required={true} autoFocus={true}
onChange={this.handlePasswordChange} />
}</FormattedMessage>
:
<>
<label htmlFor="inputEmail">
<FormattedMessage id="label_reset_password"
defaultMessage="Send a password reset email:"
description="Label for password reset field" />
</label>
<FormattedMessage id="credential_email_prompt" defaultMessage="Your registration email"
description="Placeholder for entering email">{
(placeholder) => <input type="email" id="inputEmail"
placeholder={placeholder}
autoComplete="email"
value={this.state.email}
onChange={this.handleEmailChange}
required autoFocus />
}</FormattedMessage>
</>
}
<div className="dialog-buttons">
<button className="blue" type="submit">{reset ?
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" /> :
<FormattedMessage id="button_send_request" defaultMessage="Send request"
description="Button [Send request]" />
}</button>
</div>
</form>
);
}
}
+84
View File
@@ -0,0 +1,84 @@
// Tinode config panel.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import HostSelector from '../widgets/host-selector.jsx';
export default class SettingsView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
transport: props.transport || 'def',
serverAddress: props.serverAddress,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleTransportSelected = this.handleTransportSelected.bind(this);
this.handleServerAddressChange = this.handleServerAddressChange.bind(this);
}
handleSubmit(e) {
e.preventDefault();
this.props.onUpdate({
transport: this.state.transport,
serverAddress: this.state.serverAddress
});
}
handleTransportSelected(e) {
this.setState({transport: e.currentTarget.value});
}
handleServerAddressChange(name) {
this.setState({serverAddress: name});
}
render() {
const names = {def: "default", ws: "websocket", lp: "long polling"};
var transportOptions = [];
var instance = this;
['def', 'ws', 'lp'].map(function(item) {
var id = 'transport-' + item;
var name = names[item];
transportOptions.push(
<li key={item}>
<input type="radio" id={id} name="transport-select" value={item}
checked={instance.state.transport === item}
onChange={instance.handleTransportSelected} />
<label htmlFor={id}>{name}</label>
</li>
);
});
return (
<form id="settings-form" className="panel-form" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_server_to_use" defaultMessage="Server to use:"
description="Label for server selector in SettingsView" />
</label>
</div>
<HostSelector serverAddress={this.state.serverAddress}
onServerAddressChange={this.handleServerAddressChange} />
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="label_wire_transport" defaultMessage="Wire transport:"
description="Label for wire transport selection in SettingsView" />
</label>
</div>
<div className="panel-form-row">
<ul className="quoted">
{transportOptions}
</ul>
</div>
<div className="dialog-buttons">
<button type="submit" className="blue">
<FormattedMessage id="button_update" defaultMessage="Update"
description="Button [Update]" />
</button>
</div>
</form>
);
}
};
+245
View File
@@ -0,0 +1,245 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import ErrorPanel from '../widgets/error-panel.jsx';
import LoadSpinner from '../widgets/load-spinner.jsx';
import SideNavbar from '../widgets/side-navbar.jsx';
import ContactsView from './contacts-view.jsx';
import CreateAccountView from './create-account-view.jsx';
import EditAccountView from './edit-account-view.jsx';
import AccGeneralView from './acc-general-view.jsx';
import AccNotificationsView from './acc-notifications-view.jsx';
import AccSecurityView from './acc-security-view.jsx';
import AccSupportView from './acc-support-view.jsx';
import LoginView from './login-view.jsx';
import NewTopicView from './new-topic-view.jsx';
import PasswordResetView from './password-reset-view.jsx';
import SettingsView from './settings-view.jsx';
import ValidationView from './validation-view.jsx';
// Panel titles for translation.
const messages = defineMessages({
'login': {
id: 'sidepanel_title_login',
description: 'Sidepanel title for LoginView.',
defaultMessage: 'Sign In'
},
'register': {
id: 'sidepanel_title_register',
description: 'Sidepanel title for CreateAccountView.',
defaultMessage: 'Create Account'
},
'settings': {
id: 'sidepanel_title_settings',
description: 'Sidepanel title for SettingsView.',
defaultMessage: 'Settings'
},
'edit': {
id: 'sidepanel_title_account_settings',
description: 'Sidepanel title for EditAccountView.',
defaultMessage: 'Account Settings'
},
'general': {
id: 'sidepanel_title_acc_general',
description: 'Sidepanel title for AccGeneralView.',
defaultMessage: 'General'
},
'security': {
id: 'sidepanel_title_acc_security',
description: 'Sidepanel title for AccSecurityView.',
defaultMessage: 'Security'
},
'notif': {
id: 'sidepanel_title_acc_notifications',
description: 'Sidepanel title for AccNotificationsView.',
defaultMessage: 'Notifications'
},
'support': {
id: 'sidepanel_title_acc_support',
description: 'Sidepanel title for AccSupportView.',
defaultMessage: 'Support'
},
'newtpk': {
id: 'sidepanel_title_newtpk',
description: 'Sidepanel title for NewTopicView.',
defaultMessage: 'Start New Chat'
},
'cred': {
id: 'sidepanel_title_cred',
description: 'Sidepanel title for ValidationView.',
defaultMessage: 'Confirm Credentials'
},
'reset': {
id: 'sidepanel_title_reset',
description: 'Sidepanel title for PasswordResetView.',
defaultMessage: 'Reset Password'
},
'archive': {
id: 'sidepanel_title_archive',
description: 'Sidepanel title for ContactsView-Archive.',
defaultMessage: 'Archived Chats'
},
'blocked': {
id: 'sidepanel_title_blocked',
description: 'Sidepanel title for ContactsView-Blocked.',
defaultMessage: 'Blocked Chats'
}
});
class SidepanelView extends React.Component {
constructor(props) {
super(props);
this.handleLoginRequested = this.handleLoginRequested.bind(this);
this.handleNewTopic = this.handleNewTopic.bind(this);
}
handleLoginRequested(login, password) {
this.props.onLoginRequest(login, password);
}
handleNewTopic() {
this.props.onBasicNavigate('newtpk');
}
render() {
const {formatMessage} = this.props.intl;
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
let title, avatar;
if (view == 'contacts') {
title = this.props.title;
avatar = this.props.avatar ? this.props.avatar : true;
} else {
title = formatMessage(messages[view]);
avatar = false;
}
let onCancel;
if (['login', 'contacts'].indexOf(view) == -1) {
onCancel = this.props.onCancel;
}
return (
<div id="sidepanel" className={this.props.hideSelf ? 'nodisplay' : null}>
<SideNavbar
state={view}
title={title}
avatar={avatar}
myUserId={this.props.myUserId}
onSignUp={this.props.onSignUp}
onSettings={this.props.onSettings}
onNewTopic={this.handleNewTopic}
onCancel={onCancel} />
<ErrorPanel
level={this.props.errorLevel}
text={this.props.errorText}
action={this.props.errorAction}
actionText={this.props.errorActionText}
onClearError={this.props.onError} />
<LoadSpinner show={this.props.loadSpinnerVisible} />
{view === 'login' ?
<LoginView
login={this.props.login}
disabled={this.props.loginDisabled}
onLogin={this.handleLoginRequested} /> :
view === 'register' ?
<CreateAccountView
onCreateAccount={this.props.onCreateAccount}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
view === 'settings' ?
<SettingsView
transport={this.props.transport}
serverAddress={this.props.serverAddress}
onCancel={this.props.onCancel}
onUpdate={this.props.onGlobalSettings} /> :
view === 'edit' ?
<EditAccountView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
onBasicNavigate={this.props.onBasicNavigate} /> :
view === 'general' ?
<AccGeneralView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
onUpdateAccount={this.props.onUpdateAccount}
onUpdateTags={this.props.onUpdateAccountTags}
onCredAdd={this.props.onCredAdd}
onCredDelete={this.props.onCredDelete}
onCredConfirm={this.props.onCredConfirm}
onBasicNavigate={this.props.onBasicNavigate}
onError={this.props.onError} /> :
view === 'notif' ?
<AccNotificationsView
messageSounds={this.props.messageSounds}
desktopAlerts={this.props.desktopAlerts}
desktopAlertsEnabled={this.props.desktopAlertsEnabled}
incognitoMode={this.props.incognitoMode}
onTogglePushNotifications={this.props.onTogglePushNotifications}
onToggleMessageSounds={this.props.onToggleMessageSounds}
onToggleIncognitoMode={this.props.onToggleIncognitoMode} /> :
view === 'security' ?
<AccSecurityView
tinode={this.props.tinode}
onUpdateAccount={this.props.onUpdateAccount}
onLogout={this.props.onLogout}
onDeleteAccount={this.props.onDeleteAccount}
onShowAlert={this.props.onShowAlert}
onShowBlocked={this.props.onShowBlocked} /> :
view === 'support' ?
<AccSupportView
serverAddress={this.props.serverAddress}
serverVersion={this.props.serverVersion} /> :
(view === 'contacts' || view == 'archive' || view == 'blocked') ?
<ContactsView
tinode={this.props.tinode}
myUserId={this.props.myUserId}
connected={this.props.connected}
topicSelected={this.props.topicSelected}
archive={view == 'archive'}
blocked={view == 'blocked'}
chatList={this.props.chatList}
showContextMenu={this.props.showContextMenu}
onTopicSelected={this.props.onTopicSelected}
onShowArchive={this.props.onShowArchive} /> :
view === 'newtpk' ?
<NewTopicView
searchResults={this.props.searchResults}
onInitFind={this.props.onInitFind}
onSearchContacts={this.props.onSearchContacts}
onCreateTopic={this.props.onCreateTopic}
onError={this.props.onError} /> :
view === 'cred' ?
<ValidationView
credCode={this.props.credCode}
credMethod={this.props.credMethod}
onSubmit={this.props.onValidateCredentials}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
view === 'reset' ?
<PasswordResetView
onRequest={this.props.onPasswordResetRequest}
onReset={this.props.onResetPassword} /> :
null}
</div>
);
}
};
export default injectIntl(SidepanelView);
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
// ValidationView: panel for confirming credentials, like email or phone.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
phone: {
id: 'phone_dative',
defaultMessage: 'phone',
description: "Dative case of 'phone', i.e. 'phone' in 'by phone'",
},
email: {
id: 'email_dative',
defaultMessage: 'email',
description: "Dative case of 'email', i.e. 'email' in 'by email'",
}
});
class ValidationView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
code: props.credCode || ''
};
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleChange(e) {
this.setState({code: e.target.value});
}
handleKeyPress(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
} else if (e.key == 'Escape') {
this.handleCancel(e);
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.code && this.state.code.trim()) {
this.props.onSubmit(this.props.credMethod, this.state.code.trim());
}
}
handleCancel(e) {
e.preventDefault();
this.props.onCancel();
}
render() {
const { formatMessage } = this.props.intl;
const methods = {'email': formatMessage(messages.email), 'tel': formatMessage(messages.phone)};
let method = methods[this.props.credMethod] || this.props.credMethod;
return (
<div className="panel-form">
<div className="panel-form-row">
<label className="small" htmlFor="enter-confirmation-code">
<FormattedMessage id="enter_confirmation_code_prompt"
defaultMessage="Enter confirmation code sent to you by {method}:"
description="Request to enter confirmation code"
values={{method: method}} />
</label>
</div>
<div className="panel-form-row">
<FormattedMessage id="numeric_confirmation_code_prompt"
defaultMessage="Numbers only" description="Prompt for numeric conformation code">{
(numbers_only) => <input type="text" id="enter-confirmation-code"
placeholder={numbers_only}
value={this.state.code} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_confirm" defaultMessage="Confirm" description="Button [Confirm]" />
</button>
<button className="white" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" />
</button>
</div>
</div>
);
}
};
export default injectIntl(ValidationView);
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class Alert extends React.PureComponent {
render() {
return this.props.visible ?
<div className="alert-container">
<div className="alert">
<div className="title">{this.props.title}</div>
<div className="content">{this.props.content}</div>
<div className="dialog-buttons">
{this.props.onReject ?
<button className="outline" onClick={this.props.onReject}>
{this.props.reject || <FormattedMessage id="button_cancel" />}
</button>
:
null
}
<button className="blue" onClick={this.props.onConfirm}>
{this.props.confirm || <FormattedMessage id="button_ok" />}
</button>
</div>
</div>
</div>
:
null;
}
};
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import FileProgress from './file-progress.jsx';
import { bytesToHumanSize } from '../lib/strformat.js';
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
export default class Attachment extends React.Component {
constructor(props) {
super(props);
this.state = {
downloader: null,
progress: 0
};
this.downloadFile = this.downloadFile.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
downloadFile(url, filename, mimetype) {
var downloader = this.props.tinode.getLargeFileHelper();
this.setState({downloader: downloader});
downloader.download(url, filename, mimetype, (loaded) => {
this.setState({progress: loaded / this.props.size});
}).then(() => {
this.setState({downloader: null, progress: 0});
}).catch((err) => {
if (err) {
this.props.onError("Error downloading file: " + err.message, 'err');
}
this.setState({downloader: null, progress: 0});
});
}
handleCancel() {
if (this.props.uploader) {
this.props.onCancelUpload();
} else if (this.state.downloader) {
this.state.downloader.cancel();
}
}
render() {
let filename = this.props.filename || 'file_attachment';
if (filename.length > 36) {
filename = filename.substr(0, 16) + '...' + filename.substr(-16);
}
let size = this.props.size > 0 ?
<span className="small gray">({bytesToHumanSize(this.props.size)})</span> :
null;
// Detect if the download URL is relative or absolute.
// If the URL is relative use LargeFileHelper to attach authentication
// credentials to the request.
let url, helperFunc;
if (!this.props.uploader && !this.state.downloader && isUrlRelative(this.props.downloadUrl)) {
// Relative URL. Use download helper.
url = '#';
helperFunc = (e) => {
e.preventDefault();
this.downloadFile(this.props.downloadUrl, this.props.filename, this.props.mimetype);
};
} else {
url = sanitizeUrl(this.props.downloadUrl) || 'about:blank';
helperFunc = null;
}
return (
<div className="attachment">
<div><i className="material-icons big gray">insert_drive_file</i></div>
<div className="flex-column">
<div>{filename} {size}</div>
{this.props.uploader || this.state.downloader ?
<FileProgress progress={this.props.uploader ? this.props.progress : this.state.progress}
onCancel={this.handleCancel} />
:
<div><a href={url} download={this.props.filename} onClick={helperFunc} >
<i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
defaultMessage="save" description="Call to save an attachment" />
</a></div>
}
</div>
</div>
);
}
};
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
import { AVATAR_SIZE } from '../config.js';
import { imageFileScaledToBase64, makeImageUrl } from '../lib/blob-helpers.js';
export default class AvatarUpload extends React.Component {
constructor(props) {
super(props);
this.state = {
dataUrl: props.avatar
};
this.handleFileUpload = this.handleFileUpload.bind(this);
}
componentDidUpdate(prevProps) {
if (this.props.avatar != prevProps.avatar) {
this.setState({dataUrl: this.props.avatar})
}
}
handleFileUpload(e) {
imageFileScaledToBase64(e.target.files[0], AVATAR_SIZE, AVATAR_SIZE, true,
// Success
(base64bits, mime) => {
var du = makeImageUrl({data: base64bits, type: mime});
this.setState({dataUrl: du});
this.props.onImageChanged(du);
},
// Failure
(err) => {
this.props.onError(err, 'err');
});
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
render() {
// Randomize id value in case more than one AvatarUpload is shown
// at the same time.
const randId = 'file-input-avatar-' + (Math.random() + '').substr(2);
const className = 'avatar-upload' + (this.props.readOnly ? ' read-only' : '');
return (
<div className={className}>
{this.props.readOnly || !this.state.dataUrl ?
null :
<a href="#" className="clear-avatar" onClick={(e) => {e.preventDefault(); this.props.onImageChanged(null);}}>
<i className="material-icons">clear</i>
</a>}
{this.state.dataUrl ?
<img src={this.state.dataUrl} className="preview" /> :
this.props.readOnly && this.props.uid ?
<div className="avatar-box">
<LetterTile
avatar={true}
topic={this.props.uid}
title={this.props.title} />
</div>
:
<div className="blank">{AVATAR_SIZE}&times;{AVATAR_SIZE}</div>}
{this.props.readOnly ? null :
<input type="file" id={randId} className="inputfile hidden"
accept="image/*" onChange={this.handleFileUpload} />}
{this.props.readOnly ? null :
<label htmlFor={randId} className="round">
<i className="material-icons">file_upload</i>
</label>}
</div>
);
}
};
+12
View File
@@ -0,0 +1,12 @@
// The <- button to be displayed in title bars.
import React from 'react';
export default class ButtonBack extends React.PureComponent {
render() {
return (
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onBack();}}>
<i className="material-icons">arrow_back</i>
</a>
);
}
}
+214
View File
@@ -0,0 +1,214 @@
// Single message, sent or received.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Drafty } from 'tinode-sdk'
import Attachment from './attachment.jsx';
import LetterTile from './letter-tile.jsx';
import ReceivedMarker from './received-marker.jsx'
import { sanitizeImageUrl, sanitizeUrl } from '../lib/utils.js';
export default class ChatMessage extends React.Component {
constructor(props) {
super(props);
this.state = {
progress: 0
};
if (props.uploader) {
props.uploader.onProgress = this.handleProgress.bind(this);
}
this.handleImagePreview = this.handleImagePreview.bind(this);
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
}
handleImagePreview(e) {
e.preventDefault();
this.props.onImagePreview({
url: e.target.src,
filename: e.target.title,
width: e.target.dataset.width,
height: e.target.dataset.height,
size: e.target.dataset.size,
type: e.target.dataset.mime
});
}
handleFormButtonClick(e) {
e.preventDefault();
const data = {
seq: this.props.seq
};
data.resp = {};
if (e.target.dataset.name) {
data.resp[e.target.dataset.name] = e.target.dataset.val ? e.target.dataset.val :
e.target.dataset.val === undefined ? 1 : '' + e.target.dataset.val;
}
if (e.target.dataset.act == 'url') {
data.ref = sanitizeUrl(e.target.dataset.ref) || 'about:blank';
}
const text = e.target.dataset.title || 'unknown';
this.props.onFormResponse(e.target.dataset.act, text, data);
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
const menuItems = this.props.received == Tinode.MESSAGE_STATUS_FAILED ? ['menu_item_send_retry'] : [];
this.props.showContextMenu({ seq: this.props.seq, content: this.props.content,
y: e.pageY, x: e.pageX }, menuItems);
}
handleProgress(ratio) {
this.setState({progress: ratio});
}
handleCancelUpload() {
this.props.uploader.cancel();
}
render() {
const sideClass = this.props.deleted ? 'center' :
(this.props.sequence + ' ' + (this.props.response ? 'left' : 'right'));
const bubbleClass = (this.props.sequence == 'single' || this.props.sequence == 'last') ? 'bubble tip' : 'bubble';
const avatar = this.props.deleted ? null : (this.props.userAvatar || true);
const fullDisplay = (this.props.userFrom && this.props.response &&
(this.props.sequence == 'single' || this.props.sequence == 'last'));
let content = this.props.content;
const attachments = [];
if (this.props.mimeType == Drafty.getContentType() && Drafty.isValid(content)) {
Drafty.attachments(content, function(att, i) {
if (att.mime == 'application/json') {
// Don't show json objects as attachments.
// They are not meant for users.
return;
}
attachments.push(<Attachment
tinode={this.props.tinode}
downloadUrl={Drafty.getDownloadUrl(att)}
filename={att.name} uploader={Drafty.isUploading(att)}
mimetype={att.mime} size={Drafty.getEntitySize(att)}
progress={this.state.progress}
onCancelUpload={this.handleCancelUpload}
onError={this.props.onError}
key={i} />);
}, this);
content = React.createElement(React.Fragment, null, Drafty.format(content, draftyFormatter, this));
} else if (this.props.deleted) {
// Message represents a range of deleted messages.
content = <><i className="material-icons gray">block</i> <i className="gray">
<FormattedMessage id="deleted_content"
defaultMessage="content deleted" description="Shown when messages are deleted" />
</i></>
} else if (typeof content != 'string') {
content = <>
<i className="material-icons gray">error_outline</i> <i className="gray">
<FormattedMessage id="invalid_content"
defaultMessage="invalid content" description="Shown when message is unreadable" /></i>
</>
}
return (
<li className={sideClass}>
{this.props.userFrom && this.props.response ?
<div className="avatar-box">
{fullDisplay ?
<LetterTile
topic={this.props.userFrom}
title={this.props.userName}
avatar={avatar} /> :
null}
</div> :
null}
<div>
<div className={bubbleClass}>
<div className="message-content">
{content}
{attachments}
{this.props.timestamp ?
<ReceivedMarker
timestamp={this.props.timestamp}
received={this.props.received} />
: null}
</div>
{this.props.deleted ?
null :
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span>
}
</div>
{fullDisplay ?
<div className="author">
<FormattedMessage id="user_not_found" defaultMessage="Not found"
description="In place of a user's full name when the user is not found.">{
(notFound) => {return this.props.userName || <i>{notFound}</i>}
}</FormattedMessage>
</div>
: null
}
</div>
</li>
);
}
};
// Convert Drafty object to a tree of React elements.
import { BROKEN_IMAGE_SIZE, REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js';
function draftyFormatter(style, data, values, key) {
let el = Drafty.tagName(style);
if (el) {
let attr = Drafty.attrValue(style, data) || {};
attr.key = key;
switch (style) {
case 'IM':
// Additional processing for images
if (data) {
attr.className = 'inline-image';
let dim = fitImageSize(data.width, data.height,
Math.min(this.props.viewportWidth - REM_SIZE * 4, REM_SIZE * 36), REM_SIZE * 24, false);
dim = dim || {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};
attr.style = { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' };
attr.src = sanitizeImageUrl(attr.src);
if (attr.src) {
attr.onClick = this.handleImagePreview;
attr.className += ' image-clickable';
} else {
attr.src = 'img/broken_image.png';
}
}
break;
case 'BN':
// Button
attr.onClick = this.handleFormButtonClick;
let inner = React.Children.map(values, (child) => {
return typeof child == 'string' ? child : undefined;
});
if (!inner || inner.length == 0) {
inner = [attr.name]
}
// Get text which will be sent back when the button is clicked.
attr['data-title'] = inner.join('');
break;
case 'FM':
// Form
attr.className = 'bot-form';
break;
case 'FE':
// Form element formatting is dependent on element content.
break;
}
return React.createElement(el, attr, values);
} else {
return values;
}
};
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
/* BEGIN CheckBox: styled checkbox */
export default class CheckBox extends React.PureComponent {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange() {
this.props.onChange(this.props.name, !this.props.checked);
}
render() {
return (
this.props.onChange ? (
this.props.checked ?
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box</i> :
<i className="material-icons blue clickable" onClick={this.handleChange}>check_box_outline_blank</i>
) : (
this.props.checked ?
<i className="material-icons">check_box</i> :
<i className="material-icons">check_box_outline_blank</i>
)
);
}
}
/* END CheckBox */
+148
View File
@@ -0,0 +1,148 @@
import React from 'react';
import Chip from './chip.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
/* BEGIN ChipInput: group membership widget */
export default class ChipInput extends React.Component {
constructor(props) {
super(props);
this.state = ChipInput.deriveStateFromProps(props);
this.state.input = '';
this.state.focused = false;
this.handleTextInput = this.handleTextInput.bind(this);
this.removeChipAt = this.removeChipAt.bind(this);
this.handleChipCancel = this.handleChipCancel.bind(this);
this.handleFocusGained = this.handleFocusGained.bind(this);
this.handleFocusLost = this.handleFocusLost.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
static deriveStateFromProps(props) {
return {
placeholder: props.chips ? '' : props.prompt,
sortedChips: ChipInput.sortChips(props.chips, props.staticMembers),
chipIndex: ChipInput.indexChips(props.chips)
};
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.chips != this.props.chips ||
prevProps.staticMembers != this.props.staticMembers ||
prevProps.prompt != this.props.prompt) {
this.setState(ChipInput.deriveStateFromProps(this.props));
}
if (!prevState || this.props.chips.length > prevState.sortedChips.length) {
this.setState({input: ''});
}
}
// Map chip index to user name
static indexChips(chips) {
const index = {};
let count = 0;
chips.map((item) => {
index[item.user] = count;
count ++;
});
return index;
}
// Have non-removable chips appear before all other chips.
static sortChips(chips, keep) {
const required = [];
const normal = [];
chips.map((item) => {
if (keep && keep.includes(item.user)) {
required.push(item);
} else {
normal.push(item);
}
});
return required.concat(normal);
}
handleTextInput(e) {
this.setState({input: e.target.value});
if (this.props.filterFunc) {
this.props.filterFunc(e.target.value);
}
}
removeChipAt(idx) {
const removed = this.state.sortedChips[idx];
this.props.onChipRemoved(removed.user, this.state.chipIndex[removed.user]);
}
handleChipCancel(item, idx) {
this.removeChipAt(idx);
}
handleFocusGained() {
this.setState({focused: true});
}
handleFocusLost() {
this.setState({focused: false});
if (this.props.onFocusLost) {
this.props.onFocusLost(this.state.input);
}
}
handleKeyDown(e) {
if (e.key === 'Backspace') {
if (this.state.input.length == 0 && this.state.sortedChips.length > 0) {
const at = this.state.sortedChips.length - 1;
if (this.state.sortedChips[at].user !== this.props.staticMembers) {
this.removeChipAt(at);
}
}
} else if (e.key === 'Enter') {
if (this.props.onEnter) {
this.props.onEnter(this.state.input);
}
} else if (e.key === 'Escape') {
if (this.props.onCancel) {
this.props.onCancel();
}
}
}
render() {
const chips = [];
let count = 0;
const staticMembers = this.props.staticMembers || [];
this.state.sortedChips.map((item) => {
chips.push(
<Chip
onCancel={this.handleChipCancel}
avatar={makeImageUrl(item.public ? item.public.photo : null)}
title={item.public ? item.public.fn : undefined}
noAvatar={this.props.avatarDisabled}
topic={item.user}
required={staticMembers.includes(item.user)}
invalid={item.invalid}
index={count}
key={item.user} />
);
count++;
});
const className = "chip-input" + (this.state.focused ? ' focused' : '');
return (
<div className={className}>
{chips}
<input type="text"
placeholder={this.state.placeholder}
onChange={this.handleTextInput}
onFocus={this.handleFocusGained}
onBlur={this.handleFocusLost}
onKeyDown={this.handleKeyDown}
value={this.state.input}
autoFocus />
</div>
);
}
};
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
export default class Chip extends React.PureComponent {
constructor(props) {
super(props);
this.handleCancel = this.handleCancel.bind(this);
}
handleCancel(e) {
e.preventDefault();
this.props.onCancel(this.props.topic, this.props.index);
}
render() {
const title = this.props.title || this.props.topic;
const className = this.props.invalid ? 'chip invalid' : 'chip';
return (
<div className={className}>
{this.props.noAvatar ?
<span className="spacer" /> :
<div className="avatar-box">
<LetterTile
avatar={this.props.avatar || true}
topic={this.props.topic}
title={this.props.title} />
</div>
}
<span>{title}</span>
{this.props.onCancel && !this.props.required ?
<a href="#" onClick={this.handleCancel} >&times;</a>
: <span className="spacer" />}
</div>
);
}
};
+28
View File
@@ -0,0 +1,28 @@
// A panel included into a list of contacts with an action text.
import React from 'react';
import { injectIntl } from 'react-intl';
class ContactAction extends React.PureComponent {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.onAction(this.props.action);
}
render() {
const {formatMessage} = this.props.intl;
return (
<li onClick={this.handleClick} className="action">
<div className="action-text">{formatMessage(this.props.title, this.props.values)}</div>
</li>
);
}
};
export default injectIntl(ContactAction);
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
const icon_mapping = {'muted': 'notifications_off', 'banned': 'block'};
/* Contact's labels: [you], or icons [muted], [blocked] */
export default class ContactBadges extends React.PureComponent {
render() {
let badges = null;
if (this.props.badges && this.props.badges.length > 0) {
badges = [];
this.props.badges.map(function(b) {
if (b.icon) {
badges.push(<i className="material-icons as-badge" key={b.key || b.icon}>{icon_mapping[b.icon]}</i>);
} else {
const style = 'badge' + (b.color ? ' ' + b.color : '');
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
}
});
return <>{badges}</>;
}
return null;
}
};
+117
View File
@@ -0,0 +1,117 @@
// ContactList: component for showing a list of contacts,
// such as a list of group members in a group chat.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Contact from './contact.jsx';
import ContactAction from './contact-action.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
const messages = defineMessages({
badge_you: {
id: 'badge_you',
defaultMessage: 'you',
description: 'Badge for indicating the current user'
},
badge_owner: {
id: 'badge_owner',
defaultMessage: 'owner',
description: 'Badge for indicating the owner'
}
});
class ContactList extends React.Component {
render() {
const {formatMessage} = this.props.intl;
const showCheckmark = Array.isArray(this.props.topicSelected);
const contactNodes = [];
let contactsCount = 0;
if (this.props.contacts && this.props.contacts.length > 0) {
this.props.contacts.map((c) => {
if (c.action) {
// Action item
contactNodes.push(
<ContactAction
title={c.title} action={c.action} values={c.values}
key={c.action}
onAction={this.props.onAction}
/>);
} else {
// Normal contact
const key = this.props.showMode ? c.user : (c.topic || c.user);
// If filter function is provided, filter out the items
// which don't satisfy the condition.
if (this.props.filterFunc && this.props.filter) {
let content = [key];
if (c.private && c.private.comment) {
content.push(('' + c.private.comment).toLowerCase());
}
if (c.public && c.public.fn) {
content.push(('' + c.public.fn).toLowerCase());
}
if (!this.props.filterFunc(this.props.filter, content)) {
return;
}
}
const selected = showCheckmark ?
(this.props.topicSelected.indexOf(key) > -1) :
(this.props.topicSelected === key);
const badges = [];
if (this.props.showMode) {
if (key == this.props.myUserId) {
badges.push({name: formatMessage(messages.badge_you), color: 'green'});
}
if (c.acs && c.acs.isOwner()) {
badges.push({name: formatMessage(messages.badge_owner), color: 'blue'});
}
}
const comment = Array.isArray(c.private) ?
c.private.join(',') : (c.private ? c.private.comment : null);
contactNodes.push(
<Contact
title={c.public ? c.public.fn : null}
avatar={makeImageUrl(c.public ? c.public.photo : null)}
comment={comment}
unread={this.props.showUnread ? c.unread : 0}
now={c.online && this.props.connected}
acs={c.acs}
showMode={this.props.showMode}
badges={badges}
showCheckmark={showCheckmark}
selected={selected}
showOnline={this.props.showOnline}
onSelected={this.props.onTopicSelected}
showContextMenu={this.props.showContextMenu}
item={key}
index={contactNodes.length}
key={key} />
);
contactsCount ++;
}
}, this);
}
return (
<div className={this.props.noScroll ? null : "scrollable-panel"}>
{contactsCount == 0 ?
<div className="center-medium-text"
dangerouslySetInnerHTML={{__html: this.props.emptyListMessage}} />
:
null
}
{contactNodes.length > 0 ?
<ul className="contact-box">
{contactNodes}
</ul>
:
null
}
</div>
);
}
};
export default injectIntl(ContactList);
+84
View File
@@ -0,0 +1,84 @@
// A single topic or user.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import LetterTile from './letter-tile.jsx';
import ContactBadges from './contact-badges.jsx';
import UnreadBadge from './unread-badge.jsx';
export default class Contact extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
if (this.props.onSelected) {
this.props.onSelected(this.props.item, this.props.index, this.props.now, this.props.acs);
}
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.showContextMenu({ topicName: this.props.item, y: e.pageY, x: e.pageX });
}
render() {
let title = this.props.title;
if (!title) {
title = <i><FormattedMessage id="unnamed_topic" /></i>;
} else if (title.length > 30) {
// FIXME: this is probably wrong for RTL languages.
title = title.substring(0, 28) + '...';
}
const online = this.props.now ? 'online' : 'offline';
const avatar = this.props.avatar ? this.props.avatar : true;
const badges = this.props.badges ? this.props.badges.slice() : [];
const icon_badges = [];
if (this.props.acs) {
if (this.props.showMode) {
badges.push({name: this.props.acs.getMode(), key: 'mode'});
}
if (this.props.acs.isMuted()) {
icon_badges.push({icon: 'muted'});
}
if (!this.props.acs.isJoiner()) {
icon_badges.push({icon: 'banned'});
}
}
return (
<li className={!this.props.showCheckmark && this.props.selected ? "selected" : null}
onClick={this.handleClick}>
<div className="avatar-box">
<LetterTile
avatar={avatar}
title={this.props.title}
topic={this.props.item} />
{this.props.showOnline ? <span className={online} /> :
(this.props.showCheckmark && this.props.selected ?
<i className="checkmark material-icons">check_circle</i>
: null)}
</div>
<div className="text-box">
<div><span className="contact-title">{title}</span>
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} />
</div>
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
<span><ContactBadges badges={badges} /></span>
</div>
{this.props.showContextMenu ?
<span className="menuTrigger">
<a href="#" onClick={this.handleContextClick}>
<i className="material-icons">expand_more</i>
</a>
</span> : null}
</li>
);
}
};
+419
View File
@@ -0,0 +1,419 @@
// Context Menu: popup/dropdown menu.
import React from 'react';
import ReactDOM from 'react-dom';
import { injectIntl, defineMessages } from 'react-intl';
import { REM_SIZE } from '../config.js';
import HashNavigation from '../lib/navigation.js';
const messages = defineMessages({
info: {
id: 'menu_item_info',
defaultMessage: 'Info',
description: 'Show extended topic information'
},
clear_messages: {
id: 'menu_item_clear_messages',
defaultMessage: 'Clear messages',
description: 'Delete all messages'
},
clear_for_all: {
id: 'menu_item_clear_messages_for_all',
defaultMessage: 'Clear for All',
description: 'Delete all message(s) for all members'
},
'delete': {
id: 'menu_item_delete',
defaultMessage: 'Delete',
description: 'Delete selected messages'
},
delete_for_all: {
id: 'menu_item_delete_for_all',
defaultMessage: 'Delete for All',
description: 'Delete selected message(s) for all members'
},
send_retry: {
id: 'menu_item_send_retry',
defaultMessage: 'Retry',
description: 'Retry sending message'
},
mute: {
id: 'menu_item_mute',
defaultMessage: 'Mute',
description: 'Turn off notifications'
},
unmute: {
id: 'menu_item_unmute',
defaultMessage: 'Unmute',
description: 'Turn notifications on'
},
topic_delete: {
id: 'menu_item_delete_topic',
defaultMessage: 'Delete',
description: 'Delete entire topic'
},
topic_delete_warning: {
id: 'topic_delete_warning',
defaultMessage: 'Are you sure you want to delete this conversation?',
description: 'Alert warning when deleting entire topic'
},
unblock: {
id: 'menu_item_unblock',
defaultMessage: 'Unblock',
description: 'Unblock topic or user'
},
// Generic string suitable for either topic or user.
block: {
id: 'menu_item_block',
defaultMessage: 'Block',
description: 'Block topic or user'
},
topic_block_warning: {
id: 'topic_block_warning',
defaultMessage: 'Are you sure you want to block this conversation?',
description: 'Alert warning when blocking a topic.'
},
member_delete: {
id: 'menu_item_member_delete',
defaultMessage: 'Remove',
description: 'Remove user from topic'
},
archive: {
id: 'menu_item_archive_topic',
defaultMessage: 'Archive',
description: 'Move topic from the list of active chats to archive'
}
});
class ContextMenu extends React.Component {
constructor(props) {
super(props);
const {formatMessage} = props.intl;
this.handlePageClick = this.handlePageClick.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleClick = this.handleClick.bind(this);
// Preconfigured menu items.
this.MenuItems = {
'topic_info': {
id: 'topic_info',
title: formatMessage(messages.info),
handler: null
},
'messages_clear': {
id: 'messages_clear',
title: formatMessage(messages.clear_messages),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage({id: 'menu_item_clear_messages'}), // title
formatMessage({id: 'clear_messages_warning'}), // content
(() => { this.deleteMessages(true, false, params, errorHandler); }), // onConfirm
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'messages_clear_hard': {
id: 'messages_clear_hard',
title: formatMessage(messages.clear_for_all),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage({id: 'menu_item_clear_messages_for_all'}), // title
formatMessage({id: 'delete_messages_warning'}), // content
(() => { return this.deleteMessages(true, true, params, errorHandler); }),
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'message_delete': {
id: 'message_delete',
title: formatMessage(messages.delete),
handler: (params, errorHandler) => {
return this.deleteMessages(false, false, params, errorHandler);
}
},
'message_delete_hard': {
id: 'message_delete_hard',
title: formatMessage(messages.delete_for_all),
handler: (params, errorHandler) => {
return this.deleteMessages(false, true, params, errorHandler);
}
},
'menu_item_send_retry': {
id: 'menu_item_send_retry',
title: formatMessage(messages.send_retry),
handler: (params, errorHandler) => {
return this.retryMessage(params, errorHandler);
}
},
'topic_unmute': {
id: 'topic_unmute',
title: formatMessage(messages.unmute),
handler: this.topicPermissionSetter.bind(this, '+P')
},
'topic_mute': {
id: 'topic_mute',
title: formatMessage(messages.mute),
handler: this.topicPermissionSetter.bind(this, '-P')
},
'topic_unblock': {
id: 'topic_unblock',
title: formatMessage(messages.unblock),
handler: this.topicPermissionSetter.bind(this, '+JP')
},
'topic_block': {
id: 'topic_block',
title: formatMessage(messages.block),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage({id: 'menu_item_block'}), // title
formatMessage(messages.topic_block_warning), // content
(() => {
return this.topicPermissionSetter('-JP', params, errorHandler).then((ctrl) => {
this.props.onTopicRemoved(params.topicName);
return ctrl;
});
}),
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'topic_delete': {
id: 'topic_delete',
title: formatMessage(messages.topic_delete),
handler: (params, errorHandler) => {
return props.onShowAlert(
formatMessage({id: 'menu_item_delete_topic'}), // title
formatMessage(messages.topic_delete_warning), // content
(() => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
return topic.delTopic(true).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}),
null, // "OK"
true, // Show Reject button
null // "Cancel"
);
}
},
'topic_archive': {
id: 'topic_archive',
title: formatMessage(messages.archive),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
return topic.archive(true).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
// menu_item_edit_permissions is defined elsewhere.
'permissions': {
id: 'permissions',
title: formatMessage({id: 'menu_item_edit_permissions'}),
handler: null
},
'member_delete': {
id: 'member_delete',
title: formatMessage(messages.member_delete),
handler: (params, errorHandler) => {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic || !params.user) {
console.log("Topic or user not found: '" + params.topicName + "', '" + params.user + "'");
return;
}
return topic.delSubscription(params.user).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
},
'member_mute': {
id: 'member_mute',
title: formatMessage(messages.mute),
handler: this.topicPermissionSetter.bind(this, '-P')
},
'member_unmute': {
id: 'member_unmute',
title: formatMessage(messages.unmute),
handler: this.topicPermissionSetter.bind(this, '+P')
},
'member_block': {
id: 'member_block',
title: formatMessage(messages.block),
handler: this.topicPermissionSetter.bind(this, '-JP')
},
'member_unblock': {
id: 'member_unblock',
title: formatMessage(messages.unblock),
handler: this.topicPermissionSetter.bind(this, '+JP')
},
};
}
componentDidMount() {
document.addEventListener('mousedown', this.handlePageClick, false);
document.addEventListener('keyup', this.handleEscapeKey, false);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.handlePageClick, false);
document.removeEventListener('keyup', this.handleEscapeKey, false);
}
handlePageClick(e) {
if (ReactDOM.findDOMNode(this).contains(e.target)) {
return;
}
e.preventDefault();
e.stopPropagation();
this.props.hide();
}
handleEscapeKey(e) {
if (e.keyCode === 27) {
this.props.hide();
}
}
handleClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.hide();
let item = this.props.items[e.currentTarget.dataset.id];
if (typeof item == 'string') {
item = this.MenuItems[item];
}
if (!item) {
console.log("Invalid menu item ID", e.currentTarget.dataset.id);
} else {
this.props.onAction(
item.id,
item.handler(this.props.params, this.props.onError),
this.props.params);
}
}
// Menu Actions
deleteMessages(all, hard, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found: ", params.topicName);
return;
}
// We don't know if the message is still pending (e.g. attachment is being uploaded),
// so try cancelling first. No harm if we can't cancel.
// The message can be cancelled if transmission to the server has not
// started yet or if the message send has failed.
if (!all && topic.cancelSend(params.seq)) {
return;
}
// Can't cancel. Delete instead.
const promise = all ?
topic.delMessagesAll(hard) :
topic.delMessagesList([params.seq], hard);
return promise.catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
// Retries sending failed message.
retryMessage(params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
// Remove the existing message entry.
if (!topic || !topic.flushMessage(params.seq)) {
return;
}
const msg = topic.createMessage(params.content, false);
return topic.publishDraft(msg).catch((err) => {
if (errorHandler) {
errorHandler(err.message, 'err');
}
});
}
// Function is used by context menu to set permissions.
topicPermissionSetter(mode, params, errorHandler) {
const topic = this.props.tinode.getTopic(params.topicName);
if (!topic) {
console.log("Topic not found", params.topicName);
return;
}
let result = topic.updateMode(params.user, mode);
if (errorHandler) {
result = result.catch((err) => {
errorHandler(err.message, 'err');
});
}
return result;
}
render() {
let count = 0;
let menu = [];
this.props.items.map((item) => {
if (typeof item == 'string') {
item = this.MenuItems[item];
}
if (item && item.title) {
menu.push(
item.title == '-' ?
<li className="separator" key={count} />
:
<li onClick={this.handleClick} data-id={count} key={count}>{item.title}</li>
);
}
count++;
});
// Ensure that menu is inside the app-container.
const hSize = 12 * REM_SIZE;
const vSize = REM_SIZE * (0.7 + menu.length * 2.5);
const left = (this.props.bounds.right - this.props.clickAt.x < hSize) ?
(this.props.clickAt.x - this.props.bounds.left - hSize) :
(this.props.clickAt.x - this.props.bounds.left);
const top = (this.props.bounds.bottom - this.props.clickAt.y < vSize) ?
(this.props.clickAt.y - this.props.bounds.top - vSize) :
(this.props.clickAt.y - this.props.bounds.top);
const position = {
left: left + 'px',
top: top + 'px'
};
return (
<ul className="menu" style={position}>
{menu}
</ul>
);
}
}
export default injectIntl(ContextMenu);
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import SendMessage from '../widgets/send-message.jsx';
import { bytesToHumanSize } from '../lib/strformat.js';
// Get material icon name from mime type.
function iconFromMime(mime) {
// If more icons become available in material icons, add them to this mime-to-icon mapping.
const mimeToIcon = {default: 'insert_drive_file', image: 'image', text: 'description', video: 'theaters'};
return mimeToIcon[mime] || mimeToIcon[(mime || '').split('/')[0]] || mimeToIcon['default'];
}
export default class DocPreview extends React.PureComponent {
constructor(props) {
super(props);
this.handleSendDoc = this.handleSendDoc.bind(this);
}
handleSendDoc(caption) {
this.props.onClose();
this.props.onSendMessage(this.props.content.file);
}
render() {
if (!this.props.content) {
return null;
}
return (
<div id="image-preview">
<div id="image-preview-caption-panel">
<span>{this.props.content.filename}</span>
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container">
<div className="flex-column narrow">
<i className="material-icons gray">{iconFromMime(this.props.content.type)}</i>
<div><b><FormattedMessage id="label_content_type" /></b> {this.props.content.type || 'application/octet-stream'}</div>
<div><b><FormattedMessage id="label_size" /></b> {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>
<SendMessage
noInput={true}
onSendMessage={this.handleSendDoc}
onError={this.props.onError} />
</div>
);
}
};
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import MenuCancel from './menu-cancel.jsx';
export default class ErrorPanel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
show: false,
};
this.hide = this.hide.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.level !== this.props.level) {
this.setState({
show: !(!this.props.level)
});
}
}
hide() {
this.setState({show: false});
if (this.props.onClearError) {
this.props.onClearError();
}
}
render() {
const icons = {err: 'error', warn: 'warning', info: 'info'}
const level = icons[this.props.level] || '';
const className = 'info-box ' + level;
return (
<div className={className}>
<div className="icon"><i className="material-icons">{level}</i></div>
<span>
{this.props.text}
{this.props.action ?
<>
&#32;<a href="#"
style={{ whiteSpace: 'nowrap' }}
onClick={(e) => {e.preventDefault(); this.props.action();}}>
{this.props.actionText}
</a>
</>
: null}
</span>
<div className="cancel"><MenuCancel onCancel={this.hide} /></div>
</div>
);
}
};
+22
View File
@@ -0,0 +1,22 @@
// File uload/download progress indicator with a cancel inside.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class FileProgress extends React.PureComponent {
render() {
return (
<div className="uploader">
<div><span style={{width: (this.props.progress * 100) + "%"}}></span></div>
{this.props.progress < 0.999 ?
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancel();}}>
<i className="material-icons">close</i> <FormattedMessage id="action_cancel"
defaultMessage="cancel" description="Call to action [cancel]" />
</a>
:
<FormattedMessage id="upload_finishing" defaultMessage="finishing..."
description="Notification that upload is finishing" />
}
</div>
);
}
}
+203
View File
@@ -0,0 +1,203 @@
// GroupMembers: control for managing a list of group members.
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import ChipInput from './chip-input.jsx';
import ContactList from './contact-list.jsx';
const messages = defineMessages({
no_contacts: {
id: 'no_contacts',
defaultMessage: 'You have no contacts :-(',
description: 'Shown in ContactsView when the user has no contacts'
},
contacts_not_found_short: {
id: 'contacts_not_found_short',
defaultMessage: "No contacts match ''{query}''",
description: 'Shown in ContactsView when search returned no results'
}
});
class GroupManager extends React.Component {
constructor(props) {
super(props);
this.state = {
// Array of topic members
members: props.members,
index: GroupManager.indexMembers(props.members),
staticMembers: GroupManager.staticMembers(props.members, props.keepInitialMembers, props.requiredMember),
contactFilter: '',
noContactsMessage: props.intl.formatMessage(messages.no_contacts),
selectedContacts: GroupManager.selectedContacts(props.members)
};
this.handleContactSelected = this.handleContactSelected.bind(this);
this.handleMemberRemoved = this.handleMemberRemoved.bind(this);
this.handleContactFilter = this.handleContactFilter.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
static indexMembers(members) {
let index = {};
members.map((m) => {
index[m.user] = {delta: 0, present: true}; // Delta: 0 unchanged, +1 added, -1 removed
});
return index;
}
static staticMembers(members, keepInitial, requiredMember) {
let stat = [];
members.map((m) => {
if (keepInitial || m.user == requiredMember) {
stat.push(m.user);
}
});
return stat;
}
static selectedContacts(members) {
let sel = [];
members.map((m) => {
sel.push(m.user);
});
return sel;
}
handleContactSelected(userId, index) {
let status = this.state.index[userId];
if (status) {
if (status.present) {
// Prevent duplicate members
return;
}
status.delta += 1;
status.present = true;
} else {
status = {delta: 1, present: true};
}
let m = this.state.members.slice();
m.push(this.props.contacts[index]);
const sel = GroupManager.selectedContacts(m);
const i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleMemberRemoved(userId, index) {
const status = this.state.index[userId];
if (!status || !status.present) {
return;
}
status.present = false;
status.delta -= 1;
let m = this.state.members.slice();
m.splice(index, 1);
const sel = GroupManager.selectedContacts(m);
const i = this.state.index;
i[userId] = status;
this.setState({members: m, index: i, selectedContacts: sel});
}
handleContactFilter(val) {
const {formatMessage} = this.props.intl;
const msg = !val ?
formatMessage(messages.no_contacts) :
formatMessage(messages.contacts_not_found_short, {query: val});
this.setState({contactFilter: val, noContactsMessage: msg});
}
static doContactFiltering(filter, values) {
if (filter) {
for (let i=0; i<values.length; i++) {
if (values[i].indexOf(filter) >= 0) {
return true;
}
}
return false;
}
return true;
}
handleSubmit() {
var instance = this;
var members = [];
var added = [];
var removed = [];
var keys = Object.keys(this.state.index);
keys.map(function(k) {
if (instance.state.index[k].present) {
members.push(k);
}
if (instance.state.index[k].delta > 0) {
added.push(k);
} else if (instance.state.index[k].delta < 0) {
removed.push(k);
}
});
this.props.onSubmit(members, added, removed);
}
handleCancel() {
this.props.onCancel();
}
render() {
const {formatMessage} = this.props.intl;
return (
<div id="group-manager">
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="title_group_members" defaultMessage="Group Members" description="Section title" />
</label>
</div>
<div className="panel-form-row">
<ChipInput
chips={this.state.members}
staticMembers={this.state.staticMembers}
prompt="add members"
filterFunc={this.handleContactFilter}
onChipRemoved={this.handleMemberRemoved} />
</div>
<div className="panel-form-row">
<label className="small">
<FormattedMessage id="title_all_contacts" defaultMessage="All Contacts"
description="Section title [All Contacts]" />
</label>
</div>
<ContactList
contacts={this.props.contacts}
myUserId={this.props.myUserId}
topicSelected={this.state.selectedContacts}
filter={this.state.contactFilter}
filterFunc={GroupManager.doContactFiltering}
emptyListMessage={this.state.noContactsMessage}
showOnline={false}
showUnread={false}
onTopicSelected={this.handleContactSelected} />
<div id="group-manager-buttons" className="panel-form-row">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defaultMessage="OK" description="Button [OK]" />
</button>
<button className="white" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defaultMessage="Cancel" description="Button [Cancel]" />
</button>
</div>
</div>
);
}
};
export default injectIntl(GroupManager);
+43
View File
@@ -0,0 +1,43 @@
// GroupSubs: a list of group subscribers currently online.
// Show in the topic title bar
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { MAX_ONLINE_IN_TOPIC } from '../config.js'
import LetterTile from './letter-tile.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
export default class GroupSubs extends React.Component {
constructor(props) {
super(props);
}
render() {
const usersOnline = [];
const totalCount = (this.props.subscribers || []).length;
const countToShow = Math.min(MAX_ONLINE_IN_TOPIC, totalCount);
(this.props.subscribers || []).some((sub) => {
usersOnline.push(
<div className="avatar-box" key={sub.user}>
<LetterTile
topic={sub.user}
avatar={makeImageUrl(sub.public ? sub.public.photo : null) || true}
title={sub.public ? sub.public.fn : null} />
</div>
);
return usersOnline.length == countToShow;
});
return (
<div id="topic-users">{usersOnline} {totalCount > countToShow ?
<span>
<FormattedMessage id="more_online_members" defaultMessage="+{overflow} more"
description="Shown in MessagesView title bar when the number of online subscribers exceeds MAX_ONLINE_IN_TOPIC"
values={{ overflow: (totalCount - countToShow) }} />
</span> : null}
</div>
);
}
};
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import { KNOWN_HOSTS } from '../config.js';
/* Combobox for selecting host name */
export default class HostSelector extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
hostName: props.serverAddress,
changed: false
};
this.handleHostNameChange = this.handleHostNameChange.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
}
handleHostNameChange(e) {
this.setState({hostName: e.target.value, changed: true});
}
handleEditingFinished() {
if (this.state.changed) {
this.setState({changed: false});
this.props.onServerAddressChange(this.state.hostName.trim());
}
}
render() {
var hostOptions = [];
for (let key in KNOWN_HOSTS) {
let item = KNOWN_HOSTS[key];
hostOptions.push(
<option key={item} value={item} />
);
}
return (
<div className="panel-form-row">
<input type="search" id="host-name" placeholder={this.props.hostName} list="known-hosts"
className="quoted" value={this.state.hostName} onChange={this.handleHostNameChange}
onBlur={this.handleEditingFinished} required />
<datalist id="known-hosts">
{hostOptions}
</datalist>
</div>
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import SendMessage from '../widgets/send-message.jsx';
import { REM_SIZE } from '../config.js';
import { fitImageSize } from '../lib/blob-helpers.js';
import { bytesToHumanSize } from '../lib/strformat.js';
export default class ImagePreview extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
width: 0,
height: 0
};
this.handleSendImage = this.handleSendImage.bind(this);
}
assignWidth(node) {
if (node && !this.state.width) {
const bounds = node.getBoundingClientRect();
this.setState({
width: bounds.width | 0,
height: bounds.height | 0
});
}
}
handleSendImage(caption) {
this.props.onClose();
this.props.onSendMessage(caption, this.props.content.type, this.props.content.bits,
this.props.content.width, this.props.content.height, this.props.content.filename);
}
render() {
if (!this.props.content) {
return null;
}
const dim = fitImageSize(this.props.content.width, this.props.content.height,
this.state.width, this.state.height, false);
const size = dim ? { width: dim.dstWidth + 'px', height: dim.dstHeight + 'px' } :
((this.props.content.width > this.props.content.height) ? {width: '100%'} : {height: '100%'});
size.maxWidth = '100%';
size.maxHeight = '100%';
let filename = this.props.content.filename;
// Averate font aspect ratio is ~0.5; File name takes 1/3 of the viewport width.
const maxlength = Math.max(((this.state.width / REM_SIZE / 1.5) | 0) - 2, 12);
if (filename.length > maxlength) {
filename = filename.slice(0, maxlength/2 - 1) + '…' + filename.slice(1 - maxlength/2);
}
const width = this.props.content.width || '-';
const height = this.props.content.height || '-';
return (
<div id="image-preview">
<div id="image-preview-caption-panel">
{!this.props.onSendMessage ?
<a href={this.props.content.url} download={this.props.content.filename}>
<i className="material-icons">file_download</i> <FormattedMessage
id="download_action" defaultMessage="download" description="Call to action [download]" />
</a>
:
<span>{this.props.content.filename}</span>
}
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onClose();}}><i className="material-icons gray">close</i></a>
</div>
<div id="image-preview-container" ref={(node) => this.assignWidth(node)}>
<img src={this.props.content.url} style={size} />
</div>
{this.props.onSendMessage ?
<SendMessage
messagePrompt="add_image_caption"
acceptBlank={true}
onSendMessage={this.handleSendImage}
onError={this.props.onError} />
:
<div id="image-preview-footer">
<div>
<div><b><FormattedMessage id="label_file_name" defaultMessage="File name:"
description="Label for a file name" /></b></div>
<div><span title={this.props.content.filename}>{filename ? filename : '-'}</span></div>
</div>
<div>
<div><b><FormattedMessage id="label_content_type" defaultMessage="Content type:"
description="Label for file content type (mime)" /></b></div>
<div>{this.props.content.type}</div>
</div>
<div>
<div><b><FormattedMessage id="label_size" defaultMessage="Size:"
description="Label for file size" /></b></div>
<div>{width} &times; {height} px; {bytesToHumanSize(this.props.content.size)}</div>
</div>
</div>}
</div>
);
}
};
+120
View File
@@ -0,0 +1,120 @@
// In-place text editor. Shows text with an icon which toggles it into an input field.
import React from 'react';
import ReactDOM from 'react-dom';
import VisiblePassword from './visible-password.jsx';
export default class InPlaceEdit extends React.Component {
constructor(props) {
super(props);
this.state = {
active: props.active,
initialValue: props.value || '',
value: props.value || ''
};
this.handeTextChange = this.handeTextChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleStartEditing = this.handleStartEditing.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
this.handlePasswordFinished = this.handlePasswordFinished.bind(this);
}
componentDidUpdate(prevProps, prevState) {
// If text has changed while in read mode, update text and discard changes.
// Ignore update if in edit mode.
const newValue = this.props.value || '';
if (prevState.initialValue != newValue && !prevState.active) {
this.setState({
initialValue: newValue,
value: newValue
});
}
}
handeTextChange(e) {
this.setState({value: e.target.value});
}
handleKeyDown(e) {
if (e.keyCode === 27) {
// Escape pressed
this.setState({value: this.props.value, active: false});
} else if (e.keyCode === 13) {
// Enter pressed
this.handleEditingFinished(e);
}
}
handleStartEditing() {
if (!this.props.readOnly) {
ReactDOM.findDOMNode(this).focus();
this.setState({active: true});
}
}
handleEditingFinished(event) {
if (this.props.required && !event.target.checkValidity()) {
// Empty input
this.setState({value: this.props.value, active: false});
return;
}
this.setState({active: false});
let value = this.state.value.trim();
if ((value || this.props.value) && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
handlePasswordFinished(value) {
this.setState({active: false});
if (value && (value !== this.props.value)) {
this.props.onFinished(value);
}
}
render() {
if (this.state.active) {
var fieldType = this.props.type || 'text';
} else {
var spanText = this.props.type == 'password' ? '••••••••' : this.state.value;
var spanClass = 'in-place-edit' +
(this.props.readOnly ? ' disabled' : '');
if (!spanText) {
spanText = this.props.placeholder;
spanClass += ' placeholder';
}
if (spanText.length > 20) {
// FIXME: this is wrong for RTL languages.
spanText = spanText.substring(0, 19) + '...';
}
}
return (
this.state.active ?
(fieldType == 'password' ?
<VisiblePassword
value={this.state.value}
placeholder={this.props.placeholder}
required={this.props.required ? 'required' : ''}
autoComplete={this.props.autoComplete}
autoFocus={true}
onFinished={this.handlePasswordFinished}/>
:
<input type={fieldType}
value={this.state.value}
placeholder={this.props.placeholder}
required={this.props.required ? 'required' : ''}
autoComplete={this.props.autoComplete}
autoFocus
onChange={this.handeTextChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleEditingFinished} />
)
:
<span className={spanClass} onClick={this.handleStartEditing}>
<span className="content">{spanText}</span>
</span>
);
}
};
+42
View File
@@ -0,0 +1,42 @@
// A single topic or user.
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default class Invitation extends React.PureComponent {
constructor(props) {
super(props);
this.handleButtonAction = this.handleButtonAction.bind(this);
}
handleButtonAction(evt, data) {
evt.preventDefault();
this.props.onAction(data);
}
render() {
return (
<div className="accept-invite-panel">
<div className="title">
<FormattedMessage id="chat_invitation"
defaultMessage="You are invited to start a new chat. What would you like to do?"
description="New chat invitation message: [Accept] [Ignore] [Block]." />
</div>
<div className="footer">
<button className="blue" onClick={event => { this.handleButtonAction(event, "accept"); }}>
<FormattedMessage id="chat_invitation_accept"
defaultMessage="Accept" description="Action [Accept] for chat invitation." />
</button>
<button className="white" onClick={event => { this.handleButtonAction(event, "delete"); }}>
<FormattedMessage id="chat_invitation_ignore"
defaultMessage="Ignore" description="Action [Ignore] for chat invitation." />
</button>
<button className="white" onClick={event => { this.handleButtonAction(event, "block"); }}>
<FormattedMessage id="chat_invitation_block"
defaultMessage="Block" description="Action [Block] for chat invitation." />
</button>
</div>
</div>
);
}
};
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import Tinode from 'tinode-sdk';
import { stringHash } from '../lib/strformat.js';
export default class LetterTile extends React.PureComponent {
render() {
let avatar;
if (this.props.avatar === true) {
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
const iconColor = (isGroup ? 'light-color' : 'dark-color')
+ (Math.abs(stringHash(this.props.topic)) % 16);
if (this.props.topic && this.props.title && this.props.title.trim()) {
const letter = this.props.title.trim().charAt(0);
const className = 'lettertile ' + iconColor;
avatar = (<div className={className}><div>{letter}</div></div>)
} else {
const className = 'material-icons ' + iconColor;
avatar = isGroup ?
<i className={className}>group</i> : <i className={className}>person</i>;
}
} else if (this.props.avatar) {
// If avatar image is invalid, show a placeholder.
avatar = <img className="avatar" alt="avatar" src={this.props.avatar}
onError={(e)=>{e.target.onerror = null; e.target.src="/img/broken_image.png"}} />;
} else {
avatar = null;
}
return avatar;
}
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
export default class LoadSpinner extends React.PureComponent {
render() {
return (this.props.show ?
<div className="load-spinner-box"><div className="loader-spinner"></div></div> : null);
}
}
+12
View File
@@ -0,0 +1,12 @@
// The X menu to be displayed in title bars.
import React from 'react';
export default class MenuCancel extends React.PureComponent {
render() {
return (
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onCancel();}}>
<i className="material-icons">close</i>
</a>
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
export default class MenuContacts extends React.PureComponent {
render() {
return (
<div>
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onNewTopic();}}><i className="material-icons">chat</i></a>
&nbsp;
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSettings();}}><i className="material-icons">settings</i></a>
</div>
);
}
};
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
export default class MenuStart extends React.PureComponent {
render() {
return (
<div>
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSignUp();}}><i className="material-icons">person_add</i></a>
&nbsp;
<a href="#" onClick={(e) => {e.preventDefault(); this.props.onSettings();}}><i className="material-icons">settings</i></a>
</div>
);
}
};
+29
View File
@@ -0,0 +1,29 @@
// Toggle [Title text >] -> [Title text v]
import React from 'react';
export default class MoreButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
open: props.open
};
this.handleToggle = this.handleToggle.bind(this);
}
handleToggle() {
const open = !this.state.open;
this.setState({open: open});
if (this.props.onToggle) {
this.props.onToggle(open);
}
}
render() {
return (<label className="small clean-clickable" onClick={this.handleToggle}>{this.props.title}...
{this.state.open ? <i className="material-icons">expand_more</i> :
<i className="material-icons">chevron_right</i>}
</label>);
}
}
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
invalid_id: {
id: 'error_invalid_id',
defaultMessage: 'Invalid ID',
description: 'Error message'
}
});
class NewTopicById extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
groupId: '',
};
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(e) {
this.setState({groupId: e.target.value});
}
handleKeyPress(e) {
if (e.key === 'Enter') {
this.handleSubmit(e);
}
}
handleSubmit(e) {
e.preventDefault();
if (this.state.groupId) {
var name = this.state.groupId.trim();
if (name.length > 3 && (name.substr(0, 3) == 'usr' || name.substr(0, 3) == 'grp')) {
this.props.onSubmit(name);
} else {
this.props.onError(this.props.intl.formatMessage(messages.invalid_id), 'err');
}
}
}
render() {
return (
<div className="panel-form">
<div className="panel-form-row">
<FormattedMessage id="group_user_id_prompt" defaultMessage="Group or User ID"
description="Prompt for entering user or group ID">{
(prompt) => <input type="text" placeholder={prompt}
value={this.state.groupId} onChange={this.handleChange}
onKeyPress={this.handleKeyPress} required />
}</FormattedMessage>
</div>
<div className="dialog-buttons">
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_subscribe" defaultMessage="Subscribe"
description="Button [Subscribe]" />
</button>
</div>
</div>
);
}
};
export default injectIntl(NewTopicById);
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import AvatarUpload from './avatar-upload.jsx';
import TagManager from './tag-manager.jsx';
import { MAX_TITLE_LENGTH } from '../config.js';
export default class NewTopicGroup extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
fn: '', // full/formatted name
private: '',
imageDataUrl: null,
tags: []
};
this.handleFnChange = this.handleFnChange.bind(this);
this.handlePrivateChange = this.handlePrivateChange.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleTagsChanged = this.handleTagsChanged.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleFnChange(e) {
this.setState({fn: e.target.value});
}
handlePrivateChange(e) {
this.setState({private: e.target.value});
}
handleImageChanged(img) {
this.setState({imageDataUrl: img});
}
handleTagsChanged(tags) {
this.setState({tags: tags});
}
handleSubmit(e) {
e.preventDefault();
const fn = this.state.fn.trim().substring(0, MAX_TITLE_LENGTH);
const comment = this.state.private.trim().substring(0, MAX_TITLE_LENGTH);
if (fn) {
this.props.onSubmit(fn, this.state.imageDataUrl, comment, this.state.tags);
}
}
render() {
var submitClasses = 'blue';
if (this.props.disabled) {
submitClasses += ' disabled';
}
return (
<form className="panel-form" onSubmit={this.handleSubmit}>
<div className="panel-form-row">
<div className="panel-form-column">
<label className="small" htmlFor="new-topic-fn">
<FormattedMessage id="label_topic_name" />
</label>
<FormattedMessage id="topic_name_editing_placeholder" defaultMessage="Freeform name of the group"
description="Prompt for entering topic name">{
(placeholder) => <input type="text" id="new-topic-fn" placeholder={placeholder}
value={this.state.fn} onChange={this.handleFnChange} autoFocus required />
}</FormattedMessage>
<br />
<label className="small" htmlFor="new-topic-priv">
<FormattedMessage id="label_private" />
</label>
<FormattedMessage id="private_editing_placeholder">{
(placeholder) => <input type="text" id="new-topic-priv" placeholder={placeholder}
value={this.state.private} onChange={this.handlePrivateChange} />
}</FormattedMessage>
</div>
<AvatarUpload
onError={this.props.onError}
onImageChanged={this.handleImageChanged} />
</div>
<FormattedMessage id="title_tag_manager">{
(title) => <TagManager
tags={this.state.tags}
activated={true}
onTagsChanged={this.handleTagsChanged}
title={title} />
}</FormattedMessage>
<div className="dialog-buttons">
<button className={submitClasses}>
<FormattedMessage id="button_create" defaultMessage="Create"
description="Button [Create]" />
</button>
</div>
</form>
);
}
};
+165
View File
@@ -0,0 +1,165 @@
// PermissionsEditor: Component for editing permissions
// <PermissionsEditor mode="JWROD" skip="O" onChange={this.handleCheckboxTest} />
import React from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import CheckBox from './checkbox.jsx';
import Contact from './contact.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
// Translatable permission names.
const messages = defineMessages({
'joiner': {
id: 'permission_join',
defaultMessage: "Join ({val})",
description: 'Name of J permission'
},
'reader': {
id: 'permission_read',
defaultMessage: "Read ({val})",
description: 'Name of R permission'
},
'writer': {
id: 'permission_write',
defaultMessage: "Write ({val})",
description: 'Name of W permission'
},
'preser': {
id: 'permission_pres',
defaultMessage: "Get notified ({val})",
description: 'Name of P permission'
},
'approver': {
id: 'permission_admin',
defaultMessage: "Approve ({val})",
description: 'Name of A permission'
},
'sharer': {
id: 'permission_share',
defaultMessage: "Share ({val})",
description: 'Name of S permission'
},
'deleter': {
id: 'permission_delete',
defaultMessage: "Delete ({val})",
description: 'Name of D permission'
},
'owner': {
id: 'permission_owner',
defaultMessage: "Owner ({val})",
description: 'Name of O permission'
}
});
class PermissionsEditor extends React.Component {
constructor(props) {
super(props);
this.state = {
mode: (props.mode || '').replace('N', '')
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleChange(val) {
let mode = this.state.mode;
let idx = mode.indexOf(val);
if (idx == -1) {
mode += val;
} else {
mode = mode.replace(val, '');
}
this.setState({mode: mode});
}
handleSubmit() {
// Normalize string, otherwise cannot check if mode has changed.
var mode = (this.state.mode || 'N').split('').sort().join('');
var before = (this.props.mode || 'N').split('').sort().join('')
if (mode !== before) {
this.props.onSubmit(mode);
} else {
this.props.onCancel();
}
}
handleCancel() {
this.props.onCancel();
}
render() {
const {formatMessage} = this.props.intl;
const all = 'JRWPASDO';
const names = {
'J': formatMessage(messages.joiner, {val: 'J'}),
'R': formatMessage(messages.reader, {val: 'R'}),
'W': formatMessage(messages.writer, {val: 'W'}),
'P': formatMessage(messages.preser, {val: 'P'}),
'A': formatMessage(messages.approver, {val: 'A'}),
'S': formatMessage(messages.sharer, {val: 'S'}),
'D': formatMessage(messages.deleter, {val: 'D'}),
'O': formatMessage(messages.owner, {val: 'O'})
};
let skip = this.props.skip || '';
let mode = this.state.mode;
let compare = (this.props.compare || '').replace('N', '');
let items = [];
for (let i=0; i<all.length; i++) {
let c = all.charAt(i);
if (skip.indexOf(c) >= 0 && mode.indexOf(c) < 0) {
// Permission is marked as inactive: hide unchecked permissions, disable checked permissions
continue;
}
items.push(
<tr key={c}>
<td>{names[c]}</td>
<td className="checkbox">{skip.indexOf(c) < 0 ?
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} onChange={this.handleChange}/>
:
<CheckBox name={c} checked={(mode.indexOf(c) >= 0)} />
}</td>{this.props.compare ? <td className="checkbox">
<CheckBox name={c} checked={(compare.indexOf(c) >= 0)}/>
</td> : null}
</tr>
);
}
return (
<div className="panel-form-column">
{this.props.userTitle ?
<ul className="contact-box"><Contact
item={this.props.item}
title={this.props.userTitle}
avatar={makeImageUrl(this.props.userAvatar ? this.props.userAvatar : null)} /></ul> : null}
<label className="small"><FormattedMessage id="title_permissions"
defaultMessage="Permissions" description="Section title"/></label>
<table className="permission-editor">
{this.props.compare ?
<thead><tr>
<th></th><th>{this.props.modeTitle}</th>
<th>{this.props.compareTitle}</th>
</tr></thead> :
null}
<tbody>
{items}
</tbody></table>
<br />
<div className="dialog-buttons">
<button className="outline" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" />
</button>
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" />
</button>
</div>
</div>
);
}
};
export default injectIntl(PermissionsEditor);
+55
View File
@@ -0,0 +1,55 @@
// Received/read indicator.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Tinode from 'tinode-sdk';
import { shortDateFormat } from '../lib/strformat.js';
const messages = defineMessages({
'sending': {
'id': 'message_sending',
'defaultMessage': 'sending...',
'description': 'Message being sent, in place of time stamp'
},
'failed': {
'id': 'message_sending_failed',
'defaultMessage': 'failed',
'description': 'Failed to send message, in place of time stamp'
}
});
class ReceivedMarker extends React.PureComponent {
render() {
const {formatMessage} = this.props.intl;
let timestamp;
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
timestamp = formatMessage(messages.sending);
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
timestamp = formatMessage(messages.failed);
} else {
timestamp = shortDateFormat(this.props.timestamp, this.props.intl.locale);
}
let marker = null;
if (this.props.received <= Tinode.MESSAGE_STATUS_SENDING) {
marker = (<i className="material-icons small">access_time</i>); // watch face
} else if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
marker = (<i className="material-icons small amber">warning</i>); // yellow icon /!\
} else if (this.props.received == Tinode.MESSAGE_STATUS_SENT) {
marker = (<i className="material-icons small">done</i>); // checkmark
} else if (this.props.received == Tinode.MESSAGE_STATUS_RECEIVED) {
marker = (<i className="material-icons small">done_all</i>); // double checkmark
} else if (this.props.received == Tinode.MESSAGE_STATUS_READ) {
marker = (<i className="material-icons small blue">done_all</i>); // blue double checkmark
}
return (
<span className="timestamp">
{timestamp}{'\u00a0'}{marker}
</span>
);
}
};
export default injectIntl(ReceivedMarker);
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Tinode from 'tinode-sdk';
export default class SearchContacts extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
edited: false,
search: ''
};
this.handleSearchChange = this.handleSearchChange.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.handleClear = this.handleClear.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
componentWillUnmount() {
if (this.state.edited) {
this.setState({search: '', edited: false});
this.props.onSearchContacts(Tinode.DEL_CHAR);
}
}
handleSearchChange(e) {
this.setState({search: e.target.value});
}
handleSearch(e) {
e.preventDefault();
var query = this.state.search.trim();
this.setState({edited: (query.length > 0)});
this.props.onSearchContacts(query.length > 0 ? query : Tinode.DEL_CHAR);
}
handleClear(e) {
e.preventDefault();
if (this.state.edited) {
this.props.onSearchContacts(Tinode.DEL_CHAR);
}
this.setState({search: '', edited: false});
}
handleKeyDown(e) {
if (e.key === 'Enter') {
this.handleSearch(e);
} else if (e.key === 'Escape') {
this.handleClear();
}
}
render() {
return (
<div className="panel-form">
<div className="panel-form-row">
<i className="material-icons search">search</i>
<FormattedMessage id="search_placeholder" defaultMessage="List like email:alice@example.com, tel:17025550003..."
description="Placeholder in contacts search field">{
(search_placeholder) => <input className="search" type="text"
placeholder={search_placeholder}
value={this.state.search} onChange={this.handleSearchChange}
onKeyDown={this.handleKeyDown} required autoFocus />
}</FormattedMessage>
<a href="#" onClick={this.handleClear}>
<i className="material-icons">close</i>
</a>
</div>
</div>
);
}
};
+189
View File
@@ -0,0 +1,189 @@
// Send message form.
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Drafty } from 'tinode-sdk';
import { KEYPRESS_DELAY } from '../config.js';
import { filePasted } from '../lib/blob-helpers.js';
const messages = defineMessages({
'messaging_disabled': {
id: 'messaging_disabled_prompt',
defaultMessage: 'Messaging disabled',
description: 'Prompt in SendMessage in read-only topic'
},
'type_new_message': {
id: 'new_message_prompt',
defaultMessage: 'New message',
description: 'Prompt in send message field'
},
'add_image_caption': {
id: 'image_caption_prompt',
defaultMessage: 'Image caption',
description: 'Prompt in SendMessage for attached image'
},
'file_attachment_too_large': {
id: 'file_attachment_too_large',
defaultMessage: 'The file size {size} exceeds the {limit} limit.',
description: 'Error message when attachment is too large'
},
'cannot_initiate_upload': {
id: 'cannot_initiate_file_upload',
defaultMessage: 'Cannot initiate file upload.',
description: 'Generic error messagewhen attachment fails'
},
});
class SendMessage extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
message: '',
// Make initial keypress time as if it happened 5001 milliseconds in the past.
keypressTimestamp: new Date().getTime() - KEYPRESS_DELAY - 1
};
this.handlePasteEvent = this.handlePasteEvent.bind(this);
this.handleAttachImage = this.handleAttachImage.bind(this);
this.handleAttachFile = this.handleAttachFile.bind(this);
this.handleSend = this.handleSend.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleMessageTyping = this.handleMessageTyping.bind(this);
}
componentDidMount() {
if (this.messageEditArea) {
this.messageEditArea.addEventListener('paste', this.handlePasteEvent, false);
}
}
componentWillUnmount() {
if (this.messageEditArea) {
this.messageEditArea.removeEventListener('paste', this.handlePasteEvent, false);
}
}
componentDidUpdate() {
if (this.messageEditArea) {
this.messageEditArea.focus();
}
}
handlePasteEvent(e) {
if (this.props.disabled) {
return;
}
// FIXME: handle large files too.
if (filePasted(e,
(bits, mime, width, height, fname) => {
this.props.onAttachImage(mime, bits, width, height, fname);
},
(mime, bits, fname) => {
this.props.onAttachFile(mime, bits, fname);
},
this.props.onError)) {
// If a file was pasted, don't paste base64 data into input field.
e.preventDefault();
}
}
handleAttachImage(e) {
if (e.target.files && e.target.files.length > 0) {
this.props.onAttachImage(e.target.files[0]);
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
handleAttachFile(e) {
const {formatMessage} = this.props.intl;
if (e.target.files && e.target.files.length > 0) {
this.props.onAttachFile(e.target.files[0]);
}
// Clear the value so the same file can be uploaded again.
e.target.value = '';
}
handleSend(e) {
e.preventDefault();
const message = this.state.message.trim();
if (message || this.props.acceptBlank || this.props.noInput) {
this.props.onSendMessage(message);
this.setState({message: ''});
}
}
/* Send on Enter key */
handleKeyPress(e) {
// Remove this if you don't want Enter to trigger send
if (e.key === 'Enter') {
// Have Shift-Enter insert a line break instead
if (!e.shiftKey) {
e.preventDefault();
e.stopPropagation();
this.handleSend(e);
}
}
}
handleMessageTyping(e) {
const newState = {message: e.target.value};
if (this.props.onKeyPress) {
const now = new Date().getTime();
if (now - this.state.keypressTimestamp > KEYPRESS_DELAY) {
this.props.onKeyPress();
newState.keypressTimestamp = now;
}
}
this.setState(newState);
}
render() {
const {formatMessage} = this.props.intl;
const prompt = this.props.disabled ?
formatMessage(messages.messaging_disabled) :
(this.props.messagePrompt ?
formatMessage(messages[this.props.messagePrompt]) :
formatMessage(messages.type_new_message));
return (
<div id="send-message-panel">
{!this.props.disabled ?
<>
{this.props.onAttachFile ?
<>
<a href="#" onClick={(e) => {e.preventDefault(); this.attachImage.click();}} title="Add image">
<i className="material-icons secondary">photo</i>
</a>
<a href="#" onClick={(e) => {e.preventDefault(); this.attachFile.click();}} title="Attach file">
<i className="material-icons secondary">attach_file</i>
</a>
</>
:
null}
{this.props.noInput ?
<div className="hr thin" /> :
<textarea id="sendMessage" placeholder={prompt}
value={this.state.message} onChange={this.handleMessageTyping}
onKeyPress={this.handleKeyPress}
ref={(ref) => {this.messageEditArea = ref;}}
autoFocus />}
<a href="#" onClick={this.handleSend} title="Send">
<i className="material-icons">send</i>
</a>
<input type="file" ref={(ref) => {this.attachFile = ref;}}
onChange={this.handleAttachFile} style={{display: 'none'}} />
<input type="file" ref={(ref) => {this.attachImage = ref;}} accept="image/*"
onChange={this.handleAttachImage} style={{display: 'none'}} />
</>
:
<div id="writing-disabled">{prompt}</div>
}
</div>
);
}
};
export default injectIntl(SendMessage);
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import LetterTile from './letter-tile.jsx';
import ButtonBack from './button-back.jsx';
import MenuContacts from './menu-contacts.jsx';
import MenuStart from './menu-start.jsx';
export default class SideNavbar extends React.PureComponent {
render() {
return (
<div id="side-caption-panel" className="caption-panel">
{this.props.onCancel ? <ButtonBack onBack={this.props.onCancel} /> : null}
{this.props.avatar ?
<div id="self-avatar" className="avatar-box">
<LetterTile
avatar={this.props.avatar}
topic={this.props.myUserId}
title={this.props.title} />
</div>
:
null}
<div id="sidepanel-title" className="panel-title">{this.props.title}</div>
{this.props.state === 'login' ?
<MenuStart onSignUp={this.props.onSignUp} onSettings={this.props.onSettings} /> :
this.props.state === 'contacts' ?
<MenuContacts onNewTopic={this.props.onNewTopic} onSettings={this.props.onSettings} /> :
null}
</div>
);
}
};
+146
View File
@@ -0,0 +1,146 @@
// TagManager: edit topic or user tags.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import ChipInput from './chip-input.jsx';
import { MAX_TAG_COUNT, MIN_TAG_LENGTH } from '../config.js';
import { arrayEqual } from '../lib/utils.js';
export default class TagManager extends React.Component {
constructor(props) {
super(props);
this.state = {
tags: this.props.tags,
tagInput: '',
activated: this.props.activated
};
this.handleTagInput = this.handleTagInput.bind(this);
this.handleAddTag = this.handleAddTag.bind(this);
this.handleRemoveTag = this.handleRemoveTag.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
static getDerivedStateFromProps(nextProps, prevState) {
if (!arrayEqual(nextProps.tags, prevState.tags) && !prevState.activated) {
return {tags: nextProps.tags};
}
return null;
}
handleTagInput(text) {
this.setState({tagInput: text});
if (text.length > 0) {
const last = text[text.length-1];
if (text[0] == '"') {
// This is a quoted string.
if (text.length > 1 && last == '"') {
this.handleAddTag(text.substring(1, text.length-1));
}
} else if (last == ',' || last == ' ' || last == ';' || last == '"') {
// User entered ',', ' ' or ';'
this.handleAddTag(text.substring(0, text.length-1).trim());
}
}
}
handleAddTag(tag) {
if (tag.length > 0 && this.state.tags.length < MAX_TAG_COUNT) {
const tags = this.state.tags.slice(0);
tags.push(tag);
this.setState({tags: tags, tagInput: ''});
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
return tags;
}
return this.state.tags;
}
handleRemoveTag(tag, index) {
const tags = this.state.tags.slice(0);
tags.splice(index, 1);
this.setState({tags: tags});
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
}
handleSubmit() {
// Add unprocessed input to tags, submit the list.
this.props.onSubmit(this.handleAddTag(this.state.tagInput.trim()));
this.setState({activated: false, tags: this.props.tags});
}
handleCancel() {
this.setState({activated: false, tagInput: '', tags: this.props.tags});
if (this.props.onCancel) {
this.props.onCancel();
}
}
render() {
let tags = [];
if (this.state.activated) {
this.state.tags.map((tag) => {
tags.push({user: tag, invalid: (tag.length < MIN_TAG_LENGTH)});
});
} else {
this.state.tags.map((tag) => {
tags.push(<span className="badge" key={tags.length}>{tag}</span>);
});
if (tags.length == 0) {
tags = (
<i>
<FormattedMessage id="tags_not_found" defaultMessage="No tags defined. Add some." description="" />
</i>
);
}
}
return (
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small">{this.props.title}</label>
</div>
{this.state.activated ?
<div>
<FormattedMessage id="tags_editor_no_tags" defaultMessage="Add some tags"
description="Tag editor prompt when no tags are found.">{
(add_tags_prompt) => <ChipInput
chips={tags}
avatarDisabled={true}
prompt={add_tags_prompt}
onEnter={this.handleAddTag}
onFocusLost={this.handleAddTag}
onCancel={this.handleCancel}
onChipRemoved={this.handleRemoveTag}
filterFunc={this.handleTagInput} />
}</FormattedMessage>
{this.props.onSubmit || this.props.onCancel ?
<div id="tag-manager-buttons" className="dialog-buttons panel-form-row">
<button className="outline" onClick={this.handleCancel}>
<FormattedMessage id="button_cancel" defautMessage="Cancel" description="Rejection button [Cancel]" />
</button>
<button className="blue" onClick={this.handleSubmit}>
<FormattedMessage id="button_ok" defautMessage="OK" description="Confirmation button [OK]" />
</button>
</div>
: null}
</div>
:
<div className="quoted">
<a href="#" className="flat-button" onClick={(e) => {e.preventDefault(); this.setState({activated: true});}}>
<i className="material-icons">edit</i> &nbsp;<FormattedMessage id="title_manage_tags" defaultMessage="Manage"
description="Section title for the list of tags" />
</a>
<>{tags}</>
</div>
}
</div>
);
}
};
+10
View File
@@ -0,0 +1,10 @@
// The counter of unread messages in the topic.
import React from 'react';
export default class UnreadBadge extends React.PureComponent {
render() {
return (this.props.count > 0 ?
<span className="unread">{this.props.count > 9 ? "9+" : this.props.count}</span>
: null);
}
};
+80
View File
@@ -0,0 +1,80 @@
// Password with a visiblity toggle.
import React from 'react';
export default class VisiblePassword extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
value: this.props.value,
visible: false
};
this.handleVisibility = this.handleVisibility.bind(this);
this.handeTextChange = this.handeTextChange.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleEditingFinished = this.handleEditingFinished.bind(this);
}
handeTextChange(e) {
this.setState({value: e.target.value});
if (this.props.onChange) {
this.props.onChange(e);
}
}
handleVisibility(e) {
e.preventDefault();
this.setState({visible: !this.state.visible});
}
handleKeyDown(e) {
if (e.keyCode == 27) {
// Escape pressed
this.setState({value: this.props.value, visible: false});
if (this.props.onFinished) {
this.props.onFinished();
}
} else if (e.keyCode == 13) {
// Enter pressed
this.handleEditingFinished();
}
}
handleEditingFinished(e) {
if (e) {
let currentTarget = e.currentTarget;
setTimeout(() => {
if (!currentTarget.contains(document.activeElement)) {
if (this.props.onFinished) {
this.props.onFinished(this.state.value);
}
}
}, 0);
} else if (this.props.onFinished) {
this.props.onFinished(this.state.value.trim());
}
}
render() {
return (
<div tabIndex="-1" className="group-focus"
onBlur={this.handleEditingFinished}>
<input className="with-visibility"
type={this.state.visible ? "text" : "password"}
value={this.state.value}
placeholder={this.props.placeholder}
required={this.props.required ? 'required' : ''}
autoFocus={this.props.autoFocus ? 'autoFocus' : ''}
autoComplete={this.props.autoComplete}
onChange={this.handeTextChange}
onKeyDown={this.handleKeyDown} />
<span onClick={this.handleVisibility}>
<i className="material-icons clickable light-gray">
{this.state.visible ? 'visibility' : 'visibility_off'}
</i>
</span>
</div>
);
}
}
+10120
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More