Compare commits

...
41 Commits
Author SHA1 Message Date
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
34 changed files with 2124 additions and 2095 deletions
+8 -5
View File
@@ -11,10 +11,6 @@ Although the app is generally usable, keep in mind that this is work in progress
Try possibly newer or older version live at https://api.tinode.co/. For passwords for demo accounts and other instructions [see here](https://github.com/tinode/chat/#demo).
Demo avatar photos are from https://www.pexels.com/ under [CC0 license](https://www.pexels.com/photo-license/).
Background pattern from http://subtlepatterns.com/, commercial and non-commercial use allowed with attribution.
## 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.
@@ -25,7 +21,7 @@ Background pattern from http://subtlepatterns.com/, commercial and non-commercia
The app is fully internationalized using [React-Intl](https://github.com/yahoo/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, i.e. https://api.tinode.co/#?hl=ru.
As of the time of this writing there are only two translations. More translations are welcome. Send a pull request with a json file with translated strings. Take a look at [English](/src/i18n/en.json) and [Russian](/src/i18n/ru.json) translations for guidance.
As of the time of this writing the following translations exist: English, Russian, Simplified Chinese. 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-CN.json) translations for guidance.
## Missing functionality
@@ -34,6 +30,13 @@ As of the time of this writing there are only two translations. More translation
* No end-to-end encryption.
* Emoji support is weak.
## 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.
## Responsive design
### Desktop screenshot
+68 -7
View File
@@ -199,12 +199,22 @@ input[type="search"]:focus {
box-shadow: 0 1px 0 rgba(33, 150, 243, 0.25);
}
textarea.invalid,
input[type="text"].invalid,
input[type="email"].invalid,
input[type="password"].invalid,
input[type="search"].invalid {
border-bottom: 1px solid #e57373;
box-shadow: 0 1px 0 rgba(229, 115, 115, 0.25);
}
input[type="text"]::placeholder,
input[type="email"]::placeholder,
input[type="password"]::placeholder,
input[type="search"]::placeholder,
textarea::placeholder {
color: #bbb;
font-weight: lighter;
}
input[type="text"].with-visibility,
@@ -302,6 +312,10 @@ li.right:hover .menuTrigger {
color: #D32F2F; /* red 700 */
}
.amber {
color: #FFC107; /* amber 500 */
}
.gray {
color: #757575;
}
@@ -317,7 +331,7 @@ li.right:hover .menuTrigger {
.hr {
display: block;
width: 100%;
height: 0.6rem;
height: 0.45rem;
min-height: 0.3rem;
background-color: #f0f0f0;
box-shadow: inset 0 0.25rem 0.25rem -0.25rem rgba(0,0,0,0.15),
@@ -515,6 +529,11 @@ table.permission-editor td.checkbox {
cursor: pointer;
}
.clean-clickable {
cursor: pointer;
}
/* END of top-level layout */
/* Sidepanel styles */
@@ -722,6 +741,9 @@ i.search {
border-collapse: collapse;
overflow-x: hidden;
}
.contact-box > li:last-child {
border-bottom: none; /* remove border from the last contact */
}
.contact-box > li:hover {
background-color: #eceff1; /* blue-gray 50 highlight */
}
@@ -1040,6 +1062,17 @@ i.search {
margin-right: 0.2rem;
}
#topic-users .avatar-box > .material-icons {
font-size: 1.4rem;
line-height: 1.8rem;
}
/* Overflow span "+18 more" */
#topic-users > span {
line-height: 2rem;
vertical-align: top;
}
/* Scrollable panel where the messages are shown */
#messages-panel {
position: relative;
@@ -1068,18 +1101,35 @@ i.search {
#write-only-note {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
color: #666;
height: auto;
width: auto;
line-height: 2rem;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 0.25rem 1.5rem;
box-shadow: 0.1rem 0.1rem 0.1rem rgba(0,0,0,0.2);
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
border-radius: 0.5rem;
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.12);
}
#peer-messaging-disabled-note {
position: absolute;
left: 50%;
bottom: 4.3rem;
transform: translate(-50%);
background-color: white;
color: #666;
width: fit-content;
line-height: 2rem;
text-align: center;
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
border-radius: 0.5rem;
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.12);
}
/* Footer-panel with the input for the new message */
#send-message-panel {
display: flex;
@@ -1227,7 +1277,7 @@ i.search {
display: flex;
position: relative;
max-width: 40rem;
min-width: 12rem;
min-width: 9rem;
height: auto;
padding: 0.75rem 0.5rem 0.75rem 1.25rem;
margin: 0;
@@ -1499,6 +1549,12 @@ ul.tabbar li.active > a:after {
vertical-align: middle;
}
.material-icons.as-badge {
padding-left: 0.2rem;
font-size: 1.25rem;
color: #bbb;
}
/* Badge with a label, like [you], [muted], etc */
.badge {
font-family: 'Roboto Mono', Courier, monospace, sans-serif;
@@ -1750,6 +1806,11 @@ ul.tabbar li.active > a:after {
font-size: 90%;
}
.chip.invalid {
color: #c66;
background-color: #fee;
}
.chip > a {
display: inline-block;
color: #ccc;
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -18,7 +18,7 @@
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
<link rel="canonical" href="https://api.tinode.co/" />
<!-- Google's Roboto font -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700" />
<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 -->
@@ -30,8 +30,8 @@
<script crossorigin="anonymous" src="https://unpkg.com/prop-types@15/prop-types.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-intl@2/dist/react-intl.js"></script>
<!-- Firebase scripts -->
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-messaging.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-messaging.js"></script>
</head>
<body>
+6 -6
View File
@@ -13,15 +13,15 @@
<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://api.tinode.co/" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400italic,500,700" />
<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://unpkg.com/react@16.8.1/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.1/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/prop-types@15.6.2/prop-types.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.4/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.4/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/prop-types@15.7.2/prop-types.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-intl@2.8.0/dist/react-intl.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.1/firebase-messaging.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-app.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/firebase@5.8.6/firebase-messaging.js"></script>
</head>
<body>
<noscript><div id="noscript-alertbox"><div id="noscript-title">Javascript support is required.</div>
+454 -1404
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,18 +1,18 @@
{
"name": "tinode-webapp",
"description": "Tinode messenger for the web",
"version": "0.15.13-rc3",
"version": "0.15.15-rc2",
"devDependencies": {
"@babel/core": "^7.2.2",
"@babel/preset-env": "^7.3.1",
"@babel/core": "^7.3.4",
"@babel/preset-env": "^7.3.4",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.5",
"babel-plugin-react-intl": "^3.0.1",
"browserslist": "^4.4.1",
"cssnano": "^4.1.8",
"postcss": "^7.0.6",
"uglifyjs-webpack-plugin": "^2.1.1",
"webpack": "^4.29.3",
"cssnano": "^4.1.10",
"postcss": "^7.0.17",
"uglifyjs-webpack-plugin": "^2.1.2",
"webpack": "^4.29.6",
"webpack-cli": "^3.2.3"
},
"repository": {
@@ -77,10 +77,10 @@
"author": "Tinode Authors <info@tinode.co>",
"license": "Apache-2.0",
"dependencies": {
"firebase": "^5.8.1",
"react": "^16.8.1",
"react-dom": "^16.8.1",
"firebase": "^5.8.6",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-intl": "^2.8.0",
"tinode-sdk": "^0.15.12"
"tinode-sdk": "^0.15.14"
}
}
+10
View File
@@ -22,6 +22,12 @@ export const READ_DELAY = 1000;
// The shortest allowed tag length. Matches the value on the server.
export const MIN_TAG_LENGTH = 4;
// Access mode for subscriptions initiated by the current user.
export const DEFAULT_ACCESS_MODE = 'JRWPS';
// 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;
@@ -53,3 +59,7 @@ 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;
+7 -3
View File
@@ -7,12 +7,13 @@
"label_your_name": "Your name",
"label_password": "Password",
"password_unchanged_prompt": "Unchanged",
"label_user_id": "Address:",
"label_default_access_mode": "Default access mode:",
"label_message_sound": "Message sound:",
"label_push_notifications": "Notification alerts:",
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
"title_tag_manager": "Tags (user discovery)",
"label_user_contacts": "Contacts:",
"label_user_id": "Address:",
"label_default_access_mode": "Default access mode:",
"button_logout": "Logout",
"requested_permissions": "Requested",
"granted_permissions": "Granted",
@@ -45,6 +46,8 @@
"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",
"tabtitle_find_user": "find",
"tabtitle_new_group": "new group",
"tabtitle_group_by_id": "by id",
@@ -66,7 +69,7 @@
"sidepanel_title_reset": "Reset Password",
"sidepanel_title_archive": "Archived Chats",
"update_available": "Update available. <a href=\"\">Reload</a>.",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}s…",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
"reconnect_now": "Try now",
"phone_dative": "phone",
"email_dative": "email",
@@ -98,6 +101,7 @@
"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:",
+7 -3
View File
@@ -43,7 +43,7 @@
"title_manage_tags": "Редактор тагов",
"message_sending": "в пути...",
"message_sending_failed": "ошибка",
"search_placeholder": "Список, напр. email:alice@example.com, tel:17025550003...",
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "Отправка недоступна",
"new_message_prompt": "Новое сообщение",
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
@@ -120,7 +120,7 @@
"badge_owner": "влад.",
"update_available": "Есть новая версия приложения. <a href=\"\">Обновить</a>.",
"user_not_found": "Не найден",
"reconnect_countdown": "Нет связи. Подключение через {seconds}с…",
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
"reconnect_now": "Подключить сейчас.",
"save_attachment": "сохранить",
"menu_item_archive_topic": "В архив",
@@ -129,5 +129,9 @@
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
"chat_invitation_accept": "Принять",
"chat_invitation_ignore": "Игнорировать",
"chat_invitation_block": "Заблокировать"
"chat_invitation_block": "Заблокировать",
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
"enable_peers_messaging": "Разблокировать.",
"more_online_members": "+еще {overflow}",
"label_user_contacts": "Конакты"
}
+137
View File
@@ -0,0 +1,137 @@
{
"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": "添加成员",
"button_leave": "离开",
"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_edit_account": "编辑账户",
"sidepanel_title_newtpk": "开始新会话",
"sidepanel_title_cred": "确认凭据",
"sidepanel_title_reset": "重置密码",
"sidepanel_title_archive": "已存档会话",
"update_available": "更新可用。<a href=\"\">重新载入</a>.",
"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": "往来"
}
+8
View File
@@ -14,6 +14,14 @@ export function shortDateFormat(then, 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) {
+19
View File
@@ -61,3 +61,22 @@ export function arrayEqual(a, b) {
}
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 (/^\s*(?:\+?(\d{1,3}))?[- (.]*(\d{3})[- ).]*(\d{3})[- .]*(\d{2})[- .]*(\d{2})?\s*$/.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;
}
+150 -6
View File
@@ -8,12 +8,13 @@
"label_your_name": "Your name",
"label_password": "Password",
"password_unchanged_prompt": "Unchanged",
"label_user_id": "Address:",
"label_default_access_mode": "Default access mode:",
"label_message_sound": "Message sound:",
"label_push_notifications": "Notification alerts:",
"label_push_notifications_disabled": "Notification alerts (requires HTTPS):",
"title_tag_manager": "Tags (user discovery)",
"label_user_contacts": "Contacts:",
"label_user_id": "Address:",
"label_default_access_mode": "Default access mode:",
"button_logout": "Logout",
"requested_permissions": "Requested",
"granted_permissions": "Granted",
@@ -46,6 +47,8 @@
"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",
"tabtitle_find_user": "find",
"tabtitle_new_group": "new group",
"tabtitle_group_by_id": "by id",
@@ -67,7 +70,7 @@
"sidepanel_title_reset": "Reset Password",
"sidepanel_title_archive": "Archived Chats",
"update_available": "Update available. <a href=\"\">Reload</a>.",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}s…",
"reconnect_countdown": "Disconnected. Reconnecting in {seconds}…",
"reconnect_now": "Try now",
"phone_dative": "phone",
"email_dative": "email",
@@ -99,6 +102,7 @@
"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:",
@@ -177,7 +181,7 @@
"title_manage_tags": "Редактор тагов",
"message_sending": "в пути...",
"message_sending_failed": "ошибка",
"search_placeholder": "Список, напр. email:alice@example.com, tel:17025550003...",
"search_placeholder": "Список, напр. email:alice@example.com, tel:+17025550003...",
"messaging_disabled_prompt": "Отправка недоступна",
"new_message_prompt": "Новое сообщение",
"file_attachment_too_large": "Размер файла {size} превышает {limit} лимит.",
@@ -254,7 +258,7 @@
"badge_owner": "влад.",
"update_available": "Есть новая версия приложения. <a href=\"\">Обновить</a>.",
"user_not_found": "Не найден",
"reconnect_countdown": "Нет связи. Подключение через {seconds}с…",
"reconnect_countdown": "Нет связи. Подключение через {seconds}…",
"reconnect_now": "Подключить сейчас.",
"save_attachment": "сохранить",
"menu_item_archive_topic": "В архив",
@@ -263,6 +267,146 @@
"chat_invitation": "Вас пригласили начать новый чат. Как вы хотите поступить?",
"chat_invitation_accept": "Принять",
"chat_invitation_ignore": "Игнорировать",
"chat_invitation_block": "Заблокировать"
"chat_invitation_block": "Заблокировать",
"peers_messaging_disabled": "Чат заблокирован у корреспондента.",
"enable_peers_messaging": "Разблокировать.",
"more_online_members": "+еще {overflow}",
"label_user_contacts": "Конакты"
},
"zh-CN": {
"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": "添加成员",
"button_leave": "离开",
"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_edit_account": "编辑账户",
"sidepanel_title_newtpk": "开始新会话",
"sidepanel_title_cred": "确认凭据",
"sidepanel_title_reset": "重置密码",
"sidepanel_title_archive": "已存档会话",
"update_available": "更新可用。<a href=\"\">重新载入</a>.",
"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}个"
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
// This is a generated file. Don't edit.
export const PACKAGE_VERSION = "0.15.13-rc3";
export const PACKAGE_VERSION = "0.15.15-rc2";
+123 -38
View File
@@ -1,3 +1,4 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
@@ -9,30 +10,38 @@ import PermissionsEditor from '../widgets/permissions-editor.jsx';
import TagManager from '../widgets/tag-manager.jsx';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { vcard } from '../lib/utils.js';
import { arrayEqual, asEmail, asPhone, vcard } from '../lib/utils.js';
export default class EditAccountView extends React.Component {
constructor(props) {
super(props);
let me = this.props.tinode.getMeTopic();
let defacs = me.getDefaultAccess();
let fnd = this.props.tinode.getFndTopic();
const me = this.props.tinode.getMeTopic();
const defacs = me.getDefaultAccess();
this.state = {
fullName: me.public ? me.public.fn : undefined,
avatar: makeImageUrl(me.public ? me.public.photo : null),
auth: defacs ? defacs.auth : null,
anon: defacs ? defacs.anon : null,
tags: fnd.tags(),
tags: me.tags(),
credentials: me.getCredentials() || [],
addCredActive: false,
addCredInvalid: false,
newCred: '',
showPermissionEditorFor: undefined,
previousOnTags: fnd.onTagsUpdated
previousOnTags: me.onTagsUpdated
};
this.tnNewTags = this.tnNewTags.bind(this);
this.tnCredsUpdated = this.tnCredsUpdated.bind(this);
this.handleFullNameUpdate = this.handleFullNameUpdate.bind(this);
this.handlePasswordUpdate = this.handlePasswordUpdate.bind(this);
this.handleImageChanged = this.handleImageChanged.bind(this);
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
this.handleStartAddCred = this.handleStartAddCred.bind(this);
this.handleCredChange = this.handleCredChange.bind(this);
this.handleCredKeyDown = this.handleCredKeyDown.bind(this);
this.handleCredEntered = this.handleCredEntered.bind(this);
this.handleLaunchPermissionsEditor = this.handleLaunchPermissionsEditor.bind(this);
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
@@ -40,24 +49,26 @@ export default class EditAccountView extends React.Component {
}
componentDidMount() {
let fnd = this.props.tinode.getFndTopic();
fnd.onTagsUpdated = this.tnNewTags;
if (!fnd.isSubscribed()) {
fnd.subscribe(fnd.startMetaQuery().withLaterDesc().withTags().build()).catch((err) => {
this.props.onError(err.message, 'err');
});
}
const me = this.props.tinode.getMeTopic();
me.onCredsUpdated = this.tnCredsUpdated;
me.onTagsUpdated = this.tnNewTags;
}
componentWillUnmount() {
var fnd = this.props.tinode.getFndTopic();
fnd.onTagsUpdated = this.state.previousOnTags;
const me = this.props.tinode.getMeTopic();
me.onTagsUpdated = this.state.previousOnTags;
me.onCredsUpdated = undefined;
}
tnNewTags(tags) {
this.setState({tags: tags});
}
tnCredsUpdated(creds) {
console.log("tnCredsUpdated", creds, new Error());
this.setState({credentials: creds || []});
}
handleFullNameUpdate(fn) {
this.setState({fullName: fn});
this.props.onUpdateAccount(undefined, vcard(fn, null));
@@ -81,6 +92,49 @@ export default class EditAccountView extends React.Component {
}
}
handleStartAddCred() {
this.setState({addCredActive: true});
}
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});
}
}
handleLaunchPermissionsEditor(which) {
this.setState({
showPermissionEditorFor: which,
@@ -111,13 +165,25 @@ export default class EditAccountView extends React.Component {
}
render() {
var tags = [];
this.state.tags.map(function(tag) {
/*
let tags = [];
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" /></i>;
}
*/
let credentials = [];
this.state.credentials.map((cred) => {
credentials.push(<div key={cred.meth + ":" + cred.val}>{cred.meth}: <tt>{cred.val}</tt>
{!cred.done ?
<a href="javascript:;"
onClick={this.props.onCredConfirm.bind(this, cred.meth, cred.val)}>confirm</a>
: null} <a href="javascript:;" onClick={this.props.onCredDelete.bind(this, cred.meth, cred.val)}><i
className="material-icons gray">delete_outline</i></a></div>);
});
return (
<React.Fragment>{this.state.showPermissionEditorFor ?
<PermissionsEditor
@@ -161,26 +227,6 @@ export default class EditAccountView extends React.Component {
onError={this.props.onError} />
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label><FormattedMessage id="label_user_id" defaultMessage="Address:"
description="Label for user address (ID)" /></label>
<tt>{this.props.myUserId}</tt>
</div>
<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>
<div className="hr" />
<div className="panel-form-row">
<label htmlFor="message-sound">
<FormattedMessage id="label_message_sound" defaultMessage="Message sound:"
@@ -198,7 +244,7 @@ export default class EditAccountView extends React.Component {
:
<FormattedMessage id="label_push_notifications_disabled"
defaultMessage="Notification alerts (requires HTTPS):"
description="Label for push notifications switch (disabled)" />
description="Label for push notifications switch" />
}
</label>
<CheckBox name="alert" id="desktop-alerts"
@@ -215,6 +261,45 @@ export default class EditAccountView extends React.Component {
onSubmit={this.handleTagsUpdated} />
}</FormattedMessage>
<div className="hr" />
<div className="panel-form-column">
<div>
<label className="small">
<FormattedMessage id="label_user_contacts" defaultMessage="Contacts:"
description="Label for user contacts" />
</label>
</div>
<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="javascript:;" onClick={this.handleStartAddCred}>+ Add another</a></div>
</div>
</div>
<div className="hr" />
<div className="panel-form-column">
<div className="panel-form-row">
<label className="small"><FormattedMessage id="label_user_id" defaultMessage="Address:"
description="Label for user address (ID)" /></label>
<tt>{this.props.myUserId}</tt>
</div>
<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>
<div className="hr" />
<div className="panel-form-column">
<a href="javascript:;" className="red flat-button" onClick={this.props.onLogout}>
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
+8 -3
View File
@@ -15,8 +15,10 @@ import MoreButton from '../widgets/more-button.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import TagManager from '../widgets/tag-manager.jsx';
import { NO_ACCESS_MODE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { vcard } from '../lib/utils.js';
import { arrayEqual, vcard } from '../lib/utils.js';
const messages = defineMessages({
requested: {
@@ -145,6 +147,9 @@ class InfoView extends React.Component {
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) => {
@@ -196,7 +201,7 @@ class InfoView extends React.Component {
}
}
onSubsUpdated() {
onSubsUpdated(subs) {
const topic = this.props.tinode.getTopic(this.props.topic);
if (!topic) {
return;
@@ -204,7 +209,7 @@ class InfoView extends React.Component {
this.resetSubs(topic, this.props);
if (this.previousSubsUpdated && this.previousSubsUpdated != this.onSubsUpdated) {
this.previousSubsUpdated();
this.previousSubsUpdated(subs);
}
}
+121 -57
View File
@@ -14,7 +14,7 @@ import LoadSpinner from '../widgets/load-spinner.jsx';
import LogoView from './logo-view.jsx';
import SendMessage from '../widgets/send-message.jsx';
import { KEYPRESS_DELAY, MESSAGES_PAGE } from '../config.js';
import { DEFAULT_ACCESS_MODE, KEYPRESS_DELAY, MESSAGES_PAGE } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { shortDateFormat } from '../lib/strformat.js';
@@ -38,8 +38,19 @@ const messages = defineMessages({
// Checks if the access permissions are granted but not yet accepted.
function isUnconfirmed(acs) {
const ex = acs ? acs.getExcessive() || '' : '';
return (ex.includes('R') || ex.includes('W'));
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 {
@@ -64,6 +75,7 @@ class MessagesView extends React.Component {
this.handleShowContextMenuMessage = this.handleShowContextMenuMessage.bind(this);
this.handleBackNavigation = this.handleBackNavigation.bind(this);
this.handleNewChatAcceptance = this.handleNewChatAcceptance.bind(this);
this.handleEnablePeer = this.handleEnablePeer.bind(this);
}
componentDidMount() {
@@ -108,19 +120,24 @@ class MessagesView extends React.Component {
}
}
if (topic && !topic.isSubscribed() &&
((!prevProps.connected && this.props.connected) || (this.state.topic != prevState.topic))) {
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.
const getQuery = topic.startMetaQuery()
.withLaterDesc()
.withLaterSub()
.withLaterData(MESSAGES_PAGE)
.withLaterDel();
const setQuery = Tinode.isNewGroupTopicName(this.props.topic) ?
this.props.newGroupTopicParams : undefined;
// Show "loading" spinner.
this.setState({ fetchingMessages: true });
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) {
@@ -135,7 +152,7 @@ class MessagesView extends React.Component {
});
})
.catch((err) => {
console.log('Failed subscription to', this.state.topic);
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);
@@ -157,7 +174,8 @@ class MessagesView extends React.Component {
imagePreview: null,
typingIndicator: false,
scrollPosition: 0,
fetchingMessages: false
fetchingMessages: false,
peerMessagingDisabled: false
};
} else if (nextProps.topic != prevState.topic) {
const topic = nextProps.tinode.getTopic(nextProps.topic);
@@ -188,44 +206,69 @@ class MessagesView extends React.Component {
}
});
nextState = Object.assign(nextState, {
Object.assign(nextState, {
messages: msgs,
onlineSubs: subs
});
if (topic.public) {
nextState = Object.assign(nextState, {
Object.assign(nextState, {
title: topic.public.fn,
avatar: makeImageUrl(topic.public.photo)
});
} else {
nextState = Object.assign(nextState, {
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.
nextState = Object.assign(nextState, {
Object.assign(nextState, {
messages: [],
onlineSubs: [],
title: '',
avatar: null
avatar: null,
peerMessagingDisabled: false
});
}
}
let val = nextProps.acs ? !nextProps.acs.isWriter() : true;
if (val != prevState.readOnly) {
nextState.readOnly = val;
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;
}
}
val = nextProps.acs ? !nextProps.acs.isReader() : true;
if (val != prevState.writeOnly) {
nextState.writeOnly = val;
}
val = isUnconfirmed(nextProps.acs);
if (val != prevState.unconformed) {
nextState.unconfirmed = val;
if (isUnconfirmed(nextProps.acs) == !prevState.unconformed) {
nextState.unconfirmed = !prevState.unconformed;
}
// Clear subscribers online when there is no connection.
@@ -270,9 +313,9 @@ class MessagesView extends React.Component {
this.setState({scrollPosition: event.target.scrollHeight - event.target.scrollTop});
if (event.target.scrollTop <= 0) {
this.setState((prevState, props) => {
let newState = {};
const newState = {};
if (!prevState.fetchingMessages) {
let topic = this.props.tinode.getTopic(this.state.topic);
const topic = this.props.tinode.getTopic(this.state.topic);
if (topic && topic.isSubscribed() && topic.msgHasMoreMessages()) {
newState.fetchingMessages = true;
topic.getMessagesPage(MESSAGES_PAGE).catch((err) => {
@@ -301,32 +344,42 @@ class MessagesView extends React.Component {
if (desc.acs) {
this.setState({
readOnly: !desc.acs.isWriter(),
writeOnly: !desc.acs.isReader(),
isWriter: desc.acs.isWriter(),
isReader: desc.acs.isReader(),
readingBlocked: !desc.acs.isReader('given'),
unconfirmed: isUnconfirmed(desc.acs),
});
}
}
handleSubsUpdated() {
console.log("handleSubsUpdated");
if (this.state.topic) {
var subs = [];
var topic = this.props.tinode.getTopic(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);
}
});
this.setState({onlineSubs: subs});
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
var topic = this.props.tinode.getTopic(this.state.topic);
var newState = {messages: []};
const topic = this.props.tinode.getTopic(this.state.topic);
const newState = {messages: []};
topic.messages(function(m) {
if (!m.deleted) {
newState.messages = newState.messages.concat(m);
@@ -341,9 +394,10 @@ class MessagesView extends React.Component {
newState.scrollPosition = 0;
}
// Aknowledge all messages, including own messages.
let status = topic.msgStatus(msg);
if (status >= Tinode.MESSAGE_STATUS_SENT) {
// 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.props.readTimerHandler(() => {
topic.noteRead(msg.seq);
});
@@ -394,7 +448,7 @@ class MessagesView extends React.Component {
this.props.sendMessage(Drafty.attachJSON(Drafty.parse(text), data));
} else if (action == 'url') {
const url = new URL(data.ref);
let params = url.searchParams;
const params = url.searchParams;
for (let key in data.resp) {
if (data.resp.hasOwnProperty(key)) {
params.set(key, data.resp[key]);
@@ -440,6 +494,10 @@ class MessagesView extends React.Component {
this.props.onNewChat(this.state.topic, action);
}
handleEnablePeer() {
this.props.onChangePermissions(this.state.topic, DEFAULT_ACCESS_MODE, this.state.topic);
}
render() {
const {formatMessage} = this.props.intl;
@@ -504,7 +562,7 @@ class MessagesView extends React.Component {
}
let lastSeen = null;
let cont = this.props.tinode.getMeTopic().getContact(this.state.topic);
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);
@@ -514,8 +572,8 @@ class MessagesView extends React.Component {
// TODO: also handle user agent in c.seen.ua
}
}
let avatar = this.state.avatar || true;
let online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
const avatar = this.state.avatar || true;
const online = this.props.online ? 'online' + (this.state.typingIndicator ? ' typing' : '') : 'offline';
component = (
<div id="topic-view" className={this.props.hideSelf ? 'nodisplay' : null}>
@@ -564,29 +622,35 @@ class MessagesView extends React.Component {
<ul id="scroller" className={chatBoxClass}>
{messageNodes}
</ul>
<div>Typing notifications here.</div>
</div>
{this.state.writeOnly ?
{!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
}
: 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="javascript:;"
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
tinode={this.props.tinode}
topic={this.props.topic}
disabled={this.state.readOnly}
disabled={!this.state.isWriter}
sendMessage={this.props.sendMessage}
onError={this.props.onError} />
}
onError={this.props.onError} />}
{this.state.imagePreview ?
<ImagePreview content={this.state.imagePreview}
onClose={this.handleCloseImagePreview} /> : null}
+3
View File
@@ -135,6 +135,9 @@ class SidepanelView extends React.Component {
onUpdateTags={this.props.onUpdateAccountTags}
onTogglePushNotifications={this.props.onTogglePushNotifications}
onToggleMessageSounds={this.props.onToggleMessageSounds}
onCredAdd={this.props.onCredAdd}
onCredDelete={this.props.onCredDelete}
onCredConfirm={this.props.onCredConfirm}
onLogout={this.props.onLogout}
onCancel={this.props.onCancel}
onError={this.props.onError} /> :
+84 -24
View File
@@ -14,11 +14,12 @@ import InfoView from './info-view.jsx';
import MessagesView from './messages-view.jsx';
import SidepanelView from './sidepanel-view.jsx';
import { API_KEY, APP_NAME, MEDIA_BREAKPOINT, READ_DELAY, RECEIVED_DELAY } from '../config.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
import { API_KEY, APP_NAME, DEFAULT_ACCESS_MODE, MEDIA_BREAKPOINT, READ_DELAY, RECEIVED_DELAY } from '../config.js';
import { base64ReEncode, makeImageUrl } from '../lib/blob-helpers.js';
import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/host-name.js';
import LocalStorageUtil from '../lib/local-storage.js';
import HashNavigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js'
import { updateFavicon } from '../lib/utils.js';
// Sound to play on message received.
@@ -32,7 +33,7 @@ const messages = defineMessages({
},
reconnect_countdown: {
id: 'reconnect_countdown',
defaultMessage: 'Disconnected. Reconnecting in {seconds}s…',
defaultMessage: 'Disconnected. Reconnecting in {seconds}…',
description: 'Message shown when an app update is available.'
},
reconnect_now: {
@@ -83,6 +84,9 @@ class TinodeWeb extends React.Component {
this.handleGlobalSettings = this.handleGlobalSettings.bind(this);
this.handleShowArchive = this.handleShowArchive.bind(this);
this.handleToggleMessageSounds = this.handleToggleMessageSounds.bind(this);
this.handleCredAdd = this.handleCredAdd.bind(this);
this.handleCredDelete = this.handleCredDelete.bind(this);
this.handleCredConfirm = this.handleCredConfirm.bind(this);
this.initDesktopAlerts = this.initDesktopAlerts.bind(this);
this.togglePushToken = this.togglePushToken.bind(this);
this.requestPushToken = this.requestPushToken.bind(this);
@@ -113,6 +117,8 @@ class TinodeWeb extends React.Component {
return {
connected: false,
// Connected and subscribed to 'me'
ready: false,
transport: settings.transport || null,
serverAddress: settings.serverAddress || detectServerAddress(),
serverVersion: "no connection",
@@ -141,7 +147,7 @@ class TinodeWeb extends React.Component {
topicSelected: '',
topicSelectedOnline: false,
topicSelectedAcs: null,
newGroupTopicParams: null,
newTopicParams: null,
loginDisabled: false,
displayMobile: (window.innerWidth <= MEDIA_BREAKPOINT),
showInfoPanel: false,
@@ -393,8 +399,9 @@ class TinodeWeb extends React.Component {
let count = sec / 1000;
count = count | count;
this.reconnectCountdown = setInterval(() => {
const timeLeft = (count > 99) ? secondsToTime(count) : count;
this.handleError(
formatHTMLMessage(messages.reconnect_countdown, {seconds: count}),
formatHTMLMessage(messages.reconnect_countdown, {seconds: timeLeft}),
'warn',
() => {
clearInterval(this.reconnectCountdown);
@@ -410,6 +417,7 @@ class TinodeWeb extends React.Component {
handleDisconnect(err) {
this.setState({
connected: false,
ready: false,
topicSelectedOnline: false,
dialogSelected: null,
errorText: err && err.message ? err.message : "Disconnected",
@@ -488,11 +496,14 @@ class TinodeWeb extends React.Component {
credCode: undefined,
myUserId: this.tinode.getCurrentUserID()
});
console.log("handleLoginSuccessful");
// Subscribe, fetch topic desc, the list of subscriptions. Messages are not fetched.
me.subscribe(
me.startMetaQuery().
withLaterSub().
withDesc().
withTags().
withCreds().
build()
).catch((err) => {
localStorage.removeItem('auth-token');
@@ -591,9 +602,14 @@ class TinodeWeb extends React.Component {
}
resetContactList() {
let newState = {
const newState = {
chatList: []
};
if (!this.state.ready) {
newState.ready = true;
}
this.tinode.getMeTopic().contacts((c) => {
newState.chatList.push(c);
if (this.state.topicSelected == c.topic) {
@@ -609,7 +625,7 @@ class TinodeWeb extends React.Component {
// Sending "received" notifications
tnData(data) {
const topic = this.tinode.getTopic(data.topic);
if (topic.msgStatus(data) >= Tinode.MESSAGE_STATUS_SENT) {
if (topic.msgStatus(data) >= Tinode.MESSAGE_STATUS_SENT && data.from != this.state.myUserId) {
clearTimeout(this.receivedTimer);
this.receivedTimer = setTimeout(() => {
this.receivedTimer = undefined;
@@ -625,7 +641,7 @@ class TinodeWeb extends React.Component {
if (fnd.isSubscribed()) {
this.tnFndSubsUpdated();
} else {
fnd.subscribe(fnd.startMetaQuery().withSub().withTags().build()).catch((err) => {
fnd.subscribe(fnd.startMetaQuery().withSub().build()).catch((err) => {
this.handleError(err.message, 'err');
});
}
@@ -657,6 +673,13 @@ class TinodeWeb extends React.Component {
// User clicked on a contact in the side panel or deleted a contact.
handleTopicSelected(topicName, unused_index, online, acs) {
// Clear newTopicParams after use.
if (this.state.newTopicParams && this.state.newTopicParams._topicName != topicName) {
this.setState({
newTopicParams: null
});
}
if (topicName) {
this.setState({
errorText: '',
@@ -682,9 +705,8 @@ class TinodeWeb extends React.Component {
topicSelectedAcs: null,
showInfoPanel: false
});
if (this.state.topicSelected) {
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
}
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
}
}
@@ -738,7 +760,7 @@ class TinodeWeb extends React.Component {
const mode = topic.getAccessMode().getGiven();
response = topic.setMeta({sub: {mode: mode}});
if (topic.getType() == 'p2p') {
// For P2P topics chage 'given' permission of the peer too.
// For P2P topics change 'given' permission of the peer too.
// In p2p topics the other user has the same name as the topic.
response = response.then((ctrl) => {
topic.setMeta({sub: {user: topicName, mode: mode}});
@@ -751,7 +773,11 @@ class TinodeWeb extends React.Component {
break;
case 'block':
// Ban the topic making futher invites impossible.
response = topic.setMeta({sub: {mode: 'N'}});
// Just self-ban.
const am = topic.getAccessMode().updateWant('-JP').getWant();
response = topic.setMeta({sub: {mode: am}}).then((ctrl) => {
return this.handleTopicSelected(null);
});
break;
default:
console.log("Unknown invitation action", '"' + action + '""');
@@ -807,7 +833,7 @@ class TinodeWeb extends React.Component {
}
handleUpdateAccountTagsRequest(tags) {
this.tinode.getFndTopic().setMeta({tags: tags})
this.tinode.getMeTopic().setMeta({tags: tags})
.catch((err) => {
this.handleError(err.message, 'err');
});
@@ -913,6 +939,24 @@ class TinodeWeb extends React.Component {
});
}
handleCredAdd(method, value) {
// TODO: implement
console.log("add credential", method, value);
}
handleCredDelete(method, value) {
console.log("delete credential", method, value);
const me = this.tinode.getMeTopic();
me.delCredential(method, value).catch((err) => {
this.handleError(err.message, 'err');
});
}
handleCredConfirm(method, response) {
// TODO: implement
console.log("confirm credential", method, response);
}
// User clicked Cancel button in Setting or Sign Up panel.
handleSidepanelCancel() {
const parsed = HashNavigation.parseUrlHash(window.location.hash);
@@ -934,14 +978,19 @@ class TinodeWeb extends React.Component {
// Request to start a new topic. New P2P topic requires peer's name.
handleNewTopicRequest(peerName, pub, priv, tags) {
const topicName = peerName || this.tinode.newGroupTopicName();
if (!peerName) {
this.setState(
{newGroupTopicParams: {desc: {public: pub, private: {comment: priv}}, tags: tags}},
() => {this.handleTopicSelected(topicName)}
);
const params = {
_topicName: topicName,
};
if (peerName) {
// Because we are initialing the subscription, set 'want' to all permissions.
params.sub = {mode: DEFAULT_ACCESS_MODE};
// Give the other user all permissions too.
params.desc = {defacs: {auth: DEFAULT_ACCESS_MODE}};
} else {
this.handleTopicSelected(topicName);
params.desc = {public: pub, private: {comment: priv}};
params.tags = tags;
}
this.setState({newTopicParams: params}, () => {this.handleTopicSelected(topicName)});
}
// New topic was creted, here is the new topic name.
@@ -1056,7 +1105,7 @@ class TinodeWeb extends React.Component {
const topic = this.tinode.getTopic(topicName);
const {formatMessage} = this.props.intl;
let muted = false, blocked = false, subscribed = false, deleter = false, archived = false;
let muted = false, blocked = false, self_blocked = false, subscribed = false, deleter = false, archived = false;
if (topic) {
subscribed = topic.isSubscribed();
archived = topic.isArchived();
@@ -1065,6 +1114,7 @@ class TinodeWeb extends React.Component {
if (acs) {
muted = acs.isMuted();
blocked = !acs.isJoiner();
self_blocked = !acs.isJoiner('want');
deleter = acs.isDeleter();
}
}
@@ -1073,8 +1123,8 @@ class TinodeWeb extends React.Component {
subscribed ? {title: formatMessage({id: 'menu_item_info'}), handler: this.handleShowInfoView} : null,
subscribed ? 'messages_clear' : null,
subscribed && deleter ? 'messages_clear_hard' : null,
muted ? 'topic_unmute' : 'topic_mute',
blocked ? 'topic_unblock' : 'topic_block',
muted ? (blocked ? null : 'topic_unmute') : 'topic_mute',
self_blocked ? 'topic_unblock' : 'topic_block',
!archived ? 'topic_archive' : null,
'topic_delete'
];
@@ -1181,6 +1231,11 @@ class TinodeWeb extends React.Component {
items={this.state.contextMenuItems}
hide={this.handleHideContextMenu}
onAction={this.handleContextMenuAction}
onTopicRemoved={(topicName) => {
if (topicName == this.state.topicSelected) {
this.handleTopicSelected(null);
}
}}
onError={this.handleError} />
:
null
@@ -1223,6 +1278,9 @@ class TinodeWeb extends React.Component {
onUpdateAccountTags={this.handleUpdateAccountTagsRequest}
onTogglePushNotifications={this.togglePushToken}
onToggleMessageSounds={this.handleToggleMessageSounds}
onCredAdd={this.handleCredAdd}
onCredDelete={this.handleCredDelete}
onCredConfirm={this.handleCredConfirm}
onTopicSelected={this.handleTopicSelected}
onCreateTopic={this.handleNewTopicRequest}
onNewTopic={this.handleNewTopic}
@@ -1243,6 +1301,7 @@ class TinodeWeb extends React.Component {
<MessagesView
tinode={this.tinode}
connected={this.state.connected}
ready={this.state.ready}
online={this.state.topicSelectedOnline}
acs={this.state.topicSelectedAcs}
displayMobile={this.state.displayMobile}
@@ -1260,7 +1319,7 @@ class TinodeWeb extends React.Component {
errorAction={this.state.errorAction}
errorActionText={this.state.errorActionText}
newGroupTopicParams={this.state.newGroupTopicParams}
newTopicParams={this.state.newTopicParams}
onHideMessagesView={this.handleHideMessagesView}
onData={this.tnData}
@@ -1268,6 +1327,7 @@ class TinodeWeb extends React.Component {
onNewTopicCreated={this.handleNewTopicCreated}
readTimerHandler={this.handleReadTimer}
showContextMenu={this.handleShowContextMenu}
onChangePermissions={this.handleChangePermissions}
onNewChat={this.handleNewChatInvitation}
sendMessage={this.handleSendMessage} />
+1
View File
@@ -118,6 +118,7 @@ export default class ChipInput extends React.Component {
noAvatar={this.props.avatarDisabled}
topic={item.user}
required={item.user === this.props.required}
invalid={item.invalid}
index={count}
key={item.user} />
);
+2 -1
View File
@@ -15,8 +15,9 @@ export default class Chip extends React.PureComponent {
render() {
const title = this.props.title || this.props.topic;
const className = this.props.invalid ? 'chip invalid' : 'chip';
return (
<div className="chip">
<div className={className}>
{this.props.noAvatar ?
<span className="spacer" /> :
<div className="avatar-box">
+11 -5
View File
@@ -1,17 +1,23 @@
import React from 'react';
/* Contact's labels: [you], [muted], [blocked], etc */
// FIXME: this class is unused.
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) {
let style = 'badge' + (b.color ? ' ' + b.color : '');
badges.push(<span className={style} key={b.key || b.name}>{b.name}</span>);
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 <React.Fragment>{badges}</React.Fragment>;
}
return <span>{badges}</span>;
return null;
}
};
+13 -4
View File
@@ -39,8 +39,17 @@ export default class Contact extends React.Component {
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() : [];
if (this.props.showMode && this.props.acs) {
badges.push({name: this.props.acs.getMode(), key: 'mode'});
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 (
@@ -58,10 +67,10 @@ export default class Contact extends React.Component {
</div>
<div className="text-box">
<div><span className="contact-title">{title}</span>
{this.props.unread > 0 ? <UnreadBadge count={this.props.unread} /> : null}
<UnreadBadge count={this.props.unread} /><ContactBadges badges={icon_badges} />
</div>
{this.props.comment ? <div className="contact-comment">{this.props.comment}</div> : null}
<ContactBadges badges={badges} />
<span><ContactBadges badges={badges} /></span>
</div>
{this.props.showContextMenu ?
<span className="menuTrigger">
+14 -6
View File
@@ -4,6 +4,7 @@ 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: {
@@ -126,12 +127,17 @@ class ContextMenu extends React.Component {
'topic_unblock': {
id: 'topic_unblock',
title: formatMessage(messages.unblock),
handler: this.topicPermissionSetter.bind(this, '+J')
handler: this.topicPermissionSetter.bind(this, '+JP')
},
'topic_block': {
id: 'topic_block',
title: formatMessage(messages.block),
handler: this.topicPermissionSetter.bind(this, '-J')
handler: (params, errorHandler) => {
return this.topicPermissionSetter('-JP', params, errorHandler).then((ctrl) => {
this.props.onTopicRemoved(params.topicName);
return ctrl;
});
}
},
'topic_delete': {
id: 'topic_delete',
@@ -302,11 +308,13 @@ class ContextMenu extends React.Component {
am = topic.getAccessMode().updateWant(mode).getWant();
}
return topic.setMeta({sub: {user: params.user, mode: am}}).catch((err) => {
if (errorHandler) {
let result = topic.setMeta({sub: {user: params.user, mode: am}});
if (errorHandler) {
result = result.catch((err) => {
errorHandler(err.message, 'err');
}
});
});
}
return result;
}
render() {
+18 -4
View File
@@ -1,6 +1,9 @@
/* GroupSubs: a list of group subscribers currently online */
// 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';
@@ -12,7 +15,10 @@ export default class GroupSubs extends React.Component {
render() {
const usersOnline = [];
(this.props.subscribers || []).map((sub) => {
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
@@ -21,9 +27,17 @@ export default class GroupSubs extends React.Component {
title={sub.public ? sub.public.fn : null} />
</div>
);
return usersOnline.length == countToShow;
});
return (
<div id="topic-users">{usersOnline}</div>
<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>
);
}
};
+6 -3
View File
@@ -8,12 +8,15 @@ export default class LetterTile extends React.PureComponent {
render() {
let avatar;
if (this.props.avatar === true) {
const isGroup = (Tinode.topicType(this.props.topic) == 'grp');
if (this.props.topic && this.props.title && this.props.title.trim()) {
let letter = this.props.title.trim().charAt(0);
let color = "lettertile dark-color" + (Math.abs(stringHash(this.props.topic)) % 16);
const letter = this.props.title.trim().charAt(0);
const color = 'lettertile '
+ (isGroup ? 'color' : 'dark-color')
+ (Math.abs(stringHash(this.props.topic)) % 16);
avatar = (<div className={color}><div>{letter}</div></div>)
} else {
avatar = (Tinode.topicType(this.props.topic) == 'grp') ?
avatar = isGroup ?
<i className="material-icons">group</i> :
<i className="material-icons">person</i>;
}
+2 -2
View File
@@ -13,7 +13,7 @@ export default class MoreButton extends React.PureComponent {
}
handleToggle() {
let open = !this.state.open;
const open = !this.state.open;
this.setState({open: open});
if (this.props.onToggle) {
this.props.onToggle(open);
@@ -21,7 +21,7 @@ export default class MoreButton extends React.PureComponent {
}
render() {
return (<label className="small" onClick={this.handleToggle}>{this.props.title}...
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>);
+1 -1
View File
@@ -35,7 +35,7 @@ class ReceivedMarker extends React.PureComponent {
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 red">error_outline</i>); // icon (!)
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) {
+25 -26
View File
@@ -38,32 +38,36 @@ export default class TagManager extends React.Component {
handleTagInput(text) {
this.setState({tagInput: text});
// Check if user entered ',', ' ' or ';'
if (text.length > 0) {
var last = text[text.length-1];
if (last == ',' || last == ' ' || last == ';') {
var tag = text.substr(0, text.length-1).trim();
if (tag.length >= MIN_TAG_LENGTH) {
this.handleAddTag(tag);
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) {
tag = tag.trim();
if (tag.length > 0) {
let tags = this.state.tags.slice(0);
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) {
var tags = this.state.tags.slice(0);
const tags = this.state.tags.slice(0);
tags.splice(index, 1);
this.setState({tags: tags});
if (this.props.onTagsChanged) {
@@ -72,16 +76,9 @@ export default class TagManager extends React.Component {
}
handleSubmit() {
let tags = this.state.tags.slice(0);
let inp = this.state.tagInput.trim();
if (inp.length > 0) {
tags.push(inp);
if (this.props.onTagsChanged) {
this.props.onTagsChanged(tags);
}
}
this.props.onSubmit(tags);
this.setState({activated: false, tagInput: '', tags: this.props.tags});
// 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() {
@@ -92,19 +89,21 @@ export default class TagManager extends React.Component {
}
render() {
var tags = [];
let tags = [];
if (this.state.activated) {
this.state.tags.map(function(tag) {
tags.push({user: tag});
this.state.tags.map((tag) => {
tags.push({user: tag, invalid: (tag.length < MIN_TAG_LENGTH)});
});
} else {
this.state.tags.map(function(tag) {
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>);
tags = (
<i>
<FormattedMessage id="tags_not_found" defaultMessage="No tags defined. Add some." description="" />
</i>
);
}
}
return (
+799 -468
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long