Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d32569633 | ||
|
|
31f2b4afea | ||
|
|
c2e07a89b8 | ||
|
|
ac8f0cf315 | ||
|
|
e4b0868036 | ||
|
|
3790472b4c | ||
|
|
0d2637861d | ||
|
|
4773ee5f64 | ||
|
|
30e6d5e300 | ||
|
|
53e28e3bc6 | ||
|
|
8cb96e9254 | ||
|
|
b200dc2525 | ||
|
|
5c688007c8 | ||
|
|
64021fe428 | ||
|
|
b6adc032a2 | ||
|
|
644b663ed1 | ||
|
|
56cd91d40e | ||
|
|
9eb9df649d | ||
|
|
dbf47847fe | ||
|
|
5ddace97f4 | ||
|
|
605cd12a6e | ||
|
|
a71aee9487 | ||
|
|
51e0fc420f | ||
|
|
552c891cc3 | ||
|
|
cd092108c2 | ||
|
|
4715ea4497 | ||
|
|
ed9a92896c | ||
|
|
a81a7b162d | ||
|
|
fa2288c497 | ||
|
|
ddf23d40b2 | ||
|
|
8087fd7e97 | ||
|
|
e6e51f625e | ||
|
|
e53af93409 | ||
|
|
1228d6a534 | ||
|
|
1d47c586d3 | ||
|
|
a964d51ba5 | ||
|
|
cdca509183 | ||
|
|
a6014b84ee | ||
|
|
0748f30cc1 | ||
|
|
ded440078a |
+119
-12
@@ -1590,11 +1590,18 @@
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.organization-department.expanded {
|
||||
/* Renamed from .expanded -- this rule drives the blue "this is my department" highlight from
|
||||
docs/images/部门组织框架.png (only the department the current user directly belongs to, e.g.
|
||||
"技术4人" in the reference, not every expanded node -- the reference's always-expanded root
|
||||
"总裁办50人" stays plain black). Was keyed off the same class as the expand/collapse chevron
|
||||
state, which colored ANY expanded department blue including the root, on every page load, with
|
||||
nothing to do with where the current user actually is -- see workplace-collaboration-view.jsx's
|
||||
DeptRow/DeptNode comments for the live repro (via stub data, this tenant's real org tree is empty). */
|
||||
.organization-department.me {
|
||||
color: #2094f3;
|
||||
}
|
||||
|
||||
.organization-department.expanded > .material-icons:first-child {
|
||||
.organization-department.me > .material-icons:first-child {
|
||||
color: #2094f3;
|
||||
}
|
||||
|
||||
@@ -3608,6 +3615,35 @@ img.channel {
|
||||
color: var(--clr-primary-accent);
|
||||
}
|
||||
|
||||
/* 6.3 加入群聊.png: the one system_action message (group_invitation_created) that carries a real
|
||||
accept button -- see chat-message.jsx's renderGroupInvitationCard. */
|
||||
.message-group-invitation button {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
color: #fff;
|
||||
background: var(--clr-primary-accent);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.message-group-invitation button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.message-group-invitation em {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--clr-text-secondary);
|
||||
font-style: normal;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.message-group-invitation-error {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #df4e46;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Multi-select mode (Ch.4 4.15-4.16). */
|
||||
.select-checkbox {
|
||||
align-self: center;
|
||||
@@ -4355,9 +4391,15 @@ button.full-screen .material-icons {
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
z-index: 2;
|
||||
/* Draggable while in a fullscreen connected video call (call-panel.jsx's dragStart); the grab
|
||||
cursor is the only hint of that before a viewer ever tries, since unlike the minimized bubble
|
||||
there's no other affordance. Not shown while [disabled] (audio-only calls / minimized, where
|
||||
dragging this box does nothing -- see call-panel.jsx). */
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.call-party.self[disabled] {
|
||||
cursor: unset;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -4611,6 +4653,17 @@ video {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 群系统事件(成员加入/角色变更/公告发布等),docs/images/内容沟通对话3-开启进群验证与拉人状态.png
|
||||
显示为不带背景的纯居中灰字,跟需要背景色块区分"这是一条独立提示"的 date/deleted 两种 .meta
|
||||
变体不一样——公告/进群这类事件本身已经在句子里说明了是系统提示,不需要额外的气泡背景。 */
|
||||
.meta .bubble.system {
|
||||
max-width: 90%;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background-color: transparent;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Left triangle placed bottom left flush. */
|
||||
.left .bubble.tip::before {
|
||||
content: '';
|
||||
@@ -9207,6 +9260,7 @@ div.toast.show {
|
||||
background: #ef4653;
|
||||
font-size: 0.7rem;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.people-group h2 {
|
||||
@@ -9339,6 +9393,13 @@ div.toast.show {
|
||||
border-bottom: 1px solid #eceef1;
|
||||
}
|
||||
|
||||
/* .people-request-list article is now clickable (role="button", opens the real chat -- see
|
||||
contact-management-view.jsx's FriendRequests) -- the others in the selector above aren't, so this
|
||||
is its own rule rather than joining the block just above. */
|
||||
.people-request-list article[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.people-request-list p,
|
||||
.group-request-list p {
|
||||
max-width: 24rem;
|
||||
@@ -11362,6 +11423,62 @@ div.toast.show {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Same generic confirm-sheet look this app already uses in personal-settings-view.jsx
|
||||
(.personal-sheet-backdrop/.personal-confirm-sheet, 46a98782) and group-management-view.jsx
|
||||
(.group-sheet-backdrop/.group-confirm-sheet, 4773ee5f) -- each view file keeps its own
|
||||
identically-styled copy under its own class prefix rather than a shared cross-file component. */
|
||||
.library-sheet-backdrop {
|
||||
display: grid;
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(22,26,31,0.52);
|
||||
}
|
||||
|
||||
.library-confirm-sheet {
|
||||
width: min(100%, 25rem);
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.library-confirm-sheet > .material-icons {
|
||||
color: #4d73aa;
|
||||
font-size: 2.8rem;
|
||||
}
|
||||
|
||||
.library-confirm-sheet h2 {
|
||||
margin: 0.65rem 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.library-confirm-sheet p {
|
||||
margin: 0.7rem 0 1rem;
|
||||
color: #5c646e;
|
||||
line-height: 1.6;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.library-confirm-sheet button {
|
||||
width: 100%;
|
||||
min-height: 2.9rem;
|
||||
margin: 0.45rem 0 0;
|
||||
padding: 0 1rem;
|
||||
border: 0;
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: none;
|
||||
color: #4d5660;
|
||||
background: #f0f2f4;
|
||||
}
|
||||
|
||||
.library-confirm-sheet button.danger {
|
||||
color: #fff;
|
||||
background: #d84950;
|
||||
}
|
||||
|
||||
.library-section-title {
|
||||
width: min(100%, 52rem);
|
||||
margin: 0 auto;
|
||||
@@ -12680,16 +12797,6 @@ div.toast.show {
|
||||
height: 4.3rem;
|
||||
}
|
||||
|
||||
.people-request-list article,
|
||||
.group-request-list article {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.people-request-list article > span,
|
||||
.group-request-list article > span {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.people-profile-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1523,7 +1523,7 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "稱呼",
|
||||
"translation": "語音電話",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
|
||||
+1
-1
@@ -1523,7 +1523,7 @@
|
||||
"obsolete": false
|
||||
},
|
||||
"menu_item_audio_call": {
|
||||
"translation": "称呼",
|
||||
"translation": "语音电话",
|
||||
"defaultMessage": "Call",
|
||||
"description": "Start audio call",
|
||||
"missing": false,
|
||||
|
||||
@@ -43,6 +43,7 @@ class AccSecurityView extends React.Component {
|
||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
|
||||
this.handleLogoutRequest = this.handleLogoutRequest.bind(this);
|
||||
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
|
||||
}
|
||||
|
||||
@@ -89,6 +90,26 @@ class AccSecurityView extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
// "退出登录"这个按钮原来是 onClick 里直接 `this.props.onLogout()`,没有任何二次确认——同一个文件里
|
||||
// 紧挨着的"删除帐户"按钮反而是走 onShowAlert 弹确认框的(见上面 handleDeleteAccount)。本页
|
||||
// ('security' 路由)并不是废弃页面:account-settings-view.jsx、info-view.jsx 的"安全"入口,以及
|
||||
// 第2章系统消息列表里"前往安全设置"跳转按钮(message-center-view.jsx)都会真的落到这个页面,
|
||||
// 跟 personal-settings-view.jsx 的"设置"页(那边的退出登录已经在别的改造里换成了自己的
|
||||
// ConfirmSheet/LogoutConfirm)是两条并存的独立路径——用户从系统消息点"前往安全设置"进来,单击一下
|
||||
// 图标就会被立即退出登录,且没有任何撤销机会。改成跟"删除帐户"一样走 onShowAlert,文案与
|
||||
// personal-settings-view.jsx 的 LogoutConfirm 保持一致,不新造一套措辞。
|
||||
handleLogoutRequest(e) {
|
||||
e.preventDefault();
|
||||
this.props.onShowAlert(
|
||||
'退出登录',
|
||||
'确定要退出当前登录吗?',
|
||||
(_ => this.props.onLogout()),
|
||||
null, // use default text "OK"
|
||||
true, // Show Reject button
|
||||
null // use default text "Cancel"
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>{this.state.showPermissionEditorFor ?
|
||||
@@ -143,7 +164,7 @@ class AccSecurityView extends React.Component {
|
||||
</div>
|
||||
<div className="hr" />
|
||||
<div className="panel-form-column">
|
||||
<a href="#" className="danger flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
|
||||
<a href="#" className="danger flat-button" onClick={this.handleLogoutRequest}>
|
||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
|
||||
defaultMessage="Logout" description="Button [Logout]" />
|
||||
</a>
|
||||
|
||||
@@ -486,6 +486,13 @@ function Register({errorText, errorLevel, persist, onImRegisterRequest, onPersis
|
||||
没有它,一个真实有效、首字母是小写的邀请码会被静默改错且用户毫无察觉。 */}
|
||||
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
|
||||
placeholder="请输入邀请码(非必填)" error={errors.invite} autoCapitalize="off" autoCorrect="off" />
|
||||
{/* 需求文档 1.4 第5条硬性要求:"用户名方式不发送短信,必须填写有效邀请码;注册前提示
|
||||
'未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置'"——原来这句话在整个
|
||||
注册表单里完全没有出现过(不是视觉遗漏,是从未写过对应的 JSX),用户名注册的人完全不知道
|
||||
自己放弃了手机号找回密码的能力。只在用户名路径(未填手机号)显示,不打扰手机号注册路径
|
||||
(那条路径本来就能自助找回密码,不需要这条提醒),参考图(注册页面.png)本身是两个字段
|
||||
都还没交互的初始态,不代表这条路径相关提示不应该出现。 */}
|
||||
{!usingPhone ? <p className="auth-help">未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置</p> : null}
|
||||
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
|
||||
{submitting ? '注册中…' : '注册'}
|
||||
</button>
|
||||
@@ -671,6 +678,16 @@ function Realname({authenticated = false}) {
|
||||
const data = await submitRealname({name: name.trim(), id_type: 'cn_id', id_number: idNumber.toUpperCase()});
|
||||
setStatus(data.status || 'pending');
|
||||
setError('');
|
||||
// The submissions POST only ever returns {status, submission_id} -- never masked_name/
|
||||
// masked_id_number -- but GET /security/realname has carried both fields since this feature's
|
||||
// original commit (confirmed against server source), we just weren't calling it again right
|
||||
// after a successful submit. Best-effort: the submission itself already succeeded above, so a
|
||||
// failure here shouldn't take that away from the user -- it just means the pre-existing
|
||||
// fallback below (this session's own typed draft) keeps covering the display like before.
|
||||
getRealnameStatus().then(fresh => {
|
||||
if (fresh.masked_name) setMaskedName(fresh.masked_name);
|
||||
if (fresh.masked_id_number) setMaskedIdNumber(fresh.masked_id_number);
|
||||
}).catch(() => {});
|
||||
} catch (err) {
|
||||
setError(err.message || '提交失败,请稍后重试');
|
||||
} finally {
|
||||
@@ -694,12 +711,14 @@ function Realname({authenticated = false}) {
|
||||
一旦账号真的走到 verified 状态就会显示一句参考图里根本不存在的标题。 */}
|
||||
<h2>请您使用有效身份证信息认证</h2>
|
||||
<form onSubmit={submit}>
|
||||
{/* submitRealname's success response never actually carries masked_name/masked_id_number
|
||||
(only {status, submission_id}) -- falling back to what was just typed (still sitting in
|
||||
`name`/`idNumber` local state) means a fresh submission at least shows the user what
|
||||
they submitted, instead of switching straight from their typed input to two blank
|
||||
fields the instant `editable` flips false. Only degrades to genuinely blank on a fresh
|
||||
page load with no draft in this session, which is the pre-existing, unavoidable case. */}
|
||||
{/* submitRealname's own success response never carries masked_name/masked_id_number (only
|
||||
{status, submission_id}) -- submit() above now follows up with a GET /security/realname
|
||||
to fetch the server's authoritative masked values right after a successful submission, so
|
||||
this local-draft fallback (`name`/`idNumber`, still sitting in state from what was just
|
||||
typed) only matters for the brief window before that GET resolves. It still degrades to
|
||||
genuinely blank on a fresh page load with no draft in this session and no masked value
|
||||
from a prior GET either (e.g. a submission made in an earlier session) -- that residual
|
||||
case isn't fixable from here since it depends on the follow-up GET actually having run. */}
|
||||
<label><span>姓名</span><input value={editable ? name : (maskedName || name)} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
|
||||
<label><span>证件号码</span><input value={editable ? idNumber : (maskedIdNumber || idNumber)} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
|
||||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||||
@@ -806,6 +825,26 @@ export default function AuthOnboardingView({
|
||||
onLoginRequest, onPersistenceChange, onImRegisterRequest, onError
|
||||
}) {
|
||||
const tenantName = sessionStorage.getItem('im-tenant-name') || '';
|
||||
|
||||
// errorText/errorLevel is one flat, top-level (tinode-web.jsx) piece of state shared by every
|
||||
// view in the whole app -- it's never tied to which sidepanel is showing. Login is the only view
|
||||
// here that already clears it on its own (its "切换企业" link calls onError() before navigating),
|
||||
// because it was written knowing it can carry a leftover error INTO the tenant screen. Every other
|
||||
// transition between these auth views was still missing that: submit a bad invite code on
|
||||
// Register (errorText='邀请码错误,请重新输入', view stays 'register', shown correctly), then just
|
||||
// click "已有账号?登录" -- Login mounts and, since it renders the exact same errorText/errorLevel
|
||||
// props, shows that same registration error as if it were a login failure, with zero user action
|
||||
// on this new page. Same the other way: a stale "账号或密码错误" from Login is still sitting there
|
||||
// to greet a fresh Register visit. Fixing every call site that navigates between these views
|
||||
// (TenantEntry/Login/Register/... several of which don't even receive onError as a prop today)
|
||||
// would be a lot of scattered one-off edits for the same bug; clearing it centrally whenever the
|
||||
// resolved `view` itself changes covers all of them (button clicks and browser back/forward alike)
|
||||
// from the one place they all already funnel through.
|
||||
React.useEffect(() => {
|
||||
if (onError) onError();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
switch (view) {
|
||||
case 'tenant': return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
|
||||
case 'login': return <Login tenantName={tenantName} login={login} loginDisabled={loginDisabled} persist={persist}
|
||||
|
||||
@@ -172,7 +172,7 @@ function requestStatusLabel(item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function FriendRequests({onToast}) {
|
||||
function FriendRequests({onToast, onTopicSelected, onCreateTopic}) {
|
||||
const [items, setItems] = React.useState([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
@@ -189,13 +189,19 @@ function FriendRequests({onToast}) {
|
||||
onToast(err.message || '操作失败');
|
||||
});
|
||||
};
|
||||
// 参考图 个人对话框(对方的状态)/(我的状态).png:申请的验证留言应该在真实聊天窗口里以消息
|
||||
// 气泡展示,横幅提示("以上是打招呼的消息"/"好友申请正在等待处理")已经在 messages-view.jsx
|
||||
// 里做对了,但这个列表原来每一行完全没有 onClick,点了毫无反应——点进去看这条留言/横幅的入口
|
||||
// 一直不存在。跟 Profile 的 openChat 用同一个"优先走 onCreateTopic 保证冷启动也能订阅"的写法。
|
||||
const openChat = item => (onCreateTopic || onTopicSelected) &&
|
||||
(onCreateTopic || onTopicSelected)(item.direction === 'inbound' ? item.applicant_id : item.target_user_id);
|
||||
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">
|
||||
{loading ? <p className="people-empty-hint">加载中…</p> : items.length === 0 ? <p className="people-empty-hint">{loadError ? '加载失败,请重试' : '暂无新朋友申请'}</p> : null}
|
||||
{items.map(item => <article key={item.request_id}>
|
||||
{items.map(item => <article key={item.request_id} role="button" tabIndex={0} onClick={() => openChat(item)}>
|
||||
<Avatar person={item.peer} />
|
||||
<div><strong>{(item.peer && item.peer.display_name) || item.applicant_id}</strong><p>{item.message || '请求添加您为好友'}</p><small>{new Date(item.created_at).toLocaleString()}</small></div>
|
||||
{item.status === 'pending' && item.direction === 'inbound' ?
|
||||
<span><button type="button" onClick={() => handle(item, false)}>拒绝</button><button type="button" className="primary" onClick={() => handle(item, true)}>通过验证</button></span> :
|
||||
<span><button type="button" onClick={e => { e.stopPropagation(); handle(item, false); }}>拒绝</button><button type="button" className="primary" onClick={e => { e.stopPropagation(); handle(item, true); }}>通过验证</button></span> :
|
||||
<em>{requestStatusLabel(item)}</em>}
|
||||
</article>)}
|
||||
</main></div>;
|
||||
@@ -356,6 +362,20 @@ function Verification({uid, onToast}) {
|
||||
React.useEffect(() => {
|
||||
if (!uid) return;
|
||||
getUserProfile(uid).then(data => { writeCachedData('profile-seed:' + uid, data); setProfile(data); }).catch(() => {});
|
||||
// getUserProfile 403s for anyone who isn't currently a friend (server privacy default) -- same
|
||||
// gap Profile already works around below. Without this, a `profile-seed` left over from *earlier
|
||||
// in the same session* (e.g. this pair really were friends before one side unfriended them, or a
|
||||
// request was sent and then rejected) stayed on screen forever: alreadyFriend/alreadyPending read
|
||||
// straight off `profile.relationship`, and the 403 catch above has nothing to correct it with.
|
||||
// Confirmed live: unfriend someone, then re-open this exact page for them (still reachable via
|
||||
// their still-cached, now-stale seed) -- it kept claiming "你们已经是好友了" and hid the compose
|
||||
// box entirely, with no way to send a new request short of a full page reload to drop the cache.
|
||||
// getConversationContext isn't gated on friendship and reports the true current relationship.
|
||||
getConversationContext(uid).then(ctx => {
|
||||
if (ctx && ctx.relationship) {
|
||||
setProfile(current => current ? {...current, relationship: ctx.relationship} : current);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [uid]);
|
||||
const alreadyPending = profile && (profile.relationship === 'pending_outbound' || profile.relationship === 'pending_inbound');
|
||||
const alreadyFriend = profile && profile.relationship === 'friend';
|
||||
@@ -366,7 +386,17 @@ function Verification({uid, onToast}) {
|
||||
// Patch the seed Profile will re-read on remount -- its own background getUserProfile refresh
|
||||
// can't do this for us, since a stranger-turned-pending peer still fails that gated endpoint
|
||||
// the exact same way a stranger does (server only exempts an established "friend").
|
||||
writeCachedData('profile-seed:' + uid, {...(readCachedData('profile-seed:' + uid) || {}), relationship: 'pending_outbound'});
|
||||
// Must also flip capabilities.can_add_friend to false here, not just relationship: Profile's
|
||||
// canAdd render check (contact-management-view.jsx ~line 444) reads capabilities.can_add_friend,
|
||||
// not relationship, directly. Leaving the stale can_add_friend:true (copied verbatim from the
|
||||
// pre-request search-result seed) meant Profile kept showing an active "添加好友" button after
|
||||
// a request was already sent -- confirmed live: send request, navigate back to this profile
|
||||
// (the verification screen's own auto-redirect 800ms later, or its back button, both land here),
|
||||
// and the button was still clickable, capable of firing a second, backend-undeduped request
|
||||
// (see 新增44 in the backend issue list) purely because of this front-end staleness.
|
||||
const priorSeed = readCachedData('profile-seed:' + uid) || {};
|
||||
writeCachedData('profile-seed:' + uid, {...priorSeed, relationship: 'pending_outbound',
|
||||
capabilities: {...priorSeed.capabilities, can_add_friend: false}});
|
||||
window.setTimeout(() => navigate('group-member-profile', uid), 800);
|
||||
}).catch(err => onToast(err.message || '发送失败')).finally(() => setSending(false));
|
||||
};
|
||||
@@ -382,7 +412,7 @@ function Verification({uid, onToast}) {
|
||||
</main></div>;
|
||||
}
|
||||
|
||||
function Profile({uid, friend, onTopicSelected, onToast}) {
|
||||
function Profile({uid, friend, onTopicSelected, onCreateTopic, onToast}) {
|
||||
const [profile, setProfile] = React.useState(() => (uid ? readCachedData('profile-seed:' + uid) : null));
|
||||
// A failed fetch with no seed to fall back on previously left this stuck on "加载中…" forever --
|
||||
// tracked separately from `profile` itself (which legitimately stays null while genuinely still
|
||||
@@ -410,9 +440,19 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
|
||||
// handles below. getConversationContext resolves for a P2P conversation regardless of block
|
||||
// status and correctly reports the real relationship, so it's used here purely to refresh that
|
||||
// one field on top of whatever getUserProfile/the seed cache already provided for the rest.
|
||||
//
|
||||
// 同时必须把 ctx.capabilities 一起合并进去,不能只更新 relationship:canAdd 这个渲染判断读的是
|
||||
// profile.capabilities.can_add_friend,不是 relationship 本身。实测复现:从陌生人搜索结果进来的
|
||||
// seed 一直带着 capabilities.can_add_friend=true(搜索时 can_add 确实是 true),发送好友申请后
|
||||
// relationship 变成 pending_outbound,可这里原来只覆盖 relationship 字段,capabilities 原样保留
|
||||
// 着申请前的 can_add_friend:true——canAdd 分支判断不看 relationship,于是"添加好友"按钮在申请
|
||||
// 已发出后继续显示、可以重复点击再次发起申请(配合后端不去重的缺口——新增44——实际造成重复申请)。
|
||||
// ctx.capabilities 本身就是服务端针对当前 relationship 算出来的权威值(此时 can_add_friend 已经
|
||||
// 是 false),直接一起合并即可,不需要在前端另外推导一套"这些 relationship 不能加好友"的规则。
|
||||
getConversationContext(uid).then(ctx => {
|
||||
if (ctx && ctx.relationship) {
|
||||
setProfile(current => current ? {...current, relationship: ctx.relationship} : current);
|
||||
setProfile(current => current ? {...current, relationship: ctx.relationship,
|
||||
capabilities: ctx.capabilities || current.capabilities} : current);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [uid]);
|
||||
@@ -444,7 +484,17 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
|
||||
// Real native Tinode p2p topic name (from my side) is just the peer's own user_id ("usrXXXX") --
|
||||
// NOT `profile.topic` (a separate "p2pXXXX" identifier the custom REST layer uses internally,
|
||||
// which Tinode.topicType() doesn't recognize, so selecting it left the message pane blank).
|
||||
const openChat = () => onTopicSelected(uid);
|
||||
//
|
||||
// Must go through onCreateTopic (tinode-web.jsx's handleStartTopicRequest), not the plain
|
||||
// onTopicSelected -- confirmed live: a freshly-accepted friend with zero prior message history has
|
||||
// no locally cached p2p topic yet, and onTopicSelected (handleTopicSelected) only *selects* a topic,
|
||||
// it never sends the native {sub} request that actually creates/joins it. messages-view.jsx's own
|
||||
// isWriter stays stuck at its default `false` (no ACS ever arrives because nothing subscribed),
|
||||
// permanently showing "消息已禁用" with no compose box and no way to send the first message --
|
||||
// 100% reproducible for every real "just became friends, say hi" case, not an edge case.
|
||||
// handleStartTopicRequest already guards the case this topic *does* have history (isTopicCached),
|
||||
// delegating straight to handleTopicSelected then, so this is safe to call unconditionally.
|
||||
const openChat = () => (onCreateTopic || onTopicSelected)(uid);
|
||||
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'}
|
||||
right={isFriend ? '设置' : null} onRight={() => navigate('friend-settings', uid)} />
|
||||
<main className="people-profile">
|
||||
@@ -467,7 +517,17 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
|
||||
<button type="button" onClick={openChat}><Icon>call</Icon>音视频通话</button>
|
||||
</div> :
|
||||
canAdd ? <button type="button" className="people-primary" onClick={() => navigate('friend-verification', uid)}><Icon>person_add_alt</Icon>添加好友</button> :
|
||||
<p className="people-empty-hint">{cannotAddReason}</p>}
|
||||
// cannotAddReason 覆盖的四种关系(blocked_by_peer/blocked_by_me/pending_inbound/
|
||||
// pending_outbound)在 messages-view.jsx 里都已经有对应的、真实可用的聊天视图(对方发来的
|
||||
// 留言气泡+"以上是打招呼的消息"横幅,或"好友申请正在等待处理"横幅,或拉黑后仍可输入、发送
|
||||
// 失败再展示具体原因)——原来这个非好友分支只显示 cannotAddReason 这段纯文字,没有任何
|
||||
// 入口能点进去看。同一套 openChat(优先 onCreateTopic 保证冷启动)。按钮不套进
|
||||
// .people-profile-actions 的两列grid(那是给"发消息+音视频通话"两个平级按钮设计的),
|
||||
// 单独一个满宽按钮,跟上面纯文字提示分两行。
|
||||
<>
|
||||
<p className="people-empty-hint">{cannotAddReason}</p>
|
||||
<button type="button" className="people-primary" onClick={openChat}><Icon>chat_bubble_outline</Icon>查看聊天</button>
|
||||
</>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -625,17 +685,17 @@ function ConfirmSheet({type, uid, tinode, onToast}) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function ContactManagementView({view, hashParams, myUserId, tinode, onTopicSelected, onOpenScanner}) {
|
||||
export default function ContactManagementView({view, hashParams, myUserId, tinode, onTopicSelected, onCreateTopic, onOpenScanner}) {
|
||||
const [toast, setToast] = React.useState('');
|
||||
const uid = hashParams && hashParams.uid;
|
||||
let content;
|
||||
if (view === 'address-book') content = <AddressBook myUserId={myUserId} onTopicSelected={onTopicSelected} />;
|
||||
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />;
|
||||
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} onTopicSelected={onTopicSelected} onCreateTopic={onCreateTopic} />;
|
||||
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} onToast={setToast} onOpenScanner={onOpenScanner} />;
|
||||
else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />;
|
||||
else if (view === 'friend-qr') content = <FriendQr uid={uid} onToast={setToast} />;
|
||||
else if (view === 'friend-verification') content = <Verification uid={uid} onToast={setToast} />;
|
||||
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onToast={setToast} />;
|
||||
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile uid={uid} friend={view === 'friend-profile'} onTopicSelected={onTopicSelected} onCreateTopic={onCreateTopic} onToast={setToast} />;
|
||||
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings uid={uid} initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
|
||||
else if (view === 'block-list') content = <BlockList onToast={setToast} />;
|
||||
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" uid={uid} tinode={tinode} onToast={setToast} />;
|
||||
|
||||
@@ -266,10 +266,17 @@ function FavoriteItem({item, onOpen, selecting, selected, onSelect, query, onToa
|
||||
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links', image: 'favorites-image', video: 'favorites-video'};
|
||||
const TYPE_LABELS = [['all', '全部'], ['image', '图片'], ['video', '视频'], ['file', '文件'], ['voice', '语音'], ['text', '文本'], ['link', '链接']];
|
||||
|
||||
function Favorites({view, tinode, onToast}) {
|
||||
function Favorites({view, hashParams, tinode, onToast}) {
|
||||
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : view === 'favorites-image' ? 'image' : view === 'favorites-video' ? 'video' : 'all';
|
||||
const selecting = view === 'favorites-select';
|
||||
const [type, setType] = React.useState(selecting ? 'file' : initialType);
|
||||
// 'favorites-select' 是所有类型 tab 共用的同一个路由,本身不带任何类型信息——原来不管进入
|
||||
// 多选前停在哪个 tab,一律硬编码回退成 'file'。实测复现:在"文本"tab 点"多选",列表和头部
|
||||
// 的"已选择 N 项"计数都静默换成了"文件"tab 的数据(tab 高亮也跟着跳到"文件"),用户在视觉上
|
||||
// 完全看不出发生了什么,勾选/删除会作用在自己压根没看到、以为还是文本收藏的文件项上——测试
|
||||
// 过程中真的因为这个把一条本不该删的文件收藏删掉了。现在"多选"按钮把当前 type 作为 hash 参数
|
||||
// 带过去,这里读回来,保留进入多选前用户实际所在的 tab;只有直接深链到 'favorites-select'
|
||||
// 且没带 type 参数时才落回原来的 'file' 默认值。
|
||||
const [type, setType] = React.useState(selecting ? ((hashParams && hashParams.type) || 'file') : initialType);
|
||||
// 输入值与实际发请求用的值分开:原来 query 直接进 load 的依赖数组,输入 6 个字的关键词就打
|
||||
// 6 次接口,而且返回顺序没有保证——后发的短前缀请求可能后到、把正确结果覆盖掉。
|
||||
const [queryInput, setQueryInput] = React.useState('');
|
||||
@@ -283,6 +290,16 @@ function Favorites({view, tinode, onToast}) {
|
||||
const [items, setItems] = React.useState(() => readCachedData('favorites:' + type) || null);
|
||||
const [cursor, setCursor] = React.useState('');
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
// A failed initial load (no cached list to fall back on) previously left this stuck on "加载中…"
|
||||
// forever -- the catch handler only fired a toast, which fades in 2s, and `items` never left null
|
||||
// so the loading branch below never had anything else to show. Same fix already applied to this
|
||||
// file's own DownloadPage/GroupFiles(group-management-view.jsx)/Profile(contact-management-view.jsx)
|
||||
// for the identical reason; Favorites/History were missed. Concretely hit live: favoriting a large
|
||||
// (out-of-band) file attachment makes the *server* 500 on GET /favorites?type=all from then on
|
||||
// (a real backend bug, tracked separately -- not something the frontend can prevent), and every
|
||||
// subsequent visit to "全部" silently hung on the loading spinner with only a 2s toast, no way to
|
||||
// tell "still loading" from "never going to load", no retry affordance.
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const [fileUrls, setFileUrls] = React.useState({});
|
||||
const [sourceNames, setSourceNames] = React.useState({});
|
||||
// An in-flight request isn't guaranteed to resolve before a newer one its own type/query change
|
||||
@@ -295,6 +312,7 @@ function Favorites({view, tinode, onToast}) {
|
||||
const requestId = ++requestIdRef.current;
|
||||
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setLoadError(false);
|
||||
setItems(prev => {
|
||||
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
||||
if (!query) writeCachedData('favorites:' + type, list);
|
||||
@@ -302,9 +320,14 @@ function Favorites({view, tinode, onToast}) {
|
||||
});
|
||||
setCursor(data.next_cursor || '');
|
||||
setHasMore(!!data.next_cursor);
|
||||
}).catch(err => onToast(err.message || '加载失败'));
|
||||
}).catch(err => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
onToast(err.message || '加载失败');
|
||||
if (!append) setLoadError(true);
|
||||
});
|
||||
}, [type, query, cursor]);
|
||||
React.useEffect(() => {
|
||||
setLoadError(false);
|
||||
setItems((!query && readCachedData('favorites:' + type)) || null);
|
||||
load(false);
|
||||
}, [type, query]);
|
||||
@@ -324,8 +347,13 @@ function Favorites({view, tinode, onToast}) {
|
||||
});
|
||||
}, [items]);
|
||||
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id));
|
||||
const deleteSelected = () => {
|
||||
if (!window.confirm(`确定删除已选择的 ${selected.length} 项收藏吗?`)) return;
|
||||
// 原来是裸 window.confirm()——跟这个工程已经修过的另外两处同一类问题(46a98782"退出登录"、
|
||||
// 4773ee5f"解散群组")一样:跟本项目其它地方的自定义确认弹层视觉不一致,且部分内嵌WebView/
|
||||
// kiosk环境会直接禁用同步对话框(confirm()恒返回false,删除被静默拦下、用户毫无感知)。
|
||||
// 改成两段式 state:点"删除"先进"确认中"态渲染弹层,弹层里的"确定"才真正调用删除接口。
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState(false);
|
||||
const confirmDelete = () => {
|
||||
setConfirmingDelete(false);
|
||||
batchDeleteFavorites(selected).then(result => {
|
||||
const okIds = new Set(((result && result.items) || []).filter(r => r.ok).map(r => r.favorite_id));
|
||||
setItems(values => {
|
||||
@@ -359,13 +387,21 @@ function Favorites({view, tinode, onToast}) {
|
||||
};
|
||||
const list = items || [];
|
||||
return <div className="library-page">
|
||||
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'}
|
||||
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
|
||||
{/* "返回"/"取消"都要回到多选前那个类型 tab,而不是硬编码的"文件"——TYPE_ROUTE 查不到时
|
||||
(比如 type 还是初始的 'all') 才退回 'favorites-files',跟原来的兜底保持一致。 */}
|
||||
<Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? (TYPE_ROUTE[type] || 'favorites-files') : 'messages-home'}
|
||||
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? (TYPE_ROUTE[type] || 'favorites-files') : 'favorites-select', selecting ? undefined : {type})} />
|
||||
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索收藏内容" />
|
||||
<nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''}
|
||||
onClick={() => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigateReplace(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
|
||||
// 多选模式下切 tab 之前,selected 存的是"收藏 id 的集合",不带类型信息——原来只切 type、
|
||||
// 不清 selected,导致在"文件"tab 选中 2 项后切到"图片"tab,头部仍显示"已选择 2 项"、点
|
||||
// 删除/转发会真的作用在两个已经从当前列表消失、用户此刻根本看不到也无法勾选/取消的文件上,
|
||||
// 且没有任何提示告诉用户这两项还算在内。多选场景下天然只该管"当前这个列表里勾了什么",
|
||||
// 跨 tab 静默累加选择集不是这里的预期行为(对照参考图"收藏—文件(多选)"也只画了单一
|
||||
// 类型列表内的勾选,没有跨 tab 累加的设计)。
|
||||
onClick={() => { setType(id); setSelected([]); if (TYPE_ROUTE[id] && !selecting) navigateReplace(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
|
||||
<main className="library-favorite-list">
|
||||
{items === null ? <p className="library-empty-hint">加载中…</p> :
|
||||
{items === null ? <p className="library-empty-hint">{loadError ? '加载失败,请重试' : '加载中…'}</p> :
|
||||
list.length ? <>{list.map(item => <FavoriteItem key={item.favorite_id} item={{...item, _thumbUrl: fileUrls[item.favorite_id],
|
||||
source_display: sourceNames[item.source && item.source.topic]}}
|
||||
selecting={selecting} selected={selected.includes(item.favorite_id)} onSelect={() => toggle(item.favorite_id)}
|
||||
@@ -375,8 +411,13 @@ function Favorites({view, tinode, onToast}) {
|
||||
</main>
|
||||
{selecting ? <nav className="library-batch-bar">
|
||||
<button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share', {ids: selected.join(',')})}><Icon>share</Icon><span>转发</span></button>
|
||||
<button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button>
|
||||
<button type="button" disabled={!selected.length} onClick={() => setConfirmingDelete(true)}><Icon>delete_outline</Icon><span>删除</span></button>
|
||||
</nav> : null}
|
||||
{confirmingDelete ? <div className="library-sheet-backdrop"><section className="library-confirm-sheet" role="dialog" aria-modal="true">
|
||||
<Icon>delete_outline</Icon><h2>删除收藏</h2><p>确定删除已选择的 {selected.length} 项收藏吗?</p>
|
||||
<button type="button" className="danger" onClick={confirmDelete}>确定</button>
|
||||
<button type="button" onClick={() => setConfirmingDelete(false)}>取消</button>
|
||||
</section></div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -410,7 +451,9 @@ function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
|
||||
return forTarget.length > 0 && forTarget.every(r => r.status === 'sent');
|
||||
}).length;
|
||||
onToast(successTargets === selected.length ? `已分享给 ${selected.length} 个会话` : `${successTargets}/${selected.length} 个会话分享成功`);
|
||||
window.setTimeout(() => navigate('favorites-files'), 700);
|
||||
// 发完之后回到分享前那个类型 tab,而不是硬编码的"文件"——preview 是这批收藏里第一条的详情,
|
||||
// 拿它的 type 就够找回对应 tab;preview 还没加载完时退回原来的 'favorites-files' 兜底。
|
||||
window.setTimeout(() => navigate(TYPE_ROUTE[preview && preview.type] || 'favorites-files'), 700);
|
||||
}).catch(err => onToast(err.message || '分享失败')).finally(() => setSending(false));
|
||||
};
|
||||
const previewAtt = preview && firstAttachment(preview.snapshot && preview.snapshot.content);
|
||||
@@ -418,7 +461,10 @@ function FavoriteShare({hashParams, myUserId, chatList, onToast}) {
|
||||
(!query || topicDisplayName(c.name, c.public, c.name || '').toLowerCase().includes(query.toLowerCase())));
|
||||
const nameOf = uid => { const t = contacts.find(c => c.name === uid); return (t && topicDisplayName(t.name, t.public, uid)) || uid; };
|
||||
return <div className="library-page">
|
||||
<Header title="分享" back="favorites-select" />
|
||||
{/* 同上一条注释——"favorites-select" 不带类型信息,点这里的返回箭头原来会静默把多选列表
|
||||
换成硬编码的"文件"tab。preview.type 是这批收藏第一条的真实类型,带回去能让返回箭头落回
|
||||
分享前实际所在的 tab。 */}
|
||||
<Header title="分享" back="favorites-select" backParams={preview ? {type: preview.type} : undefined} />
|
||||
{!confirming ? <>
|
||||
<Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" />
|
||||
<p className="library-section-title">最近聊天</p>
|
||||
@@ -594,6 +640,10 @@ function History({view, hashParams, myUserId, onToast}) {
|
||||
const [items, setItems] = React.useState(() => (cacheKey && readCachedData(cacheKey)) || null);
|
||||
const [cursor, setCursor] = React.useState('');
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
// Same "stuck on 加载中… forever" gap as Favorites' loadError above, same fix -- a failed initial
|
||||
// load with nothing cached to fall back on left this on the loading branch permanently, with only
|
||||
// a 2s toast (already faded by the time anyone looks back) as the only sign anything went wrong.
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
const [mediaUrls, setMediaUrls] = React.useState({});
|
||||
const [senderNames, setSenderNames] = React.useState({});
|
||||
// Same stale-response guard as Favorites' load() above and for the same reason -- a slower
|
||||
@@ -608,15 +658,21 @@ function History({view, hashParams, myUserId, onToast}) {
|
||||
getTopicMedia(topic, type, append ? cursor : '');
|
||||
call.then(data => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setLoadError(false);
|
||||
setItems(prev => {
|
||||
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
||||
if (!query && cacheKey) writeCachedData(cacheKey, list);
|
||||
return list;
|
||||
});
|
||||
setCursor(data.next_cursor || ''); setHasMore(!!data.next_cursor);
|
||||
}).catch(err => onToast(err.message || '加载失败'));
|
||||
}).catch(err => {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
onToast(err.message || '加载失败');
|
||||
if (!append) setLoadError(true);
|
||||
});
|
||||
}, [topic, view, query, cursor]);
|
||||
React.useEffect(() => {
|
||||
setLoadError(false);
|
||||
setItems((!query && cacheKey && readCachedData(cacheKey)) || null);
|
||||
load(false);
|
||||
}, [topic, view, query]);
|
||||
@@ -667,7 +723,7 @@ function History({view, hashParams, myUserId, onToast}) {
|
||||
{view === 'history-search' ? <Search value={queryInput} onChange={setQueryInput} placeholder="搜索当前会话" /> : null}
|
||||
<HistoryNav active={view} />
|
||||
<main className="library-history-content">
|
||||
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
|
||||
{items === null ? <p className="library-empty-hint">{loadError ? '加载失败,请重试' : '加载中…'}</p> : !list.length ?
|
||||
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
|
||||
view === 'history-search' ? <section className="library-message-results">
|
||||
{/* 同一处"问题13"修复暴露的缺口,History 自己的一份,只影响 type==='text':非 text 类型
|
||||
@@ -718,7 +774,7 @@ function History({view, hashParams, myUserId, onToast}) {
|
||||
|
||||
export default function ContentLibraryView({view, hashParams, tinode, myUserId, onTopicSelected, chatList}) {
|
||||
const [toast, setToast] = React.useState(''); let content;
|
||||
if (view.startsWith('favorites')) content = <Favorites view={view} tinode={tinode} onToast={setToast} />;
|
||||
if (view.startsWith('favorites')) content = <Favorites view={view} hashParams={hashParams} tinode={tinode} onToast={setToast} />;
|
||||
else if (view === 'favorite-share') content = <FavoriteShare hashParams={hashParams} myUserId={myUserId} chatList={chatList} onToast={setToast} />;
|
||||
else if (view.startsWith('file-download')) content = <DownloadPage hashParams={hashParams} tinode={tinode} onToast={setToast} />;
|
||||
else content = <History view={view} hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// Account registration.
|
||||
import React, { Suspense } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import AvatarCrop from '../widgets/avatar-crop.jsx';
|
||||
import AvatarUpload from '../widgets/avatar-upload.jsx';
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
import PhoneEdit from '../widgets/phone-edit.jsx';
|
||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
||||
|
||||
import LocalStorageUtil from '../lib/local-storage.js';
|
||||
import { imageScaled, blobToBase64, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { theCard } from '../lib/utils.js';
|
||||
import { truncateString } from '../lib/strformat.js';
|
||||
|
||||
import { AVATAR_SIZE, MAX_AVATAR_BYTES, MAX_EXTERN_ATTACHMENT_SIZE,
|
||||
MAX_TITLE_LENGTH } from '../config.js';
|
||||
|
||||
export default class CreateAccountView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
login: '',
|
||||
password: '',
|
||||
meth: '',
|
||||
email: '',
|
||||
tel: '',
|
||||
fn: '', // full/formatted name
|
||||
imageUrl: null,
|
||||
uploadUrl: null,
|
||||
newAvatar: null,
|
||||
newAvatarMime: null,
|
||||
buttonDisabled: 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.handlePhoneChange = this.handlePhoneChange.bind(this);
|
||||
this.handleFnChange = this.handleFnChange.bind(this);
|
||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
||||
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
|
||||
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
|
||||
this.uploadAvatar = this.uploadAvatar.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
|
||||
// Connection will trigger change by changing the this.props.serverVersion.
|
||||
props.tinode.connect()
|
||||
.catch(err => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
handleLoginChange(e) {
|
||||
this.setState({login: e.target.value});
|
||||
}
|
||||
|
||||
handlePasswordChange(password) {
|
||||
this.setState({password: password});
|
||||
}
|
||||
|
||||
handleEmailChange(e) {
|
||||
this.setState({meth: 'email', email: e.target.value});
|
||||
}
|
||||
|
||||
handlePhoneChange(number) {
|
||||
this.setState({meth: 'tel', tel: number});
|
||||
}
|
||||
|
||||
handleFnChange(e) {
|
||||
this.setState({fn: e.target.value});
|
||||
}
|
||||
|
||||
handleImageChanged(mime, img) {
|
||||
this.setState({newAvatar: img, newAvatarMime: mime});
|
||||
if (!img) {
|
||||
// User cleared the image.
|
||||
this.setState({imageUrl: null, uploadUrl: null});
|
||||
}
|
||||
}
|
||||
|
||||
handleToggleSaveToken() {
|
||||
LocalStorageUtil.setObject('keep-logged-in', !this.state.saveToken);
|
||||
this.setState({saveToken: !this.state.saveToken});
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.onCreateAccount(
|
||||
this.state.login.trim(),
|
||||
this.state.password.trim(),
|
||||
theCard(truncateString(this.state.fn.trim(), MAX_TITLE_LENGTH), this.state.uploadUrl),
|
||||
{
|
||||
'meth': this.state.meth,
|
||||
'val': this.state.meth == 'email' ?
|
||||
this.state.email :
|
||||
this.state.meth == 'tel' ? this.state.tel : null
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// AvatarCropView calls this method when the user has cropped the image.
|
||||
handleAvatarCropped(mime, blob, width, height) {
|
||||
const url = blob ? URL.createObjectURL(blob) : null;
|
||||
this.setState({newAvatar: null, newAvatarMime: null});
|
||||
if (blob) {
|
||||
this.uploadAvatar(mime, blob, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
handleAvatarCropCancel() {
|
||||
this.setState({newAvatar: null, newAvatarMime: null});
|
||||
}
|
||||
|
||||
// Utility method for converting cropped avatar blob to bytes for sending inband or
|
||||
// for uploading it to the server out of band.
|
||||
uploadAvatar(mime, blob, width, height) {
|
||||
const readyToUpload = image => {
|
||||
let {mime, blob} = image;
|
||||
this.setState({imageUrl: URL.createObjectURL(blob), buttonDisabled: true});
|
||||
if (blob.size > MAX_AVATAR_BYTES) {
|
||||
// Too large to send inband - uploading out of band and sending as a link.
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
uploader.upload(blob, 'newacc')
|
||||
.then(url => this.setState({uploadUrl: url}))
|
||||
.catch(err => this.props.onError(err.message, 'err'))
|
||||
.finally(_ => this.setState({buttonDisabled: false}));
|
||||
} else {
|
||||
// Convert blob to base64-encoded bits.
|
||||
blobToBase64(blob)
|
||||
.then(b64 => this.setState({uploadUrl: makeImageUrl({data: b64.bits, type: mime})}))
|
||||
.finally(_ => this.setState({buttonDisabled: false}));
|
||||
}
|
||||
};
|
||||
|
||||
if (width > AVATAR_SIZE || height > AVATAR_SIZE || width != height) {
|
||||
// Avatar is not square or too large even after cropping. Shrink it and make square.
|
||||
imageScaled(blob, AVATAR_SIZE, AVATAR_SIZE, MAX_EXTERN_ATTACHMENT_SIZE, true)
|
||||
.then(scaled => readyToUpload(scaled))
|
||||
.catch(err => this.props.onError(err.message, 'err'));
|
||||
} else {
|
||||
readyToUpload({mime: mime, blob: blob, width: width, height: height});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.newAvatar) {
|
||||
return (
|
||||
<AvatarCrop
|
||||
avatar={this.state.newAvatar}
|
||||
mime={this.state.newAvatarMime}
|
||||
onSubmit={this.handleAvatarCropped}
|
||||
onCancel={this.handleAvatarCropCancel}
|
||||
onError={this.props.onError} />
|
||||
);
|
||||
}
|
||||
|
||||
let submitClasses = 'primary';
|
||||
if (this.props.disabled) {
|
||||
submitClasses += ' disabled';
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel-form-column" onSubmit={this.handleSubmit}>
|
||||
<div className="panel-form-row">
|
||||
<div className="umn">
|
||||
<FormattedMessage id="login_prompt" defaultMessage="Login"
|
||||
description="Placeholer for username/login">{
|
||||
(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" defaultMessage="Password"
|
||||
description="Placeholder/prompt for entering password">{
|
||||
(password_prompt) => <VisiblePassword placeholder={password_prompt} autoComplete="new-password"
|
||||
value={this.state.password} onFinished={this.handlePasswordChange}
|
||||
required={true} />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
<AvatarUpload
|
||||
tinode={this.props.tinode}
|
||||
avatar={this.state.imageUrl}
|
||||
onImageUpdated={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>
|
||||
{this.props.reqCredMethod == 'email' ?
|
||||
<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>
|
||||
: this.props.reqCredMethod == 'tel' ?
|
||||
<><div className="panel-form-row">
|
||||
<label className="small gray"><FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
|
||||
description="Prompt for entering a mobile phone number" /></label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<PhoneEdit
|
||||
autoFocus={false}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onSubmit={this.handlePhoneChange} />
|
||||
</div></>
|
||||
: null
|
||||
}
|
||||
<div className="panel-form-row">
|
||||
<CheckBox id="save-token" name="save-token" checked={this.state.saveToken}
|
||||
onChange={this.handleToggleSaveToken} />
|
||||
<FormattedMessage id="stay_logged_in" defaultMessage="Stay logged in"
|
||||
description="Label for a checkbox">{
|
||||
(stay_logged_in) => <label htmlFor="save-token"> {stay_logged_in}</label>
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
<div className="dialog-buttons">
|
||||
<button className={submitClasses} type="submit" disabled={this.state.buttonDisabled}>
|
||||
<FormattedMessage id="button_sign_up" defaultMessage="Sign up"
|
||||
description="Create account button [Sign Up]" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
updateCurrentGroupAnnouncement, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
|
||||
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
|
||||
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile,
|
||||
getGroupMemberProfile, fetchAllPages
|
||||
getGroupMemberProfile, fetchAllPages, getFriends, inviteGroupMembers
|
||||
} from '../lib/im-api.js';
|
||||
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
||||
|
||||
@@ -67,7 +67,7 @@ export const GROUP_MANAGEMENT_VIEWS = [
|
||||
'group-my-nickname', 'group-members', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
|
||||
'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed',
|
||||
'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files',
|
||||
'group-file-upload'
|
||||
'group-file-upload', 'group-invite-friends'
|
||||
];
|
||||
|
||||
// Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather
|
||||
@@ -467,7 +467,12 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
|
||||
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(topic, item.user_id, item)}>
|
||||
<Avatar item={item} icon="person" /><small>{item.group_nickname || item.display_name}</small>
|
||||
</button>)}
|
||||
<button type="button" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}>
|
||||
{/* docs/images/内容沟通对话3-开启进群验证与拉人状态.png 里"你邀请XX、XX加入群聊"证明这条
|
||||
"从好友里选人直接邀请"的路径是产品设计的一部分,POST /groups/{topic}/invitations
|
||||
(inviteGroupMembers) 也早就是个真实可用的接口——原来这个格子只弹一句"请通过转发/分享
|
||||
群二维码邀请成员",把这条路堵死了(二维码分享走的是另一条"申请入群待审批"的路,跟"邀请"
|
||||
语义不同,不能互相替代)。 */}
|
||||
<button type="button" onClick={() => navigate('group-invite-friends', topic)}>
|
||||
<span className="group-member-tile-action"><Icon>add</Icon></span><small>添加</small>
|
||||
</button>
|
||||
</div>
|
||||
@@ -644,6 +649,7 @@ function Governance({topic, myRole, tinode, onToast}) {
|
||||
// and returns, so this is one of the highest-traffic pages in the file.
|
||||
const [gov, setGov] = React.useState(() => (topic && readCachedData('group-governance:' + topic)) || null);
|
||||
const [dissolving, setDissolving] = React.useState(false);
|
||||
const [confirmingDissolve, setConfirmingDissolve] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
@@ -659,18 +665,27 @@ function Governance({topic, myRole, tinode, onToast}) {
|
||||
updateGroupGovernance(topic, {[key]: value, version: gov ? gov.version : 0}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
|
||||
.catch(err => onToast(err.message || '设置失败'));
|
||||
};
|
||||
const dissolve = () => {
|
||||
// 原来是裸 window.confirm()——本工程已经在"退出登录"那次修复里明确认定这类原生弹窗和
|
||||
// 其余确认弹层不一致(personal-settings-view.jsx 的 LogoutConfirm/ConfirmSheet 那条注释:
|
||||
// "a native confirm() is also environment-dependent: some embedded WebViews/kiosk contexts
|
||||
// disable synchronous dialogs outright...silently preventing [the action] with no error
|
||||
// shown")。解散群组同样不可逆、后果同样重大,却是本文件里唯一还在用 window.confirm() 的
|
||||
// 危险操作——本文件自己在 OwnerSelect 里已经有现成的同款弹层视觉语言(.group-sheet-backdrop
|
||||
// /.group-confirm-sheet),改成两段式状态复用它,不再依赖浏览器原生对话框。
|
||||
const requestDissolve = () => {
|
||||
// Backend already refuses to actually destroy the group for a non-owner (the same native
|
||||
// {del hard:true} call it silently downgrades into "remove me from this group" instead) --
|
||||
// but that response is indistinguishable from a real success, so without this gate a member
|
||||
// who somehow lands on this page got told "群聊已解散" when they'd really just left it.
|
||||
if (myRole !== 'owner') return;
|
||||
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
|
||||
setConfirmingDissolve(true);
|
||||
};
|
||||
const dissolve = () => {
|
||||
const t = tinode ? tinode.getTopic(topic) : null;
|
||||
if (!t) return;
|
||||
if (!t) { setConfirmingDissolve(false); return; }
|
||||
setDissolving(true);
|
||||
t.delTopic(true).then(() => { onToast('群聊已解散'); navigate('group-list'); })
|
||||
.catch(err => onToast(err.message || '解散失败')).finally(() => setDissolving(false));
|
||||
.catch(err => { onToast(err.message || '解散失败'); setConfirmingDissolve(false); }).finally(() => setDissolving(false));
|
||||
};
|
||||
if (!gov) return <div className="group-page"><Header title="群组管理" back="group-settings" backTopic={topic} /><main className="group-governance"><p className="group-inline-empty">{loadError ? '群组管理加载失败' : '加载中…'}</p></main></div>;
|
||||
return <div className="group-page">
|
||||
@@ -689,8 +704,14 @@ function Governance({topic, myRole, tinode, onToast}) {
|
||||
<span><strong>{label}</strong><small>{desc}</small></span>
|
||||
<Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} />
|
||||
</div>)}</section>
|
||||
<button type="button" className="group-danger" disabled={dissolving || myRole !== 'owner'} onClick={dissolve}>解散群组</button>
|
||||
<button type="button" className="group-danger" disabled={dissolving || myRole !== 'owner'} onClick={requestDissolve}>解散群组</button>
|
||||
</main>
|
||||
{confirmingDissolve ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
|
||||
<h2>解散群组</h2>
|
||||
<p>解散后群聊将无法恢复,确定要解散该群聊吗?</p>
|
||||
<button type="button" className="danger" disabled={dissolving} onClick={dissolve}>{dissolving ? '处理中…' : '确定'}</button>
|
||||
<button type="button" disabled={dissolving} onClick={() => setConfirmingDissolve(false)}>取消</button>
|
||||
</section></div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -848,6 +869,72 @@ function MemberPicker({type, topic, onToast}) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
// docs/images/内容沟通对话3-开启进群验证与拉人状态.png:"你邀请时间旅行者、李惠惠加入群聊" 这条系统
|
||||
//事件证明产品设计里确实有一条"从我的好友里挑人直接邀请进群"的路径,且 `POST /groups/{topic}/invitations`
|
||||
// (im-api.js 的 inviteGroupMembers) 早就封装好、服务端也确认真实可用——但全仓库检索不到任何调用点,
|
||||
// GroupSettings 成员格的"添加"格子原来只弹一句"请通过转发/分享群二维码邀请成员",把这条本该走的路
|
||||
// 堵死成了唯一路径(二维码分享走的是另一条"申请入群待审批"的路,语义并不一样,不能算是同一功能的替代)。
|
||||
// 复用 MemberPicker 同款字母分组多选布局,数据源换成好友列表并按当前群成员名单过滤掉已在群里的人。
|
||||
function InviteFriends({topic, onToast}) {
|
||||
const [friends, setFriends] = React.useState(null);
|
||||
const [memberIds, setMemberIds] = React.useState(null);
|
||||
const [selected, setSelected] = React.useState([]);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [toast, setToast] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
fetchAllPages(cursor => getFriends('', cursor)).then(setFriends).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => setMemberIds(new Set(items.map(m => m.user_id))))
|
||||
// 成员表拉取失败不应该拦掉整个邀请流程——退化成"不过滤",最坏情况后端自己会在
|
||||
// 响应里把已在群里的人标成 already_member(治理开关那份文档已经实测过这个字段)。
|
||||
.catch(() => setMemberIds(new Set()));
|
||||
}, [topic]);
|
||||
const eligible = (friends || []).filter(f => !memberIds || !memberIds.has(f.user_id));
|
||||
const visible = eligible.filter(m => (m.remark || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
|
||||
const groups = groupBy(visible, m => alphaInitial(m.remark || m.display_name));
|
||||
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
|
||||
const done = () => {
|
||||
if (!selected.length || busy) return;
|
||||
setBusy(true);
|
||||
inviteGroupMembers(topic, selected).then(result => {
|
||||
const items = (result && result.items) || [];
|
||||
// 已实测过的两种真实 status:新邀请是 "pending"(对方会在跟我的会话里收到"立即加入"卡片),
|
||||
// 治理开关关闭邀请权限那份文档里实测过 "already_member"(对方已经在群里,直接被服务端忽略)。
|
||||
// 未识别的 status 一律计入"失败",宁可少报成功也不要谎报。
|
||||
const already = items.filter(it => it.status === 'already_member').length;
|
||||
const sent = items.filter(it => it.status === 'pending').length;
|
||||
const failed = Math.max(selected.length - sent - already, 0);
|
||||
const parts = [];
|
||||
if (sent) parts.push(`已发送 ${sent} 条邀请`);
|
||||
if (already) parts.push(`${already} 人已在群内`);
|
||||
if (failed) parts.push(`${failed} 人邀请失败`);
|
||||
setToast(parts.join(',') || '邀请已发送');
|
||||
window.setTimeout(() => navigate('group-settings', topic), 1500);
|
||||
}).catch(err => onToast(err.message || '邀请失败')).finally(() => setBusy(false));
|
||||
};
|
||||
return <div className="group-page">
|
||||
<Header title="邀请好友" back="group-settings" backTopic={topic}
|
||||
right={`完成${selected.length ? `(${selected.length})` : ''}`} onRight={selected.length ? done : undefined} />
|
||||
<Search value={query} onChange={setQuery} placeholder="搜索好友" />
|
||||
<main className="group-member-picker">
|
||||
{friends === null || memberIds === null ? <p className="group-inline-empty">{loadError ? '好友列表加载失败' : '加载中…'}</p> :
|
||||
groups.length === 0 ? <p className="group-inline-empty">{friends.length ? '好友都已在群里了' : '暂无可邀请的好友'}</p> :
|
||||
groups.map(([letter, list]) => <React.Fragment key={letter}>
|
||||
<div className="group-section-title">{letter}</div>
|
||||
{list.map(item => <button type="button" key={item.user_id} onClick={() => toggle(item.user_id)}>
|
||||
<Avatar item={item} icon="person" /><span><strong>{item.remark || item.display_name}</strong></span>
|
||||
<i className={'group-check' + (selected.includes(item.user_id) ? ' checked' : '')}>
|
||||
{selected.includes(item.user_id) ? <Icon>check</Icon> : null}
|
||||
</i>
|
||||
</button>)}
|
||||
</React.Fragment>)}
|
||||
</main>
|
||||
<CenterToast message={toast} onDone={() => setToast('')} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Muted({topic, onToast}) {
|
||||
const [items, setItems] = React.useState(() => (topic && readCachedData('group-muted:' + topic)) || null);
|
||||
// groupGovernancePayload's mute list only ever returns a flat user_id (no display_name field --
|
||||
@@ -967,6 +1054,21 @@ const FILE_TYPE_META = {
|
||||
image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'],
|
||||
document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']
|
||||
};
|
||||
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
|
||||
// POST /groups/{topic}/files 的 file_type 字段是客户端在登记时自己声明的(addGroupFile 的
|
||||
// im-api.js 签名早就留了这个参数),不是服务端拿 mime_type 反推的——实测:即使 mime_type 正确
|
||||
// 存成 "image/png",只要请求体没带 file_type,服务端就把它落成字面量 "file"(不是留空)。这里
|
||||
// 的 onFile 上传流程原来完全没传这个字段,导致真实是图片/视频/语音的群文件在列表里全部显示成
|
||||
// 灰色通用文件图标,跟 FILE_TYPE_META 已经准备好的 image/video/audio/document 四种彩色图标从未
|
||||
// 对应起来过。跟 content-library-view.jsx 的 fileTypeFromMime 用的是同一套判断(mime 前缀优先,
|
||||
// 帶附件扩展名兜底文档类型),保持两处"什么算图片/视频/文档"的口径一致。
|
||||
function fileTypeFromMime(mime, name) {
|
||||
const prefix = (mime || '').split('/')[0];
|
||||
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
|
||||
const ext = (/\.([a-zA-Z0-9]+)$/.exec(name || '') || [])[1];
|
||||
if (mime === 'application/pdf' || DOCUMENT_EXT.includes((ext || '').toLowerCase())) return 'document';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function GroupFiles({topic, tinode, onToast}) {
|
||||
const [queryInput, setQueryInput] = React.useState('');
|
||||
@@ -1016,7 +1118,8 @@ function GroupFiles({topic, tinode, onToast}) {
|
||||
if (!file || !tinode) return;
|
||||
setUploading({name: file.name, progress: 0});
|
||||
uploadLargeFile(tinode, file, frac => setUploading(u => u ? {...u, progress: Math.round(frac * 100)} : u))
|
||||
.then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size}))
|
||||
.then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size,
|
||||
fileType: fileTypeFromMime(file.type, file.name)}))
|
||||
.then(() => { setUploading(null); load(); })
|
||||
.catch(err => { setUploading(null); onToast(err.message || '上传失败'); });
|
||||
};
|
||||
@@ -1112,12 +1215,21 @@ function MyNicknameEditor({topic, onToast}) {
|
||||
const [value, setValue] = React.useState('');
|
||||
// getGroupMembers' list items never include group_nickname (confirmed live) -- only
|
||||
// GET .../members/me/profile actually returns it, same fix as GroupProfile's preview row above.
|
||||
// 同一个 GET 实测确认真的带 version 字段(不是空字符串/undefined),但这里原来保存时
|
||||
// 一直硬编码 version:0——跟本文件其余"编辑单个字段+乐观锁"的写法(GroupNameEditor/
|
||||
// GroupDescriptionEditor 用 getGroup 拿到的真实 version,Governance/OwnerSelect 的
|
||||
// 注释更是明确写了"version<=0 时服务端会整个跳过乐观锁 WHERE 子句,两次并发写入会
|
||||
// 静默覆盖")不一致。实测验证过这里同样如此:明明真实 version 是 1,PUT 时传 0 依然
|
||||
// 200 成功且服务端自己把 version 推到了 2——多标签页/多端几乎同时改自己在本群的昵称
|
||||
// 时,后写入的会静默覆盖先写入的,没有任何冲突信号。跟其余几处一样,把已经拿到手的
|
||||
// 真实 version 存下来,保存时原样带上。
|
||||
const [version, setVersion] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
getMyGroupProfile(topic).then(data => setValue(data.nickname || '')).catch(() => {});
|
||||
getMyGroupProfile(topic).then(data => { setValue(data.nickname || ''); setVersion(data.version || 0); }).catch(() => {});
|
||||
}, [topic]);
|
||||
const save = () => {
|
||||
updateMyGroupProfile(topic, value.trim(), 0).then(() => {
|
||||
updateMyGroupProfile(topic, value.trim(), version).then(() => {
|
||||
onToast('保存成功'); window.setTimeout(() => navigate('group-settings', topic), 600);
|
||||
}).catch(err => onToast(err.message || '保存失败'));
|
||||
};
|
||||
@@ -1168,6 +1280,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
|
||||
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-admin-add') content = <MemberPicker type="admin-add" topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-mute-add') content = <MemberPicker type="mute-add" topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-invite-friends') content = <InviteFriends topic={topic} onToast={setToast} />;
|
||||
else if (view === 'group-muted') content = <Muted topic={topic} onToast={setToast} />;
|
||||
else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = <OwnerSelect topic={topic} onToast={setToast} />;
|
||||
else content = <GroupFiles topic={topic} tinode={tinode} onToast={setToast} />;
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Login form.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import CheckBox from '../widgets/checkbox.jsx';
|
||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
||||
|
||||
export default class LoginView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
login: props.login,
|
||||
password: '',
|
||||
hostName: props.serverAddress,
|
||||
saveToken: props.persist
|
||||
};
|
||||
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() {
|
||||
this.props.onPersistenceChange(!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() {
|
||||
let submitClasses = 'primary';
|
||||
if (this.props.disabled) {
|
||||
submitClasses += ' disabled';
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="login-form" onSubmit={this.handleSubmit}>
|
||||
<FormattedMessage id="login_prompt" defaultMessage="Login"
|
||||
description="Placeholer for username/login">
|
||||
{
|
||||
(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="Placeholder/prompt for entering password">
|
||||
{
|
||||
(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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -106,6 +106,14 @@ function draftyPreview(content) {
|
||||
const label = {IM: '[图片]', VD: '[视频]', AU: '[语音]', EX: '[文件]', TC: '[名片]', VC: '[通话]'}[content.ent[0].tp];
|
||||
if (label) return label;
|
||||
}
|
||||
// 服务端的 system_notice/system_action(好友通过、群成员加入、群邀请卡片等,见 chat-message.jsx
|
||||
// 对 x-im-type 的处理)以及合并转发消息都不是 Drafty,也没有 `txt` 字段,真正的可读文案在
|
||||
// `text`(system_*)或 `comment`/`title`(forward_bundle)——原来这里只认 `txt`,这三类消息在
|
||||
// 会话列表的预览行一律回退成没有信息量的"[消息]",即使内容本身早就是一句完整人话
|
||||
// (比如"邀请你加入群聊。"),聊天页里点开看到的和列表预览完全对不上。
|
||||
if (typeof content.text === 'string' && content.text) return content.text;
|
||||
if (typeof content.comment === 'string' && content.comment) return content.comment;
|
||||
if (typeof content.title === 'string' && content.title) return '[聊天记录] ' + content.title;
|
||||
return content.txt || '[消息]';
|
||||
}
|
||||
|
||||
@@ -412,6 +420,12 @@ function MessageActions() {
|
||||
const NOTIFICATION_TEXT = {
|
||||
'group.created': '你创建了一个新群聊',
|
||||
'group.join_request.created': '有人申请加入你管理的群聊',
|
||||
// 实测 asn494180 账号的真实系统消息列表里出现过原样展示的 "group.announcement.updated"——
|
||||
// db/mysql/tenant_groups.go 里 insertGroupEventTx 一共写了 9 种 group.* 事件码
|
||||
// (announcement.updated/created/join_request.created/member.joined/member.left/
|
||||
// member.updated/mute.updated/owner.transferred/role.updated/settings.updated),这张表原来
|
||||
// 漏收了公告更新这一种,是 9 种里唯一没有查表命中、直接把机器码露给用户看的。
|
||||
'group.announcement.updated': '群公告有更新',
|
||||
'group.member.joined': '有成员加入了群聊',
|
||||
'group.member.left': '有成员退出了群聊',
|
||||
'group.member.updated': '群成员资料有更新',
|
||||
@@ -419,6 +433,17 @@ const NOTIFICATION_TEXT = {
|
||||
'group.owner.transferred': '群主已转让',
|
||||
'group.role.updated': '群成员角色有变更',
|
||||
'group.settings.updated': '群设置有更新',
|
||||
// 上面那 9 种都是 insertGroupEventTx 的静态字符串调用,唯独审批入群申请这一条不是:
|
||||
// TenantGroupJoinRequestResolve(tenant_groups.go:642)拼的是
|
||||
// "group.join_request."+status,status 是 "accepted" 或 "rejected"(server/store/types/groups.go
|
||||
// 的 GroupJoinRequestStatusAccepted/Rejected),实际落进 summary 的是
|
||||
// "group.join_request.accepted"/"group.join_request.rejected" 这两个完整机器码,跟本已收录的
|
||||
// "group.join_request.created"(申请提交时那条)是三个不同的 key。当初核对 9 种静态事件码时
|
||||
// 这条动态拼接的漏了,管理员在群管理页批准/拒绝入群申请后,申请人这边系统消息列表同样会原样
|
||||
// 展示机器码——跟 group.announcement.updated 是完全同一类问题,只是触发路径不同(未在真实
|
||||
// 环境里等到一条这样的通知,是读 im_be 源码逐个比对 insertGroupEventTx 调用点发现的)。
|
||||
'group.join_request.accepted': '你的入群申请已通过',
|
||||
'group.join_request.rejected': '你的入群申请被拒绝',
|
||||
accepted: '对方已通过你的好友申请',
|
||||
rejected: '对方拒绝了你的好友申请',
|
||||
// 新样本:security.risk.continue/cancel 事件的 summary 直接是 decision 字面量
|
||||
|
||||
+214
-12
@@ -3,7 +3,24 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { Drafty, Tinode, TheCard, LOCAL_SEQID } from 'tinode-sdk';
|
||||
import { Drafty, Tinode, TheCard } from 'tinode-sdk';
|
||||
|
||||
// webpack.config.js maps the 'tinode-sdk' import to the global `tinode` UMD bundle, which only
|
||||
// re-exports {AccessMode, Drafty, TheCard, Tinode} (see umd/tinode.dev.js's own top-level export
|
||||
// list) -- LOCAL_SEQID was never one of them, despite tinode-sdk's ES module genuinely exporting
|
||||
// it (confirmed directly against umd/tinode.dev.js, which still defines it internally as
|
||||
// `const LOCAL_SEQID = 0xFFFFFFF;` and uses it in several of its own topic methods -- it's just
|
||||
// never attached to the UMD bundle's exported global object). Importing it by name here silently
|
||||
// resolved to `undefined` instead of a build error (an externals mapping can't tell "not on this
|
||||
// object" from "doesn't exist"), and every `seq < LOCAL_SEQID` below was therefore
|
||||
// `seq < undefined`, which JS always evaluates to false -- confirmed live: "撤回"
|
||||
// (menu_item_recall)never appeared in the long-press
|
||||
// menu for ANY message, own or admin, however fresh, because the seq-range check that's supposed
|
||||
// to just filter out not-yet-confirmed local placeholder messages was unconditionally rejecting
|
||||
// every real, server-confirmed message too. Hardcoding the same value the SDK itself uses (rather
|
||||
// than trying to change what webpack's externals map exposes, which every other page importing
|
||||
// from 'tinode-sdk' already depends on staying exactly as-is) is the narrowest fix.
|
||||
const LOCAL_SEQID = 0xFFFFFFF;
|
||||
|
||||
const CallPanel = React.lazy(_ => import('../widgets/call-panel.jsx'));
|
||||
import ChatMessage from '../widgets/chat-message.jsx';
|
||||
@@ -29,9 +46,9 @@ import { DEFAULT_P2P_ACCESS_MODE, EDIT_PREVIEW_LENGTH, IMAGE_PREVIEW_DIM, IMMEDI
|
||||
import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constants.js';
|
||||
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
|
||||
import { topicDisplayName } from '../lib/utils.js';
|
||||
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
|
||||
getFavorites, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
|
||||
updateTopicDraft } from '../lib/im-api.js';
|
||||
import { acceptGroupInvitation, checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap,
|
||||
getConversationContext, getFavorites, getFriendRequests, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement,
|
||||
getTopicDraft, recallMessage, updateTopicDraft } from '../lib/im-api.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
|
||||
|
||||
@@ -205,6 +222,7 @@ class MessagesView extends React.Component {
|
||||
this.handleShowAnnouncement = this.handleShowAnnouncement.bind(this);
|
||||
this.handleShowJoinRequests = this.handleShowJoinRequests.bind(this);
|
||||
this.handleSendFriendVerification = this.handleSendFriendVerification.bind(this);
|
||||
this.handleAcceptGroupInvitation = this.handleAcceptGroupInvitation.bind(this);
|
||||
this.handleBatchDelete = this.handleBatchDelete.bind(this);
|
||||
this.handleBatchFavorite = this.handleBatchFavorite.bind(this);
|
||||
this.handleAttachFile = this.handleAttachFile.bind(this);
|
||||
@@ -477,6 +495,7 @@ class MessagesView extends React.Component {
|
||||
selectedPin: 0,
|
||||
subsVersion: 0,
|
||||
relationshipContext: null,
|
||||
pendingRequestMessage: null,
|
||||
groupRoles: null,
|
||||
myGroupRole: null,
|
||||
groupAnnouncement: null,
|
||||
@@ -505,6 +524,7 @@ class MessagesView extends React.Component {
|
||||
dragging: false,
|
||||
selectedPin: 0,
|
||||
relationshipContext: null,
|
||||
pendingRequestMessage: null,
|
||||
groupRoles: null,
|
||||
myGroupRole: null,
|
||||
groupAnnouncement: null,
|
||||
@@ -664,7 +684,7 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
subscribe(topic) {
|
||||
if (topic.isSubscribed() || !this.props.ready) {
|
||||
if (!this.props.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -675,7 +695,24 @@ class MessagesView extends React.Component {
|
||||
let getQuery = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux();
|
||||
if (this.state.isReader || newTopic) {
|
||||
// Reading is either permitted or we don't know because it's a new topic. Ask for messages.
|
||||
getQuery = getQuery.withLaterData(MESSAGES_PAGE);
|
||||
//
|
||||
// withData(undefined, undefined, MESSAGES_PAGE) instead of the seemingly-more-natural
|
||||
// withLaterData(MESSAGES_PAGE): the latter asks the server for "whatever is newer than
|
||||
// topic._maxSeq", which is only correct when _maxSeq reflects a real, contiguous, locally-cached
|
||||
// history. But tinode-web.jsx's prefetchLatestMessagePreviews() (conversation list "last
|
||||
// message" preview) may have already called topic.subscribe(startMetaQuery().withLaterData(1))
|
||||
// for this exact topic the moment the chat list rendered -- often *before* this view ever mounts,
|
||||
// e.g. the very first time a brand-new conversation is opened in a fresh session -- which sets
|
||||
// _maxSeq to that one prefetched message's seq without caching anything before it. From that
|
||||
// point on withLaterData(MESSAGES_PAGE) asks for "later than the one message I already have" and
|
||||
// gets back nothing: confirmed live, a brand-new 4-message P2P conversation opened this way
|
||||
// rendered only the single latest message, silently missing the rest of the history.
|
||||
// withData(undefined, undefined, limit) has no such since-cursor -- it always asks for "the
|
||||
// latest `limit` messages" regardless of what the client thinks it already has, which is
|
||||
// correct both in this poisoned-prefetch case and in the plain fresh-topic case (there
|
||||
// topic._maxSeq is 0, so withLaterData(limit) already degrades to the exact same
|
||||
// since:undefined query -- this is a strict fix, not a behavior change for the common path).
|
||||
getQuery = getQuery.withData(undefined, undefined, MESSAGES_PAGE);
|
||||
if (this.state.isReader) {
|
||||
getQuery = getQuery.withLaterDel();
|
||||
}
|
||||
@@ -684,7 +721,7 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
|
||||
const setQuery = newTopic ? this.props.newTopicParams : undefined;
|
||||
topic.subscribe(getQuery.build(), setQuery)
|
||||
const doSubscribe = () => topic.subscribe(getQuery.build(), setQuery)
|
||||
.then(ctrl => {
|
||||
if (ctrl.code == 303) {
|
||||
// Redirect to another topic requested.
|
||||
@@ -725,6 +762,24 @@ class MessagesView extends React.Component {
|
||||
blankState.title = this.props.intl.formatMessage(messages.not_found);
|
||||
this.setState(blankState);
|
||||
});
|
||||
|
||||
if (topic.isSubscribed()) {
|
||||
// Same prefetch race as the comment on the data query above: the topic may already be
|
||||
// "attached" server-side from prefetchLatestMessagePreviews' single-message subscribe, before
|
||||
// this view ever asked for anything. The SDK's own Topic.subscribe() unconditionally
|
||||
// short-circuits to `Promise.resolve(this)` once `_attached` is true (tinode.dev.js)
|
||||
// *without even looking at the getParams passed in* -- calling topic.subscribe(getQuery...)
|
||||
// directly in that state would silently no-op and this view would never receive the real
|
||||
// history/pinned-messages/peer read-receipts it just asked for. leave(false) clears _attached
|
||||
// (both client-side via _resetSub() and server-side, since it's a real round trip) without
|
||||
// discarding already-cached messages (that only happens for leave(true), i.e. unsubscribe) --
|
||||
// once it resolves, the follow-up doSubscribe() is guaranteed to be a genuine, un-shortcut
|
||||
// subscribe that gets the server to actually answer getQuery instead of silently dropping it.
|
||||
topic.leave(false).catch(_ => { /* best-effort; doSubscribe() below still needs to run */ }).then(doSubscribe);
|
||||
return;
|
||||
}
|
||||
|
||||
doSubscribe();
|
||||
}
|
||||
|
||||
leave(oldTopicName) {
|
||||
@@ -1198,12 +1253,48 @@ class MessagesView extends React.Component {
|
||||
}
|
||||
const topicName = topic.name;
|
||||
getConversationContext(topicName).then(data => {
|
||||
if (this.state.topic == topicName) {
|
||||
this.setState({relationshipContext: data});
|
||||
if (this.state.topic != topicName) {
|
||||
return;
|
||||
}
|
||||
this.setState({relationshipContext: data});
|
||||
this.loadPendingRequestMessage(topicName, data);
|
||||
}).catch(_ => {});
|
||||
}
|
||||
|
||||
// 个人对话框(对方的状态).png / (我的状态).png:pending_inbound/pending_outbound 关系下,聊天
|
||||
// 页顶部第一条应该是发起好友申请时留的验证留言原文,以真实消息气泡的样式渲染(对方发的居左、
|
||||
// 自己发的居右),下面再跟现有的 #relationship-status-note 横幅。这条留言从来不是一条真实的
|
||||
// Tinode 消息(没有走 topic.publish(),只存在 im_friend_requests 表的 message 字段里),所以
|
||||
// 不会自然出现在 messageNodes 里——relationshipContextPayload 已经带了 request_id,用它去
|
||||
// GET /friend-requests 换真实留言文本;找不到(比如留言本身是空的)就回退成参考图里的默认文案。
|
||||
loadPendingRequestMessage(topicName, ctx) {
|
||||
const relationship = ctx && ctx.relationship;
|
||||
if (relationship != 'pending_inbound' && relationship != 'pending_outbound') {
|
||||
this.setState({pendingRequestMessage: null});
|
||||
return;
|
||||
}
|
||||
if (!ctx.request_id) {
|
||||
this.setState({pendingRequestMessage: {text: '请求添加你为好友', response: relationship == 'pending_inbound', ts: null}});
|
||||
return;
|
||||
}
|
||||
const direction = relationship == 'pending_inbound' ? 'inbound' : 'outbound';
|
||||
getFriendRequests(direction, 'all').then(data => {
|
||||
if (this.state.topic != topicName) {
|
||||
return;
|
||||
}
|
||||
const match = (data.items || []).find(item => item.request_id == ctx.request_id);
|
||||
this.setState({pendingRequestMessage: {
|
||||
text: (match && match.message) || '请求添加你为好友',
|
||||
response: relationship == 'pending_inbound',
|
||||
ts: match ? new Date(match.created_at) : null
|
||||
}});
|
||||
}).catch(_ => {
|
||||
if (this.state.topic == topicName) {
|
||||
this.setState({pendingRequestMessage: {text: '请求添加你为好友', response: relationship == 'pending_inbound', ts: null}});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ch.6: role badges (docs/images/群标识.png) and the announcement/join-request banners at the top of a
|
||||
// group chat (docs/images/群公告状态.png, 新邀请进群申请.png) all need data no P2P topic has -- fetch it
|
||||
// once per group-topic-load, same best-effort pattern as loadRelationshipContext above. Every `topic`
|
||||
@@ -1384,6 +1475,29 @@ class MessagesView extends React.Component {
|
||||
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
|
||||
}
|
||||
|
||||
// 6.3 加入群聊.png:被邀请人在跟邀请人的会话里收到一张"邀请你加入群聊……立即加入"的卡片。
|
||||
// 后端 serveIMGroupInvitations 确实往这个 P2P 会话发了一条 x-im-type=system_action、
|
||||
// template=group_invitation_created 的消息,content.actions 里带着真实的
|
||||
// {type:"group_invitation_accept", label:"立即加入", target_id:<invitation_id>}——但
|
||||
// chat-message.jsx 原来对所有 system_action/system_notice 消息一律只取 content.text 渲染成
|
||||
// 纯文本,这个 action 数据被整体丢弃,卡片上不会出现任何按钮,被邀请人无法接受邀请。
|
||||
// acceptGroupInvitation() 这个封装早就在 im-api.js 里,只是从未有调用点。
|
||||
handleAcceptGroupInvitation(invitationId) {
|
||||
return acceptGroupInvitation(invitationId).then(result => {
|
||||
if (result && result.status == 'joined') {
|
||||
this.showToast('已加入群聊');
|
||||
} else if (result && result.status == 'pending_approval') {
|
||||
this.showToast('申请已提交,等待管理员通过');
|
||||
} else {
|
||||
this.showToast('已处理该邀请');
|
||||
}
|
||||
return result;
|
||||
}).catch(err => {
|
||||
this.showToast(err.message || '加入群聊失败,请重试');
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
showToast(text) {
|
||||
this.setState({toast: text});
|
||||
window.clearTimeout(this.toastTimer);
|
||||
@@ -1501,7 +1615,6 @@ class MessagesView extends React.Component {
|
||||
// We are forwarding a message.
|
||||
msg = this.props.forwardMessage.msg;
|
||||
head = this.props.forwardMessage.head;
|
||||
this.handleCancelReply();
|
||||
} else if (this.state.reply) {
|
||||
if (this.state.reply.editing) {
|
||||
if (msg == this.state.contentToEdit) {
|
||||
@@ -1519,9 +1632,25 @@ class MessagesView extends React.Component {
|
||||
msg = Drafty.parse(msg);
|
||||
this.applyMentionResolution(msg);
|
||||
}
|
||||
msg = Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg);
|
||||
// Both tinode.dev.js helpers below mutate their first argument in place and return that
|
||||
// same reference (Drafty.sanitizeEntities: `return content` after patching content.ent[i]
|
||||
// itself; Drafty.append: `first.txt += ...`, `first.fmt.push(...)`, then `return first`) --
|
||||
// neither ever clones. Passing `this.state.reply.content` straight through used to hand this
|
||||
// live React-state object to both, so every call here permanently welded whatever `msg` this
|
||||
// particular attempt carried onto the quoted content the *reply itself* points to. Invisible
|
||||
// on an ordinary send since handleCancelReply() clears state.reply right after, discarding the
|
||||
// now-mutated copy along with it -- but the URL risk dialog above defers doSend (and this
|
||||
// append) until *after* the check resolves, and cancelling it deliberately leaves state.reply
|
||||
// alone so the reply survives (see doSend's own comment). Confirmed live: reply to a message,
|
||||
// type a flagged link, send, click "取消发送", send the exact same text again, confirm through
|
||||
// the dialog this time -- the composed message quoted the reply once but then repeated the
|
||||
// typed text twice, because the first (cancelled) attempt had already appended it into
|
||||
// state.reply.content, and the second attempt appended onto *that*. A deep clone here keeps
|
||||
// both helpers' in-place mutations confined to a throwaway copy every time, however many times
|
||||
// sendMessage runs for the same still-open reply.
|
||||
const replyContentCopy = JSON.parse(JSON.stringify(this.state.reply.content));
|
||||
msg = Drafty.append(Drafty.sanitizeEntities(replyContentCopy), msg);
|
||||
}
|
||||
this.handleCancelReply();
|
||||
} else if (typeof msg == 'string' && this.mentionedUsers && this.mentionedUsers.length) {
|
||||
// 4.2-4.3 @提醒:Drafty.parse 本来就会把纯文本里的"@word"自动识别成 MN 实体,但
|
||||
// data.val 存的是匹配到的原始文字(tinode.dev.js 的 ENTITY_TYPES 表,pack: val =>
|
||||
@@ -1560,6 +1689,19 @@ class MessagesView extends React.Component {
|
||||
// the same backend version=0-skips-the-lock issue explained in handleComposerChange above.
|
||||
const draftVersionAtSend = this.state.draftVersion || 0;
|
||||
const doSend = () => {
|
||||
// Was called eagerly above (right after building `msg`/`head` from `this.state.reply`/
|
||||
// `this.props.forwardMessage`), synchronously and unconditionally -- including on a message
|
||||
// that turns out to need the URL risk dialog below, which only resolves *after* this function
|
||||
// already returned. That meant clicking send on a reply/edit containing a link made the
|
||||
// "回复"/编辑 preview bar vanish immediately, before the user had even seen the risk prompt;
|
||||
// clicking "取消发送" then left the reply/edit permanently abandoned -- the quoted message was
|
||||
// gone from the composer with no way back except manually replying again, even though the user
|
||||
// only meant to cancel sending the flagged text, not the reply itself. Moving the call here
|
||||
// means it only fires at the point a send is actually going to happen: immediately for the
|
||||
// common non-URL case (this function runs synchronously right away, same timing as before),
|
||||
// or deferred until "继续发送" for a flagged one -- and never at all if the dialog is cancelled,
|
||||
// so the reply/edit state and its preview bar correctly survive a cancelled risk prompt.
|
||||
this.handleCancelReply();
|
||||
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
|
||||
// Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or
|
||||
// reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.
|
||||
@@ -1584,6 +1726,23 @@ class MessagesView extends React.Component {
|
||||
const RISK_RANK = {block: 2, warn: 1, allow: 0};
|
||||
const urls = Array.from(new Set(urlMatches));
|
||||
return Promise.all(urls.map(url => checkURL(url, 'message_compose'))).then(results => {
|
||||
// The user may have already switched to a different conversation while checkURL was
|
||||
// still in flight. getDerivedStateFromProps only protects against an ALREADY-shown
|
||||
// dialog surviving a later topic switch (it nulls riskDialog when nextProps.topic !=
|
||||
// prevState.topic) -- it can't protect against this .then() running AFTER that switch,
|
||||
// which would otherwise setState({riskDialog:{...}}) fresh right here, popping the risk
|
||||
// dialog up while a completely different conversation is now open. doSend() still calls
|
||||
// this.props.sendMessage using whatever topic is current AT CONFIRM TIME (see doSend's
|
||||
// own comment above), not the one this message was actually typed in, so confirming that
|
||||
// dialog would silently deliver this message into the wrong conversation. Confirmed live:
|
||||
// typed a flagged URL in topic A, switched to topic B before checkURL resolved, and the
|
||||
// resulting dialog rendered while viewing topic B -- confirming it delivered topic A's
|
||||
// message into topic B instead. If the topic has moved on, treat this message the same
|
||||
// as if it were abandoned -- no dialog, no send -- the same "lost, but not hung forever"
|
||||
// outcome already accepted just below for two flagged messages sent in quick succession.
|
||||
if (this.state.topic !== topicForDraft) {
|
||||
return false;
|
||||
}
|
||||
const result = results.reduce((worst, r) => (RISK_RANK[r.decision] || 0) > (RISK_RANK[worst.decision] || 0) ? r : worst);
|
||||
if (result.decision === 'allow') {
|
||||
return doSend();
|
||||
@@ -2315,6 +2474,16 @@ class MessagesView extends React.Component {
|
||||
replyToSeq = null;
|
||||
}
|
||||
|
||||
// group_invitation_created is the one system_action template with a real, backend-honored
|
||||
// action button (see chat-message.jsx's renderGroupInvitationCard) -- it still needs the
|
||||
// full ChatMessage bubble treatment. Every other system_action/system_notice template is
|
||||
// purely informational and belongs in the plain centered style below.
|
||||
const systemActions = msg.content && typeof msg.content == 'object' && Array.isArray(msg.content.actions) ?
|
||||
msg.content.actions : null;
|
||||
const isActionableSystemMessage = systemActions && systemActions.some(a => a && a.type == 'group_invitation_accept');
|
||||
const isPlainSystemMessage = msg.head && (msg.head['x-im-type'] == 'system_notice' || msg.head['x-im-type'] == 'system_action') &&
|
||||
!isActionableSystemMessage && msg.content && typeof msg.content == 'object' && typeof msg.content.text == 'string';
|
||||
|
||||
if (msg.hi) {
|
||||
// Deleted message.
|
||||
messageNodes.push(
|
||||
@@ -2323,6 +2492,22 @@ class MessagesView extends React.Component {
|
||||
deletedText={this.props.intl.formatMessage(messages.message_deleted)}
|
||||
key={msg.seq} />
|
||||
);
|
||||
} else if (isPlainSystemMessage) {
|
||||
const thisDate = new Date(msg.ts);
|
||||
if (!prevDate || prevDate.toDateString() != thisDate.toDateString()) {
|
||||
messageNodes.push(
|
||||
<MetaMessage
|
||||
date={relativeDateFormat(msg.ts)}
|
||||
locale={this.props.intl.locale}
|
||||
key={'date-' + msg.seq} />
|
||||
);
|
||||
prevDate = thisDate;
|
||||
}
|
||||
messageNodes.push(
|
||||
<MetaMessage
|
||||
systemText={msg.content.text}
|
||||
key={msg.seq} />
|
||||
);
|
||||
} else {
|
||||
const thisDate = new Date(msg.ts);
|
||||
// This message was sent on a different date than the previous.
|
||||
@@ -2360,6 +2545,7 @@ class MessagesView extends React.Component {
|
||||
userIsAdmin={this.state.isAdmin}
|
||||
relationship={this.state.relationshipContext ? this.state.relationshipContext.relationship : null}
|
||||
onSendFriendVerification={this.handleSendFriendVerification}
|
||||
onAcceptGroupInvitation={this.handleAcceptGroupInvitation}
|
||||
selectMode={this.props.selectMode}
|
||||
selected={this.props.selectedSeqs && this.props.selectedSeqs.includes(msg.seq)}
|
||||
onToggleSelect={this.props.onToggleMessageSelect}
|
||||
@@ -2438,6 +2624,22 @@ class MessagesView extends React.Component {
|
||||
</button>
|
||||
<div id="messages-panel" ref={this.handleScrollReference}>
|
||||
<ul id="scroller" className={chatBoxClass}>
|
||||
{this.state.pendingRequestMessage ?
|
||||
<>
|
||||
{this.state.pendingRequestMessage.ts ?
|
||||
<MetaMessage date={relativeDateFormat(this.state.pendingRequestMessage.ts)}
|
||||
locale={this.props.intl.locale} key="pending-request-date" /> : null}
|
||||
<li className={'single ' + (this.state.pendingRequestMessage.response ? 'left' : 'right')}
|
||||
key="pending-request-message">
|
||||
<div>
|
||||
<div className="bubble tip">
|
||||
<div className="content-meta">
|
||||
<div className="message-content">{this.state.pendingRequestMessage.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</> : null}
|
||||
{messageNodes}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
// A password reset form.
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import PhoneEdit from '../widgets/phone-edit.jsx';
|
||||
import VisiblePassword from '../widgets/visible-password.jsx';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
password_reset_email_sent: {
|
||||
id: 'password_reset_email_sent',
|
||||
defaultMessage: 'An email with security code has been sent.',
|
||||
description: 'Notification that the email with password reset instructions has been sent'
|
||||
},
|
||||
password_reset_sms_sent: {
|
||||
id: 'password_reset_sms_sent',
|
||||
defaultMessage: 'A text message with security code has been sent.',
|
||||
description: 'Notification that the SMS with password reset instructions has been sent'
|
||||
}
|
||||
});
|
||||
|
||||
class PasswordResetView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tel: '',
|
||||
email: '',
|
||||
password: '',
|
||||
sent: false,
|
||||
haveCode: false,
|
||||
code: ''
|
||||
};
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||
this.handleCodeChange = this.handleCodeChange.bind(this);
|
||||
this.handleShowCodeField = this.handleShowCodeField.bind(this);
|
||||
|
||||
// Connection will trigger change by changing the this.props.serverVersion.
|
||||
props.tinode.connect()
|
||||
.catch(err => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const parsed = HashNavigation.parseUrlHash(window.location.hash);
|
||||
const newState = {
|
||||
token: parsed.params.token,
|
||||
scheme: parsed.params.scheme,
|
||||
code: parsed.params.code || ''
|
||||
}
|
||||
|
||||
this.savedCred = parsed.params.cred;
|
||||
if (this.props.reqCredMethod && parsed.params.cred) {
|
||||
newState[this.props.reqCredMethod] = this.savedCred;
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.reqCredMethod != this.props.reqCredMethod && this.props.reqCredMethod) {
|
||||
this.setState({[this.props.reqCredMethod]: this.savedCred || ''});
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.token) {
|
||||
// Reset using token.
|
||||
this.props.onReset(this.state.password.trim(), {
|
||||
scheme: 'token',
|
||||
secret: this.state.token
|
||||
});
|
||||
} else if (this.state.code && this.props.reqCredMethod) {
|
||||
// Reset using security code.
|
||||
// The secret is structured as <code>:<cred_method>:<cred_value>, "123456:email:alice@example.com".
|
||||
const cred = this.props.reqCredMethod == 'email' ? this.state.email.trim() : this.state.tel.trim();
|
||||
this.props.onReset(this.state.password.trim(), {
|
||||
scheme: 'code',
|
||||
secret: btoa(`${this.state.code}:${this.props.reqCredMethod}:${cred}`)
|
||||
});
|
||||
} else {
|
||||
// Send email or SMS with the secret code.
|
||||
const email = this.state.email.trim();
|
||||
const tel = this.state.tel.trim();
|
||||
this.setState({email: email, tel: tel});
|
||||
this.props.onRequest(this.props.reqCredMethod, email || tel)
|
||||
.then(_ => {
|
||||
this.setState({sent: true});
|
||||
const msg = this.props.reqCredMethod == 'email' ? messages.password_reset_email_sent :
|
||||
this.props.reqCredMethod == 'tel' ? messages.password_reset_sms_sent : null;
|
||||
if (msg) {
|
||||
this.props.onError(this.props.intl.formatMessage(msg), 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleEmailChange(e) {
|
||||
this.setState({email: e.target.value});
|
||||
}
|
||||
|
||||
handlePasswordChange(e) {
|
||||
this.setState({password: e.target.value});
|
||||
}
|
||||
|
||||
handleCodeChange(e) {
|
||||
this.setState({code: e.target.value.replace(/[^\d]/g, '')});
|
||||
}
|
||||
|
||||
handleShowCodeField(e) {
|
||||
e.preventDefault();
|
||||
this.setState({haveCode: true});
|
||||
}
|
||||
|
||||
render() {
|
||||
const showCredentialInput = !((this.state.token || (this.state.code && this.props.reqCredMethod)) && this.state.scheme);
|
||||
const showPasswordInput = !showCredentialInput || this.state.haveCode || this.state.sent;
|
||||
|
||||
const passwordInput = (<>
|
||||
<label className="small gray" htmlFor="new-password">
|
||||
<FormattedMessage id="label_new_password"
|
||||
defaultMessage="New password"
|
||||
description="Label for entering a new password" />
|
||||
</label>
|
||||
<FormattedMessage id="new_password_placeholder" defaultMessage="Enter new password"
|
||||
description="Placeholder for entering new password">{
|
||||
(placeholder) => <VisiblePassword
|
||||
id="new-password"
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
value={this.state.password}
|
||||
required={true} autoFocus={true}
|
||||
onChange={this.handlePasswordChange} />
|
||||
}</FormattedMessage>
|
||||
</>);
|
||||
|
||||
const emailInput = (<>
|
||||
{this.state.haveCode ?
|
||||
null :
|
||||
<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>
|
||||
</>);
|
||||
|
||||
const phoneInput = (<>
|
||||
{this.state.haveCode ?
|
||||
null :
|
||||
<div><label>
|
||||
<FormattedMessage id="label_reset_password_tel"
|
||||
defaultMessage="Send SMS to reset password"
|
||||
description="Label for password reset field by phone" />
|
||||
</label></div>
|
||||
}
|
||||
<div>
|
||||
<label className="small gray">
|
||||
<FormattedMessage id="mobile_phone_number" defaultMessage="Mobile phone number"
|
||||
description="Prompt for entering a mobile phone number" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="panel-form-row">
|
||||
<PhoneEdit
|
||||
autoFocus={true}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onSubmit={number => this.setState({tel: number})} />
|
||||
</div>
|
||||
</>);
|
||||
|
||||
const codeInput = (<>
|
||||
<div>
|
||||
<label className="small gray" htmlFor="enter-confirmation-code">
|
||||
<FormattedMessage id="enter_confirmation_code_prompt"
|
||||
defaultMessage="Confirmation code"
|
||||
description="Request to enter confirmation code" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<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} maxLength={10}
|
||||
value={this.state.code} onChange={this.handleCodeChange} required />
|
||||
}</FormattedMessage>
|
||||
</div>
|
||||
</>);
|
||||
|
||||
const credentialInput = this.props.reqCredMethod == 'email' ?
|
||||
emailInput : this.props.reqCredMethod == 'tel' ? phoneInput : null;
|
||||
|
||||
return (
|
||||
<form id="password-reset-form" onSubmit={this.handleSubmit}>
|
||||
{!this.state.sent && showCredentialInput ? credentialInput : null}
|
||||
{this.state.haveCode || this.state.sent ? codeInput : null}
|
||||
{showPasswordInput ? passwordInput : null}
|
||||
<div className="dialog-buttons">
|
||||
{this.state.haveCode || this.state.sent || this.state.token || this.state.code ? null :
|
||||
<a href="#" onClick={this.handleShowCodeField} style={{marginRight: 'auto'}}>
|
||||
<FormattedMessage id="password_i_have_code" defaultMessage="I have code"
|
||||
description="Call to open field to enter password reset code" />
|
||||
</a>
|
||||
}
|
||||
<button className="primary" type="submit">{
|
||||
showPasswordInput ?
|
||||
<FormattedMessage id="button_reset" defaultMessage="Reset" description="Button [Reset]" />
|
||||
:
|
||||
<FormattedMessage id="button_send_request" defaultMessage="Send request"
|
||||
description="Button [Send request]" />
|
||||
}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(PasswordResetView);
|
||||
@@ -367,20 +367,40 @@ function CacheClear({onToast, onClose}) {
|
||||
const confirm = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
// navigator.storage/caches can be entirely absent (private browsing in some browsers, older
|
||||
// WebViews) rather than just rejecting -- referencing a missing global throws synchronously,
|
||||
// which a bare .catch() on the call below can't catch (it never gets that far), so without
|
||||
// this check the whole confirm() throws, `done` never becomes true, and the "已完成" state is
|
||||
// never reached even though the button correctly stops spinning via `finally`.
|
||||
const hasStorageEstimate = typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate;
|
||||
// Was a before/after navigator.storage.estimate() diff around the caches.delete() call --
|
||||
// measured live (see this round's regression notes): estimate() does NOT reflect a deletion
|
||||
// synchronously, Chrome keeps reporting the pre-delete `usageDetails.caches` figure for well
|
||||
// over 500ms after `caches.keys()` already comes back empty. Every real run of this button
|
||||
// therefore computed `before - after` from two numbers that were still identical, landing on
|
||||
// "已释放约 0.0 MB" even when megabytes of real thumbnails/temp files were actually deleted --
|
||||
// confirmed by seeding a 2MB dummy Cache Storage entry and watching this path report 0.0 MB
|
||||
// freed for it. Summing each entry's real blob size before deleting it sidesteps the OS-level
|
||||
// storage accounting lag entirely -- the number shown is exactly what this run deleted, not an
|
||||
// estimate that may or may not have caught up yet.
|
||||
// caches can be entirely absent (private browsing in some browsers, older WebViews) rather than
|
||||
// just rejecting -- referencing a missing global throws synchronously, which a bare .catch() on
|
||||
// the call below can't catch (it never gets that far), so without this check the whole
|
||||
// confirm() throws, `done` never becomes true, and the "已完成" state is never reached even
|
||||
// though the button correctly stops spinning via `finally`.
|
||||
const hasCaches = typeof caches !== 'undefined';
|
||||
const before = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
|
||||
let freedBytes = 0;
|
||||
if (hasCaches) {
|
||||
const keys = await caches.keys();
|
||||
for (const key of keys) {
|
||||
const cache = await caches.open(key);
|
||||
const requests = await cache.keys();
|
||||
for (const request of requests) {
|
||||
const response = await cache.match(request);
|
||||
if (!response) continue;
|
||||
// clone() -- match() gives back the one real (non-clonable-twice) body for this entry;
|
||||
// reading .blob() off it directly would leave nothing for a concurrent reader of the
|
||||
// same cache, however unlikely that is here.
|
||||
freedBytes += (await response.clone().blob().catch(() => null))?.size || 0;
|
||||
}
|
||||
}
|
||||
await Promise.all(keys.map(k => caches.delete(k)));
|
||||
}
|
||||
const after = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
|
||||
setFreed(Math.max(0, before - after));
|
||||
setFreed(freedBytes);
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
onToast(err.message || '清理失败,请重试');
|
||||
|
||||
@@ -19,14 +19,11 @@ import SecuritySupportView, {SECURITY_SUPPORT_VIEWS} from './security-support-vi
|
||||
|
||||
const AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));
|
||||
import ContactsView from './contacts-view.jsx';
|
||||
const CreateAccountView = React.lazy(_ => import('./create-account-view.jsx'));
|
||||
import AccGeneralView from './acc-general-view.jsx';
|
||||
import AccNotificationsView from './acc-notifications-view.jsx';
|
||||
const AccSecurityView = React.lazy(_ => import('./acc-security-view.jsx'));
|
||||
import AccSupportView from './acc-support-view.jsx';
|
||||
import LoginView from './login-view.jsx';
|
||||
import NewTopicView from './new-topic-view.jsx';
|
||||
const PasswordResetView = React.lazy(_ => import('./password-reset-view.jsx'));
|
||||
import SettingsView from './settings-view.jsx';
|
||||
import ValidationView from './validation-view.jsx';
|
||||
const WallpapersView = React.lazy(_ => import('./wallpapers.jsx'));
|
||||
@@ -196,7 +193,8 @@ class SidepanelView extends React.PureComponent {
|
||||
if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {
|
||||
return <div id="sidepanel" className="contact-management-panel">
|
||||
<ContactManagementView view={view} hashParams={this.props.hashParams} myUserId={this.props.myUserId}
|
||||
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} onOpenScanner={this.props.onOpenScanner} key={view} />
|
||||
tinode={this.props.tinode} onTopicSelected={this.props.onTopicSelected} onCreateTopic={this.props.onCreateTopic}
|
||||
onOpenScanner={this.props.onOpenScanner} key={view} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -281,27 +279,7 @@ class SidepanelView extends React.PureComponent {
|
||||
|
||||
<LoadSpinner show={this.props.loadSpinnerVisible} />
|
||||
|
||||
{view === 'login' ?
|
||||
<LoginView
|
||||
login={this.props.login}
|
||||
disabled={this.props.loginDisabled}
|
||||
persist={this.props.persist}
|
||||
onLogin={this.props.onLoginRequest}
|
||||
onPersistenceChange={this.props.onPersistenceChange} /> :
|
||||
|
||||
view === 'register' ?
|
||||
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
|
||||
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
|
||||
<CreateAccountView
|
||||
tinode={this.props.tinode}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onCreateAccount={this.props.onCreateAccount}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} />
|
||||
</Suspense> :
|
||||
|
||||
view === 'settings' ?
|
||||
{view === 'settings' ?
|
||||
<SettingsView
|
||||
transport={this.props.transport}
|
||||
serverAddress={this.props.serverAddress}
|
||||
@@ -415,19 +393,6 @@ class SidepanelView extends React.PureComponent {
|
||||
credToken={this.props.credToken}
|
||||
onSubmit={this.props.onValidateCredentials}
|
||||
onCancel={this.props.onCancel} /> :
|
||||
|
||||
view === 'reset' ?
|
||||
<Suspense fallback={<div className="panel-form-row"><FormattedMessage id="loading_note"
|
||||
defaultMessage="Loading..." description="Message shown when component is loading"/></div>}>
|
||||
<PasswordResetView
|
||||
tinode={this.props.tinode}
|
||||
reqCredMethod={this.props.reqCredMethod}
|
||||
onShowCountrySelector={this.props.onShowCountrySelector}
|
||||
onRequest={this.props.onPasswordResetRequest}
|
||||
onReset={this.props.onResetPassword}
|
||||
onCancel={this.props.onCancel}
|
||||
onError={this.props.onError} />
|
||||
</Suspense> :
|
||||
null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -127,6 +127,7 @@ const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','c
|
||||
'group-my-nickname','group-members','group-role-message','group-qr','group-governance','group-admins','group-admin-add',
|
||||
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
|
||||
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
|
||||
'group-invite-friends',
|
||||
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
|
||||
'favorites-links','favorites-image','favorites-video','favorites-select','favorite-share','file-download','file-downloading',
|
||||
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',
|
||||
@@ -800,7 +801,18 @@ class TinodeWeb extends React.Component {
|
||||
// Topic for MessagesView selector.
|
||||
let topicName = hash.path[1] || null;
|
||||
if (topicName != this.state.topicSelected) {
|
||||
if (!Tinode.topicType(topicName)) {
|
||||
// A syntactically valid topic name is not enough on its own: mobilePanel='topic-view' only
|
||||
// ever pairs correctly with MessagesView actually being allowed to render, which requires
|
||||
// !chapterOneActive (i.e. this.tinode.isAuthenticated()) -- see tinode-web.jsx's render().
|
||||
// Without this check, opening (or being redirected/deep-linked to) a #/<topic> URL while
|
||||
// logged out set mobilePanel to 'topic-view' anyway; on mobile that made *both* the
|
||||
// SidepanelView gate (`mobilePanel=='sidepanel'`) and the MessagesView gate
|
||||
// (`!chapterOneActive`) false at once, so neither rendered -- a permanently blank white
|
||||
// screen (app-container empty, no thrown error) that a hash change alone couldn't recover
|
||||
// from, only a full reload. Reproduced live: logging out, then setting location.hash to a
|
||||
// real group's `#/<topic>` URL. Falling back to the same "invalid topic" branch already used
|
||||
// for a malformed name reuses a path already proven safe with topicName=null.
|
||||
if (!Tinode.topicType(topicName) || !this.tinode.isAuthenticated()) {
|
||||
// Clear invalid topic name and hide messages view on mobile.
|
||||
topicName = null;
|
||||
newState.mobilePanel = 'sidepanel';
|
||||
|
||||
@@ -196,9 +196,20 @@ function ImgFromFile({fileId, className}) {
|
||||
const LEVEL_NUMERALS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||
const levelLabel = n => LEVEL_NUMERALS[n - 1] || String(n);
|
||||
|
||||
function DeptRow({dept, depth, expanded, onToggle}) {
|
||||
// isMe check shared with PersonRow below -- organization member/participant ids across this
|
||||
// chapter's endpoints aren't consistently confirmed to carry the 'usr' prefix (see resolveUserName's
|
||||
// comment), so the same "try bare, then usr-prefixed" comparison is duplicated at both call sites.
|
||||
const isMyUserId = (id, myUserId) => id === myUserId || 'usr' + id === myUserId;
|
||||
|
||||
function DeptRow({dept, depth, expanded, containsMe, onToggle}) {
|
||||
const icon = depth === 0 ? 'business' : 'diamond';
|
||||
return <button type="button" className={`organization-department depth-${depth}${expanded ? ' expanded' : ''}`}
|
||||
// 部门本身的蓝色高亮不能跟"是否展开"挂钩:depth 0 的根节点默认就是展开的(见 DeptNode 的
|
||||
// useState(depth===0)),如果蓝色只看 expanded,根节点会一直被误判成"当前用户所在部门"而
|
||||
// 高亮蓝色——参考图(部门组织框架.png)里根节点"总裁办50人(一级)"明明是展开状态,颜色却是
|
||||
// 黑色默认色,只有真正包含当前用户的"技术4人(二级)"才是蓝色实心菱形。展开/收起(chevron
|
||||
// 方向)继续用 expanded 单独控制,颜色改用 containsMe(本部门自己的成员列表里有没有当前用户,
|
||||
// 由 DeptNode 根据已加载的 members 算出,不是"是否展开")。
|
||||
return <button type="button" className={`organization-department depth-${depth}${containsMe ? ' me' : ''}`}
|
||||
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={onToggle}>
|
||||
<Icon>{icon}</Icon>
|
||||
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({levelLabel(depth + 1)}级)</strong></span>
|
||||
@@ -250,16 +261,25 @@ function DeptNode({dept, depth, myUserId}) {
|
||||
|
||||
const toggle = () => { const next = !expanded; setExpanded(next); if (next) load(); };
|
||||
React.useEffect(() => { if (expanded) load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
||||
// Only this department's own (already-loaded) member list decides the blue highlight -- not
|
||||
// whether it's merely expanded (see DeptRow's comment) and not whether a *descendant* department
|
||||
// contains the current user either. Confirmed against 部门组织框架.png with stub data standing in
|
||||
// for this tenant's empty org tree (real审 GET /organization/departments|members both return zero
|
||||
// items here): the root "总裁办" is an ancestor of 符小小 (via 技术) but stays black, only 技术
|
||||
// itself -- whose own direct member list actually contains her -- turns blue. `members` is null
|
||||
// before this node has ever loaded, so an unexpanded department can't know yet; that's an accepted
|
||||
// gap (see the fix's commit message), not something this pass invents new backend support for.
|
||||
const containsMe = (members || []).some(p => isMyUserId(p.user_id, myUserId));
|
||||
|
||||
return <>
|
||||
<DeptRow dept={dept} depth={depth} expanded={expanded} onToggle={toggle} />
|
||||
<DeptRow dept={dept} depth={depth} expanded={expanded} containsMe={containsMe} onToggle={toggle} />
|
||||
{expanded && loading ? <p className="organization-loading">加载中…</p> : null}
|
||||
{expanded && !loading && loadError ?
|
||||
<p className="organization-loading">加载失败 <a href="#" onClick={e => { e.preventDefault(); load(); }}>重试</a></p> : null}
|
||||
{expanded && !loading && !loadError ? <>
|
||||
{(children || []).map(child => <DeptNode key={child.department_id} dept={child} depth={depth + 1} myUserId={myUserId} />)}
|
||||
{(members || []).map(person => <PersonRow key={person.user_id} person={person} depth={depth + 1}
|
||||
level={depth + 2} isMe={person.user_id === myUserId || 'usr' + person.user_id === myUserId} />)}
|
||||
level={depth + 2} isMe={isMyUserId(person.user_id, myUserId)} />)}
|
||||
</> : null}
|
||||
</>;
|
||||
}
|
||||
@@ -581,7 +601,7 @@ function OrganizationDirectory({myUserId}) {
|
||||
searchResults.map((item, idx) => item.type === 'department' ?
|
||||
<SearchDeptRow key={'d' + (item.department && item.department.department_id || idx)} dept={item.department} /> :
|
||||
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level={3}
|
||||
isMe={item.user && (item.user.user_id === myUserId || 'usr' + item.user.user_id === myUserId)} />)}
|
||||
isMe={!!(item.user && isMyUserId(item.user.user_id, myUserId))} />)}
|
||||
</section> :
|
||||
<section className="organization-tree">
|
||||
{roots == null ? <p className="organization-loading">加载中…</p> :
|
||||
|
||||
+100
-14
@@ -69,6 +69,7 @@ class CallPanel extends React.PureComponent {
|
||||
this.isOutgoingCall = props.callState == CALL_STATE_OUTGOING_INITATED;
|
||||
|
||||
this.containerRef = React.createRef();
|
||||
this.selfBoxRef = React.createRef();
|
||||
this.localRef = React.createRef();
|
||||
this.remoteRef = React.createRef();
|
||||
|
||||
@@ -125,6 +126,10 @@ class CallPanel extends React.PureComponent {
|
||||
this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
|
||||
|
||||
this.bounds = {};
|
||||
// Which element the in-progress drag is moving: 'container' for the whole minimized bubble,
|
||||
// 'self' for just the local self-view preview box inside a fullscreen connected video call.
|
||||
// Null when no drag is in progress (also doubles as the pointermove/pointerup no-op guard).
|
||||
this.dragTarget = null;
|
||||
|
||||
this.dragStart = (event) => {
|
||||
if (!event.target.closest('.draggable')) {
|
||||
@@ -152,27 +157,105 @@ class CallPanel extends React.PureComponent {
|
||||
const parent = el.offsetParent;
|
||||
this.bounds.maxX = parent.offsetWidth;
|
||||
this.bounds.maxY = parent.offsetHeight;
|
||||
this.dragTarget = 'container';
|
||||
return;
|
||||
}
|
||||
// Fullscreen connected video call: let the user drag their own small self-view preview
|
||||
// out of the way instead of it being stuck wherever it defaults to (spec 8.9 "双方视频
|
||||
// 通话中": "小窗应支持拖动且不能遮挡关键按钮"). Only relevant when the box actually exists
|
||||
// (base.css hides it entirely for audio-only calls / while minimized, and now also while
|
||||
// still ringing -- see the render()'s `disabled` prop just below -- via
|
||||
// .call-party.self[disabled]) and only when the drag actually starts on that box, not on
|
||||
// the peer's video/avatar card behind it. The `this.state.remoteStream` check mirrors
|
||||
// render()'s own `connected` gate: without it, a pointerdown during the pre-connect ringing
|
||||
// phase (box still hidden by CSS, but not yet [disabled] before this fix) would already set
|
||||
// dragTarget='self' and start moving an invisible box.
|
||||
if (!this.props.minimized && !this.state.audioOnly && this.state.remoteStream &&
|
||||
this.selfBoxRef.current && event.target.closest('.call-party.self')) {
|
||||
event.stopPropagation();
|
||||
const el = this.selfBoxRef.current;
|
||||
// Pointer events are captured on the outer container (same element the listeners below
|
||||
// are attached to) rather than the box itself, purely so drag()/dragEnd() below have one
|
||||
// consistent place to check hasPointerCapture() regardless of which element is moving.
|
||||
this.containerRef.current.setPointerCapture(event.pointerId);
|
||||
el.style.cursor = 'grabbing';
|
||||
this.bounds.left = el.offsetLeft;
|
||||
this.bounds.top = el.offsetTop;
|
||||
this.bounds.width = el.offsetWidth;
|
||||
this.bounds.height = el.offsetHeight;
|
||||
// Switch from right/bottom CSS positioning to explicit left/top so drag() can move it freely.
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
|
||||
const parent = el.offsetParent;
|
||||
this.bounds.maxX = parent.offsetWidth;
|
||||
this.bounds.maxY = parent.offsetHeight;
|
||||
this.dragTarget = 'self';
|
||||
}
|
||||
};
|
||||
this.drag = (event) => {
|
||||
event.stopPropagation();
|
||||
const el = this.containerRef.current;
|
||||
if (this.props.minimized && this.containerRef && el.hasPointerCapture(event.pointerId)) {
|
||||
// Calculate new position. Restrict to the visible area of the parent element.
|
||||
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0),
|
||||
this.bounds.maxX - this.bounds.width);
|
||||
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0),
|
||||
this.bounds.maxY - this.bounds.height);
|
||||
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
const container = this.containerRef.current;
|
||||
if (!this.dragTarget || !container.hasPointerCapture(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
// Calculate new position. Restrict to the visible area of the parent element.
|
||||
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0),
|
||||
this.bounds.maxX - this.bounds.width);
|
||||
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0),
|
||||
this.bounds.maxY - this.bounds.height);
|
||||
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
};
|
||||
this.dragEnd = (event) => {
|
||||
event.stopPropagation();
|
||||
if (this.props.minimized && this.containerRef) {
|
||||
this.containerRef.current.releasePointerCapture(event.pointerId);
|
||||
this.containerRef.current.style.cursor = 'grab';
|
||||
const container = this.containerRef.current;
|
||||
if (!this.dragTarget) {
|
||||
return;
|
||||
}
|
||||
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
|
||||
if (container && container.hasPointerCapture(event.pointerId)) {
|
||||
container.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (el) {
|
||||
el.style.cursor = 'grab';
|
||||
}
|
||||
this.dragTarget = null;
|
||||
};
|
||||
// dragStart's inline left/top/right (set the first time the minimized bubble is dragged, see
|
||||
// above) always win over base.css's #video-container.minimized:not(.audio-minimized) mobile
|
||||
// rule -- inline style beats any stylesheet selector regardless of media query. A desktop user
|
||||
// who drags the bubble somewhere and then narrows the window below 640px (a real everyday
|
||||
// action -- snapping the browser to half the screen, say) would otherwise be stuck with the
|
||||
// bubble pinned at its old desktop-width position/size instead of docking as the intended
|
||||
// mobile full-width strip. Clearing the manual offsets once the mobile breakpoint applies lets
|
||||
// the stylesheet rule take back over; harmless (a no-op) whenever nothing was ever dragged.
|
||||
// Same idea for the self-view box's own drag above, keyed off its own (768px) breakpoint
|
||||
// (base.css ".call-party.self" mobile rule only ever touches `bottom`, but a stale inline
|
||||
// left/top from a desktop-width drag would still leave it sitting at the wrong height once
|
||||
// that rule's extra bottom clearance was meant to apply).
|
||||
this.handleWindowResize = () => {
|
||||
if (this.props.minimized && this.containerRef.current &&
|
||||
window.matchMedia('(width<=640px)').matches) {
|
||||
const el = this.containerRef.current;
|
||||
el.style.removeProperty('left');
|
||||
el.style.removeProperty('right');
|
||||
el.style.removeProperty('top');
|
||||
}
|
||||
if (!this.props.minimized && this.selfBoxRef.current &&
|
||||
window.matchMedia('(width<=768px)').matches) {
|
||||
const el = this.selfBoxRef.current;
|
||||
el.style.removeProperty('left');
|
||||
el.style.removeProperty('right');
|
||||
el.style.removeProperty('top');
|
||||
el.style.removeProperty('bottom');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -191,6 +274,7 @@ class CallPanel extends React.PureComponent {
|
||||
this.containerRef.current.addEventListener('pointermove', this.drag);
|
||||
this.containerRef.current.addEventListener('pointerup', this.dragEnd);
|
||||
}
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -199,6 +283,7 @@ class CallPanel extends React.PureComponent {
|
||||
topic.onInfo = this.previousOnInfo;
|
||||
this.stop();
|
||||
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
if (this.containerRef) {
|
||||
this.containerRef.current.removeEventListener('pointerdown', this.dragStart);
|
||||
this.containerRef.current.removeEventListener('pointermove', this.drag);
|
||||
@@ -874,7 +959,8 @@ class CallPanel extends React.PureComponent {
|
||||
{connected &&
|
||||
<div className="call-duration">{formatElapsedTime(this.state.elapsedSeconds)}</div>}
|
||||
<div id="video-container-panel" className="draggable">
|
||||
<div className="call-party self" disabled={this.state.audioOnly || this.props.minimized}>
|
||||
<div className="call-party self" ref={this.selfBoxRef}
|
||||
disabled={this.state.audioOnly || this.props.minimized || !connected}>
|
||||
<video ref={this.localRef} autoPlay muted playsInline />
|
||||
<div className="caller-name inactive">
|
||||
<FormattedMessage id="calls_you_label"
|
||||
|
||||
@@ -47,7 +47,10 @@ class BaseChatMessage extends React.PureComponent {
|
||||
|
||||
this.state = {
|
||||
progress: 0,
|
||||
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq)
|
||||
voicePlayed: isVoicePlayed(props.tinode.getCurrentUserID(), props.topic, props.seq),
|
||||
invitationBusy: false,
|
||||
invitationResult: null,
|
||||
invitationError: null
|
||||
};
|
||||
|
||||
if (props.uploader) {
|
||||
@@ -62,6 +65,7 @@ class BaseChatMessage extends React.PureComponent {
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.handleDraftyClick = this.handleDraftyClick.bind(this);
|
||||
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
|
||||
this.handleAcceptGroupInvitation = this.handleAcceptGroupInvitation.bind(this);
|
||||
|
||||
this.formatterContext = {
|
||||
formatMessage: props.intl.formatMessage.bind(props.intl),
|
||||
@@ -232,6 +236,39 @@ class BaseChatMessage extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// See the long comment above messages-view.jsx's handleAcceptGroupInvitation for the backstory --
|
||||
// this just drives this one message bubble's own busy/done/error display around that call.
|
||||
handleAcceptGroupInvitation(invitationId) {
|
||||
if (this.state.invitationBusy || this.state.invitationResult || !this.props.onAcceptGroupInvitation) {
|
||||
return;
|
||||
}
|
||||
this.setState({invitationBusy: true, invitationError: null});
|
||||
this.props.onAcceptGroupInvitation(invitationId).then(result => {
|
||||
this.setState({invitationBusy: false, invitationResult: (result && result.status) || 'joined'});
|
||||
}).catch(err => {
|
||||
this.setState({invitationBusy: false, invitationError: (err && err.message) || '操作失败,请重试'});
|
||||
});
|
||||
}
|
||||
|
||||
// docs/images/加入群聊.png: a card with the inviter's notice text and a single "立即加入" button.
|
||||
// Only rendered for the one system_action template that actually carries an actionable
|
||||
// `content.actions` entry -- every other system_action/system_notice message keeps falling through
|
||||
// to the plain-text path right below, unchanged.
|
||||
renderGroupInvitationCard(text, action) {
|
||||
const {invitationBusy, invitationResult, invitationError} = this.state;
|
||||
return <div className="message-group-invitation">
|
||||
<div>{text}</div>
|
||||
{invitationResult == 'joined' ? <em>已加入群聊</em> :
|
||||
invitationResult == 'pending_approval' ? <em>已提交申请,等待管理员通过</em> :
|
||||
invitationResult ? <em>{invitationResult}</em> :
|
||||
<button type="button" disabled={invitationBusy}
|
||||
onClick={() => this.handleAcceptGroupInvitation(action.target_id)}>
|
||||
{invitationBusy ? '处理中…' : (action.label || '立即加入')}
|
||||
</button>}
|
||||
{invitationError ? <small className="message-group-invitation-error">{invitationError}</small> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
// Reason caption shown under a failed outgoing message in a 1:1 chat, based on the real
|
||||
// relationship state (see docs/images/个人对话—拉黑状态/已读状态.png and .../个人对话—删除状态.png).
|
||||
// Returns null for relationships that don't explain a send failure (friend, pending, unknown) so
|
||||
@@ -281,7 +318,22 @@ class BaseChatMessage extends React.PureComponent {
|
||||
// message like "有成员通过审批加入了群聊。" was rendering as a bare warning icon. Reduce either to
|
||||
// its `text` upfront so the existing plain-string path renders it as an ordinary bubble -- these
|
||||
// are meant to read like a normal message, not a special banner.
|
||||
if (this.props.head && (this.props.head['x-im-type'] === 'system_notice' || this.props.head['x-im-type'] === 'system_action') &&
|
||||
//
|
||||
// One template is the exception: `group_invitation_created` (docs/images/加入群聊.png) is the one
|
||||
// system_action message that actually carries a real, backend-honored action -- confirmed live
|
||||
// against server/im_http.go's serveIMGroupInvitations, content.actions[0] is
|
||||
// {type:"group_invitation_accept", label:"立即加入", target_id:<invitation_id>}, and
|
||||
// acceptGroupInvitation() already existed in im-api.js with zero call sites because this action
|
||||
// data was being thrown away right here along with everything else system_action carries. Collapsing
|
||||
// it to bare text (like every other system_action template correctly does) left the invitee with no
|
||||
// way to ever accept -- the reference image's whole point is the "立即加入" button.
|
||||
const systemActions = content && typeof content == 'object' && Array.isArray(content.actions) ? content.actions : null;
|
||||
const inviteAction = this.props.head && this.props.head['x-im-type'] === 'system_action' && systemActions &&
|
||||
systemActions.find(a => a && a.type === 'group_invitation_accept');
|
||||
let invitationCard = null;
|
||||
if (inviteAction && typeof content.text == 'string') {
|
||||
invitationCard = this.renderGroupInvitationCard(content.text, inviteAction);
|
||||
} else if (this.props.head && (this.props.head['x-im-type'] === 'system_notice' || this.props.head['x-im-type'] === 'system_action') &&
|
||||
content && typeof content == 'object' && typeof content.text == 'string') {
|
||||
content = content.text;
|
||||
}
|
||||
@@ -322,6 +374,23 @@ class BaseChatMessage extends React.PureComponent {
|
||||
// Content consists of 1-5 emoji characters. Count how many and use it to increase the font size.
|
||||
textSizeClass += ' emoji-' + (content || '').match(/\p{RGI_Emoji}/vg).length;
|
||||
}
|
||||
} else if (content == null) {
|
||||
// Tinode's wire protocol marks a recalled message by nulling its `content` in place (the seq
|
||||
// slot stays, everything else is gone) -- see server's message.recalled handling and
|
||||
// IM-前后端对接文档.md 1256行. That's a *different* deletion shape from the msg.hi-flagged kind
|
||||
// messages-view.jsx already handles by rendering a dedicated <MetaMessage deleted deletedText=.../>
|
||||
// instead of a ChatMessage at all (deletedText is that component's own prop, not this one's --
|
||||
// there's no equivalent plumbed in here). Recalled messages still go through ordinary ChatMessage,
|
||||
// so without this branch a null content fell all the way through to the generic "invalid content"
|
||||
// case below -- confirmed live: recalling a message left the *other* party's chat showing a bare
|
||||
// "⚠ invalid content" (无效内容) row where "消息已删除" belongs (the recalling side just drops the
|
||||
// row from its own list locally, unaffected by this). Reuses the exact same i18n id/defaultMessage
|
||||
// messages-view.jsx's `messages.message_deleted` already registers (translated zh string:
|
||||
// "消息已删除"), not a new string. `content == null` (not `===`) intentionally also catches
|
||||
// `undefined`, same as every other falsy-content branch here.
|
||||
content = <><i className="material-icons gray">block</i> <i className="gray">
|
||||
<FormattedMessage id="message_deleted" defaultMessage="message deleted"
|
||||
description="Placeholder shown in the timeline where a message was recalled or deleted" /></i></>;
|
||||
} else {
|
||||
content = <><i className="material-icons gray">warning_amber</i> <i className="gray">
|
||||
<FormattedMessage id="invalid_content"
|
||||
@@ -353,7 +422,7 @@ class BaseChatMessage extends React.PureComponent {
|
||||
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
|
||||
<div className="content-meta">
|
||||
<div className={textSizeClass}>
|
||||
{bundleCard || content}
|
||||
{invitationCard || bundleCard || content}
|
||||
{attachments}
|
||||
</div>
|
||||
{this.props.timestamp ?
|
||||
|
||||
@@ -20,6 +20,14 @@ export default class MetaMessage extends React.PureComponent {
|
||||
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
|
||||
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
|
||||
bubbleClass += ' deleted';
|
||||
} else if (this.props.systemText) {
|
||||
// docs/images/内容沟通对话3-开启进群验证与拉人状态.png:群系统事件(成员加入/退出/角色变更/
|
||||
// 群公告发布等)在参考图里是居中的纯灰字,不带头像、不带气泡、不带发送者名字——跟这里的
|
||||
// 日期分隔条是同一种视觉语言。这类消息之前是当成普通消息交给 ChatMessage 渲染的(服务端
|
||||
// 这类消息没有真实的 `from`,chat-message.jsx 的 fullDisplay 逻辑因此在系统消息下方多画出
|
||||
// 一行解析不到发送者、回退成"未找到"的作者名,明显不该出现)。
|
||||
content = <span className="gray">{this.props.systemText}</span>;
|
||||
bubbleClass += ' system';
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
|
||||
+349
-286
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -56,6 +56,7 @@ class AccSecurityView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Comp
|
||||
this.handleHidePermissionsEditor = this.handleHidePermissionsEditor.bind(this);
|
||||
this.handlePermissionsChanged = this.handlePermissionsChanged.bind(this);
|
||||
this.handleDeleteAccount = this.handleDeleteAccount.bind(this);
|
||||
this.handleLogoutRequest = this.handleLogoutRequest.bind(this);
|
||||
this.handleCheckboxClick = this.handleCheckboxClick.bind(this);
|
||||
}
|
||||
handlePasswordUpdate(pwd) {
|
||||
@@ -95,6 +96,10 @@ class AccSecurityView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Comp
|
||||
} = this.props.intl;
|
||||
this.props.onShowAlert(formatMessage(messages.delete_account), formatMessage(messages.delete_account_warning), _ => this.props.onDeleteAccount(), null, true, null);
|
||||
}
|
||||
handleLogoutRequest(e) {
|
||||
e.preventDefault();
|
||||
this.props.onShowAlert('退出登录', '确定要退出当前登录吗?', _ => this.props.onLogout(), null, true, null);
|
||||
}
|
||||
render() {
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
|
||||
children: this.state.showPermissionEditorFor ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(_widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
|
||||
@@ -203,10 +208,7 @@ class AccSecurityView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Comp
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
|
||||
href: "#",
|
||||
className: "danger flat-button",
|
||||
onClick: e => {
|
||||
e.preventDefault();
|
||||
this.props.onLogout();
|
||||
},
|
||||
onClick: this.handleLogoutRequest,
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
|
||||
className: "material-icons",
|
||||
children: "exit_to_app"
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,319 +0,0 @@
|
||||
"use strict";
|
||||
(self["webpackChunktinode_webapp"] = self["webpackChunktinode_webapp"] || []).push([["src_views_create-account-view_jsx"],{
|
||||
|
||||
/***/ "./src/views/create-account-view.jsx":
|
||||
/*!*******************************************!*\
|
||||
!*** ./src/views/create-account-view.jsx ***!
|
||||
\*******************************************/
|
||||
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
__webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
||||
/* harmony export */ "default": function() { return /* binding */ CreateAccountView; }
|
||||
/* harmony export */ });
|
||||
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ "react");
|
||||
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
|
||||
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react-intl */ "react-intl");
|
||||
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
|
||||
/* harmony import */ var _widgets_avatar_crop_jsx__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../widgets/avatar-crop.jsx */ "./src/widgets/avatar-crop.jsx");
|
||||
/* harmony import */ var _widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../widgets/avatar-upload.jsx */ "./src/widgets/avatar-upload.jsx");
|
||||
/* harmony import */ var _widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../widgets/checkbox.jsx */ "./src/widgets/checkbox.jsx");
|
||||
/* harmony import */ var _widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ../widgets/phone-edit.jsx */ "./src/widgets/phone-edit.jsx");
|
||||
/* harmony import */ var _widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ../widgets/visible-password.jsx */ "./src/widgets/visible-password.jsx");
|
||||
/* harmony import */ var _lib_local_storage_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ../lib/local-storage.js */ "./src/lib/local-storage.js");
|
||||
/* harmony import */ var _lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ../lib/blob-helpers.js */ "./src/lib/blob-helpers.js");
|
||||
/* harmony import */ var _lib_utils_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ../lib/utils.js */ "./src/lib/utils.js");
|
||||
/* harmony import */ var _lib_strformat_js__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ../lib/strformat.js */ "./src/lib/strformat.js");
|
||||
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
|
||||
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class CreateAccountView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
login: '',
|
||||
password: '',
|
||||
meth: '',
|
||||
email: '',
|
||||
tel: '',
|
||||
fn: '',
|
||||
imageUrl: null,
|
||||
uploadUrl: null,
|
||||
newAvatar: null,
|
||||
newAvatarMime: null,
|
||||
buttonDisabled: false,
|
||||
saveToken: _lib_local_storage_js__WEBPACK_IMPORTED_MODULE_7__["default"].getObject('keep-logged-in')
|
||||
};
|
||||
this.handleLoginChange = this.handleLoginChange.bind(this);
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||
this.handlePhoneChange = this.handlePhoneChange.bind(this);
|
||||
this.handleFnChange = this.handleFnChange.bind(this);
|
||||
this.handleImageChanged = this.handleImageChanged.bind(this);
|
||||
this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);
|
||||
this.handleAvatarCropped = this.handleAvatarCropped.bind(this);
|
||||
this.handleAvatarCropCancel = this.handleAvatarCropCancel.bind(this);
|
||||
this.uploadAvatar = this.uploadAvatar.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
props.tinode.connect().catch(err => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
handleLoginChange(e) {
|
||||
this.setState({
|
||||
login: e.target.value
|
||||
});
|
||||
}
|
||||
handlePasswordChange(password) {
|
||||
this.setState({
|
||||
password: password
|
||||
});
|
||||
}
|
||||
handleEmailChange(e) {
|
||||
this.setState({
|
||||
meth: 'email',
|
||||
email: e.target.value
|
||||
});
|
||||
}
|
||||
handlePhoneChange(number) {
|
||||
this.setState({
|
||||
meth: 'tel',
|
||||
tel: number
|
||||
});
|
||||
}
|
||||
handleFnChange(e) {
|
||||
this.setState({
|
||||
fn: e.target.value
|
||||
});
|
||||
}
|
||||
handleImageChanged(mime, img) {
|
||||
this.setState({
|
||||
newAvatar: img,
|
||||
newAvatarMime: mime
|
||||
});
|
||||
if (!img) {
|
||||
this.setState({
|
||||
imageUrl: null,
|
||||
uploadUrl: null
|
||||
});
|
||||
}
|
||||
}
|
||||
handleToggleSaveToken() {
|
||||
_lib_local_storage_js__WEBPACK_IMPORTED_MODULE_7__["default"].setObject('keep-logged-in', !this.state.saveToken);
|
||||
this.setState({
|
||||
saveToken: !this.state.saveToken
|
||||
});
|
||||
}
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.onCreateAccount(this.state.login.trim(), this.state.password.trim(), (0,_lib_utils_js__WEBPACK_IMPORTED_MODULE_9__.theCard)((0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_10__.truncateString)(this.state.fn.trim(), _config_js__WEBPACK_IMPORTED_MODULE_11__.MAX_TITLE_LENGTH), this.state.uploadUrl), {
|
||||
'meth': this.state.meth,
|
||||
'val': this.state.meth == 'email' ? this.state.email : this.state.meth == 'tel' ? this.state.tel : null
|
||||
});
|
||||
}
|
||||
handleAvatarCropped(mime, blob, width, height) {
|
||||
const url = blob ? URL.createObjectURL(blob) : null;
|
||||
this.setState({
|
||||
newAvatar: null,
|
||||
newAvatarMime: null
|
||||
});
|
||||
if (blob) {
|
||||
this.uploadAvatar(mime, blob, width, height);
|
||||
}
|
||||
}
|
||||
handleAvatarCropCancel() {
|
||||
this.setState({
|
||||
newAvatar: null,
|
||||
newAvatarMime: null
|
||||
});
|
||||
}
|
||||
uploadAvatar(mime, blob, width, height) {
|
||||
const readyToUpload = image => {
|
||||
let {
|
||||
mime,
|
||||
blob
|
||||
} = image;
|
||||
this.setState({
|
||||
imageUrl: URL.createObjectURL(blob),
|
||||
buttonDisabled: true
|
||||
});
|
||||
if (blob.size > _config_js__WEBPACK_IMPORTED_MODULE_11__.MAX_AVATAR_BYTES) {
|
||||
const uploader = this.props.tinode.getLargeFileHelper();
|
||||
uploader.upload(blob, 'newacc').then(url => this.setState({
|
||||
uploadUrl: url
|
||||
})).catch(err => this.props.onError(err.message, 'err')).finally(_ => this.setState({
|
||||
buttonDisabled: false
|
||||
}));
|
||||
} else {
|
||||
(0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__.blobToBase64)(blob).then(b64 => this.setState({
|
||||
uploadUrl: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__.makeImageUrl)({
|
||||
data: b64.bits,
|
||||
type: mime
|
||||
})
|
||||
})).finally(_ => this.setState({
|
||||
buttonDisabled: false
|
||||
}));
|
||||
}
|
||||
};
|
||||
if (width > _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE || height > _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE || width != height) {
|
||||
(0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_8__.imageScaled)(blob, _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE, _config_js__WEBPACK_IMPORTED_MODULE_11__.AVATAR_SIZE, _config_js__WEBPACK_IMPORTED_MODULE_11__.MAX_EXTERN_ATTACHMENT_SIZE, true).then(scaled => readyToUpload(scaled)).catch(err => this.props.onError(err.message, 'err'));
|
||||
} else {
|
||||
readyToUpload({
|
||||
mime: mime,
|
||||
blob: blob,
|
||||
width: width,
|
||||
height: height
|
||||
});
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (this.state.newAvatar) {
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_avatar_crop_jsx__WEBPACK_IMPORTED_MODULE_2__["default"], {
|
||||
avatar: this.state.newAvatar,
|
||||
mime: this.state.newAvatarMime,
|
||||
onSubmit: this.handleAvatarCropped,
|
||||
onCancel: this.handleAvatarCropCancel,
|
||||
onError: this.props.onError
|
||||
}, void 0, false);
|
||||
}
|
||||
let submitClasses = 'primary';
|
||||
if (this.props.disabled) {
|
||||
submitClasses += ' disabled';
|
||||
}
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("form", {
|
||||
className: "panel-form-column",
|
||||
onSubmit: this.handleSubmit,
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "panel-form-row",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "umn",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "login_prompt",
|
||||
defaultMessage: "Login",
|
||||
description: "Placeholer for username/login",
|
||||
children: login_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("input", {
|
||||
type: "text",
|
||||
placeholder: login_prompt,
|
||||
autoComplete: "user-name",
|
||||
value: this.state.login,
|
||||
onChange: this.handleLoginChange,
|
||||
required: true,
|
||||
autoFocus: true
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "password_prompt",
|
||||
defaultMessage: "Password",
|
||||
description: "Placeholder/prompt for entering password",
|
||||
children: password_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_6__["default"], {
|
||||
placeholder: password_prompt,
|
||||
autoComplete: "new-password",
|
||||
value: this.state.password,
|
||||
onFinished: this.handlePasswordChange,
|
||||
required: true
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
|
||||
tinode: this.props.tinode,
|
||||
avatar: this.state.imageUrl,
|
||||
onImageUpdated: this.handleImageChanged,
|
||||
onError: this.props.onError
|
||||
}, void 0, false)]
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "panel-form-row",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "full_name_prompt",
|
||||
defaultMessage: "Full name, e.g. John Doe",
|
||||
description: "Input placeholder for person's full name",
|
||||
children: full_name_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("input", {
|
||||
type: "text",
|
||||
placeholder: full_name_prompt,
|
||||
autoComplete: "name",
|
||||
value: this.state.fn,
|
||||
onChange: this.handleFnChange,
|
||||
required: true
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false), this.props.reqCredMethod == 'email' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "panel-form-row",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "email_prompt",
|
||||
defaultMessage: "Email, e.g. jdoe@example.com",
|
||||
description: "Input placeholder for email entry",
|
||||
children: email_prompt => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("input", {
|
||||
type: "email",
|
||||
placeholder: email_prompt,
|
||||
autoComplete: "email",
|
||||
value: this.state.email,
|
||||
onChange: this.handleEmailChange,
|
||||
required: true
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false) : this.props.reqCredMethod == 'tel' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.Fragment, {
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "panel-form-row",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("label", {
|
||||
className: "small gray",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "mobile_phone_number",
|
||||
defaultMessage: "Mobile phone number",
|
||||
description: "Prompt for entering a mobile phone number"
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "panel-form-row",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
|
||||
autoFocus: false,
|
||||
onShowCountrySelector: this.props.onShowCountrySelector,
|
||||
onSubmit: this.handlePhoneChange
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "panel-form-row",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
|
||||
id: "save-token",
|
||||
name: "save-token",
|
||||
checked: this.state.saveToken,
|
||||
onChange: this.handleToggleSaveToken
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "stay_logged_in",
|
||||
defaultMessage: "Stay logged in",
|
||||
description: "Label for a checkbox",
|
||||
children: stay_logged_in => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("label", {
|
||||
htmlFor: "save-token",
|
||||
children: ["\xA0", stay_logged_in]
|
||||
}, void 0, true)
|
||||
}, void 0, false)]
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("div", {
|
||||
className: "dialog-buttons",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)("button", {
|
||||
className: submitClasses,
|
||||
type: "submit",
|
||||
disabled: this.state.buttonDisabled,
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_12__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "button_sign_up",
|
||||
defaultMessage: "Sign up",
|
||||
description: "Create account button [Sign Up]"
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true);
|
||||
}
|
||||
}
|
||||
;
|
||||
|
||||
/***/ })
|
||||
|
||||
}]);
|
||||
//# sourceMappingURL=src_views_create-account-view_jsx.dev.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,276 +0,0 @@
|
||||
"use strict";
|
||||
(self["webpackChunktinode_webapp"] = self["webpackChunktinode_webapp"] || []).push([["src_views_password-reset-view_jsx"],{
|
||||
|
||||
/***/ "./src/views/password-reset-view.jsx":
|
||||
/*!*******************************************!*\
|
||||
!*** ./src/views/password-reset-view.jsx ***!
|
||||
\*******************************************/
|
||||
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
__webpack_require__.r(__webpack_exports__);
|
||||
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ "react");
|
||||
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
|
||||
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react-intl */ "react-intl");
|
||||
/* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
|
||||
/* harmony import */ var _widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ../widgets/phone-edit.jsx */ "./src/widgets/phone-edit.jsx");
|
||||
/* harmony import */ var _widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ../widgets/visible-password.jsx */ "./src/widgets/visible-password.jsx");
|
||||
/* harmony import */ var _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ../lib/navigation.js */ "./src/lib/navigation.js");
|
||||
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
|
||||
password_reset_email_sent: {
|
||||
id: 'password_reset_email_sent',
|
||||
defaultMessage: 'An email with security code has been sent.',
|
||||
description: 'Notification that the email with password reset instructions has been sent'
|
||||
},
|
||||
password_reset_sms_sent: {
|
||||
id: 'password_reset_sms_sent',
|
||||
defaultMessage: 'A text message with security code has been sent.',
|
||||
description: 'Notification that the SMS with password reset instructions has been sent'
|
||||
}
|
||||
});
|
||||
class PasswordResetView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComponent) {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
tel: '',
|
||||
email: '',
|
||||
password: '',
|
||||
sent: false,
|
||||
haveCode: false,
|
||||
code: ''
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this);
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this);
|
||||
this.handleCodeChange = this.handleCodeChange.bind(this);
|
||||
this.handleShowCodeField = this.handleShowCodeField.bind(this);
|
||||
props.tinode.connect().catch(err => {
|
||||
this.props.onError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
componentDidMount() {
|
||||
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].parseUrlHash(window.location.hash);
|
||||
const newState = {
|
||||
token: parsed.params.token,
|
||||
scheme: parsed.params.scheme,
|
||||
code: parsed.params.code || ''
|
||||
};
|
||||
this.savedCred = parsed.params.cred;
|
||||
if (this.props.reqCredMethod && parsed.params.cred) {
|
||||
newState[this.props.reqCredMethod] = this.savedCred;
|
||||
}
|
||||
this.setState(newState);
|
||||
}
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.reqCredMethod != this.props.reqCredMethod && this.props.reqCredMethod) {
|
||||
this.setState({
|
||||
[this.props.reqCredMethod]: this.savedCred || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.token) {
|
||||
this.props.onReset(this.state.password.trim(), {
|
||||
scheme: 'token',
|
||||
secret: this.state.token
|
||||
});
|
||||
} else if (this.state.code && this.props.reqCredMethod) {
|
||||
const cred = this.props.reqCredMethod == 'email' ? this.state.email.trim() : this.state.tel.trim();
|
||||
this.props.onReset(this.state.password.trim(), {
|
||||
scheme: 'code',
|
||||
secret: btoa(`${this.state.code}:${this.props.reqCredMethod}:${cred}`)
|
||||
});
|
||||
} else {
|
||||
const email = this.state.email.trim();
|
||||
const tel = this.state.tel.trim();
|
||||
this.setState({
|
||||
email: email,
|
||||
tel: tel
|
||||
});
|
||||
this.props.onRequest(this.props.reqCredMethod, email || tel).then(_ => {
|
||||
this.setState({
|
||||
sent: true
|
||||
});
|
||||
const msg = this.props.reqCredMethod == 'email' ? messages.password_reset_email_sent : this.props.reqCredMethod == 'tel' ? messages.password_reset_sms_sent : null;
|
||||
if (msg) {
|
||||
this.props.onError(this.props.intl.formatMessage(msg), 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handleEmailChange(e) {
|
||||
this.setState({
|
||||
email: e.target.value
|
||||
});
|
||||
}
|
||||
handlePasswordChange(e) {
|
||||
this.setState({
|
||||
password: e.target.value
|
||||
});
|
||||
}
|
||||
handleCodeChange(e) {
|
||||
this.setState({
|
||||
code: e.target.value.replace(/[^\d]/g, '')
|
||||
});
|
||||
}
|
||||
handleShowCodeField(e) {
|
||||
e.preventDefault();
|
||||
this.setState({
|
||||
haveCode: true
|
||||
});
|
||||
}
|
||||
render() {
|
||||
const showCredentialInput = !((this.state.token || this.state.code && this.props.reqCredMethod) && this.state.scheme);
|
||||
const showPasswordInput = !showCredentialInput || this.state.haveCode || this.state.sent;
|
||||
const passwordInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
|
||||
className: "small gray",
|
||||
htmlFor: "new-password",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "label_new_password",
|
||||
defaultMessage: "New password",
|
||||
description: "Label for entering a new password"
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "new_password_placeholder",
|
||||
defaultMessage: "Enter new password",
|
||||
description: "Placeholder for entering new password",
|
||||
children: placeholder => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(_widgets_visible_password_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
|
||||
id: "new-password",
|
||||
placeholder: placeholder,
|
||||
autoComplete: "new-password",
|
||||
value: this.state.password,
|
||||
required: true,
|
||||
autoFocus: true,
|
||||
onChange: this.handlePasswordChange
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true);
|
||||
const emailInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
|
||||
children: [this.state.haveCode ? null : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
|
||||
htmlFor: "inputEmail",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "label_reset_password",
|
||||
defaultMessage: "Send a password reset email",
|
||||
description: "Label for password reset field"
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "credential_email_prompt",
|
||||
defaultMessage: "Your registration email",
|
||||
description: "Placeholder for entering email",
|
||||
children: placeholder => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("input", {
|
||||
type: "email",
|
||||
id: "inputEmail",
|
||||
placeholder: placeholder,
|
||||
autoComplete: "email",
|
||||
value: this.state.email,
|
||||
onChange: this.handleEmailChange,
|
||||
required: true,
|
||||
autoFocus: true
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true);
|
||||
const phoneInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
|
||||
children: [this.state.haveCode ? null : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "label_reset_password_tel",
|
||||
defaultMessage: "Send SMS to reset password",
|
||||
description: "Label for password reset field by phone"
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
|
||||
className: "small gray",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "mobile_phone_number",
|
||||
defaultMessage: "Mobile phone number",
|
||||
description: "Prompt for entering a mobile phone number"
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||
className: "panel-form-row",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(_widgets_phone_edit_jsx__WEBPACK_IMPORTED_MODULE_2__["default"], {
|
||||
autoFocus: true,
|
||||
onShowCountrySelector: this.props.onShowCountrySelector,
|
||||
onSubmit: number => this.setState({
|
||||
tel: number
|
||||
})
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true);
|
||||
const codeInput = (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.Fragment, {
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("label", {
|
||||
className: "small gray",
|
||||
htmlFor: "enter-confirmation-code",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "enter_confirmation_code_prompt",
|
||||
defaultMessage: "Confirmation code",
|
||||
description: "Request to enter confirmation code"
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "numeric_confirmation_code_prompt",
|
||||
defaultMessage: "Numbers only",
|
||||
description: "Prompt for numeric conformation code",
|
||||
children: numbers_only => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("input", {
|
||||
type: "text",
|
||||
id: "enter-confirmation-code",
|
||||
placeholder: numbers_only,
|
||||
maxLength: 10,
|
||||
value: this.state.code,
|
||||
onChange: this.handleCodeChange,
|
||||
required: true
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true);
|
||||
const credentialInput = this.props.reqCredMethod == 'email' ? emailInput : this.props.reqCredMethod == 'tel' ? phoneInput : null;
|
||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("form", {
|
||||
id: "password-reset-form",
|
||||
onSubmit: this.handleSubmit,
|
||||
children: [!this.state.sent && showCredentialInput ? credentialInput : null, this.state.haveCode || this.state.sent ? codeInput : null, showPasswordInput ? passwordInput : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||
className: "dialog-buttons",
|
||||
children: [this.state.haveCode || this.state.sent || this.state.token || this.state.code ? null : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
|
||||
href: "#",
|
||||
onClick: this.handleShowCodeField,
|
||||
style: {
|
||||
marginRight: 'auto'
|
||||
},
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "password_i_have_code",
|
||||
defaultMessage: "I have code",
|
||||
description: "Call to open field to enter password reset code"
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("button", {
|
||||
className: "primary",
|
||||
type: "submit",
|
||||
children: showPasswordInput ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "button_reset",
|
||||
defaultMessage: "Reset",
|
||||
description: "Button [Reset]"
|
||||
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||
id: "button_send_request",
|
||||
defaultMessage: "Send request",
|
||||
description: "Button [Send request]"
|
||||
}, void 0, false)
|
||||
}, void 0, false)]
|
||||
}, void 0, true)]
|
||||
}, void 0, true);
|
||||
}
|
||||
}
|
||||
/* harmony default export */ __webpack_exports__["default"] = ((0,react_intl__WEBPACK_IMPORTED_MODULE_1__.injectIntl)(PasswordResetView));
|
||||
|
||||
/***/ })
|
||||
|
||||
}]);
|
||||
//# sourceMappingURL=src_views_password-reset-view_jsx.dev.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -71,6 +71,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
||||
};
|
||||
this.isOutgoingCall = props.callState == _constants_js__WEBPACK_IMPORTED_MODULE_4__.CALL_STATE_OUTGOING_INITATED;
|
||||
this.containerRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
|
||||
this.selfBoxRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
|
||||
this.localRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
|
||||
this.remoteRef = react__WEBPACK_IMPORTED_MODULE_0___default().createRef();
|
||||
this.remoteIceCandidatesCache = [];
|
||||
@@ -113,6 +114,7 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
||||
this.handleDataChannelOpen = this.handleDataChannelOpen.bind(this);
|
||||
this.handleDataChannelClose = this.handleDataChannelClose.bind(this);
|
||||
this.bounds = {};
|
||||
this.dragTarget = null;
|
||||
this.dragStart = event => {
|
||||
if (!event.target.closest('.draggable')) {
|
||||
return;
|
||||
@@ -135,23 +137,71 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
||||
const parent = el.offsetParent;
|
||||
this.bounds.maxX = parent.offsetWidth;
|
||||
this.bounds.maxY = parent.offsetHeight;
|
||||
this.dragTarget = 'container';
|
||||
return;
|
||||
}
|
||||
if (!this.props.minimized && !this.state.audioOnly && this.state.remoteStream && this.selfBoxRef.current && event.target.closest('.call-party.self')) {
|
||||
event.stopPropagation();
|
||||
const el = this.selfBoxRef.current;
|
||||
this.containerRef.current.setPointerCapture(event.pointerId);
|
||||
el.style.cursor = 'grabbing';
|
||||
this.bounds.left = el.offsetLeft;
|
||||
this.bounds.top = el.offsetTop;
|
||||
this.bounds.width = el.offsetWidth;
|
||||
this.bounds.height = el.offsetHeight;
|
||||
el.style.right = 'auto';
|
||||
el.style.bottom = 'auto';
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
const parent = el.offsetParent;
|
||||
this.bounds.maxX = parent.offsetWidth;
|
||||
this.bounds.maxY = parent.offsetHeight;
|
||||
this.dragTarget = 'self';
|
||||
}
|
||||
};
|
||||
this.drag = event => {
|
||||
event.stopPropagation();
|
||||
const el = this.containerRef.current;
|
||||
if (this.props.minimized && this.containerRef && el.hasPointerCapture(event.pointerId)) {
|
||||
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0), this.bounds.maxX - this.bounds.width);
|
||||
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0), this.bounds.maxY - this.bounds.height);
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
const container = this.containerRef.current;
|
||||
if (!this.dragTarget || !container.hasPointerCapture(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
this.bounds.left = Math.min(Math.max(this.bounds.left + event.movementX, 0), this.bounds.maxX - this.bounds.width);
|
||||
this.bounds.top = Math.min(Math.max(this.bounds.top + event.movementY, 0), this.bounds.maxY - this.bounds.height);
|
||||
el.style.left = `${this.bounds.left}px`;
|
||||
el.style.top = `${this.bounds.top}px`;
|
||||
};
|
||||
this.dragEnd = event => {
|
||||
event.stopPropagation();
|
||||
if (this.props.minimized && this.containerRef) {
|
||||
this.containerRef.current.releasePointerCapture(event.pointerId);
|
||||
this.containerRef.current.style.cursor = 'grab';
|
||||
const container = this.containerRef.current;
|
||||
if (!this.dragTarget) {
|
||||
return;
|
||||
}
|
||||
const el = this.dragTarget == 'self' ? this.selfBoxRef.current : container;
|
||||
if (container && container.hasPointerCapture(event.pointerId)) {
|
||||
container.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (el) {
|
||||
el.style.cursor = 'grab';
|
||||
}
|
||||
this.dragTarget = null;
|
||||
};
|
||||
this.handleWindowResize = () => {
|
||||
if (this.props.minimized && this.containerRef.current && window.matchMedia('(width<=640px)').matches) {
|
||||
const el = this.containerRef.current;
|
||||
el.style.removeProperty('left');
|
||||
el.style.removeProperty('right');
|
||||
el.style.removeProperty('top');
|
||||
}
|
||||
if (!this.props.minimized && this.selfBoxRef.current && window.matchMedia('(width<=768px)').matches) {
|
||||
const el = this.selfBoxRef.current;
|
||||
el.style.removeProperty('left');
|
||||
el.style.removeProperty('right');
|
||||
el.style.removeProperty('top');
|
||||
el.style.removeProperty('bottom');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -167,12 +217,14 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
||||
this.containerRef.current.addEventListener('pointermove', this.drag);
|
||||
this.containerRef.current.addEventListener('pointerup', this.dragEnd);
|
||||
}
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
const topic = this.props.tinode.getTopic(this.props.topic);
|
||||
topic.onInfo = this.previousOnInfo;
|
||||
this.stop();
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
if (this.containerRef) {
|
||||
this.containerRef.current.removeEventListener('pointerdown', this.dragStart);
|
||||
this.containerRef.current.removeEventListener('pointermove', this.drag);
|
||||
@@ -772,7 +824,8 @@ class CallPanel extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCompon
|
||||
className: "draggable",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
|
||||
className: "call-party self",
|
||||
disabled: this.state.audioOnly || this.props.minimized,
|
||||
ref: this.selfBoxRef,
|
||||
disabled: this.state.audioOnly || this.props.minimized || !connected,
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("video", {
|
||||
ref: this.localRef,
|
||||
autoPlay: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user