Compare commits
@@ -1,26 +0,0 @@
|
|||||||
[{
|
|
||||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "android_app",
|
|
||||||
"package_name": "co.tinode.tindroidx",
|
|
||||||
"sha256_cert_fingerprints":
|
|
||||||
["25:9A:71:EE:BD:2E:9F:D3:2D:64:1E:90:49:33:BD:F3:E3:42:BD:C4:DD:FA:BA:68:EE:D2:EB:76:65:33:DC:92"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"relation": ["delegate_permission/common.get_login_creds"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "web",
|
|
||||||
"site": "https://web.tinode.co"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"relation": ["delegate_permission/common.get_login_creds"],
|
|
||||||
"target": {
|
|
||||||
"namespace": "android_app",
|
|
||||||
"package_name": "co.tinode.tindroidx",
|
|
||||||
"sha256_cert_fingerprints":
|
|
||||||
["5B:BE:6A:C4:A7:66:A8:BB:4B:C1:52:66:E5:A4:BD:66:3C:33:50:04:11:CB:17:44:45:89:5E:3C:8D:55:77:7A"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -879,6 +879,13 @@
|
|||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 参会确认 now shows 4 stats (已接受/待响应/已拒绝/待定, see workplace-collaboration-view.jsx's
|
||||||
|
MeetingDetail) -- .report-statistics (日报/月报团队统计的应提交/已提交/未提交) stays at 3, only
|
||||||
|
this selector needs the extra column. */
|
||||||
|
.meeting-status > div {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.meeting-status span,
|
.meeting-status span,
|
||||||
.report-statistics > div span {
|
.report-statistics > div span {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1099,6 +1106,13 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DailyReportCard renders <article><header><em>, not the <button> the two rules below (1196-1197)
|
||||||
|
assume -- so submitted/draft there always fell through to this rule's one fixed color, unable to
|
||||||
|
tell the two states apart (MonthlyReportList's own rows really are <button>s, so it never hit
|
||||||
|
this gap). Same colors as the button-based rules, just re-declared at matching structure. */
|
||||||
|
.report-cards article > header em.submitted { color: #28784f; background: #e5f5ec; }
|
||||||
|
.report-cards article > header em.draft { color: #ad6c11; background: #fff2dd; }
|
||||||
|
|
||||||
.report-cards article p {
|
.report-cards article p {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
margin: 0.8rem 0;
|
margin: 0.8rem 0;
|
||||||
@@ -1181,6 +1195,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same class of bug as .library-file-card above: this row is a <button> (月报列表), and the global
|
||||||
|
`button{line-height:2.2rem}` reset inherits into the stacked 月份/摘要 <strong>/<small> lines --
|
||||||
|
the article>header variant just below doesn't need this, it's a plain <article>, not a <button>. */
|
||||||
|
.report-cards > button strong,
|
||||||
|
.report-cards > button small {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.report-cards > button small {
|
.report-cards > button small {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
color: #858d97;
|
color: #858d97;
|
||||||
@@ -1258,7 +1280,12 @@
|
|||||||
|
|
||||||
.report-author {
|
.report-author {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
/* Same dead-avatar-column issue .report-cards article > header had (see its comment) and was
|
||||||
|
fixed for -- DailyReportDetail/MonthlyReportDetail (the only two renderers of this class)
|
||||||
|
never render an <img> here either, just the name+badge span and the status <em>. The unused
|
||||||
|
leading 3rem column squeezed the name into a column narrow enough to wrap "员工" onto its own
|
||||||
|
line, while the status badge landed in what should've been the name's share of the row. */
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
@@ -1283,10 +1310,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.report-author em {
|
.report-author em {
|
||||||
color: #2c8257;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Was one fixed color (green) regardless of report.status's actual value -- a "未提交" (not yet
|
||||||
|
submitted) badge showing the same green as "已提交" reads backwards. */
|
||||||
|
.report-author em.submitted { color: #28784f; }
|
||||||
|
.report-author em.draft { color: #ad6c11; }
|
||||||
|
|
||||||
.report-detail h2,
|
.report-detail h2,
|
||||||
.report-detail h3,
|
.report-detail h3,
|
||||||
.report-detail p {
|
.report-detail p {
|
||||||
@@ -2117,6 +2148,8 @@ input[type="email"],
|
|||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
input[type="tel"],
|
input[type="tel"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="time"],
|
||||||
textarea {
|
textarea {
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border-bottom: 1px solid var(--clr-input-border);
|
border-bottom: 1px solid var(--clr-input-border);
|
||||||
@@ -2128,6 +2161,8 @@ input[type="text"],
|
|||||||
input[type="email"],
|
input[type="email"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="tel"],
|
input[type="tel"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="time"],
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -2137,7 +2172,9 @@ input[type="text"]:focus,
|
|||||||
input[type="email"]:focus,
|
input[type="email"]:focus,
|
||||||
input[type="password"]:focus,
|
input[type="password"]:focus,
|
||||||
input[type="search"]:focus,
|
input[type="search"]:focus,
|
||||||
input[type="tel"]:focus {
|
input[type="tel"]:focus,
|
||||||
|
input[type="date"]:focus,
|
||||||
|
input[type="time"]:focus {
|
||||||
border-bottom: 1px solid var(--clr-input-border-focus);
|
border-bottom: 1px solid var(--clr-input-border-focus);
|
||||||
box-shadow: 0 1px 0 var(--clr-input-shadow-focus);
|
box-shadow: 0 1px 0 var(--clr-input-shadow-focus);
|
||||||
}
|
}
|
||||||
@@ -3114,7 +3151,7 @@ img.channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Online-offline indicator positioned inside avatar-box */
|
/* Online-offline indicator positioned inside avatar-box */
|
||||||
.avatar-box .online, .offline {
|
.avatar-box .online, .avatar-box .offline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 0.75rem;
|
width: 0.75rem;
|
||||||
min-width: 0.75rem;
|
min-width: 0.75rem;
|
||||||
@@ -3582,9 +3619,15 @@ img.channel {
|
|||||||
.select-checkbox.blue {
|
.select-checkbox.blue {
|
||||||
color: var(--clr-primary-accent);
|
color: var(--clr-primary-accent);
|
||||||
}
|
}
|
||||||
/* Flexbox auto-margin trick: pins the checkbox to the far left of the row regardless of
|
/* Flexbox auto-margin trick: pins the checkbox to the far left of the row for sent (.right)
|
||||||
whether the message itself is left- or right-aligned (docs/images/多选-选中对话框.png). */
|
messages, where it would otherwise cluster next to the bubble at the row's right edge along with
|
||||||
.chat-box li .select-checkbox {
|
everything else justify-content:flex-end pulls there. Scoped to .right only -- applying it
|
||||||
|
unconditionally (as this rule originally did) claims 100% of the row's remaining space for the
|
||||||
|
auto margin regardless of alignment, which on a received (.left) message drags the avatar+bubble
|
||||||
|
that follow the checkbox all the way to the row's right edge instead of leaving them where
|
||||||
|
justify-content's default flex-start already puts them: immediately after the checkbox, at the
|
||||||
|
row's left edge (docs/images/多选-选中对话框.png). */
|
||||||
|
.chat-box li.right .select-checkbox {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3637,12 +3680,29 @@ img.channel {
|
|||||||
.batch-forward-confirm {
|
.batch-forward-confirm {
|
||||||
max-width: 24rem;
|
max-width: 24rem;
|
||||||
}
|
}
|
||||||
|
/* Also carries .forward-dialog for its base sheet chrome, but that class's own height:80% (right
|
||||||
|
for forward-dialog.jsx's long contact list) leaves this content-sized confirm sheet mostly empty
|
||||||
|
space below its buttons. Same (single-class) specificity as .forward-dialog itself, so a bare
|
||||||
|
.batch-forward-confirm{height:auto} would depend on which rule happens to come later in this
|
||||||
|
file to win -- the compound selector here beats it unconditionally instead. */
|
||||||
|
.forward-dialog.batch-forward-confirm {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
.batch-forward-target {
|
.batch-forward-target {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.6rem 0;
|
padding: 0.6rem 0;
|
||||||
}
|
}
|
||||||
|
/* .contact-box > li (2969 above) is more specific than the bare .batch-forward-target class above,
|
||||||
|
so its padding and its avatar-reserving divider (the gradient's gap starts at 3.5rem, assuming an
|
||||||
|
avatar occupies that space) win by default -- this row has no avatar, so the divider ends up
|
||||||
|
looking like it starts partway across the row. Reasserted here at matching specificity with a
|
||||||
|
full-width divider instead. */
|
||||||
|
.contact-box > li.batch-forward-target {
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
background: linear-gradient(to right, var(--clr-background-extra-light-gray), var(--clr-background-extra-light-gray)) 0 100% / 100% 1px no-repeat, var(--clr-bright);
|
||||||
|
}
|
||||||
/* 从只读 div 改成可输入的留言框后,要显式清掉 input 的原生边框/宽度,否则会缩成一个小方框。 */
|
/* 从只读 div 改成可输入的留言框后,要显式清掉 input 的原生边框/宽度,否则会缩成一个小方框。 */
|
||||||
.batch-forward-comment {
|
.batch-forward-comment {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -3900,7 +3960,18 @@ img.channel {
|
|||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#send-message-panel .material-icons.secondary, .material-icons.disabled {
|
#send-message-panel .material-icons.secondary {
|
||||||
|
color: var(--clr-text-tertiary)!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split out from the rule above -- it was originally a second, unscoped clause on it (a copy-paste
|
||||||
|
slip, matching the same .avatar-box .online/.offline mistake elsewhere in this file), which in
|
||||||
|
practice is the only rule greying out letter-tile.jsx's generic (no-letter) deleted-topic icon
|
||||||
|
(`<i class="material-icons {color} disabled">`, no wrapper class of its own to hang a scoped rule
|
||||||
|
off of) -- letter-tile.jsx's *other* deleted-avatar case (.lettertile.disabled) has its own real
|
||||||
|
rule and was never affected. Kept as an explicit, separately-scoped rule instead of silently
|
||||||
|
dropping a behavior nothing else provides. */
|
||||||
|
i.material-icons.disabled {
|
||||||
color: var(--clr-text-tertiary)!important;
|
color: var(--clr-text-tertiary)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4221,7 +4292,7 @@ button.full-screen {
|
|||||||
min-width: unset;
|
min-width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimized button.full-screen {
|
.minimized:not(.audio-minimized) button.full-screen {
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
@@ -4236,7 +4307,7 @@ button.full-screen .material-icons {
|
|||||||
font-size: x-large;
|
font-size: x-large;
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimized button.full-screen .material-icons {
|
.minimized:not(.audio-minimized) button.full-screen .material-icons {
|
||||||
font-size: large;
|
font-size: large;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4362,7 +4433,7 @@ video {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.caller-card .trusted-badge {
|
.caller-card .trusted-badge, .call-peer-card h1 .trusted-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5497,29 +5568,12 @@ ul.tabbar li.active > a {
|
|||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-hold {
|
/* .compose-record-overlay (8588 below) replaced this holdMode presentation -- see
|
||||||
flex: 1;
|
audio-recorder.jsx's holdMode render for why. */
|
||||||
justify-content: center;
|
.compose-record-overlay canvas {
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-hold canvas {
|
|
||||||
width: 6rem;
|
width: 6rem;
|
||||||
margin-right: 0;
|
height: 1.8rem;
|
||||||
}
|
margin-top: 0.4rem;
|
||||||
|
|
||||||
.audio-hold-hint {
|
|
||||||
color: var(--clr-text-tertiary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-hold.cancelling {
|
|
||||||
background-color: var(--clr-danger-text, #d93025);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-hold.cancelling .duration,
|
|
||||||
.audio-hold.cancelling .audio-hold-hint {
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio .duration {
|
.audio .duration {
|
||||||
@@ -5550,7 +5604,11 @@ ul.tabbar li.active > a {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-player div.playback {
|
/* Targets the wrapper div around canvas.playback + .timer (audio-player.jsx's non-short render) --
|
||||||
|
was accidentally written as div.playback, but "playback" is the canvas's own class, not this
|
||||||
|
wrapper's (which had no class at all), so this never matched anything; canvas+timer fell back to
|
||||||
|
block-level stacking (timer on its own line below the waveform) instead of sitting side by side. */
|
||||||
|
.audio-player .playback-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -6763,6 +6821,20 @@ div.toast.show {
|
|||||||
margin-top: 1.2rem;
|
margin-top: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .auth-pin-input used to be sized/positioned with hardcoded top/width/height values guessed to
|
||||||
|
match wherever .auth-pin-boxes happened to render on each page -- .auth-pin-content's <h1> and
|
||||||
|
.youth-password-gate's shorter top padding push the boxes to two different heights, so one
|
||||||
|
shared guessed `top` could only ever be right for one of them (confirmed live twice: the two
|
||||||
|
pages' real box positions differ by ~9.25rem, and a value tuned for one left the other's boxes
|
||||||
|
only ~32% overlapping the real input, still mostly unclickable). Wrapping both in their own
|
||||||
|
tightly-fitting, positioned container instead -- inset:0 on the input then always matches
|
||||||
|
.auth-pin-boxes' actual rendered box exactly, on any page, with no measured constant to keep
|
||||||
|
in sync. */
|
||||||
|
.auth-pin-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-pin-boxes {
|
.auth-pin-boxes {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 4rem);
|
grid-template-columns: repeat(4, 4rem);
|
||||||
@@ -6780,12 +6852,21 @@ div.toast.show {
|
|||||||
|
|
||||||
.auth-pin-input {
|
.auth-pin-input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 11rem;
|
inset: 0;
|
||||||
width: 19rem;
|
margin: 0;
|
||||||
height: 5rem;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* input[type="text"]'s global reset (2131 above) sets a fixed height:2.5rem at higher specificity
|
||||||
|
(0,1,1) than a bare .auth-pin-input's (0,1,0) -- it was winning and pinning this input to 2.5rem
|
||||||
|
tall regardless of the boxes' actual height, undoing the inset:0 fix above for the vertical axis
|
||||||
|
(width happened to still come out right only because the reset's own width:100% agrees with this
|
||||||
|
element's actual need). Selector bumped to definitively outrank it, not just happen to. */
|
||||||
|
.auth-pin-wrap .auth-pin-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-pin-content .auth-primary {
|
.auth-pin-content .auth-primary {
|
||||||
width: min(100%, 24rem);
|
width: min(100%, 24rem);
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
@@ -7056,6 +7137,7 @@ div.toast.show {
|
|||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -7522,6 +7604,20 @@ div.toast.show {
|
|||||||
box-shadow: 0 0.2rem 0.8rem rgba(37,49,65,0.04);
|
box-shadow: 0 0.2rem 0.8rem rgba(37,49,65,0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-notification-load-more {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid #eceef1;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: none;
|
||||||
|
color: #567;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.system-notification-list article.read .system-notification-card {
|
.system-notification-list article.read .system-notification-card {
|
||||||
opacity: 0.82;
|
opacity: 0.82;
|
||||||
}
|
}
|
||||||
@@ -7552,22 +7648,6 @@ div.toast.show {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-notification-card p button {
|
|
||||||
display: inline;
|
|
||||||
width: auto;
|
|
||||||
min-width: 0;
|
|
||||||
height: auto;
|
|
||||||
min-height: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #1683f8;
|
|
||||||
background: transparent;
|
|
||||||
font: inherit;
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-notification-card dl {
|
.system-notification-card dl {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -9106,6 +9186,16 @@ div.toast.show {
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The 4th column is reserved for the trailing chevron, but the <em> badge before it only renders
|
||||||
|
when item.badge is truthy (most of these rows never have one) -- grid auto-placement fills
|
||||||
|
columns left-to-right in DOM order regardless, so with no badge the chevron auto-lands in
|
||||||
|
column 3 instead of 4, leaving column 4 empty and the icon sitting well short of the row's
|
||||||
|
right edge. Pinned to column 4 explicitly so its position doesn't depend on how many siblings
|
||||||
|
happened to render before it. */
|
||||||
|
.people-entry-list > button > .material-icons:last-child {
|
||||||
|
grid-column: 4;
|
||||||
|
}
|
||||||
|
|
||||||
.people-entry-list em {
|
.people-entry-list em {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -9162,7 +9252,8 @@ div.toast.show {
|
|||||||
.group-list button > span:nth-child(2),
|
.group-list button > span:nth-child(2),
|
||||||
.group-request-list article > div,
|
.group-request-list article > div,
|
||||||
.group-admin-page article > span:nth-child(2),
|
.group-admin-page article > span:nth-child(2),
|
||||||
.group-muted-list article > span:nth-child(2) {
|
.group-muted-list article > span:nth-child(2),
|
||||||
|
.group-member-picker > button > span:nth-child(2) {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -9205,7 +9296,14 @@ div.toast.show {
|
|||||||
.people-index {
|
.people-index {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
top: 13rem;
|
/* Anchored against .people-page (the nearest positioned ancestor), which starts above the
|
||||||
|
header -- 13rem used to land ~200px above the actual friend list (overlapping the fixed
|
||||||
|
4-row entry list: 新朋友/群组/文件助手/系统消息), a leftover value from before that entry
|
||||||
|
list existed in its current form. Real layout above the letter-grouped list is header
|
||||||
|
(4.5rem) + search bar incl. margins (4.4rem) + entry list incl. bottom margin (4 x 4.3rem
|
||||||
|
rows + 0.8rem margin = 18rem) + "好友通讯录" section title's own top margin (0.6rem) =
|
||||||
|
27.5rem -- confirmed live via getBoundingClientRect against the real rendered page. */
|
||||||
|
top: 27.5rem;
|
||||||
right: 0.35rem;
|
right: 0.35rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -9353,15 +9451,6 @@ div.toast.show {
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.people-search-result em {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
color: #fff;
|
|
||||||
background: #398afa;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-qr-page main,
|
.people-qr-page main,
|
||||||
.group-qr-page main {
|
.group-qr-page main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -9684,8 +9773,12 @@ div.toast.show {
|
|||||||
|
|
||||||
.people-settings section small,
|
.people-settings section small,
|
||||||
.group-settings section small {
|
.group-settings section small {
|
||||||
|
min-width: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
overflow: hidden;
|
||||||
color: #949ba4;
|
color: #949ba4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.people-switch,
|
.people-switch,
|
||||||
@@ -10212,20 +10305,6 @@ div.toast.show {
|
|||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-announcement-detail footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid #eceef1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-announcement-detail footer > span {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-announcement-detail > small {
|
.group-announcement-detail > small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -10309,12 +10388,15 @@ div.toast.show {
|
|||||||
flex: 0 0 3.6rem;
|
flex: 0 0 3.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "添加"格:跟成员头像同样尺寸的浅色方块,视觉上与真实成员头像区分开 */
|
/* "添加"格:跟成员头像同样尺寸的浅色方块,视觉上与真实成员头像区分开。宽高本该跟上面那条规则
|
||||||
|
一致(3.6rem)——之前这里写成了3rem,两条规则特异性相同、这条排在后面,字面覆盖了 width/
|
||||||
|
height,但没盖到上面那条的 flex:0 0 3.6rem(这条完全没提 flex),容器又是 flex-direction:
|
||||||
|
column,主轴尺寸吃 flex-basis 不吃 height,实际渲成了 3rem 宽、3.6rem 高的长方形。 */
|
||||||
.group-member-tile-action {
|
.group-member-tile-action {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 3rem;
|
width: 3.6rem;
|
||||||
height: 3rem;
|
height: 3.6rem;
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
color: #98a0aa;
|
color: #98a0aa;
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
@@ -10569,11 +10651,6 @@ div.toast.show {
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-member-picker > button > span:nth-child(2) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-check {
|
.group-check {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -10847,6 +10924,30 @@ div.toast.show {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Favorites/History/DownloadPage's loading/error placeholder text and Favorites/History's "加载
|
||||||
|
更多" pagination button -- both had zero rules anywhere in this file, falling back to a bare
|
||||||
|
<p>'s default margins and a plain <button>'s default outline respectively. */
|
||||||
|
.library-empty-hint {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
color: #9299a2;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-load-more {
|
||||||
|
display: block;
|
||||||
|
width: min(100%, 52rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border: 1px solid #eceef1;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
box-shadow: none;
|
||||||
|
color: #567;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.library-favorite-item {
|
.library-favorite-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
@@ -10866,7 +10967,7 @@ div.toast.show {
|
|||||||
|
|
||||||
.library-favorite-content > footer {
|
.library-favorite-content > footer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto 2rem;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.65rem;
|
margin-top: 0.65rem;
|
||||||
@@ -10900,6 +11001,52 @@ div.toast.show {
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 需求7.4"长文本折叠、展开和复制":clamped 限定默认显示高度,展开态(FavoriteText 的
|
||||||
|
expanded=true) 去掉这个 class 即可显示全文;library-text-actions 放"展开/收起"(仅溢出
|
||||||
|
时才渲染)和"复制"两个按钮,颜色沿用应用里其它文字按钮的强调色。
|
||||||
|
没有用 -webkit-line-clamp:实测这个环境里它对这个 <p> 不生效——getComputedStyle 能读到
|
||||||
|
display/-webkit-line-clamp/overflow 都是设置的值,但视觉上完全不裁切,scrollHeight 也跟
|
||||||
|
clientHeight 相等,是那套盒模型本身在当前渲染环境下没有实际生效,不是 CSS 写错了。改用
|
||||||
|
.library-text-content 已有的 line-height:1.65 反推的 max-height(5 行 = 5*1.65em)+
|
||||||
|
overflow:hidden,是普通块级盒子,不依赖任何厂商前缀的特殊渲染路径,行为可预期。 */
|
||||||
|
.library-text-content.clamped {
|
||||||
|
max-height: 8.25em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* library-text-wrap 故意不用 display:flex——曾经这样写过,结果 .clamped 的 <p> 变成了 flex
|
||||||
|
item:flex/grid item 的 specified display 会被 UA 按 CSS Display 规范"blockify",实测这里的
|
||||||
|
-webkit-box 被换成了 flow-root(getComputedStyle 验证过),-webkit-line-clamp 整个失效,折叠
|
||||||
|
彻底不生效。改回普通块级 + margin 做间距,让 .clamped 保持自己声明的 -webkit-box 不被更外层
|
||||||
|
的 flex 容器改写。 */
|
||||||
|
.library-text-wrap {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-text-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.1rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-text-actions button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #3b82c4;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-text-actions button .material-icons {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.library-file-card {
|
.library-file-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
||||||
@@ -10935,6 +11082,13 @@ div.toast.show {
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
/* .library-file-card is a <button>, and the global `button{line-height:2.2rem}` reset (meant for
|
||||||
|
a single line of centered action-button text) inherits straight down into this <strong>/<small>
|
||||||
|
pair -- getComputedStyle confirmed the <strong> line box is 29.33px tall for 14px text, so its
|
||||||
|
own bottom edge sits ~15px below the actual glyphs even before <small>'s 0.3rem margin-top,
|
||||||
|
making the gap between filename and size look much bigger than that nominal margin. Reset once
|
||||||
|
here so both children (which don't set their own line-height) inherit something sane instead. */
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-file-card strong {
|
.library-file-card strong {
|
||||||
@@ -10959,13 +11113,44 @@ div.toast.show {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared by VoiceCard/LinkText/MediaThumb's "removed" state (content-library-view.jsx) -- same
|
.library-link-text {
|
||||||
opacity + red label language as .library-file-card.removed/em above, but these three don't share
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: auto;
|
||||||
|
min-height: 2.8rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border: 1px solid #eceef1;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--clr-primary-accent);
|
||||||
|
background: #fafbfc;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-link-text span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared by VoiceCard/LinkText/MediaThumb/text-favorite's "removed" state (content-library-view.jsx)
|
||||||
|
-- same opacity + red label language as .library-file-card.removed/em above, but these don't share
|
||||||
its grid layout (voice/media assume an icon+waveform or an image backdrop that isn't there once
|
its grid layout (voice/media assume an icon+waveform or an image backdrop that isn't there once
|
||||||
the source message is gone), so the removed state uses a simple centered flex row instead. */
|
the source message is gone), so the removed state uses a simple centered flex row instead.
|
||||||
|
.library-text-content.removed covers a recalled/deleted *text* favorite or history-search hit --
|
||||||
|
plain text has no dedicated wrapper component of its own (FavoriteItem/History render it inline),
|
||||||
|
so the same modifier class is applied straight to the existing <p className="library-text-content">
|
||||||
|
rather than introducing a whole new component just for this one state. */
|
||||||
.library-voice-card.removed,
|
.library-voice-card.removed,
|
||||||
.library-link-text.removed,
|
.library-link-text.removed,
|
||||||
.library-media-thumb.removed {
|
.library-media-thumb.removed,
|
||||||
|
.library-text-content.removed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -10975,15 +11160,15 @@ div.toast.show {
|
|||||||
|
|
||||||
.library-voice-card.removed em,
|
.library-voice-card.removed em,
|
||||||
.library-link-text.removed em,
|
.library-link-text.removed em,
|
||||||
.library-media-thumb.removed em {
|
.library-media-thumb.removed em,
|
||||||
|
.library-text-content.removed em {
|
||||||
color: #d74a53;
|
color: #d74a53;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-voice-card {
|
.library-voice-card {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 2rem minmax(4rem, 12rem) auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
width: min(100%, 20rem);
|
width: min(100%, 20rem);
|
||||||
@@ -11402,9 +11587,12 @@ div.toast.show {
|
|||||||
color: #9299a2;
|
color: #9299a2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DownloadPage's loading ("取消") and done ("用其他应用打开") states each render exactly one button
|
||||||
|
here -- never two side by side -- so the 2nd 1fr column sat permanently empty, pinning the
|
||||||
|
button to the left half with the right half dead space. */
|
||||||
.library-download-actions {
|
.library-download-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11491,6 +11679,14 @@ div.toast.show {
|
|||||||
background: #dcecff;
|
background: #dcecff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same class of bug as .library-file-card/.library-record-header above: this is a <button> and
|
||||||
|
the global `button{line-height:2.2rem}` reset inherits into the sender-name <strong> and date
|
||||||
|
<small>, inflating the gap around the search-result snippet in between. */
|
||||||
|
.library-message-results strong,
|
||||||
|
.library-message-results small {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.library-message-results small {
|
.library-message-results small {
|
||||||
color: #969da5;
|
color: #969da5;
|
||||||
}
|
}
|
||||||
@@ -11501,8 +11697,44 @@ div.toast.show {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-record-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
/* Same fix as .library-file-card > div above: this is used as both a plain <div> and a <button>
|
||||||
|
(see the comment on the JSX call sites) -- only the <button> usage inherits the global
|
||||||
|
`button{line-height:2.2rem}` reset, so without resetting it here the two usages render this
|
||||||
|
row at two different heights/vertical rhythms depending on which element it happens to be. */
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-record-header strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-record-header small {
|
||||||
|
flex: none;
|
||||||
|
color: #9299a2;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
.library-record-files .library-file-card,
|
.library-record-files .library-file-card,
|
||||||
.library-record-links .library-link-card {
|
.library-record-links .library-link-text {
|
||||||
margin-bottom: 0.7rem;
|
margin-bottom: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11847,12 +12079,31 @@ div.toast.show {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Name + trusted badge used to share one nowrap/ellipsis box -- when the name alone was long
|
||||||
|
enough to need truncating, the badge (last in source order) got truncated away with it,
|
||||||
|
entirely outside the visible area instead of just following a shortened name. Name now
|
||||||
|
truncates in its own flex child; the badge is a non-shrinking sibling so it always stays
|
||||||
|
on-screen right after whatever of the name fit. */
|
||||||
.call-peer-card h1 {
|
.call-peer-card h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin: 1.3rem 0 0.45rem;
|
margin: 1.3rem 0 0.45rem;
|
||||||
|
max-width: 100%;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.55rem;
|
font-size: 1.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-peer-card h1 > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-peer-card h1 .trusted-badge {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.call-peer-card p {
|
.call-peer-card p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgba(255,255,255,0.78);
|
color: rgba(255,255,255,0.78);
|
||||||
@@ -12603,9 +12854,13 @@ div.toast.show {
|
|||||||
width: unset;
|
width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
#video-container.minimized {
|
/* :not(.audio-minimized) -- same specificity (1,1,0) as #video-container.audio-minimized (4149
|
||||||
|
above), and this rule comes later in the file so it was winning even for a voice call's pill,
|
||||||
|
stretching it into the same full-width box meant for the video floating window. */
|
||||||
|
#video-container.minimized:not(.audio-minimized) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
right: auto;
|
||||||
top: var(--caption-panel-height);
|
top: var(--caption-panel-height);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 11rem;
|
height: 11rem;
|
||||||
@@ -12811,10 +13066,14 @@ div.toast.show {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same class of bug as .library-file-card above: .personal-identity is a <button> (头像+昵称+企业名
|
||||||
|
身份卡片, "我的" 页首屏第一眼看到的内容), and the global `button{line-height:2.2rem}` reset
|
||||||
|
inherits into these three stacked lines, inflating the gaps between nickname/企业名/ID. */
|
||||||
.personal-identity strong {
|
.personal-identity strong {
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -12822,6 +13081,7 @@ div.toast.show {
|
|||||||
.personal-identity small {
|
.personal-identity small {
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
color: #8a919a;
|
color: #8a919a;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.personal-menu-row {
|
.personal-menu-row {
|
||||||
@@ -13992,13 +14252,20 @@ div.toast.show {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-loading {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #6e7781;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.report-reason-list {
|
.report-reason-list {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-reason-list button {
|
.report-reason-list button {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2.8rem minmax(0, 1fr) 1.5rem;
|
grid-template-columns: minmax(0, 1fr) 1.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -14014,17 +14281,17 @@ div.toast.show {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-reason-list button > span:first-child {
|
/* ReportReasons (security-support-view.jsx) renders each row as a single `<span><strong>label
|
||||||
display: grid;
|
</strong></span>` followed by the trailing chevron -- there is no separate icon element, and
|
||||||
place-items: center;
|
the reference design (docs/images/举报.png) confirms this list was never meant to carry a
|
||||||
width: 2.5rem;
|
per-row icon badge. This rule used to target that lone span as `span:first-child` with a fixed
|
||||||
height: 2.5rem;
|
2.5rem blue square + white text (sized/colored for an icon glyph, not a 4-character label) --
|
||||||
border-radius: 0.4rem;
|
the 3-column grid above reserved a matching 2.8rem first column for it. With no icon in the
|
||||||
color: #fff;
|
DOM, the real label text got force-fit into that narrow column: white-on-blue where it
|
||||||
background: #4d70a8;
|
overlapped the badge box, white-on-white (invisible) past its edge, so only a garbled sliver of
|
||||||
}
|
each reason's label was ever legible. Folded into the plain block/min-width:0 treatment below
|
||||||
|
(and the button grid down to 2 columns) so the label gets the full flexible column instead. */
|
||||||
.report-reason-list button > span:nth-child(2),
|
.report-reason-list button > span:first-child,
|
||||||
.report-reason-list strong,
|
.report-reason-list strong,
|
||||||
.report-reason-list small {
|
.report-reason-list small {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -14049,9 +14316,15 @@ div.toast.show {
|
|||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same collision as .report-reason-list above: ReportDetails renders this section as just
|
||||||
|
`<span>{"举报原因"/reasonLabel}</span><button>修改</button>` -- two children, no leading icon --
|
||||||
|
but the grid reserved a 2.7rem first column for one anyway (matching the now-unused
|
||||||
|
`.material-icons` rule below, presumably meant for some future/removed icon). With no icon
|
||||||
|
present, the label span itself was squeezed into that 36px column, wrapping "举报原因"/the
|
||||||
|
reason text onto multiple lines instead of the single flexible column it needs. */
|
||||||
.report-selected-reason {
|
.report-selected-reason {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2.7rem minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" rx="26" fill="#ffffff"/>
|
||||||
|
<path transform="rotate(-18 96 96)" fill="#1677ff"
|
||||||
|
d="M96,13.5 A82.5,82.5 0 0 1 178.5,96 L178.5,96 A82.5,82.5 0 0 1 96,178.5 L43.2,178.5 A29.7,29.7 0 0 1 13.5,148.8 L13.5,96 A82.5,82.5 0 0 1 96,13.5 Z"/>
|
||||||
|
<text x="96" y="98" text-anchor="middle" dominant-baseline="central"
|
||||||
|
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="77" fill="#ffffff">M</text>
|
||||||
|
<circle cx="156" cy="156" r="30" fill="#ffffff"/>
|
||||||
|
<circle cx="156" cy="156" r="24" fill="#3ba55c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 614 B |
@@ -1 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="192" height="192"><defs><filter id="f" color-interpolation-filters="sRGB"><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-1" result="offset"/><feComposite in="SourceGraphic" in2="offset" result="fbSourceGraphic"/><feColorMatrix result="fbSourceGraphicAlpha" in="fbSourceGraphic" values="0 0 0 -1 0 0 0 0 -1 0 0 0 0 -1 0 0 0 0 1 0"/><feFlood flood-opacity=".251" flood-color="#000" result="flood"/><feComposite in2="fbSourceGraphic" in="flood" operator="in" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-3" result="offset"/><feComposite in2="offset" in="fbSourceGraphic" result="composite2"/></filter><filter id="d" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#FFF" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="b" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><filter id="c" color-interpolation-filters="sRGB"><feFlood flood-opacity=".196" flood-color="#000" result="flood"/><feComposite in="flood" in2="SourceGraphic" operator="out" result="composite1"/><feGaussianBlur in="composite1" result="blur"/><feOffset dy="-2" result="offset"/><feComposite in="offset" in2="SourceGraphic" operator="atop" result="composite2"/></filter><linearGradient id="a"><stop offset="0" stop-opacity=".251"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient xlink:href="#a" id="e" x1="102.814" y1="97.076" x2="219.019" y2="170.481" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.29306 0 0 1.00596 -43.959 .807)"/></defs><g fill-rule="evenodd"><path d="M14.102 64A88 87.925 0 008 95.924c-.012 44.455 18.485 70.138 1.863 79.822-12.733 7.419 43.153 8.258 70.137 8.248V64H14.102z" fill="#fc5d3e" filter="url(#b)"/><path d="M80 64v119.994c8.24-.003 14.055-.08 14.055-.08L96 183.85a88 87.925 0 0088-87.926A88 87.925 0 00177.99 64H80z" fill="#45ae49" filter="url(#c)"/><path d="M96 8A88 87.925 0 009.52 80h172.978A88 87.925 0 0096 8z" fill="#2d526b" filter="url(#d)"/></g><path d="M88.002 40.975l-8.6 112.808 39.791 26.94c34.169-9.369 59.381-38.298 63.971-73.4z" fill="url(#e)" fill-rule="evenodd" opacity=".5"/><text y="153.195" x="53.125" style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" font-weight="700" font-size="160" font-family="Arial" letter-spacing="0" word-spacing="0"><tspan style="line-height:125%;-inkscape-font-specification:'Arial, Bold';text-align:start" y="153.195" x="53.125" fill="#fff">t</tspan></text></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" rx="26" fill="#ffffff"/>
|
||||||
|
<path transform="rotate(-18 96 96)" fill="#1677ff"
|
||||||
|
d="M96,13.5 A82.5,82.5 0 0 1 178.5,96 L178.5,96 A82.5,82.5 0 0 1 96,178.5 L43.2,178.5 A29.7,29.7 0 0 1 13.5,148.8 L13.5,96 A82.5,82.5 0 0 1 96,13.5 Z"/>
|
||||||
|
<text x="96" y="98" text-anchor="middle" dominant-baseline="central"
|
||||||
|
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="77" fill="#ffffff">M</text>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 510 B |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 938 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 500">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#1a2c52"/>
|
||||||
|
<stop offset="1" stop-color="#0e1730"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="750" height="500" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(375 168)">
|
||||||
|
<rect x="-70" y="-70" width="140" height="140" rx="19" fill="#ffffff"/>
|
||||||
|
<path transform="rotate(-18)" fill="#1677ff"
|
||||||
|
d="M0,-60 A60,60 0 0 1 60,0 L60,0 A60,60 0 0 1 0,60 L-38.4,60 A21.6,21.6 0 0 1 -60,38.4 L-60,0 A60,60 0 0 1 0,-60 Z"/>
|
||||||
|
<text x="0" y="2" text-anchor="middle" dominant-baseline="central"
|
||||||
|
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="56" fill="#ffffff">M</text>
|
||||||
|
</g>
|
||||||
|
<text x="375" y="345" text-anchor="middle"
|
||||||
|
font-family="Roboto, Arial, sans-serif" font-weight="700" font-size="56" fill="#ffffff">MeetingIM</text>
|
||||||
|
<text x="375" y="392" text-anchor="middle"
|
||||||
|
font-family="Roboto, Arial, sans-serif" font-weight="400" font-size="24" fill="#aab4c8">即时通讯</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -2,27 +2,27 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Tinode</title>
|
<title>MeetingIM</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="application-name" content="Tinode Web, version for developers" />
|
<meta name="application-name" content="MeetingIM, version for developers" />
|
||||||
<meta name="description" content="Tinode instant messenger, development version" />
|
<meta name="description" content="MeetingIM instant messenger, development version" />
|
||||||
<meta name="application-url" content="https://web.tinode.co/">
|
<meta name="application-url" content="https://web.118im.com/">
|
||||||
<!-- Browser styling for Chrome, FF, Opera -->
|
<!-- Browser styling for Chrome, FF, Opera -->
|
||||||
<meta name="theme-color" content="#3949AB" />
|
<meta name="theme-color" content="#3949AB" />
|
||||||
<!-- Ask Google not to offer page translation -->
|
<!-- Ask Google not to offer page translation -->
|
||||||
<meta name="google" value="notranslate" />
|
<meta name="google" value="notranslate" />
|
||||||
<!-- Open Graph description -->
|
<!-- Open Graph description -->
|
||||||
<meta name="og:url" content="https://web.tinode.co/">
|
<meta name="og:url" content="https://web.118im.com/">
|
||||||
<meta name="og:title" content="Tinode Web Development">
|
<meta name="og:title" content="MeetingIM Development">
|
||||||
<meta name="og:image" content="img/og-logo.jpeg">
|
<meta name="og:image" content="img/og-logo.jpeg">
|
||||||
<meta name="og:description" content="Tinode instant messenger, development version" />
|
<meta name="og:description" content="MeetingIM instant messenger, development version" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
||||||
<link rel="canonical" href="https://web.tinode.co/" />
|
<link rel="canonical" href="https://web.118im.com/" />
|
||||||
<!-- Google's Roboto font -->
|
<!-- Google's Roboto font -->
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
||||||
<!-- Google's material design icons -->
|
<!-- Google's material design icons -->
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||||
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
||||||
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
MeetingIM does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
||||||
then <a href="">try again</a>.
|
then <a href="">try again</a>.
|
||||||
</div>
|
</div>
|
||||||
</div></noscript>
|
</div></noscript>
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Tinode</title>
|
<title>MeetingIM</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="application-name" content="Tinode Web" />
|
<meta name="application-name" content="MeetingIM" />
|
||||||
<meta name="description" content="Tinode instant messenger in a browser" />
|
<meta name="description" content="MeetingIM instant messenger in a browser" />
|
||||||
<meta name="application-url" content="https://web.tinode.co/">
|
<meta name="application-url" content="https://web.118im.com/">
|
||||||
<meta name="theme-color" content="#3949AB" />
|
<meta name="theme-color" content="#3949AB" />
|
||||||
<meta name="google" value="notranslate" />
|
<meta name="google" value="notranslate" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<meta name="og:url" content="https://web.tinode.co/">
|
<meta name="og:url" content="https://web.118im.com/">
|
||||||
<meta name="og:title" content="Tinode Web">
|
<meta name="og:title" content="MeetingIM">
|
||||||
<meta name="og:image" content="img/og-logo.jpeg">
|
<meta name="og:image" content="img/og-logo.jpeg">
|
||||||
<meta name="og:description" content="Tinode instant messenger in a browser" />
|
<meta name="og:description" content="MeetingIM instant messenger in a browser" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
<link rel="shortcut icon" id="shortcut-icon" href="img/logo32x32.png" type="image/png" />
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
<link rel="apple-touch-icon" sizes="192x192" href="img/logo192.png" type="image/png" />
|
||||||
<link rel="canonical" href="https://web.tinode.co/" />
|
<link rel="canonical" href="https://web.118im.com/" />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,500,700,700i" />
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||||
<link rel="stylesheet" href="css/base.min.css" />
|
<link rel="stylesheet" href="css/base.min.css" />
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
<noscript><div id="noscript-message"><div id="noscript-title">Javascript support is required.</div>
|
||||||
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
<div id="noscript-content">It appears JavaScript is turned off or is not supported by your browser.
|
||||||
TinodeWeb does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
MeetingIM does not work without JavaScript. Please enable JavaScript by changing your browser options,
|
||||||
then <a href="">try again</a>.</div></div></noscript>
|
then <a href="">try again</a>.</div></div></noscript>
|
||||||
<div id="mountPoint"></div>
|
<div id="mountPoint"></div>
|
||||||
<script src="firebase-init.js"></script>
|
<script src="firebase-init.js"></script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Tinode",
|
"name": "MeetingIM",
|
||||||
"short_name": "Tinode",
|
"short_name": "MeetingIM",
|
||||||
"description": "Tinode Web App.\nSee more at https://tinode.co/",
|
"description": "MeetingIM instant messenger.",
|
||||||
"categories": ["chat", "communication", "productivity"],
|
"categories": ["chat", "communication", "productivity"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
@@ -25,18 +25,7 @@
|
|||||||
"theme_color": "#3949AB",
|
"theme_color": "#3949AB",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"gcm_sender_id": "103953800507",
|
"gcm_sender_id": "103953800507",
|
||||||
"prefer_related_applications": true,
|
"prefer_related_applications": false,
|
||||||
"related_applications": [
|
|
||||||
{
|
|
||||||
"platform": "play",
|
|
||||||
"url": "https://play.google.com/store/apps/details?id=co.tinode.tindroidx",
|
|
||||||
"id": "co.tinode.tindroidx"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"platform": "itunes",
|
|
||||||
"url": "https://apps.apple.com/us/app/tinode/id1483763538"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"audio-capture": {
|
"audio-capture": {
|
||||||
"description": "Required for recording voice messages"
|
"description": "Required for recording voice messages"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const APP_NAME = BASE_APP_NAME + '/' + (PACKAGE_VERSION || '0.25');
|
|||||||
export const API_KEY = process.env.TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
export const API_KEY = process.env.TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
|
||||||
|
|
||||||
// The array of possible hosts to connect to.
|
// The array of possible hosts to connect to.
|
||||||
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};
|
export const KNOWN_HOSTS = {hosted: 'web.118im.com', local: 'localhost:6060'};
|
||||||
|
|
||||||
// Default host name and port to connect to.
|
// Default host name and port to connect to.
|
||||||
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
|
||||||
@@ -119,14 +119,13 @@ export const MIN_DURATION = 2_000;
|
|||||||
// Maximum duration of an audio recording in milliseconds (10 min).
|
// Maximum duration of an audio recording in milliseconds (10 min).
|
||||||
export const MAX_DURATION = 600_000;
|
export const MAX_DURATION = 600_000;
|
||||||
|
|
||||||
// Link for "Contact Us".
|
// No real destination exists for these (this is a private multi-tenant product, not the open-source
|
||||||
export const LINK_CONTACT_US = 'mailto:support@tinode.co';
|
// Tinode project) -- null rather than the upstream project's own contact/legal pages, which acc-support-view.jsx
|
||||||
|
// renders as inert text instead of a link (same treatment auth-onboarding-view.jsx already gives its
|
||||||
// Link to Privacy Policy.
|
// own placeholder 用户协议/隐私政策 links).
|
||||||
export const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';
|
export const LINK_CONTACT_US = null;
|
||||||
|
export const LINK_PRIVACY_POLICY = null;
|
||||||
// Link to Terms of Service.
|
export const LINK_TERMS_OF_SERVICE = null;
|
||||||
export const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';
|
|
||||||
|
|
||||||
// When user starts a P2P topic, automatic subscription does not
|
// When user starts a P2P topic, automatic subscription does not
|
||||||
// happen until the first message. Setting this option to true enables
|
// happen until the first message. Setting this option to true enables
|
||||||
|
|||||||
@@ -4,9 +4,17 @@ import { KNOWN_HOSTS, DEFAULT_HOST } from '../config.js';
|
|||||||
export function detectServerAddress() {
|
export function detectServerAddress() {
|
||||||
let host = DEFAULT_HOST;
|
let host = DEFAULT_HOST;
|
||||||
if (typeof window.location == 'object') {
|
if (typeof window.location == 'object') {
|
||||||
if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') {
|
if (window.location.protocol == 'file:') {
|
||||||
|
// No real page origin to derive a host/port from at all (e.g. a file:// build opened
|
||||||
|
// directly) -- this is the one case KNOWN_HOSTS.local's hardcoded port is actually needed.
|
||||||
host = KNOWN_HOSTS.local;
|
host = KNOWN_HOSTS.local;
|
||||||
} else if (window.location.hostname) {
|
} else if (window.location.hostname) {
|
||||||
|
// Was `hostname == 'localhost'` on its own branch returning the hardcoded KNOWN_HOSTS.local
|
||||||
|
// ('localhost:6060') unconditionally -- any local/self-hosted deployment or dev proxy not on
|
||||||
|
// port 6060 (this project's own dev-proxy runs on 8093) got a wrong host and every "no
|
||||||
|
// cached tenant/host info yet" connection attempt (e.g. right after clearing storage) failed
|
||||||
|
// outright before a later reconnect could recover with the right address. Same-origin
|
||||||
|
// same-port assumption already used for every non-localhost host applies here too.
|
||||||
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,16 @@ const REASON_MESSAGES = {
|
|||||||
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
|
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
|
||||||
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
|
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
|
||||||
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
|
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
|
||||||
// 独立密码校验的两个 reason(对接文档 809 行有写),青少年模式关闭/开启走的都是这条接口。
|
// CODE_INVALID is shared by two unrelated verify endpoints -- independent-password PIN check
|
||||||
// 原来没收录,报错时直接透传后端英文原文——PIN 输错显示 "code invalid",连续错误锁定后
|
// (青少年模式关闭/开启走的这条接口,对接文档 809 行) *and* the plain SMS verification-code
|
||||||
// 显示 "too many attempts",且不告诉用户要等多久能重试。
|
// verify endpoints (/auth/verification-codes/{id}/verify, /security/challenges/{id}/verify --
|
||||||
CODE_INVALID: '独立密码不正确',
|
// confirmed live: entering a wrong SMS code on the "忘记独立密码?" identity-check screen also
|
||||||
|
// gets back reason=CODE_INVALID). This table only keys off the reason string with no per-call-site
|
||||||
|
// override, so a message written for just one of the two ("独立密码不正确") showed up verbatim on
|
||||||
|
// the *other* endpoint's screen, where there's no "独立密码" concept in view at all -- confusing,
|
||||||
|
// wrong-sounding copy for a real, correctly-rejected wrong code. Kept generic enough to be accurate
|
||||||
|
// for both rather than wrong in either.
|
||||||
|
CODE_INVALID: '验证码或独立密码不正确',
|
||||||
TOO_MANY_ATTEMPTS: details => details && details.retry_after_seconds
|
TOO_MANY_ATTEMPTS: details => details && details.retry_after_seconds
|
||||||
? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试`
|
? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试`
|
||||||
: '连续输错次数过多,请稍后重试'
|
: '连续输错次数过多,请稍后重试'
|
||||||
@@ -268,7 +274,8 @@ const UPLOAD_CTRL_TEXT_CN = {
|
|||||||
'too large': '文件大小超出限制',
|
'too large': '文件大小超出限制',
|
||||||
'malformed': '请求内容有误,请稍后重试',
|
'malformed': '请求内容有误,请稍后重试',
|
||||||
'permission denied': '没有权限执行此操作',
|
'permission denied': '没有权限执行此操作',
|
||||||
'gone': '内容不存在或已被删除'
|
'gone': '内容不存在或已被删除',
|
||||||
|
'not found': '内容不存在或已被删除'
|
||||||
};
|
};
|
||||||
export function uploadLargeFile(tinode, file, onProgress) {
|
export function uploadLargeFile(tinode, file, onProgress) {
|
||||||
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
|
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
|
||||||
@@ -760,8 +767,8 @@ export const getOrganizationDepartmentMembers = (departmentId, cursor = '') =>
|
|||||||
// server/im_http_test.go's own request fixtures. This function used to send `query`, silently making
|
// server/im_http_test.go's own request fixtures. This function used to send `query`, silently making
|
||||||
// every caller's search box a no-op (server ignores the unrecognized param and returns the unfiltered
|
// every caller's search box a no-op (server ignores the unrecognized param and returns the unfiltered
|
||||||
// list every time).
|
// list every time).
|
||||||
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = ''} = {}) =>
|
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = '', limit = ''} = {}) =>
|
||||||
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor}));
|
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor, limit}));
|
||||||
export const searchOrganization = (query = '', types = 'department,user', cursor = '') =>
|
export const searchOrganization = (query = '', types = 'department,user', cursor = '') =>
|
||||||
imGet('/organization/search' + qs({q: query, types, cursor}));
|
imGet('/organization/search' + qs({q: query, types, cursor}));
|
||||||
export const getMeetings = (cursor = '', limit = 20) => imGet('/meetings' + qs({limit, cursor}));
|
export const getMeetings = (cursor = '', limit = 20) => imGet('/meetings' + qs({limit, cursor}));
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ export default class HashNavigation {
|
|||||||
window.location.hash = url;
|
window.location.hash = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For switching between sub-tabs of the *same* page (e.g. content-library-view.jsx's favorites/
|
||||||
|
// history type tabs), not for navigating to a different page. `navigateTo`/`location.hash =`
|
||||||
|
// always pushes a new history entry, so clicking through N tabs pushes N entries -- the system
|
||||||
|
// back button (and this class's own goBack()) then has to step back through every tab the user
|
||||||
|
// ever visited before it reaches whatever page they were actually on before this one. `replaceState`
|
||||||
|
// updates the hash (still correct for a refresh or a copied link) without adding a history entry,
|
||||||
|
// so it doesn't inflate navigationCount either -- a tab switch was never a real in-app navigation
|
||||||
|
// to begin with. replaceState doesn't fire `hashchange` on its own, so dispatch one manually for
|
||||||
|
// the existing `window.addEventListener('hashchange', ...)` listener (tinode-web.jsx's
|
||||||
|
// handleHashRoute) to pick up; that handler re-reads window.location.hash itself rather than the
|
||||||
|
// event, so a synthetic event with no detail is indistinguishable from a real one to it.
|
||||||
|
static navigateReplace(url) {
|
||||||
|
window.history.replaceState(null, '', '#' + url);
|
||||||
|
window.dispatchEvent(new Event('hashchange'));
|
||||||
|
}
|
||||||
|
|
||||||
// Real "back": retraces the hash the user was actually on before, via native browser history,
|
// Real "back": retraces the hash the user was actually on before, via native browser history,
|
||||||
// rather than a page's own guess at where it must have been reached from. Returns whether it
|
// rather than a page's own guess at where it must have been reached from. Returns whether it
|
||||||
// actually did that -- every call site already has its own hardcoded-destination `navigate(...)`
|
// actually did that -- every call site already has its own hardcoded-destination `navigate(...)`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Odds and ends
|
// Odds and ends
|
||||||
|
|
||||||
import { Tinode } from 'tinode-sdk';
|
import { Tinode } from 'tinode-sdk';
|
||||||
import { WALLPAPER_DEFAULTS } from '../config.js';
|
import { WALLPAPER_DEFAULTS, BASE_APP_NAME } from '../config.js';
|
||||||
|
|
||||||
// The backend's REST group layer writes a group's display name into its native Tinode `public`
|
// The backend's REST group layer writes a group's display name into its native Tinode `public`
|
||||||
// blob under a plain `name` key instead of the protocol-standard `fn` the SDK (and every other
|
// blob under a plain `name` key instead of the protocol-standard `fn` the SDK (and every other
|
||||||
@@ -36,7 +36,7 @@ export function updateFavicon(count) {
|
|||||||
}
|
}
|
||||||
head.appendChild(newIcon);
|
head.appendChild(newIcon);
|
||||||
|
|
||||||
document.title = (count > 0 ? '('+count+') ' : '') + 'Tinode';
|
document.title = (count > 0 ? '('+count+') ' : '') + BASE_APP_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create theCard which represents user's or topic's "public" info.
|
// Create theCard which represents user's or topic's "public" info.
|
||||||
|
|||||||
@@ -2,27 +2,31 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { Tinode } from 'tinode-sdk';
|
|
||||||
|
|
||||||
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
|
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
|
||||||
|
|
||||||
|
// Renders as a real link when a destination is configured, or as inert (non-navigating) text
|
||||||
|
// otherwise -- same treatment auth-onboarding-view.jsx already gives its own placeholder
|
||||||
|
// 用户协议/隐私政策 links when there's no real page to send the user to.
|
||||||
|
function FlatLink({href, icon, children}) {
|
||||||
|
return href ?
|
||||||
|
<a href={href} className="flat-button" target="_blank"><i className="material-icons">{icon}</i> {children}</a> :
|
||||||
|
<span className="flat-button"><i className="material-icons">{icon}</i> {children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
export default class AccSupportView extends React.PureComponent {
|
export default class AccSupportView extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="scrollable-panel">
|
<div className="scrollable-panel">
|
||||||
<div className="panel-form-column">
|
<div className="panel-form-column">
|
||||||
<a href={LINK_CONTACT_US} className="flat-button" target="_blank">
|
<FlatLink href={LINK_CONTACT_US} icon="email">
|
||||||
<i className="material-icons">email</i> <FormattedMessage id="link_contact_us"
|
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
|
||||||
defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
|
</FlatLink>
|
||||||
</a>
|
<FlatLink href={LINK_TERMS_OF_SERVICE} icon="description">
|
||||||
<a href={LINK_TERMS_OF_SERVICE} className="flat-button" target="_blank">
|
<FormattedMessage id="link_terms_of_service" defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
|
||||||
<i className="material-icons">description</i> <FormattedMessage id="link_terms_of_service"
|
</FlatLink>
|
||||||
defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
|
<FlatLink href={LINK_PRIVACY_POLICY} icon="policy">
|
||||||
</a>
|
<FormattedMessage id="link_privacy_policy" defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
|
||||||
<a href={LINK_PRIVACY_POLICY} className="flat-button" target="_blank">
|
</FlatLink>
|
||||||
<i className="material-icons">policy</i> <FormattedMessage id="link_privacy_policy"
|
|
||||||
defaultMessage="Privacy Policy" description="Ancor text for privacy policy link" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hr" />
|
<div className="hr" />
|
||||||
<div className="panel-form-column">
|
<div className="panel-form-column">
|
||||||
@@ -31,10 +35,6 @@ export default class AccSupportView extends React.PureComponent {
|
|||||||
description="Label for a client version" /></label>
|
description="Label for a client version" /></label>
|
||||||
{APP_NAME}
|
{APP_NAME}
|
||||||
</div>
|
</div>
|
||||||
<div className="panel-form-row">
|
|
||||||
<label className="small"><FormattedMessage id="label_sdk" defaultMessage="SDK:" /></label>
|
|
||||||
{Tinode.getLibrary()}
|
|
||||||
</div>
|
|
||||||
<div className="panel-form-row">
|
<div className="panel-form-row">
|
||||||
<label className="small"><FormattedMessage id="label_server" defaultMessage="Server:"
|
<label className="small"><FormattedMessage id="label_server" defaultMessage="Server:"
|
||||||
description="Label for a server version" /></label>
|
description="Label for a server version" /></label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
import { BASE_APP_NAME } from '../config.js';
|
||||||
import {
|
import {
|
||||||
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
|
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
|
||||||
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
|
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
|
||||||
@@ -24,8 +25,8 @@ function Icon({children, className = ''}) {
|
|||||||
|
|
||||||
function AppMark({tenantName}) {
|
function AppMark({tenantName}) {
|
||||||
return <div className="auth-brand">
|
return <div className="auth-brand">
|
||||||
<div className="auth-logo"><span>J</span></div>
|
<div className="auth-logo"><span>{BASE_APP_NAME[0]}</span></div>
|
||||||
<h1>{tenantName || '即时通讯'}</h1>
|
<h1>{tenantName || BASE_APP_NAME}</h1>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ function PasswordField(props) {
|
|||||||
aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />;
|
aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Agreement({checked, onChange, prefix = '同意捷信密使'}) {
|
function Agreement({checked, onChange, prefix = `同意${BASE_APP_NAME}`}) {
|
||||||
return <label className="auth-agreement">
|
return <label className="auth-agreement">
|
||||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
|
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
|
||||||
<span>{prefix} <a href="#agreement" onClick={e => e.preventDefault()}>《用户协议》</a> 和 <a href="#privacy" onClick={e => e.preventDefault()}>《隐私政策》</a></span>
|
<span>{prefix} <a href="#agreement" onClick={e => e.preventDefault()}>《用户协议》</a> 和 <a href="#privacy" onClick={e => e.preventDefault()}>《隐私政策》</a></span>
|
||||||
@@ -232,9 +233,14 @@ function EnterpriseRegister() {
|
|||||||
return <div className="auth-page">
|
return <div className="auth-page">
|
||||||
<Header title="企业注册" />
|
<Header title="企业注册" />
|
||||||
<main className="auth-content auth-content-narrow">
|
<main className="auth-content auth-content-narrow">
|
||||||
|
{/* .active marks a step (and the connector after it) as reached, not "this is the current
|
||||||
|
step" -- steps stay lit once passed. There's no separate server-tracked "确定信息" state
|
||||||
|
(submitting the form *is* confirming it), so step 2 only ever gets skipped straight from
|
||||||
|
1 to 3; without submitted also keeping step 1 lit, submitting made it look like step 1 had
|
||||||
|
been undone instead of completed. */}
|
||||||
<ol className="auth-steps">
|
<ol className="auth-steps">
|
||||||
<li className={submitted ? '' : 'active'}><span>1</span><b>填写企业信息</b></li>
|
<li className="active"><span>1</span><b>填写企业信息</b></li>
|
||||||
<li><span>2</span><b>确定信息</b></li>
|
<li className={submitted ? 'active' : ''}><span>2</span><b>确定信息</b></li>
|
||||||
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
|
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
|
||||||
</ol>
|
</ol>
|
||||||
<form className="auth-form" onSubmit={submit}>
|
<form className="auth-form" onSubmit={submit}>
|
||||||
@@ -377,7 +383,7 @@ function VerificationButton({phone, scene = 'register', onChallenge, onError}) {
|
|||||||
// no phone-vs-username mode toggle. "Phone takes precedence" (per the spec text) is validation-only:
|
// no phone-vs-username mode toggle. "Phone takes precedence" (per the spec text) is validation-only:
|
||||||
// if a phone is filled it must be verified; the invite code is only required for a username-only
|
// if a phone is filled it must be verified; the invite code is only required for a username-only
|
||||||
// signup (matching the mock's original submit-time validation, now with real backend calls behind it).
|
// signup (matching the mock's original submit-time validation, now with real backend calls behind it).
|
||||||
function Register({errorText, errorLevel, onImRegisterRequest}) {
|
function Register({errorText, errorLevel, persist, onImRegisterRequest, onPersistenceChange}) {
|
||||||
const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''});
|
const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''});
|
||||||
const [challengeId, setChallengeId] = React.useState('');
|
const [challengeId, setChallengeId] = React.useState('');
|
||||||
const [agreed, setAgreed] = React.useState(false);
|
const [agreed, setAgreed] = React.useState(false);
|
||||||
@@ -386,6 +392,19 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
|
|||||||
const change = key => e => setForm({...form, [key]: e.target.value});
|
const change = key => e => setForm({...form, [key]: e.target.value});
|
||||||
const usingPhone = !!form.phone;
|
const usingPhone = !!form.phone;
|
||||||
|
|
||||||
|
// Same silent "stay logged in" opt-in Login has (see its own copy of this effect for why there's
|
||||||
|
// no visible checkbox) -- missing here meant every account created by registering (as opposed to
|
||||||
|
// logging in) never had keep-logged-in/auth-token set, so handleLoginSuccessful() never persisted
|
||||||
|
// the session: the very next refresh silently lost myUserId with no redirect to the login page
|
||||||
|
// and no error, just a permanently empty messages-home shell. Every fresh registration hit this on
|
||||||
|
// its first reload.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!persist) {
|
||||||
|
onPersistenceChange(true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// onImRegisterRequest (tinode-web.jsx's handleImRegisterRequest -> a WS connect+createAccountBasic
|
// onImRegisterRequest (tinode-web.jsx's handleImRegisterRequest -> a WS connect+createAccountBasic
|
||||||
// chain) isn't awaited and doesn't return a promise -- on the username/invite-code path (no
|
// chain) isn't awaited and doesn't return a promise -- on the username/invite-code path (no
|
||||||
// verifyRegisterCode await ahead of it) `submit` returns and its `finally` clears `submitting`
|
// verifyRegisterCode await ahead of it) `submit` returns and its `finally` clears `submitting`
|
||||||
@@ -451,7 +470,7 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
|
|||||||
|
|
||||||
return <div className="auth-landing auth-register-page">
|
return <div className="auth-landing auth-register-page">
|
||||||
<form className="auth-landing-form auth-register-form" onSubmit={submit}>
|
<form className="auth-landing-form auth-register-form" onSubmit={submit}>
|
||||||
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || '讯工捷迅'} />
|
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || BASE_APP_NAME} />
|
||||||
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
|
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
|
||||||
<Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})}
|
<Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})}
|
||||||
placeholder="请输入用户名" error={errors.username} autoComplete="username" />
|
placeholder="请输入用户名" error={errors.username} autoComplete="username" />
|
||||||
@@ -462,14 +481,17 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
|
|||||||
action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId}
|
action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId}
|
||||||
onError={msg => setErrors(e => ({...e, code: msg}))} />} />
|
onError={msg => setErrors(e => ({...e, code: msg}))} />} />
|
||||||
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
|
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
|
||||||
|
{/* 邀请码大小写敏感(server/store/... 的唯一索引是 ascii_bin 排序),不能像信用代码那样
|
||||||
|
.toUpperCase() 统一大小写;autoCapitalize="off" 只是防止移动端输入法把首字母偷偷转大写,
|
||||||
|
没有它,一个真实有效、首字母是小写的邀请码会被静默改错且用户毫无察觉。 */}
|
||||||
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
|
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
|
||||||
placeholder="请输入邀请码(非必填)" error={errors.invite} />
|
placeholder="请输入邀请码(非必填)" error={errors.invite} autoCapitalize="off" autoCorrect="off" />
|
||||||
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
|
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
|
||||||
{submitting ? '注册中…' : '注册'}
|
{submitting ? '注册中…' : '注册'}
|
||||||
</button>
|
</button>
|
||||||
<p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
|
<p className="auth-switch">已有{BASE_APP_NAME}账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
|
||||||
</form>
|
</form>
|
||||||
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" />
|
<Agreement checked={agreed} onChange={setAgreed} prefix={`注册即认同${BASE_APP_NAME}`} />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,10 +688,20 @@ function Realname({authenticated = false}) {
|
|||||||
<Header title="实名认证" back={authenticated ? 'security' : 'login'} />
|
<Header title="实名认证" back={authenticated ? 'security' : 'login'} />
|
||||||
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
|
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
|
||||||
<main className="auth-realname-content">
|
<main className="auth-realname-content">
|
||||||
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
|
{/* docs/images/实名认证-已实名.png 与 docs/images/实名认证.png 的标题文案完全一致,都是
|
||||||
|
"请您使用有效身份证信息认证",已实名状态并没有换一句"认证信息"——原来这里按 status
|
||||||
|
分叉出两句不同文案,是从最初的 mock 阶段就带着、从未跟真实原型图核对过的臆造文案,
|
||||||
|
一旦账号真的走到 verified 状态就会显示一句参考图里根本不存在的标题。 */}
|
||||||
|
<h2>请您使用有效身份证信息认证</h2>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<label><span>姓名</span><input value={editable ? name : maskedName} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
|
{/* submitRealname's success response never actually carries masked_name/masked_id_number
|
||||||
<label><span>证件号码</span><input value={editable ? idNumber : maskedIdNumber} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
|
(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. */}
|
||||||
|
<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}
|
{error ? <div className="auth-field-error">{error}</div> : null}
|
||||||
<button className="auth-primary" type="submit" disabled={!editable || busy}>
|
<button className="auth-primary" type="submit" disabled={!editable || busy}>
|
||||||
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
|
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
|
||||||
@@ -757,8 +789,10 @@ function IndependentPassword({authenticated, hashParams}) {
|
|||||||
<Header title="" back={authenticated ? 'security' : 'login'} />
|
<Header title="" back={authenticated ? 'security' : 'login'} />
|
||||||
<main className="auth-pin-content">
|
<main className="auth-pin-content">
|
||||||
<h1>设置独立密码</h1>
|
<h1>设置独立密码</h1>
|
||||||
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
|
<div className="auth-pin-wrap">
|
||||||
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
|
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
|
||||||
|
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
|
||||||
|
</div>
|
||||||
{error ? <div className="auth-field-error">{error}</div> : null}
|
{error ? <div className="auth-field-error">{error}</div> : null}
|
||||||
<button className="auth-primary" disabled={pin.length !== 4 || busy} onClick={submit}>{busy ? '提交中…' : '确定'}</button>
|
<button className="auth-primary" disabled={pin.length !== 4 || busy} onClick={submit}>{busy ? '提交中…' : '确定'}</button>
|
||||||
</main>
|
</main>
|
||||||
@@ -778,7 +812,8 @@ export default function AuthOnboardingView({
|
|||||||
errorText={errorText} errorLevel={errorLevel} onLoginRequest={onLoginRequest} onPersistenceChange={onPersistenceChange} onError={onError} />;
|
errorText={errorText} errorLevel={errorLevel} onLoginRequest={onLoginRequest} onPersistenceChange={onPersistenceChange} onError={onError} />;
|
||||||
case 'enterprise-register': return <EnterpriseRegister />;
|
case 'enterprise-register': return <EnterpriseRegister />;
|
||||||
case 'enterprise-feedback': return <EnterpriseFeedback />;
|
case 'enterprise-feedback': return <EnterpriseFeedback />;
|
||||||
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} onImRegisterRequest={onImRegisterRequest} />;
|
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} persist={persist}
|
||||||
|
onImRegisterRequest={onImRegisterRequest} onPersistenceChange={onPersistenceChange} />;
|
||||||
case 'reset': return <PasswordReset />;
|
case 'reset': return <PasswordReset />;
|
||||||
case 'identity-check': return <IdentityCheck authenticated={authenticated} hashParams={hashParams} />;
|
case 'identity-check': return <IdentityCheck authenticated={authenticated} hashParams={hashParams} />;
|
||||||
case 'realname': return <Realname authenticated={authenticated} />;
|
case 'realname': return <Realname authenticated={authenticated} />;
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ function AddressBook({myUserId, onTopicSelected}) {
|
|||||||
const [friends, setFriends] = React.useState(() => (friendsCacheKey && readCachedData(friendsCacheKey)) || []);
|
const [friends, setFriends] = React.useState(() => (friendsCacheKey && readCachedData(friendsCacheKey)) || []);
|
||||||
const [pendingCount, setPendingCount] = React.useState(0);
|
const [pendingCount, setPendingCount] = React.useState(0);
|
||||||
const [loading, setLoading] = React.useState(() => !(friendsCacheKey && readCachedData(friendsCacheKey)));
|
const [loading, setLoading] = React.useState(() => !(friendsCacheKey && readCachedData(friendsCacheKey)));
|
||||||
|
const [loadError, setLoadError] = React.useState(false);
|
||||||
// Same bootstrap the message center already fetches (and caches under the same key) -- only need
|
// Same bootstrap the message center already fetches (and caches under the same key) -- only need
|
||||||
// it here for the file assistant's real topic, so a plain read of the shared cache is enough.
|
// it here for the file assistant's real topic, so a plain read of the shared cache is enough.
|
||||||
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
|
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
|
||||||
@@ -117,11 +118,11 @@ function AddressBook({myUserId, onTopicSelected}) {
|
|||||||
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
|
fetchAllPages(cursor => getFriends('', cursor)).then(list => {
|
||||||
if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); }
|
if (friendsCacheKey) { writeCachedData(friendsCacheKey, list); }
|
||||||
setFriends(list);
|
setFriends(list);
|
||||||
}).catch(() => {}).finally(() => setLoading(false));
|
}).catch(() => setLoadError(true)).finally(() => setLoading(false));
|
||||||
fetchAllPages(cursor => getFriendRequests('inbound', 'pending', cursor))
|
fetchAllPages(cursor => getFriendRequests('inbound', 'pending', cursor))
|
||||||
.then(list => setPendingCount(list.length)).catch(() => {});
|
.then(list => setPendingCount(list.length)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.includes(query));
|
const visible = friends.filter(item => `${item.display_name}${item.remark || ''}`.toLowerCase().includes(query.toLowerCase()));
|
||||||
const groups = visible.reduce((result, item) => {
|
const groups = visible.reduce((result, item) => {
|
||||||
const key = alphaInitial(item.remark || item.display_name);
|
const key = alphaInitial(item.remark || item.display_name);
|
||||||
return {...result, [key]: (result[key] || []).concat(item)};
|
return {...result, [key]: (result[key] || []).concat(item)};
|
||||||
@@ -148,7 +149,7 @@ function AddressBook({myUserId, onTopicSelected}) {
|
|||||||
<main className="people-scroll">
|
<main className="people-scroll">
|
||||||
<section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => openEntry(item)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>
|
<section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => openEntry(item)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>
|
||||||
<h3 className="people-section-title">好友通讯录</h3>
|
<h3 className="people-section-title">好友通讯录</h3>
|
||||||
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">暂无好友</p> : null}
|
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">{loadError ? '通讯录加载失败' : '暂无好友'}</p> : null}
|
||||||
{sortedKeys.map(initial => <section className="people-group" id={'people-group-' + initial} key={initial}><h2>{initial}</h2>
|
{sortedKeys.map(initial => <section className="people-group" id={'people-group-' + initial} key={initial}><h2>{initial}</h2>
|
||||||
{groups[initial].map(person => <button type="button" key={person.user_id} onClick={() => { seedProfile(person); navigate('friend-profile', person.user_id); }}>
|
{groups[initial].map(person => <button type="button" key={person.user_id} onClick={() => { seedProfile(person); navigate('friend-profile', person.user_id); }}>
|
||||||
{/* 副行只在确实是另一个名字时才显示。后端在有备注时会把 display_name 也设成备注
|
{/* 副行只在确实是另一个名字时才显示。后端在有备注时会把 display_name 也设成备注
|
||||||
@@ -174,9 +175,10 @@ function requestStatusLabel(item) {
|
|||||||
function FriendRequests({onToast}) {
|
function FriendRequests({onToast}) {
|
||||||
const [items, setItems] = React.useState([]);
|
const [items, setItems] = React.useState([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [loadError, setLoadError] = React.useState(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchAllPages(cursor => getFriendRequests('all', 'all', cursor))
|
fetchAllPages(cursor => getFriendRequests('all', 'all', cursor))
|
||||||
.then(setItems).catch(() => {}).finally(() => setLoading(false));
|
.then(setItems).catch(() => setLoadError(true)).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
const handle = (item, accept) => {
|
const handle = (item, accept) => {
|
||||||
const nextStatus = accept ? 'accepted' : 'rejected';
|
const nextStatus = accept ? 'accepted' : 'rejected';
|
||||||
@@ -188,7 +190,7 @@ function FriendRequests({onToast}) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">
|
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">暂无新朋友申请</p> : null}
|
{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}>
|
||||||
<Avatar person={item.peer} />
|
<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>
|
<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>
|
||||||
@@ -548,7 +550,9 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
|
|||||||
}} /></div>
|
}} /></div>
|
||||||
<button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
|
<button type="button" onClick={() => navigate('clear-chat-confirm', uid)}><span>清空聊天记录</span><Icon>chevron_right</Icon></button>
|
||||||
{editingRemark ?
|
{editingRemark ?
|
||||||
<div className="people-remark-edit"><input value={remarkDraft} maxLength="20" onChange={e => setRemarkDraft(e.target.value)} autoFocus /><button type="button" onClick={saveRemark}>保存</button></div> :
|
// Backend column is VARCHAR(64) and actually enforces it (65 chars -> 400) -- confirmed
|
||||||
|
// live, this used to cap input at less than a third of what the server accepts.
|
||||||
|
<div className="people-remark-edit"><input value={remarkDraft} maxLength="64" onChange={e => setRemarkDraft(e.target.value)} autoFocus /><button type="button" onClick={saveRemark}>保存</button></div> :
|
||||||
<button type="button" onClick={() => { setRemarkDraft(remark); setEditingRemark(true); }}><span>备注</span>{remark ? <small>{remark}</small> : null}<Icon>chevron_right</Icon></button>}
|
<button type="button" onClick={() => { setRemarkDraft(remark); setEditingRemark(true); }}><span>备注</span>{remark ? <small>{remark}</small> : null}<Icon>chevron_right</Icon></button>}
|
||||||
<div><span>{blocked ? '取消拉黑' : '拉黑'}</span><Toggle checked={!!blocked} onChange={value => {
|
<div><span>{blocked ? '取消拉黑' : '拉黑'}</span><Toggle checked={!!blocked} onChange={value => {
|
||||||
if (value) { navigate('block-confirm', uid); return; }
|
if (value) { navigate('block-confirm', uid); return; }
|
||||||
@@ -572,8 +576,9 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
|
|||||||
function BlockList({onToast}) {
|
function BlockList({onToast}) {
|
||||||
const [people, setPeople] = React.useState([]);
|
const [people, setPeople] = React.useState([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [loadError, setLoadError] = React.useState(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchAllPages(cursor => getBlocks(cursor)).then(setPeople).catch(() => {}).finally(() => setLoading(false));
|
fetchAllPages(cursor => getBlocks(cursor)).then(setPeople).catch(() => setLoadError(true)).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
const remove = userId => {
|
const remove = userId => {
|
||||||
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
|
unblockUser(userId).then(() => { setPeople(items => items.filter(item => item.user_id !== userId)); onToast('已移出黑名单'); })
|
||||||
@@ -584,7 +589,7 @@ function BlockList({onToast}) {
|
|||||||
return <div className="people-page"><Header title="黑名单" back="android-settings" /><main className="people-block-list">
|
return <div className="people-page"><Header title="黑名单" back="android-settings" /><main className="people-block-list">
|
||||||
{loading ? <p className="people-empty-hint">加载中…</p> : people.length ?
|
{loading ? <p className="people-empty-hint">加载中…</p> : people.length ?
|
||||||
people.map(item => <article key={item.user_id}><Avatar person={item.user} /><span><strong>{(item.user && item.user.display_name) || item.user_id}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => remove(item.user_id)}>移出</button></article>) :
|
people.map(item => <article key={item.user_id}><Avatar person={item.user} /><span><strong>{(item.user && item.user.display_name) || item.user_id}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => remove(item.user_id)}>移出</button></article>) :
|
||||||
<div className="people-empty"><Icon>person_off</Icon><strong>黑名单为空</strong></div>}
|
<div className="people-empty"><Icon>person_off</Icon><strong>{loadError ? '黑名单加载失败' : '黑名单为空'}</strong></div>}
|
||||||
</main></div>;
|
</main></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,35 @@ import HashNavigation from '../lib/navigation.js';
|
|||||||
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
||||||
import {
|
import {
|
||||||
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
|
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
|
||||||
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, resolveFileUrl, getGroup, getUserProfile
|
searchTopicMessages, getMessageContext, getTopicMedia, checkURL, getFileUrl, resolveFileUrl, getGroup, getUserProfile,
|
||||||
|
getConversationContext
|
||||||
} from '../lib/im-api.js';
|
} from '../lib/im-api.js';
|
||||||
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js';
|
||||||
import { topicDisplayName } from '../lib/utils.js';
|
import { topicDisplayName } from '../lib/utils.js';
|
||||||
|
|
||||||
// favoritePayload's source.topic is the raw conversation topic id (grpXXX/usrXXX) -- resolve it to a
|
// favoritePayload's source.topic is the raw conversation topic id -- for a group that's grpXXX, but
|
||||||
// display name the same way every other list in this app does, cached since many favorites usually
|
// for a P2P source it's the *composite* p2p topic id (e.g. "p2pU_as9N_wgZphV5XTJmzy3A"), not the bare
|
||||||
// share a handful of source conversations.
|
// "usrXXX" uid the comment here used to assume. Passing that composite id straight to
|
||||||
|
// /users/{id}/profile 404s every time (confirmed live: MALFORMED_REQUEST/"user not found"), so every
|
||||||
|
// P2P-sourced favorite silently fell back to displaying the raw p2pXXX topic string instead of the
|
||||||
|
// friend's name -- the .catch(()=>{}) below swallowed the failure with no visible error. There is no
|
||||||
|
// way to derive the peer's uid from that composite string on the client: im_be's ParseP2P (server/
|
||||||
|
// im_http.go's resolveConversationContextTopic) decodes it, but that decoding never runs client-side,
|
||||||
|
// and the already-subscribed chatList doesn't help either since Tinode keys a P2P contact by the
|
||||||
|
// peer's bare uid there, not by this composite id, so no local list lookup can bridge the two.
|
||||||
|
// GET /conversations/{topic}/context (im-api.js's getConversationContext, already used elsewhere for
|
||||||
|
// relationship state) exists precisely to resolve a p2p topic server-side and conveniently returns
|
||||||
|
// the resolved peer's own `public` data in the same response -- reuse that instead of guessing at a
|
||||||
|
// uid shape that doesn't apply here. Cached since many favorites usually share a handful of source
|
||||||
|
// conversations.
|
||||||
const sourceNameCache = new Map();
|
const sourceNameCache = new Map();
|
||||||
function resolveSourceName(topic, onDone) {
|
function resolveSourceName(topic, onDone) {
|
||||||
if (!topic) return;
|
if (!topic) return;
|
||||||
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
|
if (sourceNameCache.has(topic)) { onDone(sourceNameCache.get(topic)); return; }
|
||||||
if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); return; }
|
if (Tinode.isSelfTopicName(topic)) { sourceNameCache.set(topic, '已保存的消息'); onDone('已保存的消息'); return; }
|
||||||
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) : getUserProfile(topic).then(p => p.display_name);
|
const call = topic.startsWith('grp') ? getGroup(topic).then(g => g.name) :
|
||||||
call.then(name => { sourceNameCache.set(topic, name); onDone(name); }).catch(() => {});
|
getConversationContext(topic).then(ctx => ctx.peer && topicDisplayName(ctx.peer_user_id, ctx.peer.public, ctx.peer_user_id));
|
||||||
|
call.then(name => { if (name) { sourceNameCache.set(topic, name); onDone(name); } }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
|
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
|
||||||
@@ -37,6 +51,13 @@ const navigate = (view, params) => {
|
|||||||
HashNavigation.navigateTo(hash);
|
HashNavigation.navigateTo(hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Same as navigate() above but for switching this page's own type tabs (Favorites' 全部/图片/.../链接,
|
||||||
|
// History's 聊天记录/图片/视频/文件/链接) -- these change which content shows on the *same* page, not
|
||||||
|
// which page the user is on, so they shouldn't push a browser-history entry the way a real navigate()
|
||||||
|
// does. Previously every tab click called navigate(), so N tab switches pushed N history entries and
|
||||||
|
// the system back button just cycled through past tabs instead of leaving the page.
|
||||||
|
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
|
||||||
|
|
||||||
export const CONTENT_LIBRARY_VIEWS = [
|
export const CONTENT_LIBRARY_VIEWS = [
|
||||||
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
|
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
|
||||||
'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download',
|
'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download',
|
||||||
@@ -91,6 +112,14 @@ function resolveAttachmentUrl(att) {
|
|||||||
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
|
if (direct.startsWith('blob:') || direct.startsWith('data:')) return Promise.resolve(direct);
|
||||||
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
|
return getFileUrl(direct.slice(direct.lastIndexOf('/') + 1)).catch(() => null);
|
||||||
}
|
}
|
||||||
|
// A video entity's .val/.ref (what resolveAttachmentUrl above resolves) is the video's own bytes --
|
||||||
|
// meant for a real <video> element, not decodable by a plain <img>. The poster frame lives in the
|
||||||
|
// separate .preview/.premime fields the backend already sends alongside it, same convention
|
||||||
|
// lib/formatters.js's quoteImageOrVideo()/inlineVideoAttr() rely on for the chat-bubble quote case.
|
||||||
|
function videoPosterUrl(att) {
|
||||||
|
if (!att || !att.preview) return null;
|
||||||
|
return 'data:' + (att.premime || 'image/jpeg') + ';base64,' + att.preview;
|
||||||
|
}
|
||||||
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
|
// searchTopicMessages/getTopicMedia results spread the original Drafty content's own fields directly
|
||||||
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
|
// onto each item (mediaMessagePayload/messageSearchPayload flatten `content`, they don't nest it) --
|
||||||
// reconstruct the Drafty shape from whatever ent/fmt/txt survived so the same attachment-extraction
|
// reconstruct the Drafty shape from whatever ent/fmt/txt survived so the same attachment-extraction
|
||||||
@@ -109,15 +138,27 @@ function plainTextOf(content, fallbackText) {
|
|||||||
}
|
}
|
||||||
// file_type -> {icon, color} matching Ch.6's group-file convention (a colored square + letter/icon).
|
// file_type -> {icon, color} matching Ch.6's group-file convention (a colored square + letter/icon).
|
||||||
const FILE_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
|
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'];
|
||||||
function extFromName(name) {
|
function extFromName(name) {
|
||||||
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
|
const m = /\.([a-zA-Z0-9]+)$/.exec(name || '');
|
||||||
return m ? m[1].toUpperCase() : '';
|
return m ? m[1].toUpperCase() : '';
|
||||||
}
|
}
|
||||||
|
// Ch.6's getGroupFiles() returns a server-classified file_type field; favorites/history items have
|
||||||
|
// no equivalent (they're derived from a Drafty attachment, not a registered file record), so it's
|
||||||
|
// approximated here from mime/extension instead of left unclassified.
|
||||||
|
function fileTypeFromMime(mime, name) {
|
||||||
|
const prefix = (mime || '').split('/')[0];
|
||||||
|
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
|
||||||
|
const ext = extFromName(name).toLowerCase();
|
||||||
|
if (mime === 'application/pdf' || DOCUMENT_EXT.includes(ext)) return 'document';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
function FileCard({name, size, mime, sender, removed, onOpen}) {
|
function FileCard({name, size, mime, sender, removed, onOpen}) {
|
||||||
const ext = extFromName(name);
|
const ext = extFromName(name);
|
||||||
|
const [, color] = FILE_TYPE_META[fileTypeFromMime(mime, name)];
|
||||||
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}>
|
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}>
|
||||||
<span>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
|
<span style={{backgroundColor: color}}>{ext || (mime || '').split('/')[0].toUpperCase() || '?'}</span>
|
||||||
<div><strong>{name || '未命名文件'}</strong><small>{typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}</small></div>
|
<div><strong>{name || '未命名文件'}</strong><small>{typeof size === 'number' ? bytesToHumanSize(size) : (size || '未知大小')}{sender ? ` · ${sender}` : ''}</small></div>
|
||||||
{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}
|
{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}
|
||||||
</button>;
|
</button>;
|
||||||
@@ -153,34 +194,70 @@ function LinkText({url, onOpen, removed}) {
|
|||||||
return <button type="button" className="library-link-text" onClick={() => onOpen && onOpen(url)}><Icon>link</Icon><span>{url}</span></button>;
|
return <button type="button" className="library-link-text" onClick={() => onOpen && onOpen(url)}><Icon>link</Icon><span>{url}</span></button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaThumb({url, video, duration, onOpen, removed}) {
|
// 需求7.4「支持长文本折叠、展开和复制」外加"搜索结果应对匹配关键词进行高亮显示"——原实现整段
|
||||||
|
// 需求在这里都没接:文本收藏是一段不限高度的纯 <p>,长文本不折叠也没有展开/收起,没有复制按钮,
|
||||||
|
// 收藏搜索的 query 也从没传到这一层,History 那边已经在用的 Highlight 组件在 Favorites 里从未
|
||||||
|
// 被引用过(实测:在"我的收藏-文本"搜索一个真实命中的关键词,结果列表 0 个 <mark>)。
|
||||||
|
// overflowing 用 scrollHeight/clientHeight 判断 line-clamp 是否真的裁到了内容(标准判定手法),
|
||||||
|
// 避免给短文本也显示一个没用的"展开"按钮;复制走跟 context-menu.jsx 的 copyMessageText 完全一样
|
||||||
|
// 的 navigator.clipboard.writeText,保持与聊天消息长按菜单里"复制"一致的行为。
|
||||||
|
function FavoriteText({text, query, onToast}) {
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const [overflowing, setOverflowing] = React.useState(false);
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (el) setOverflowing(el.scrollHeight - el.clientHeight > 1);
|
||||||
|
}, [text]);
|
||||||
|
const copy = () => {
|
||||||
|
navigator.clipboard.writeText(text || '').then(() => onToast('已复制')).catch(() => onToast('复制失败'));
|
||||||
|
};
|
||||||
|
return <div className="library-text-wrap">
|
||||||
|
<p ref={ref} className={'library-text-content' + (expanded ? '' : ' clamped')}><Highlight text={text} query={query} /></p>
|
||||||
|
<div className="library-text-actions">
|
||||||
|
{overflowing ? <button type="button" onClick={() => setExpanded(v => !v)}>{expanded ? '收起' : '展开'}</button> : null}
|
||||||
|
<button type="button" onClick={copy}><Icon>content_copy</Icon>复制</button>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaThumb({url, poster, video, duration, onOpen, removed}) {
|
||||||
if (removed) {
|
if (removed) {
|
||||||
return <div className="library-media-thumb removed"><Icon>{video ? 'videocam_off' : 'broken_image'}</Icon><em>{video ? '视频已失效' : '图片已失效'}</em></div>;
|
return <div className="library-media-thumb removed"><Icon>{video ? 'videocam_off' : 'broken_image'}</Icon><em>{video ? '视频已失效' : '图片已失效'}</em></div>;
|
||||||
}
|
}
|
||||||
|
// video's own bytes (url) can't be decoded by <img> -- only the poster frame can. Images have no
|
||||||
|
// separate poster; their own url doubles as the thumbnail.
|
||||||
|
const imgSrc = video ? poster : url;
|
||||||
return <button type="button" className="library-media-thumb" onClick={onOpen}>
|
return <button type="button" className="library-media-thumb" onClick={onOpen}>
|
||||||
{url ? <img src={url} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
|
{imgSrc ? <img src={imgSrc} alt={video ? '视频缩略图' : '图片缩略图'} /> : <Icon>{video ? 'videocam' : 'image'}</Icon>}
|
||||||
{video ? <><Icon>play_circle</Icon>{duration ? <small>{secondsToTime(duration)}</small> : null}</> : null}
|
{/* duration follows the AU/VD entity storage convention of milliseconds (see sendVideoAttachment/
|
||||||
|
insertVideo), same conversion widgets/audio-player.jsx already does for its own duration prop. */}
|
||||||
|
{video ? <><Icon>play_circle</Icon>{duration > 0 ? <small>{secondsToTime(duration / 1000)}</small> : null}</> : null}
|
||||||
</button>;
|
</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FavoriteItem({item, onOpen, selecting, selected, onSelect}) {
|
function FavoriteItem({item, onOpen, selecting, selected, onSelect, query, onToast}) {
|
||||||
const att = firstAttachment(item.snapshot && item.snapshot.content);
|
const att = firstAttachment(item.snapshot && item.snapshot.content);
|
||||||
const source = item.source_display || (item.source && item.source.topic) || '';
|
const source = item.source_display || (item.source && item.source.topic) || '';
|
||||||
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
|
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
|
||||||
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||||
<div className="library-favorite-content">
|
<div className="library-favorite-content">
|
||||||
{item.type === 'text' ? <p className="library-text-content">{plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}</p> : null}
|
|
||||||
{/* 需求 7.3「失效文件需展示明确状态而非直接报错」原来只有 file 类型传了 removed(FileCard),
|
{/* 需求 7.3「失效文件需展示明确状态而非直接报错」原来只有 file 类型传了 removed(FileCard),
|
||||||
voice/link/image/video 三类完全没处理——即便后端把"问题13"(availability 恒为 available)
|
text/voice/link/image/video 全部没处理——即便后端把"问题13"(availability 恒为 available)
|
||||||
修好,这三类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和
|
修好,这五类在 UI 上也不会有任何变化,因为组件本身就不认识这个状态,只会静默显示一个和
|
||||||
"还在加载中"没法区分的占位。四个类型现在统一都传。后端 messageAvailability(im_http.go)
|
"还在加载中"没法区分的占位(text 类型更糟:源消息撤回后 snapshot 里的旧文本会原样一直
|
||||||
在源消息被删/被撤回时给 'removed'。 */}
|
显示下去,没有"加载中"和"已失效"的区别,看起来像是这条消息完全没被撤回)。现在五个类型
|
||||||
|
统一都传。后端 messageAvailability(im_http.go)在源消息被删/被撤回时给 'removed'。 */}
|
||||||
|
{item.type === 'text' ? (item.availability === 'removed' ?
|
||||||
|
<p className="library-text-content removed"><Icon>block</Icon><em>文本已失效</em></p> :
|
||||||
|
<FavoriteText text={plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)} query={query} onToast={onToast} />) : null}
|
||||||
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime}
|
{item.type === 'file' ? <FileCard name={att && att.name} size={att && Drafty.getEntitySize(att)} mime={att && att.mime}
|
||||||
removed={item.availability === 'removed'} onOpen={onOpen} /> : null}
|
removed={item.availability === 'removed'} onOpen={onOpen} /> : null}
|
||||||
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} removed={item.availability === 'removed'} /> : null}
|
{item.type === 'voice' ? <VoiceCard att={att} head={item.snapshot && item.snapshot.head} removed={item.availability === 'removed'} /> : null}
|
||||||
{item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}
|
{item.type === 'link' ? <LinkText url={(item.snapshot && item.snapshot.content && item.snapshot.content.url) || plainTextOf(item.snapshot && item.snapshot.content, item.snapshot && item.snapshot.text)}
|
||||||
onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
|
onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
|
||||||
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} video={item.type === 'video'} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
|
{item.type === 'image' || item.type === 'video' ? <MediaThumb url={item._thumbUrl} poster={item.type === 'video' ? videoPosterUrl(att) : null}
|
||||||
|
video={item.type === 'video'} duration={att && att.duration} onOpen={onOpen} removed={item.availability === 'removed'} /> : null}
|
||||||
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
|
<footer><span>来自 {source}</span><time>{shortDateFormat(new Date(item.collected_at))}</time></footer>
|
||||||
</div>
|
</div>
|
||||||
</article>;
|
</article>;
|
||||||
@@ -208,8 +285,16 @@ function Favorites({view, tinode, onToast}) {
|
|||||||
const [hasMore, setHasMore] = React.useState(false);
|
const [hasMore, setHasMore] = React.useState(false);
|
||||||
const [fileUrls, setFileUrls] = React.useState({});
|
const [fileUrls, setFileUrls] = React.useState({});
|
||||||
const [sourceNames, setSourceNames] = 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
|
||||||
|
// triggered -- without this, a slow full-list request landing after a fast, narrower search
|
||||||
|
// response overwrites the correct filtered results with the stale unfiltered ones (confirmed
|
||||||
|
// live: search box showing a query, list showing all items). Each load() call claims the counter
|
||||||
|
// for itself; a response only applies if nothing newer has been requested since.
|
||||||
|
const requestIdRef = React.useRef(0);
|
||||||
const load = React.useCallback((append) => {
|
const load = React.useCallback((append) => {
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
|
getFavorites({type, query, cursor: append ? cursor : ''}).then(data => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
setItems(prev => {
|
setItems(prev => {
|
||||||
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
||||||
if (!query) writeCachedData('favorites:' + type, list);
|
if (!query) writeCachedData('favorites:' + type, list);
|
||||||
@@ -278,13 +363,13 @@ function Favorites({view, tinode, onToast}) {
|
|||||||
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
|
right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} />
|
||||||
<Search value={queryInput} onChange={setQueryInput} placeholder="搜索收藏内容" />
|
<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' : ''}
|
<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) navigate(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
|
onClick={() => { setType(id); if (TYPE_ROUTE[id] && !selecting) navigateReplace(TYPE_ROUTE[id]); }}>{label}</button>)}</nav>
|
||||||
<main className="library-favorite-list">
|
<main className="library-favorite-list">
|
||||||
{items === null ? <p className="library-empty-hint">加载中…</p> :
|
{items === null ? <p className="library-empty-hint">加载中…</p> :
|
||||||
list.length ? <>{list.map(item => <FavoriteItem key={item.favorite_id} item={{...item, _thumbUrl: fileUrls[item.favorite_id],
|
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]}}
|
source_display: sourceNames[item.source && item.source.topic]}}
|
||||||
selecting={selecting} selected={selected.includes(item.favorite_id)} onSelect={() => toggle(item.favorite_id)}
|
selecting={selecting} selected={selected.includes(item.favorite_id)} onSelect={() => toggle(item.favorite_id)}
|
||||||
onOpen={() => openItem(item)} />)}
|
onOpen={() => openItem(item)} query={query} onToast={onToast} />)}
|
||||||
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}</> :
|
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}</> :
|
||||||
<div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div>}
|
<div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div>}
|
||||||
</main>
|
</main>
|
||||||
@@ -485,7 +570,7 @@ function Highlight({text, query}) {
|
|||||||
|
|
||||||
function HistoryNav({active}) {
|
function HistoryNav({active}) {
|
||||||
const tabs = [['history-search', '聊天记录'], ['history-images', '图片'], ['history-videos', '视频'], ['history-files', '文件'], ['history-links', '链接']];
|
const tabs = [['history-search', '聊天记录'], ['history-images', '图片'], ['history-videos', '视频'], ['history-files', '文件'], ['history-links', '链接']];
|
||||||
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigate(item[0])}>{item[1]}</button>)}</nav>;
|
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigateReplace(item[0])}>{item[1]}</button>)}</nav>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_TITLES = {'history-search': '查找聊天记录', 'history-files': '文件', 'history-links': '链接', 'history-images': '图片', 'history-videos': '视频'};
|
const HISTORY_TITLES = {'history-search': '查找聊天记录', 'history-files': '文件', 'history-links': '链接', 'history-images': '图片', 'history-videos': '视频'};
|
||||||
@@ -511,12 +596,18 @@ function History({view, hashParams, myUserId, onToast}) {
|
|||||||
const [hasMore, setHasMore] = React.useState(false);
|
const [hasMore, setHasMore] = React.useState(false);
|
||||||
const [mediaUrls, setMediaUrls] = React.useState({});
|
const [mediaUrls, setMediaUrls] = React.useState({});
|
||||||
const [senderNames, setSenderNames] = React.useState({});
|
const [senderNames, setSenderNames] = React.useState({});
|
||||||
|
// Same stale-response guard as Favorites' load() above and for the same reason -- a slower
|
||||||
|
// request for a since-abandoned topic/tab/query can still resolve after a newer one and
|
||||||
|
// overwrite its correct results.
|
||||||
|
const requestIdRef = React.useRef(0);
|
||||||
const load = React.useCallback((append) => {
|
const load = React.useCallback((append) => {
|
||||||
if (!topic) return;
|
if (!topic) return;
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
const type = HISTORY_TYPE[view];
|
const type = HISTORY_TYPE[view];
|
||||||
const call = view === 'history-search' ? searchTopicMessages(topic, {query, type, cursor: append ? cursor : ''}) :
|
const call = view === 'history-search' ? searchTopicMessages(topic, {query, type, cursor: append ? cursor : ''}) :
|
||||||
getTopicMedia(topic, type, append ? cursor : '');
|
getTopicMedia(topic, type, append ? cursor : '');
|
||||||
call.then(data => {
|
call.then(data => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
setItems(prev => {
|
setItems(prev => {
|
||||||
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
const list = append ? (prev || []).concat(data.items || []) : (data.items || []);
|
||||||
if (!query && cacheKey) writeCachedData(cacheKey, list);
|
if (!query && cacheKey) writeCachedData(cacheKey, list);
|
||||||
@@ -579,9 +670,18 @@ function History({view, hashParams, myUserId, onToast}) {
|
|||||||
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
|
{items === null ? <p className="library-empty-hint">加载中…</p> : !list.length ?
|
||||||
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
|
<div className="library-empty"><Icon>{view === 'history-search' ? 'search_off' : 'inbox'}</Icon><strong>暂无内容</strong></div> :
|
||||||
view === 'history-search' ? <section className="library-message-results">
|
view === 'history-search' ? <section className="library-message-results">
|
||||||
|
{/* 同一处"问题13"修复暴露的缺口,History 自己的一份,只影响 type==='text':非 text 类型
|
||||||
|
始终走 TYPE_LABEL[item.type] 的固定方括号标签("[图片]"等),从不读 item.snippet,
|
||||||
|
所以 removed 与否对它们没有可观察差异,不需要改。text 类型是唯一真正显示 item.snippet
|
||||||
|
的分支——一个没有搜索关键词的浏览(query 为空,后端不加内容过滤,撤回/删除的消息也会
|
||||||
|
原样出现在结果里)如果命中一条已撤回的文本消息,item.snippet 现在会是后端
|
||||||
|
messageText() 对 nil content 兜底出来的字面量字符串 "null"(json.Marshal(nil)),而不
|
||||||
|
是空字符串——不判断 availability 的话,用户会看到一行显示"null"的消息,比"看起来没被
|
||||||
|
撤回"更令人困惑。 */}
|
||||||
{list.map(item => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
|
{list.map(item => <button type="button" key={item.seq} onClick={() => jump(item.seq)}>
|
||||||
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
|
<Avatar item={{}} /><span><strong>{nameOf(item.from)}</strong><p>{item.type === 'text' ?
|
||||||
<Highlight text={item.snippet || ''} query={query} /> : (TYPE_LABEL[item.type] || item.snippet || '')}</p>
|
(item.availability === 'removed' ? '文本已失效' : <Highlight text={item.snippet || ''} query={query} />) :
|
||||||
|
(TYPE_LABEL[item.type] || item.snippet || '')}</p>
|
||||||
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
|
<small>{shortDateFormat(new Date(item.ts))}</small></span><Icon>chevron_right</Icon>
|
||||||
</button>)}
|
</button>)}
|
||||||
</section> :
|
</section> :
|
||||||
@@ -603,8 +703,14 @@ function History({view, hashParams, myUserId, onToast}) {
|
|||||||
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)}
|
<LinkText url={item.url || plainTextOf(item.txt !== undefined ? {txt: item.txt, fmt: item.fmt, ent: item.ent} : undefined, item.text)}
|
||||||
onOpen={openLink} removed={item.availability === 'removed'} />
|
onOpen={openLink} removed={item.availability === 'removed'} />
|
||||||
</React.Fragment>)}</section> :
|
</React.Fragment>)}</section> :
|
||||||
<section className="library-media-grid">{list.map(item => <MediaThumb key={item.seq} url={mediaUrls[item.seq]} video={view === 'history-videos'}
|
<section className="library-media-grid">{list.map(item => {
|
||||||
duration={item.duration} onOpen={() => jump(item.seq)} removed={item.availability === 'removed'} />)}</section>}
|
// mediaMessagePayload (getTopicMedia) is flat and has no top-level `duration`/poster fields --
|
||||||
|
// the real values live inside the reconstructed Drafty entity, same as history-files' FileCard
|
||||||
|
// already reconstructs `att` for name/size/mime just above.
|
||||||
|
const videoAtt = view === 'history-videos' ? attachmentFromFlat(item) : null;
|
||||||
|
return <MediaThumb key={item.seq} url={mediaUrls[item.seq]} poster={videoAtt ? videoPosterUrl(videoAtt) : null} video={view === 'history-videos'}
|
||||||
|
duration={videoAtt && videoAtt.duration} onOpen={() => jump(item.seq)} removed={item.availability === 'removed'} />;
|
||||||
|
})}</section>}
|
||||||
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
|
{hasMore ? <button type="button" className="library-load-more" onClick={() => load(true)}>加载更多</button> : null}
|
||||||
</main>
|
</main>
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@@ -535,14 +535,15 @@ function GroupProfile({topic, myUserId, onToast}) {
|
|||||||
<button type="button" onClick={() => navigate('group-my-nickname', topic)}>
|
<button type="button" onClick={() => navigate('group-my-nickname', topic)}>
|
||||||
<span>群昵称</span><small>{nicknameLabel}</small><Icon>chevron_right</Icon>
|
<span>群昵称</span><small>{nicknameLabel}</small><Icon>chevron_right</Icon>
|
||||||
</button>
|
</button>
|
||||||
{canEdit ? <button type="button" onClick={() => navigate('group-nickname', topic)}>
|
{/* Swapping to a bare <div> when !canEdit used to drop every .group-profile row rule (all of
|
||||||
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
|
them are scoped to > button/> label) -- same fix as the avatar row just above: keep
|
||||||
</button> :
|
<button>, gate it with disabled like that row already does. */}
|
||||||
<div><span>群名称</span><small>{group.name}</small></div>}
|
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-nickname', topic)}>
|
||||||
{canEdit ? <button type="button" onClick={() => navigate('group-description', topic)}>
|
<span>群名称</span><small>{group.name}</small><Icon>chevron_right</Icon>
|
||||||
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
|
</button>
|
||||||
</button> :
|
<button type="button" disabled={!canEdit} onClick={() => canEdit && navigate('group-description', topic)}>
|
||||||
<div><span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small></div>}
|
<span>群简介</span><small>{group.description || '群主很懒,还没有群简介'}</small><Icon>chevron_right</Icon>
|
||||||
|
</button>
|
||||||
<button type="button" className="group-profile-qr" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
|
<button type="button" className="group-profile-qr" onClick={() => navigate('group-qr', topic)}><span>群二维码</span><Icon>qr_code</Icon><Icon>chevron_right</Icon></button>
|
||||||
<button type="button" onClick={() => navigate('group-members', topic)}><span>群成员</span><small>共{group.member_count}位成员</small><Icon>chevron_right</Icon></button>
|
<button type="button" onClick={() => navigate('group-members', topic)}><span>群成员</span><small>共{group.member_count}位成员</small><Icon>chevron_right</Icon></button>
|
||||||
</main>
|
</main>
|
||||||
@@ -637,7 +638,7 @@ const GOVERNANCE_FIELDS = [
|
|||||||
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']
|
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']
|
||||||
];
|
];
|
||||||
|
|
||||||
function Governance({topic, tinode, onToast}) {
|
function Governance({topic, myRole, tinode, onToast}) {
|
||||||
// Router page for AdminList/Muted/OwnerSelect/the avatar editor -- remounts (per GroupManagementView's
|
// Router page for AdminList/Muted/OwnerSelect/the avatar editor -- remounts (per GroupManagementView's
|
||||||
// `key={view}` note) and reflashes its 5 toggles every time the user finishes one of those sub-tasks
|
// `key={view}` note) and reflashes its 5 toggles every time the user finishes one of those sub-tasks
|
||||||
// and returns, so this is one of the highest-traffic pages in the file.
|
// and returns, so this is one of the highest-traffic pages in the file.
|
||||||
@@ -659,6 +660,11 @@ function Governance({topic, tinode, onToast}) {
|
|||||||
.catch(err => onToast(err.message || '设置失败'));
|
.catch(err => onToast(err.message || '设置失败'));
|
||||||
};
|
};
|
||||||
const dissolve = () => {
|
const dissolve = () => {
|
||||||
|
// 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;
|
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
|
||||||
const t = tinode ? tinode.getTopic(topic) : null;
|
const t = tinode ? tinode.getTopic(topic) : null;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
@@ -683,7 +689,7 @@ function Governance({topic, tinode, onToast}) {
|
|||||||
<span><strong>{label}</strong><small>{desc}</small></span>
|
<span><strong>{label}</strong><small>{desc}</small></span>
|
||||||
<Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} />
|
<Toggle checked={!!gov[key]} disabled={gov[key + '_editable'] === false} onChange={v => setField(key, v)} />
|
||||||
</div>)}</section>
|
</div>)}</section>
|
||||||
<button type="button" className="group-danger" disabled={dissolving} onClick={dissolve}>解散群组</button>
|
<button type="button" className="group-danger" disabled={dissolving || myRole !== 'owner'} onClick={dissolve}>解散群组</button>
|
||||||
</main>
|
</main>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@@ -719,8 +725,18 @@ function AdminList({topic, onToast}) {
|
|||||||
}, [topic]);
|
}, [topic]);
|
||||||
React.useEffect(() => { load(); }, [load]);
|
React.useEffect(() => { load(); }, [load]);
|
||||||
const remove = userId => {
|
const remove = userId => {
|
||||||
deleteGroupAdmin(topic, userId).then(() => { setToast('管理员已移除'); load(); })
|
// deleteGroupAdmin succeeding doesn't mean a follow-up load() will show it -- getGroupMembers
|
||||||
.catch(err => onToast(err.message || '操作失败'));
|
// is backed by a server-side cache that this exact query shape can keep serving stale for a
|
||||||
|
// while (confirmed live: an unfiltered re-query right after removal still showed the removed
|
||||||
|
// admin, while a query with different params the session hadn't used yet returned correctly).
|
||||||
|
// Patch the already-known-correct result into local state and the shared cache directly
|
||||||
|
// instead of trusting a re-fetch to reflect what was just confirmed to have happened.
|
||||||
|
deleteGroupAdmin(topic, userId).then(() => {
|
||||||
|
setToast('管理员已移除');
|
||||||
|
setAdmins(prev => prev ? prev.filter(a => a.user_id !== userId) : prev);
|
||||||
|
const cached = readCachedData('group-members:' + topic);
|
||||||
|
if (cached) writeCachedData('group-members:' + topic, cached.map(m => m.user_id === userId ? {...m, role: 'member'} : m));
|
||||||
|
}).catch(err => onToast(err.message || '操作失败'));
|
||||||
};
|
};
|
||||||
return <div className="group-page">
|
return <div className="group-page">
|
||||||
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
|
<Header title="设置群管理员" back="group-governance" backTopic={topic} right="添加" onRight={() => navigate('group-admin-add', topic)} />
|
||||||
@@ -752,7 +768,7 @@ function GroupMembers({topic, onToast}) {
|
|||||||
setMembers(items);
|
setMembers(items);
|
||||||
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
}).catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||||
}, [topic]);
|
}, [topic]);
|
||||||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
|
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
|
||||||
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
|
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
|
||||||
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
|
const roleLabel = role => role === 'owner' ? '群主' : role === 'admin' ? '管理员' : '';
|
||||||
return <div className="group-page">
|
return <div className="group-page">
|
||||||
@@ -787,7 +803,7 @@ function MemberPicker({type, topic, onToast}) {
|
|||||||
.then(items => setMembers(items.filter(m => m.eligible !== false)))
|
.then(items => setMembers(items.filter(m => m.eligible !== false)))
|
||||||
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||||
}, [topic, mute]);
|
}, [topic, mute]);
|
||||||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
|
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
|
||||||
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
|
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
|
||||||
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
|
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
|
||||||
const done = () => {
|
const done = () => {
|
||||||
@@ -901,7 +917,7 @@ function OwnerSelect({topic, onToast}) {
|
|||||||
.then(items => setMembers(items.filter(m => m.eligible !== false)))
|
.then(items => setMembers(items.filter(m => m.eligible !== false)))
|
||||||
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
.catch(() => { onToast('加载失败'); setLoadError(true); });
|
||||||
}, [topic]);
|
}, [topic]);
|
||||||
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
|
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').toLowerCase().includes(query.toLowerCase()));
|
||||||
const admins = visible.filter(m => m.role === 'admin');
|
const admins = visible.filter(m => m.role === 'admin');
|
||||||
const others = visible.filter(m => m.role !== 'admin');
|
const others = visible.filter(m => m.role !== 'admin');
|
||||||
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
|
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
|
||||||
@@ -1148,7 +1164,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
|
|||||||
else if (view === 'group-my-nickname') content = <MyNicknameEditor topic={topic} myUserId={myUserId} onToast={setToast} />;
|
else if (view === 'group-my-nickname') content = <MyNicknameEditor topic={topic} myUserId={myUserId} onToast={setToast} />;
|
||||||
else if (view === 'group-members') content = <GroupMembers topic={topic} onToast={setToast} />;
|
else if (view === 'group-members') content = <GroupMembers topic={topic} onToast={setToast} />;
|
||||||
else if (view === 'group-qr') content = <GroupQr topic={topic} onToast={setToast} />;
|
else if (view === 'group-qr') content = <GroupQr topic={topic} onToast={setToast} />;
|
||||||
else if (view === 'group-governance') content = <Governance topic={topic} tinode={tinode} onToast={setToast} />;
|
else if (view === 'group-governance') content = <Governance topic={topic} myRole={myRole} tinode={tinode} onToast={setToast} />;
|
||||||
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList topic={topic} onToast={setToast} />;
|
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-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-mute-add') content = <MemberPicker type="mute-add" topic={topic} onToast={setToast} />;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import CheckBox from '../widgets/checkbox.jsx';
|
|||||||
import ContactList from '../widgets/contact-list.jsx';
|
import ContactList from '../widgets/contact-list.jsx';
|
||||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||||
import GroupManager from '../widgets/group-manager.jsx';
|
import GroupManager from '../widgets/group-manager.jsx';
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
import MenuCancel from '../widgets/menu-cancel.jsx';
|
import MenuCancel from '../widgets/menu-cancel.jsx';
|
||||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
||||||
import ShowQRCode from '../widgets/show-qrcode.jsx';
|
import ShowQRCode from '../widgets/show-qrcode.jsx';
|
||||||
@@ -445,7 +446,18 @@ class InfoView extends React.Component {
|
|||||||
this.setState({selectedContact: uid});
|
this.setState({selectedContact: uid});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every drill-in click inside InfoView (open members/permissions/qrcode/security/...) goes through
|
||||||
|
// props.onNavigate -> tinode-web.jsx's infoNavigator -> a real navigateTo() push, so this "go back
|
||||||
|
// one level" (or "close InfoView entirely" at the root) should be the matching pop -- goBack() first,
|
||||||
|
// same fix/pattern as tinode-web.jsx's handleHideMessagesView and content-library-view.jsx's Header.
|
||||||
|
// Before this fix every level of InfoView's own drill-down (info -> members -> a member's profile ->
|
||||||
|
// permissions -> ...) pushed going in AND pushed again coming back out via this function, so the
|
||||||
|
// system back button had to be pressed once per level ever visited, and each press could land back
|
||||||
|
// inside a sub-panel the user had already explicitly backed out of.
|
||||||
handleBackNavigate() {
|
handleBackNavigate() {
|
||||||
|
if (HashNavigation.goBack()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const args = (this.props.panel || 'info').split('/');
|
const args = (this.props.panel || 'info').split('/');
|
||||||
if (args[0] == 'info') {
|
if (args[0] == 'info') {
|
||||||
this.props.onNavigate(null);
|
this.props.onNavigate(null);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Tinode } from 'tinode-sdk';
|
import { Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
import { BASE_APP_NAME } from '../config.js';
|
||||||
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
|
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
|
||||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||||
import { useCachedData } from '../lib/use-cached-data.js';
|
import { useCachedData } from '../lib/use-cached-data.js';
|
||||||
@@ -19,6 +20,15 @@ const navigate = (view, topic) => {
|
|||||||
HashNavigation.navigateTo(hash);
|
HashNavigation.navigateTo(hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// BottomNavigation's 消息<->工作台 switch is the app's root-level tab bar, not a navigation to a
|
||||||
|
// "different page" in the back-stack sense -- same bug/fix as content-library-view.jsx's
|
||||||
|
// navigateReplace (see its comment): navigate() here pushed a history entry on every tab click, so
|
||||||
|
// bouncing between 消息 and 工作台 a few times (a completely normal usage pattern for the app's only
|
||||||
|
// two bottom tabs) made the back button just cycle through past tab switches instead of leaving the
|
||||||
|
// app, exactly like a standard bottom-tab-bar app's back button should not have to unwind every past
|
||||||
|
// tab switch to back out.
|
||||||
|
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
|
||||||
|
|
||||||
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
|
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
|
||||||
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
|
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
|
||||||
// a user screenshot showing every contact opening the identical "接语彤" script). Real "frequently
|
// a user screenshot showing every contact opening the identical "接语彤" script). Real "frequently
|
||||||
@@ -182,7 +192,7 @@ function normalizeConversations(chatList, myUserId) {
|
|||||||
|
|
||||||
function BottomNavigation() {
|
function BottomNavigation() {
|
||||||
return <nav className="messages-bottom-nav" aria-label="主导航">
|
return <nav className="messages-bottom-nav" aria-label="主导航">
|
||||||
<button type="button" onClick={() => navigate('workbench')}><Icon>dashboard</Icon><span>工作台</span></button>
|
<button type="button" onClick={() => navigateReplace('workbench')}><Icon>dashboard</Icon><span>工作台</span></button>
|
||||||
<button type="button" className="active"><Icon>person</Icon><span>消息</span></button>
|
<button type="button" className="active"><Icon>person</Icon><span>消息</span></button>
|
||||||
</nav>;
|
</nav>;
|
||||||
}
|
}
|
||||||
@@ -235,7 +245,15 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
|
|||||||
// Was `useState(null)` + its own effect -- refetched from scratch (blank entries/badges for a
|
// Was `useState(null)` + its own effect -- refetched from scratch (blank entries/badges for a
|
||||||
// beat) every time this component remounted, e.g. after a trip to another sidepanel and back.
|
// beat) every time this component remounted, e.g. after a trip to another sidepanel and back.
|
||||||
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
|
const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);
|
||||||
const offline = view === 'messages-offline' || (!!myUserId && connected === false);
|
// sidepanel-view.jsx 会在 sidePanelSelected 已经是 'messages-home'(例如收藏夹里的旧链接、浏览器
|
||||||
|
// 从上次关闭前恢复的标签页)但 myUserId 还没就绪时,照样把这个视图渲染出来(它自己的
|
||||||
|
// `fullPage = !myUserId || view !== 'messages-home'` 分支就是为这种情况准备的)——如果这时候
|
||||||
|
// token 续登录失败或者还没跑完,原来这里只看 `!!myUserId && connected===false`,myUserId 本身是
|
||||||
|
// null 时这个条件恒为 false,于是不会进入"加载中"分支,会话列表和 bootstrap 又因为
|
||||||
|
// useCachedData 的 key 是 null 而永远不发请求,最终呈现的是一句容易误导人的"暂无会话/未找到
|
||||||
|
// 相关会话"——用户会以为账号是空的,而不是"还没登录成功"。myUserId 本身为空也按"未连接"处理,
|
||||||
|
// 至少诚实地展示"加载中"而不是一个看起来正常但其实是半成品的空列表。
|
||||||
|
const offline = view === 'messages-offline' || !myUserId || connected === false;
|
||||||
|
|
||||||
// "置顶" only ever changed a background tint (see the `pinned` comment inside
|
// "置顶" only ever changed a background tint (see the `pinned` comment inside
|
||||||
// normalizeConversations) -- every other IM this product's users have used floats a pinned chat to
|
// normalizeConversations) -- every other IM this product's users have used floats a pinned chat to
|
||||||
@@ -325,7 +343,14 @@ function MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOp
|
|||||||
{item.unread ? <b>{item.unread > 99 ? '99+' : item.unread}</b> : item.muted ? <Icon>notifications_off</Icon> : null}
|
{item.unread ? <b>{item.unread > 99 ? '99+' : item.unread}</b> : item.muted ? <Icon>notifications_off</Icon> : null}
|
||||||
</span>
|
</span>
|
||||||
</button>)}
|
</button>)}
|
||||||
{filtered.length === 0 ? <div className="messages-empty"><Icon>search_off</Icon><p>未找到相关会话</p></div> : null}
|
{/* "未找到相关会话"(搜索图标)只在用户真的输入过搜索词却匹配不到时才准确——`conversations`
|
||||||
|
本身为空(例如尚未完成登录/数据尚未拉取到、或租户把系统消息与文件助手都关闭且用户还没有
|
||||||
|
任何会话)跟"搜索没匹配到"是两种不同情况,原来两者共用同一句"未找到相关会话"+放大镜
|
||||||
|
图标,会把"这个账号目前就是没有任何会话"说成"你的搜索没找到东西",用户没搜索过却看到
|
||||||
|
搜索无结果的图标和文案。 */}
|
||||||
|
{filtered.length === 0 ? (query.trim() ?
|
||||||
|
<div className="messages-empty"><Icon>search_off</Icon><p>未找到相关会话</p></div> :
|
||||||
|
<div className="messages-empty"><Icon>forum</Icon><p>暂无会话</p></div>) : null}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<BottomNavigation />
|
<BottomNavigation />
|
||||||
@@ -385,6 +410,8 @@ function MessageActions() {
|
|||||||
// 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700,
|
// 好友申请处理结果写 summary=status(server/im_http.go:5956 与 db/mysql/tenant_adapter.go:8643,8700,
|
||||||
// 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。
|
// 值就是 "accepted"/"rejected")。已记入后端问题清单,在后端给出可读文案之前前端按已知码兜底翻译。
|
||||||
const NOTIFICATION_TEXT = {
|
const NOTIFICATION_TEXT = {
|
||||||
|
'group.created': '你创建了一个新群聊',
|
||||||
|
'group.join_request.created': '有人申请加入你管理的群聊',
|
||||||
'group.member.joined': '有成员加入了群聊',
|
'group.member.joined': '有成员加入了群聊',
|
||||||
'group.member.left': '有成员退出了群聊',
|
'group.member.left': '有成员退出了群聊',
|
||||||
'group.member.updated': '群成员资料有更新',
|
'group.member.updated': '群成员资料有更新',
|
||||||
@@ -415,16 +442,29 @@ function systemNotificationText(item) {
|
|||||||
|
|
||||||
function SystemMessages({authenticated}) {
|
function SystemMessages({authenticated}) {
|
||||||
const [items, setItems] = React.useState([]);
|
const [items, setItems] = React.useState([]);
|
||||||
|
const [cursor, setCursor] = React.useState('');
|
||||||
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState('');
|
const [error, setError] = React.useState('');
|
||||||
const [toast, setToast] = React.useState('');
|
const [toast, setToast] = React.useState('');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
getSystemNotifications().then(data => setItems(data.items || []))
|
getSystemNotifications().then(data => { setItems(data.items || []); setCursor(data.next_cursor || ''); })
|
||||||
.catch(err => setError(err.message || '加载失败'))
|
.catch(err => setError(err.message || '加载失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// GET /system-notifications does return next_cursor whenever there's more (real accounts with
|
||||||
|
// 50 unread saw it truncate silently at 20, with no way to reach or ever mark the rest read).
|
||||||
|
const loadMore = () => {
|
||||||
|
if (loadingMore || !cursor) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
getSystemNotifications(cursor).then(data => {
|
||||||
|
setItems(list => list.concat(data.items || []));
|
||||||
|
setCursor(data.next_cursor || '');
|
||||||
|
}).catch(() => {}).finally(() => setLoadingMore(false));
|
||||||
|
};
|
||||||
|
|
||||||
const markRead = item => {
|
const markRead = item => {
|
||||||
if (item.read_at) return;
|
if (item.read_at) return;
|
||||||
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i));
|
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i));
|
||||||
@@ -457,14 +497,15 @@ function SystemMessages({authenticated}) {
|
|||||||
猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */}
|
猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */}
|
||||||
{item.action && typeof item.action.topic === 'string' && item.action.topic.startsWith('grp') ?
|
{item.action && typeof item.action.topic === 'string' && item.action.topic.startsWith('grp') ?
|
||||||
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('group-settings', item.action.topic); }}>查看群聊 <Icon>chevron_right</Icon></button> :
|
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('group-settings', item.action.topic); }}>查看群聊 <Icon>chevron_right</Icon></button> :
|
||||||
item.event_type && item.event_type.startsWith('friend.request') ?
|
item.event_type === 'friend.request.created' ?
|
||||||
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('friend-requests'); }}>查看新朋友 <Icon>chevron_right</Icon></button> :
|
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('friend-requests'); }}>查看新朋友 <Icon>chevron_right</Icon></button> :
|
||||||
item.action && (item.action.device || item.action.ip) ?
|
item.action && (item.action.device || item.action.ip) ?
|
||||||
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button> :
|
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button> :
|
||||||
null}
|
null}
|
||||||
<footer>保持警惕<br />即时通讯安全团队</footer>
|
<footer>保持警惕<br />{BASE_APP_NAME}安全团队</footer>
|
||||||
</div>
|
</div>
|
||||||
</article>)}
|
</article>)}
|
||||||
|
{cursor ? <button type="button" className="system-notification-load-more" disabled={loadingMore} onClick={loadMore}>{loadingMore ? '加载中…' : '加载更多'}</button> : null}
|
||||||
</main>
|
</main>
|
||||||
<Toast message={toast} onDone={() => setToast('')} />
|
<Toast message={toast} onDone={() => setToast('')} />
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import { Drafty, Tinode, TheCard } from 'tinode-sdk';
|
import { Drafty, Tinode, TheCard, LOCAL_SEQID } from 'tinode-sdk';
|
||||||
|
|
||||||
const CallPanel = React.lazy(_ => import('../widgets/call-panel.jsx'));
|
const CallPanel = React.lazy(_ => import('../widgets/call-panel.jsx'));
|
||||||
import ChatMessage from '../widgets/chat-message.jsx';
|
import ChatMessage from '../widgets/chat-message.jsx';
|
||||||
@@ -288,6 +288,45 @@ class MessagesView extends React.Component {
|
|||||||
|
|
||||||
this.componentSetup({}, {});
|
this.componentSetup({}, {});
|
||||||
|
|
||||||
|
// Ch.7: jump to a message found via chat-history search/media grid (content-library-view.jsx's
|
||||||
|
// History) -- the componentDidUpdate check a few lines below only fires on a *change* to
|
||||||
|
// jumpToSeq while this component stays mounted, but tinode-web.jsx never renders MessagesView
|
||||||
|
// and a Ch.7 side panel at the same time (`!chapterSevenActive` gates it) -- every jump from
|
||||||
|
// History's search results/image grid/video grid therefore unmounts the Ch.7 view and mounts a
|
||||||
|
// *brand new* MessagesView instance with jumpToSeq already set on its very first render, which
|
||||||
|
// componentDidUpdate never sees (it doesn't run for the initial mount). Confirmed live: the
|
||||||
|
// seq stayed in the URL indefinitely (jumpToMessage's own `removeUrlParam` call never fired)
|
||||||
|
// and the conversation opened at the bottom instead of scrolling to the found message --
|
||||||
|
// 100% reproducible for every Ch.7 "定位到消息来源" entry point, not an intermittent timing issue.
|
||||||
|
if (this.props.jumpToSeq) {
|
||||||
|
const seq = parseInt(this.props.jumpToSeq, 10);
|
||||||
|
const topicObj = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
|
||||||
|
const attemptJump = () => this.jumpToMessage(seq);
|
||||||
|
// A first-ever mount also means `componentSetup()` just above only *started* subscribing
|
||||||
|
// this topic (subscribe() -> topic.subscribe() is async, fire-and-forget from here) --
|
||||||
|
// jumpToMessage's own topic.getMeta() call requires an already-established subscription,
|
||||||
|
// and firing immediately races ahead of it: confirmed live, this raced getMeta() call came
|
||||||
|
// back as a real `{"code":403,"text":"permission denied"}` control message (not a local
|
||||||
|
// guard, an actual rejected server round trip) while the real subscribe's own request was
|
||||||
|
// still in flight one id apart. Poll the already-exposed isSubscribed() flag rather than
|
||||||
|
// guessing a fixed delay or reworking subscribe()'s fire-and-forget call sites to expose
|
||||||
|
// their promise; capped at 5s so a genuinely stuck subscribe still falls through to
|
||||||
|
// jumpToMessage's own existing error handling instead of waiting forever.
|
||||||
|
if (topicObj && !topicObj.isSubscribed()) {
|
||||||
|
let tries = 0;
|
||||||
|
const waitForSubscription = () => {
|
||||||
|
if (topicObj.isSubscribed() || ++tries > 25) {
|
||||||
|
attemptJump();
|
||||||
|
} else {
|
||||||
|
window.setTimeout(waitForSubscription, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForSubscription();
|
||||||
|
} else {
|
||||||
|
attemptJump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getBootstrap().then(data => {
|
getBootstrap().then(data => {
|
||||||
this.setState({messagePolicy: data.message_policy || null});
|
this.setState({messagePolicy: data.message_policy || null});
|
||||||
}).catch(_ => {});
|
}).catch(_ => {});
|
||||||
@@ -1088,10 +1127,22 @@ class MessagesView extends React.Component {
|
|||||||
// allowAdmin=true, so the backend has always accepted this; the reference screenshot
|
// allowAdmin=true, so the backend has always accepted this; the reference screenshot
|
||||||
// (消息页面—长按弹出.png) lists "群撤回" as its own, always-shown menu item alongside "撤回" --
|
// (消息页面—长按弹出.png) lists "群撤回" as its own, always-shown menu item alongside "撤回" --
|
||||||
// this was previously never offered at all since the only condition checked was authorship.
|
// this was previously never offered at all since the only condition checked was authorship.
|
||||||
|
//
|
||||||
|
// `params.seq > 0` alone is not enough to mean "a real, server-persisted message": Tinode
|
||||||
|
// SDK gives every message that hasn't been confirmed by the server yet (still sending, or
|
||||||
|
// failed outright) a local placeholder seq of LOCAL_SEQID (0xFFFFFFF = 268435455) -- a large
|
||||||
|
// *positive* number, so it passed this check. Round 11 live testing reproduced this: sending
|
||||||
|
// a message into a since-blocked P2P topic left a bubble stuck in "发送失败" with seq
|
||||||
|
// 268435455, and this menu still offered "撤回" for it -- clicking it fired
|
||||||
|
// POST /topics/.../messages/268435455/recall, which the server correctly rejects (no such
|
||||||
|
// message), surfacing a confusing "请求内容有误,请稍后重试" toast for an action that should
|
||||||
|
// never have been offered. `seq < LOCAL_SEQID` excludes exactly that placeholder range,
|
||||||
|
// matching the same bound the SDK itself uses internally to distinguish a confirmed seq from
|
||||||
|
// a queued one.
|
||||||
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
|
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
|
||||||
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
|
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
|
||||||
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && recallWindow > 0 &&
|
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && params.seq < LOCAL_SEQID &&
|
||||||
params.timestamp && params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
|
recallWindow > 0 && params.timestamp && params.timestamp.getTime() > (new Date().getTime() - recallWindow * 1000)) {
|
||||||
menuItems.push('menu_item_recall');
|
menuItems.push('menu_item_recall');
|
||||||
}
|
}
|
||||||
// Self -> (1) always hard-delete with [Delete] title.
|
// Self -> (1) always hard-delete with [Delete] title.
|
||||||
@@ -2228,7 +2279,9 @@ class MessagesView extends React.Component {
|
|||||||
const messageNodes = [];
|
const messageNodes = [];
|
||||||
let previousFrom = null;
|
let previousFrom = null;
|
||||||
let prevDate = null;
|
let prevDate = null;
|
||||||
let chatBoxClass = null;
|
// Set upfront (not just inside the loop below) so an empty (zero-message) conversation still
|
||||||
|
// gets the base .chat-box class instead of leaving <ul id="scroller"> with no class at all.
|
||||||
|
let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
|
||||||
topic.messages((msg, prev, next, i) => {
|
topic.messages((msg, prev, next, i) => {
|
||||||
let nextFrom = next ? (next.from || 'chan') : null;
|
let nextFrom = next ? (next.from || 'chan') : null;
|
||||||
|
|
||||||
@@ -2254,7 +2307,6 @@ class MessagesView extends React.Component {
|
|||||||
userName = user.public.fn;
|
userName = user.public.fn;
|
||||||
userAvatar = makeImageUrl(user.public.photo);
|
userAvatar = makeImageUrl(user.public.photo);
|
||||||
}
|
}
|
||||||
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
|
|
||||||
|
|
||||||
// Ref for this chat message.
|
// Ref for this chat message.
|
||||||
const ref = this.getOrCreateMessageRef(msg.seq);
|
const ref = this.getOrCreateMessageRef(msg.seq);
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ class NewTopicView extends React.Component {
|
|||||||
|
|
||||||
handleTabClick(e) {
|
handleTabClick(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
// Same-page tab switch (find user / new group / find by id), not a navigation to a different
|
||||||
|
// page -- navigateTo() pushed a history entry per click purely to keep the `tab` url param in
|
||||||
|
// sync, so repeatedly switching tabs made the back button cycle through past tabs instead of
|
||||||
|
// leaving this page. navigateReplace() keeps the param in sync without that side effect.
|
||||||
|
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
||||||
this.setState({tabSelected: e.currentTarget.dataset.id});
|
this.setState({tabSelected: e.currentTarget.dataset.id});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import QRCode from 'qrcodejs';
|
|||||||
|
|
||||||
import HashNavigation from '../lib/navigation.js';
|
import HashNavigation from '../lib/navigation.js';
|
||||||
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||||||
import { BASE_APP_NAME, LINK_CONTACT_US } from '../config.js';
|
import { BASE_APP_NAME } from '../config.js';
|
||||||
import { PACKAGE_VERSION } from '../version.js';
|
import { PACKAGE_VERSION } from '../version.js';
|
||||||
import {
|
import {
|
||||||
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
|
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
|
||||||
@@ -22,7 +22,7 @@ const navigate = (view, params) => {
|
|||||||
export const PERSONAL_SETTINGS_VIEWS = [
|
export const PERSONAL_SETTINGS_VIEWS = [
|
||||||
'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',
|
'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',
|
||||||
'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
|
'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
|
||||||
'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
|
'change-password',
|
||||||
'app-version', 'privacy-settings', 'friend-discovery-settings',
|
'app-version', 'privacy-settings', 'friend-discovery-settings',
|
||||||
'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
|
'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
|
||||||
'notification-permission', 'permission-settings-reminder', 'camera-permission-info',
|
'notification-permission', 'permission-settings-reminder', 'camera-permission-info',
|
||||||
@@ -282,7 +282,7 @@ function PersonalQr({onToast}) {
|
|||||||
|
|
||||||
// docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了"青少年模式"/"实名认证"两行状态
|
// docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了"青少年模式"/"实名认证"两行状态
|
||||||
// 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分iOS/安卓两份。
|
// 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分iOS/安卓两份。
|
||||||
function SettingsList({onToast, onLogout}) {
|
function SettingsList({onToast, onLogout, tinode, chatList, onDeleteAccount}) {
|
||||||
// 'youth-mode' is shared with the YouthMode page's own fetch of the same endpoint; 'me-realname'
|
// 'youth-mode' is shared with the YouthMode page's own fetch of the same endpoint; 'me-realname'
|
||||||
// has no other reader today but gets the same treatment for consistency -- both status badges here
|
// has no other reader today but gets the same treatment for consistency -- both status badges here
|
||||||
// otherwise flash a plausible-but-possibly-wrong value (realnameLabel's own fallback below shows
|
// otherwise flash a plausible-but-possibly-wrong value (realnameLabel's own fallback below shows
|
||||||
@@ -291,6 +291,13 @@ function SettingsList({onToast, onLogout}) {
|
|||||||
// blank one.
|
// blank one.
|
||||||
const [youthSettings, setYouthSettings] = React.useState(() => readCachedData('youth-mode') || null);
|
const [youthSettings, setYouthSettings] = React.useState(() => readCachedData('youth-mode') || null);
|
||||||
const [realname, setRealname] = React.useState(() => readCachedData('me-realname') || null);
|
const [realname, setRealname] = React.useState(() => readCachedData('me-realname') || null);
|
||||||
|
// 清空缓存/清除聊天记录/注销账号三个确认弹层原来是各自独立的 view(hash 路由),route 一跳,
|
||||||
|
// "设置"这个列表整个连同 Header 一起卸载重挂载——ConfirmSheet 的 .personal-sheet-backdrop 本来是
|
||||||
|
// 设计成半透明蒙层盖在被弹出页面上方的(backdrop 4:52 rgba,明显是"衬着底下内容看"的视觉语言),
|
||||||
|
// 但因为底下真的没有任何东西在渲染,视觉上跟直接跳转到一个空白新页面没有区别。改成本组件自己的
|
||||||
|
// 本地 state,弹层作为兄弟节点叠在列表上方,列表本身不卸载——group-management-view.jsx 的
|
||||||
|
// OwnerSelect(选择新群主)已经是这个正确模式,这里照抄,不是发明新写法。
|
||||||
|
const [sheet, setSheet] = React.useState(null); // null | 'cache' | 'history' | 'delete' | 'logout'
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
getYouthMode().then(d => { writeCachedData('youth-mode', d); setYouthSettings(d); }).catch(() => {});
|
getYouthMode().then(d => { writeCachedData('youth-mode', d); setYouthSettings(d); }).catch(() => {});
|
||||||
getRealnameStatus().then(d => { writeCachedData('me-realname', d); setRealname(d); }).catch(() => {});
|
getRealnameStatus().then(d => { writeCachedData('me-realname', d); setRealname(d); }).catch(() => {});
|
||||||
@@ -298,15 +305,10 @@ function SettingsList({onToast, onLogout}) {
|
|||||||
const youthEnabled = youthSettings ? youthSettings.enabled : null;
|
const youthEnabled = youthSettings ? youthSettings.enabled : null;
|
||||||
const realnameStatus = realname ? realname.status : null;
|
const realnameStatus = realname ? realname.status : null;
|
||||||
const realnameLabel = {verified: '已认证', pending: '审核中', rejected: '未通过'}[realnameStatus] || '未认证';
|
const realnameLabel = {verified: '已认证', pending: '审核中', rejected: '未通过'}[realnameStatus] || '未认证';
|
||||||
const logout = () => {
|
|
||||||
if (window.confirm('确定要退出当前登录吗?')) {
|
|
||||||
onLogout ? onLogout() : onToast('当前环境不支持退出登录');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list">
|
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list">
|
||||||
<section>
|
<section>
|
||||||
<MenuRow icon="delete_sweep" title="清除缓存" route="cache-clear-confirm" />
|
<MenuRow icon="delete_sweep" title="清除缓存" onClick={() => setSheet('cache')} />
|
||||||
<MenuRow icon="mark_chat_read" title="清除所有聊天记录" route="clear-history-confirm" />
|
<MenuRow icon="mark_chat_read" title="清除所有聊天记录" onClick={() => setSheet('history')} />
|
||||||
<MenuRow icon="password" title="修改密码" route="change-password" />
|
<MenuRow icon="password" title="修改密码" route="change-password" />
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@@ -321,9 +323,14 @@ function SettingsList({onToast, onLogout}) {
|
|||||||
用的是同一套 hash 导航,可以直接指过去。 */}
|
用的是同一套 hash 导航,可以直接指过去。 */}
|
||||||
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
|
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
|
||||||
</section>
|
</section>
|
||||||
<section><MenuRow icon="no_accounts" title="注销账号" route="delete-account-confirm" danger /></section>
|
<section><MenuRow icon="no_accounts" title="注销账号" onClick={() => setSheet('delete')} danger /></section>
|
||||||
<button type="button" className="personal-logout" onClick={logout}>退出当前登录</button>
|
<button type="button" className="personal-logout" onClick={() => setSheet('logout')}>退出当前登录</button>
|
||||||
</main></div>;
|
</main>
|
||||||
|
{sheet === 'cache' ? <CacheClear onToast={onToast} onClose={() => setSheet(null)} /> : null}
|
||||||
|
{sheet === 'history' ? <ClearHistoryConfirm onToast={onToast} tinode={tinode} chatList={chatList} onClose={() => setSheet(null)} /> : null}
|
||||||
|
{sheet === 'delete' ? <DeleteAccountConfirm onToast={onToast} onDeleteAccount={onDeleteAccount} onClose={() => setSheet(null)} /> : null}
|
||||||
|
{sheet === 'logout' ? <LogoutConfirm onToast={onToast} onLogout={onLogout} onClose={() => setSheet(null)} /> : null}
|
||||||
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger = true, busy, onConfirm, onCancel, children}) {
|
function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger = true, busy, onConfirm, onCancel, children}) {
|
||||||
@@ -335,11 +342,25 @@ function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger
|
|||||||
</section></div>;
|
</section></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Was a bare `window.confirm()` -- the only one of these four confirmation flows not using this
|
||||||
|
// same ConfirmSheet pattern, an inconsistency with cache/history/delete-account below (their own
|
||||||
|
// comments describe deliberately moving away from route-per-confirmation dialogs to this in-place
|
||||||
|
// sheet). A native confirm() is also environment-dependent: some embedded WebViews/kiosk contexts
|
||||||
|
// disable synchronous dialogs outright (confirm() then returns false unconditionally, silently
|
||||||
|
// preventing logout with no error shown), which this sheet-based approach doesn't depend on.
|
||||||
|
function LogoutConfirm({onLogout, onToast, onClose}) {
|
||||||
|
const confirm = () => {
|
||||||
|
onClose();
|
||||||
|
onLogout ? onLogout() : onToast('当前环境不支持退出登录');
|
||||||
|
};
|
||||||
|
return <ConfirmSheet icon="logout" title="退出登录" body="确定要退出当前登录吗?" onConfirm={confirm} onCancel={onClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
// No backend endpoint clears cache/history -- both are pure client-side operations (confirmed:
|
// No backend endpoint clears cache/history -- both are pure client-side operations (confirmed:
|
||||||
// server/main.go's ~74 routes have nothing for either). Cache clearing empties Cache Storage/IDB;
|
// server/main.go's ~74 routes have nothing for either). Cache clearing empties Cache Storage/IDB;
|
||||||
// clearing all history calls the native Tinode delMessagesAll(false) per open conversation, same
|
// clearing all history calls the native Tinode delMessagesAll(false) per open conversation, same
|
||||||
// method contact-management-view.jsx/group-management-view.jsx already use for single-topic clears.
|
// method contact-management-view.jsx/group-management-view.jsx already use for single-topic clears.
|
||||||
function CacheClear({onToast}) {
|
function CacheClear({onToast, onClose}) {
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const [done, setDone] = React.useState(false);
|
const [done, setDone] = React.useState(false);
|
||||||
const [freed, setFreed] = React.useState(0);
|
const [freed, setFreed] = React.useState(0);
|
||||||
@@ -370,25 +391,25 @@ function CacheClear({onToast}) {
|
|||||||
return <ConfirmSheet icon={done ? 'check_circle' : 'delete_sweep'} title={done ? '缓存已清理' : '清空缓存'}
|
return <ConfirmSheet icon={done ? 'check_circle' : 'delete_sweep'} title={done ? '缓存已清理' : '清空缓存'}
|
||||||
body={done ? `已释放约 ${(freed / (1024 * 1024)).toFixed(1)} MB 存储空间。` : '确定清空本地的缓存数据?将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}
|
body={done ? `已释放约 ${(freed / (1024 * 1024)).toFixed(1)} MB 存储空间。` : '确定清空本地的缓存数据?将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}
|
||||||
confirmLabel={done ? '完成' : '确定'} confirmDanger={!done} busy={busy}
|
confirmLabel={done ? '完成' : '确定'} confirmDanger={!done} busy={busy}
|
||||||
onConfirm={done ? () => navigate('android-settings') : confirm} onCancel={() => navigate('android-settings')} />;
|
onConfirm={done ? onClose : confirm} onCancel={onClose} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClearHistoryConfirm({tinode, chatList, onToast}) {
|
function ClearHistoryConfirm({tinode, chatList, onToast, onClose}) {
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); navigate('android-settings'); return; }
|
if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); onClose(); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
Promise.all(chatList.map(c => {
|
Promise.all(chatList.map(c => {
|
||||||
const topic = tinode.getTopic(c.name);
|
const topic = tinode.getTopic(c.name);
|
||||||
return topic ? topic.delMessagesAll(false).catch(() => {}) : Promise.resolve();
|
return topic ? topic.delMessagesAll(false).catch(() => {}) : Promise.resolve();
|
||||||
})).then(() => {
|
})).then(() => {
|
||||||
onToast('聊天记录已清空');
|
onToast('聊天记录已清空');
|
||||||
navigate('android-settings');
|
onClose();
|
||||||
}).finally(() => setBusy(false));
|
}).finally(() => setBusy(false));
|
||||||
};
|
};
|
||||||
return <ConfirmSheet icon="mark_chat_read" title="清除所有聊天记录"
|
return <ConfirmSheet icon="mark_chat_read" title="清除所有聊天记录"
|
||||||
body="确定清除所有聊天记录?此操作不可恢复,但不会影响好友/群聊关系和收藏内容。" busy={busy}
|
body="确定清除所有聊天记录?此操作不可恢复,但不会影响好友/群聊关系和收藏内容。" busy={busy}
|
||||||
onConfirm={confirm} onCancel={() => navigate('android-settings')} />;
|
onConfirm={confirm} onCancel={onClose} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changing password has no REST endpoint -- it's native Tinode {acc} protocol
|
// Changing password has no REST endpoint -- it's native Tinode {acc} protocol
|
||||||
@@ -431,7 +452,7 @@ function ChangePassword({onUpdatePassword, onToast}) {
|
|||||||
// no cooldown/soft-delete and no password re-check (confirmed against server/user.go) -- adding a
|
// no cooldown/soft-delete and no password re-check (confirmed against server/user.go) -- adding a
|
||||||
// password confirmation step here is a deliberate, conservative frontend-only safeguard given how
|
// password confirmation step here is a deliberate, conservative frontend-only safeguard given how
|
||||||
// irreversible and consequential this action is, not something the backend requires.
|
// irreversible and consequential this action is, not something the backend requires.
|
||||||
function DeleteAccountConfirm({onDeleteAccount, onToast}) {
|
function DeleteAccountConfirm({onDeleteAccount, onToast, onClose}) {
|
||||||
const [step, setStep] = React.useState(1);
|
const [step, setStep] = React.useState(1);
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
@@ -448,10 +469,10 @@ function DeleteAccountConfirm({onDeleteAccount, onToast}) {
|
|||||||
// to be disclosed here since it's the only place the user sees before confirming.
|
// to be disclosed here since it's the only place the user sees before confirming.
|
||||||
return <ConfirmSheet icon="warning" title="注销账号"
|
return <ConfirmSheet icon="warning" title="注销账号"
|
||||||
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。如果你是某些群的群主,这些群也将被永久解散,其他成员将无法继续使用。确定要继续吗?"
|
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。如果你是某些群的群主,这些群也将被永久解散,其他成员将无法继续使用。确定要继续吗?"
|
||||||
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={() => navigate('android-settings')} />;
|
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={onClose} />;
|
||||||
}
|
}
|
||||||
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
|
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
|
||||||
busy={busy} onConfirm={proceed} onCancel={() => navigate('android-settings')}>
|
busy={busy} onConfirm={proceed} onCancel={onClose}>
|
||||||
<input type="password" autoFocus value={password} onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
|
<input type="password" autoFocus value={password} onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
|
||||||
</ConfirmSheet>;
|
</ConfirmSheet>;
|
||||||
}
|
}
|
||||||
@@ -470,9 +491,10 @@ function AppVersion({onToast}) {
|
|||||||
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
|
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
|
||||||
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
|
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
|
||||||
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
|
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
|
||||||
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信,复用代码里已有的
|
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信;LINK_CONTACT_US 本身也
|
||||||
LINK_CONTACT_US——如果这不是最终要用的联系方式,需要产品另外提供真实地址。 */}
|
没有真实地址(原来指向开源 Tinode 项目自己的域名,是品牌泄漏,已改为 null),所以跟
|
||||||
<MenuRow title="联系我们" onClick={() => { window.location.href = LINK_CONTACT_US; }} />
|
"用户条款"/"隐私协议"两行一样降级成 toast,不指向任何外部地址。 */}
|
||||||
|
<MenuRow title="联系我们" onClick={() => onToast('联系我们')} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>;
|
</div>;
|
||||||
@@ -693,9 +715,11 @@ function YouthModeVerify({hashParams, onToast}) {
|
|||||||
return <div className="personal-page youth-mode-page">
|
return <div className="personal-page youth-mode-page">
|
||||||
<Header title={mode === 'enable' ? '开启青少年模式' : '关闭青少年模式'} back={mode === 'enable' ? 'youth-mode-off' : 'youth-mode-on'} />
|
<Header title={mode === 'enable' ? '开启青少年模式' : '关闭青少年模式'} back={mode === 'enable' ? 'youth-mode-off' : 'youth-mode-on'} />
|
||||||
<main className="youth-password-gate">
|
<main className="youth-password-gate">
|
||||||
<div className="auth-pin-boxes" aria-hidden="true">{[0, 1, 2, 3].map(i => <span key={i}>{password[i] ? '●' : ''}</span>)}</div>
|
<div className="auth-pin-wrap">
|
||||||
<input className="auth-pin-input" type="text" inputMode="numeric" autoComplete="off" autoFocus
|
<div className="auth-pin-boxes" aria-hidden="true">{[0, 1, 2, 3].map(i => <span key={i}>{password[i] ? '●' : ''}</span>)}</div>
|
||||||
value={password} onChange={e => setPassword(e.target.value.replace(/\D/g, '').slice(0, 4))} aria-label="独立密码" />
|
<input className="auth-pin-input" type="text" inputMode="numeric" autoComplete="off" autoFocus
|
||||||
|
value={password} onChange={e => setPassword(e.target.value.replace(/\D/g, '').slice(0, 4))} aria-label="独立密码" />
|
||||||
|
</div>
|
||||||
{error ? <div className="auth-field-error">{error}</div> : null}
|
{error ? <div className="auth-field-error">{error}</div> : null}
|
||||||
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy || remainingLockSeconds > 0} onClick={submit}>
|
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy || remainingLockSeconds > 0} onClick={submit}>
|
||||||
{busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'}
|
{busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'}
|
||||||
@@ -754,11 +778,8 @@ export default function PersonalSettingsView({view, hashParams, myUserId, tinode
|
|||||||
else if (view === 'profile-gender') content = <GenderEditor onToast={setToast} />;
|
else if (view === 'profile-gender') content = <GenderEditor onToast={setToast} />;
|
||||||
else if (view === 'profile-region') content = <RegionEditor onToast={setToast} />;
|
else if (view === 'profile-region') content = <RegionEditor onToast={setToast} />;
|
||||||
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
|
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
|
||||||
else if (view === 'android-settings') content = <SettingsList onToast={setToast} onLogout={onLogout} />;
|
else if (view === 'android-settings') content = <SettingsList onToast={setToast} onLogout={onLogout} tinode={tinode} chatList={chatList} onDeleteAccount={onDeleteAccount} />;
|
||||||
else if (view === 'cache-clear-confirm') content = <CacheClear onToast={setToast} />;
|
|
||||||
else if (view === 'clear-history-confirm') content = <ClearHistoryConfirm onToast={setToast} tinode={tinode} chatList={chatList} />;
|
|
||||||
else if (view === 'change-password') content = <ChangePassword onToast={setToast} onUpdatePassword={onUpdatePassword} />;
|
else if (view === 'change-password') content = <ChangePassword onToast={setToast} onUpdatePassword={onUpdatePassword} />;
|
||||||
else if (view === 'delete-account-confirm') content = <DeleteAccountConfirm onToast={setToast} onDeleteAccount={onDeleteAccount} />;
|
|
||||||
else if (view === 'app-version') content = <AppVersion onToast={setToast} />;
|
else if (view === 'app-version') content = <AppVersion onToast={setToast} />;
|
||||||
else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />;
|
else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />;
|
||||||
else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />;
|
else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />;
|
||||||
|
|||||||
@@ -164,6 +164,17 @@ class SidepanelView extends React.PureComponent {
|
|||||||
myUserId={this.props.myUserId}
|
myUserId={this.props.myUserId}
|
||||||
onTopicSelected={this.props.onTopicSelected}
|
onTopicSelected={this.props.onTopicSelected}
|
||||||
onOpenScanner={this.props.onOpenScanner} />
|
onOpenScanner={this.props.onOpenScanner} />
|
||||||
|
{/* This branch never rendered ErrorPanel at all -- on desktop (non-mobile), opening a real
|
||||||
|
conversation lands here (view stays 'messages-home' behind the split-view topic pane),
|
||||||
|
and messages-view.jsx's own ErrorPanel is only rendered on displayMobile. Every
|
||||||
|
this.props.onError(...) call reachable from an open conversation on desktop (failed
|
||||||
|
uploads, failed forwards, URL risk blocks, etc.) had no visible outlet at all. */}
|
||||||
|
<ErrorPanel
|
||||||
|
level={this.props.errorLevel}
|
||||||
|
text={this.props.errorText}
|
||||||
|
action={this.props.errorAction}
|
||||||
|
actionText={this.props.errorActionText}
|
||||||
|
onClearError={this.props.onError} />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','c
|
|||||||
'executive-mailbox','executive-mailbox-notice',
|
'executive-mailbox','executive-mailbox-notice',
|
||||||
'personal-center','personal-profile','profile-nickname','profile-birth-date',
|
'personal-center','personal-profile','profile-nickname','profile-birth-date',
|
||||||
'profile-signature','profile-gender','profile-region','personal-qrcode','android-settings',
|
'profile-signature','profile-gender','profile-region','personal-qrcode','android-settings',
|
||||||
'cache-clear-confirm','clear-history-confirm','change-password','delete-account-confirm',
|
'change-password',
|
||||||
'app-version','privacy-settings','friend-discovery-settings',
|
'app-version','privacy-settings','friend-discovery-settings',
|
||||||
'youth-mode-off','youth-mode-on','youth-mode-verify',
|
'youth-mode-off','youth-mode-on','youth-mode-verify',
|
||||||
'notification-permission','permission-settings-reminder','camera-permission-info',
|
'notification-permission','permission-settings-reminder','camera-permission-info',
|
||||||
@@ -177,7 +177,8 @@ const CTRL_ERROR_TEXT_CN = {
|
|||||||
'authentication required': '请先登录',
|
'authentication required': '请先登录',
|
||||||
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
|
'unknown authentication scheme': '登录方式不受支持,请刷新页面重试',
|
||||||
'policy violation': '当前操作不符合安全策略要求',
|
'policy violation': '当前操作不符合安全策略要求',
|
||||||
'permission denied': '没有权限执行此操作,请重新登录'
|
'permission denied': '没有权限执行此操作,请重新登录',
|
||||||
|
'internal error': '服务器繁忙,请稍后重试'
|
||||||
};
|
};
|
||||||
function translateCtrlError(err) {
|
function translateCtrlError(err) {
|
||||||
const raw = err && err.message;
|
const raw = err && err.message;
|
||||||
@@ -215,6 +216,17 @@ class TinodeWeb extends React.Component {
|
|||||||
|
|
||||||
this.selfRef = React.createRef();
|
this.selfRef = React.createRef();
|
||||||
|
|
||||||
|
// message-center-view.jsx's conversation-list preview reads topic.latestMessage(), which the
|
||||||
|
// vendored SDK only ever populates from a topic's own local message cache -- itself only filled
|
||||||
|
// in once that specific topic has actually been subscribed with a data fetch. me.contacts()
|
||||||
|
// (resetContactList below) yields the real, already-cached Topic instances for every contact --
|
||||||
|
// including ones the user has never opened this session -- so most rows fall straight through to
|
||||||
|
// the '暂无消息' fallback despite `touched`/`seq` on the same object proving real history exists
|
||||||
|
// server-side. This tracks which topics we've already kicked off a one-shot "just the last
|
||||||
|
// message" fetch for, so repeated resetContactList() calls (it fires on nearly every {meta}/{pres}
|
||||||
|
// event, not just once at boot) don't re-subscribe the same topic over and over.
|
||||||
|
this.previewPrefetchAttempted = new Set();
|
||||||
|
|
||||||
this.state = this.getBlankState();
|
this.state = this.getBlankState();
|
||||||
|
|
||||||
this.handleResize = this.handleResize.bind(this);
|
this.handleResize = this.handleResize.bind(this);
|
||||||
@@ -616,10 +628,16 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initFCMessaging() {
|
initFCMessaging() {
|
||||||
const {formatMessage, locale} = this.props.intl;
|
const {locale} = this.props.intl;
|
||||||
const onError = (msg, err) => {
|
const onError = (msg, err) => {
|
||||||
console.error(msg, err);
|
console.error(msg, err);
|
||||||
this.handleError(formatMessage(messages.push_init_failed), 'err');
|
// Was routed through the same handleError()/errorText the login/register form's own alert
|
||||||
|
// renders from -- push notifications are best-effort (the app works fully without them), but
|
||||||
|
// sharing that channel meant this could cover up a real, concurrent login/register error, or
|
||||||
|
// (since initFCMessaging is called right as a login request starts) just surface on its own,
|
||||||
|
// with no login error at all, on a form the user hasn't touched yet. Silently degrading
|
||||||
|
// (console log + the state resets below, which already disable the desktop-alerts setting)
|
||||||
|
// is enough; failing to enable push is not something that needs to interrupt the auth flow.
|
||||||
this.setState({firebaseToken: null});
|
this.setState({firebaseToken: null});
|
||||||
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
|
||||||
}
|
}
|
||||||
@@ -750,6 +768,20 @@ class TinodeWeb extends React.Component {
|
|||||||
// Handle for hash navigation (hashchange) event: update state.
|
// Handle for hash navigation (hashchange) event: update state.
|
||||||
handleHashRoute() {
|
handleHashRoute() {
|
||||||
const hash = HashNavigation.parseUrlHash(window.location.hash);
|
const hash = HashNavigation.parseUrlHash(window.location.hash);
|
||||||
|
// Browser back navigation after a successful login re-renders one of the pre-login views
|
||||||
|
// (most commonly 'login') while the underlying Tinode connection is still authenticated --
|
||||||
|
// hash routing and the WS session's lifecycle are independent, so nothing else here would
|
||||||
|
// otherwise catch this. Landing back on a login/register form while already logged in used to
|
||||||
|
// let a stale, disabled submit button sit there permanently (loginDisabled is only ever reset
|
||||||
|
// on a real login attempt's own success/failure, and none happens here), and a form actually
|
||||||
|
// submitted from this state hit doLogin's/handleNewAccountRequest's own "already authenticated"
|
||||||
|
// short-circuit -- silently skipping credential validation for login, or surfacing a raw
|
||||||
|
// untranslated "already authenticated (409)" for registration. Redirecting away before any of
|
||||||
|
// that code runs removes the only way to reach it.
|
||||||
|
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
|
||||||
|
HashNavigation.navigateTo('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Start with panel parameters.
|
// Start with panel parameters.
|
||||||
const newState = {
|
const newState = {
|
||||||
infoPanel: hash.params.info,
|
infoPanel: hash.params.info,
|
||||||
@@ -1073,6 +1105,12 @@ class TinodeWeb extends React.Component {
|
|||||||
me.onSubsUpdated = this.tnMeSubsUpdated;
|
me.onSubsUpdated = this.tnMeSubsUpdated;
|
||||||
this.setState({
|
this.setState({
|
||||||
connected: true,
|
connected: true,
|
||||||
|
// Only the failure branch of doLogin's promise chain reset this; a success never needed to
|
||||||
|
// since it navigates away immediately -- until the same Login form could be reached again
|
||||||
|
// afterwards (browser back) with this still stuck true, permanently disabling the submit
|
||||||
|
// button. handleHashRoute's redirect above now closes off that specific path, but resetting
|
||||||
|
// it here too means this can't get stuck regardless of how the form ends up rendered again.
|
||||||
|
loginDisabled: false,
|
||||||
credMethod: undefined,
|
credMethod: undefined,
|
||||||
credCode: undefined,
|
credCode: undefined,
|
||||||
credToken: undefined,
|
credToken: undefined,
|
||||||
@@ -1257,6 +1295,8 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.prefetchLatestMessagePreviews(newState.chatList);
|
||||||
|
|
||||||
const past = new Date(0);
|
const past = new Date(0);
|
||||||
newState.chatList.sort((a, b) => {
|
newState.chatList.sort((a, b) => {
|
||||||
// Pinned topics take precedence.
|
// Pinned topics take precedence.
|
||||||
@@ -1270,6 +1310,41 @@ class TinodeWeb extends React.Component {
|
|||||||
this.setState(newState);
|
this.setState(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See the comment on this.previewPrefetchAttempted in the constructor for why this exists.
|
||||||
|
// seq > 0 is the same 'does this topic actually have any messages at all' signal
|
||||||
|
// message-center-view.jsx would otherwise have no way to distinguish from 'genuinely empty' --
|
||||||
|
// skip topics with no history instead of firing a subscribe that would just come back empty.
|
||||||
|
//
|
||||||
|
// !this.tinode.isConnected() live repro: resetContactList() fires as soon as the persisted local
|
||||||
|
// cache restores 'me's last-known contact list (log line "Persistent cache initialized"), which is
|
||||||
|
// *before* the websocket has even sent {hi} -- every subscribe() call made at that point instantly
|
||||||
|
// rejects with a 503 "Websocket is not connected". Skip attempting entirely while disconnected
|
||||||
|
// (rather than catching the failure) so these topics *aren't* added to previewPrefetchAttempted yet
|
||||||
|
// -- resetContactList() runs again moments later once the real {meta topic:'me'} response arrives
|
||||||
|
// over the now-live connection, and this gets a genuine second try then.
|
||||||
|
prefetchLatestMessagePreviews(chatList) {
|
||||||
|
if (!this.tinode.isConnected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const c of chatList) {
|
||||||
|
if (!c.topic || c.seq <= 0 || c.latestMessage() || this.previewPrefetchAttempted.has(c.topic)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.previewPrefetchAttempted.add(c.topic);
|
||||||
|
// Topic.subscribe(getParams, setParams) -- the query is the *first* argument. Passing it
|
||||||
|
// second (as setParams) was silently swallowed: the resulting {sub} packet had no `get`
|
||||||
|
// clause at all (confirmed live: `out: {"sub":{"id":...,"topic":"..."}}`, no `get` key), so
|
||||||
|
// subscribe succeeded but never actually requested -- let alone received -- any message data.
|
||||||
|
c.subscribe(c.startMetaQuery().withLaterData(1).build()).catch(err => {
|
||||||
|
// Leave it in previewPrefetchAttempted either way -- a topic that fails once we're
|
||||||
|
// definitely connected (e.g. a stale contact the user no longer has access to) isn't going
|
||||||
|
// to succeed on the next resetContactList() a moment later either, and would otherwise retry
|
||||||
|
// on every single {meta}/{pres} event for the rest of the session.
|
||||||
|
console.warn(`Failed to prefetch latest message for '${c.topic}'`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static checkIfPinsUpdated(me, chatList) {
|
static checkIfPinsUpdated(me, chatList) {
|
||||||
for (let i = 0; i < chatList.length; i++) {
|
for (let i = 0; i < chatList.length; i++) {
|
||||||
const c = chatList[i];
|
const c = chatList[i];
|
||||||
@@ -1357,12 +1432,22 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In mobile view user requested to show sidepanel
|
// In mobile view user requested to show sidepanel (this is the chat's own "<-" back arrow,
|
||||||
|
// #hide-message-view in messages-view.jsx). Opening a topic (handleTopicSelected) is a real
|
||||||
|
// navigateTo() push, so closing it should be the matching pop -- goBack() first, same pattern as
|
||||||
|
// content-library-view.jsx's Header back button. Before this fix it unconditionally pushed a new
|
||||||
|
// "no topic" hash entry instead of popping the "topic open" one: open chat A (push) -> tap back
|
||||||
|
// (push again, but the "chat A open" entry from a moment ago is left sitting right underneath,
|
||||||
|
// never consumed) -> open chat B (push) -> the system back button then has to be pressed twice to
|
||||||
|
// leave the chat area at all, and the second press lands back on chat A, not wherever the user
|
||||||
|
// actually came from before opening any chat.
|
||||||
handleHideMessagesView() {
|
handleHideMessagesView() {
|
||||||
this.setState({
|
this.setState({
|
||||||
mobilePanel: 'sidepanel'
|
mobilePanel: 'sidepanel'
|
||||||
});
|
});
|
||||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
|
if (!HashNavigation.goBack()) {
|
||||||
|
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is sending a message, either plain text or a drafty object, possibly
|
// User is sending a message, either plain text or a drafty object, possibly
|
||||||
@@ -1480,8 +1565,21 @@ class TinodeWeb extends React.Component {
|
|||||||
if (public_ && public_.photo && public_.photo.ref) {
|
if (public_ && public_.photo && public_.photo.ref) {
|
||||||
attachments = [public_.photo.ref];
|
attachments = [public_.photo.ref];
|
||||||
}
|
}
|
||||||
|
// cred_ is `null` on the username-only registration path (auth-onboarding-view.jsx's Register
|
||||||
|
// passes it literally as `null` when there's no phone credential to attach, only an invite
|
||||||
|
// code). tinode-sdk's Tinode.credential(meth, ...) checks `typeof meth == 'object'` to decide
|
||||||
|
// whether its first argument is really a single {meth,val,resp,params} object -- but
|
||||||
|
// `typeof null === 'object'` is true in JS, so it unconditionally destructures `{val, params,
|
||||||
|
// resp, meth} = meth` and crashes with a raw "Cannot destructure property 'val' of 'meth' as
|
||||||
|
// it is null" the moment it receives a real `null` (as opposed to an object with merely
|
||||||
|
// undefined fields, which every *login* call site here happens to always pass and which
|
||||||
|
// destructures fine). This made username-only registration (with or without a valid invite
|
||||||
|
// code -- client-side validation passes either way before reaching here) always throw and
|
||||||
|
// never send the {acc} message at all, with the raw JS error text leaking into the "注册"
|
||||||
|
// button's error banner. Not calling Tinode.credential() at all when there is nothing to
|
||||||
|
// wrap sidesteps the SDK's bug without patching vendored node_modules code.
|
||||||
return this.tinode.createAccountBasic(login_, password_,
|
return this.tinode.createAccountBasic(login_, password_,
|
||||||
{public: public_, tags: tags_, cred: Tinode.credential(cred_), attachments: attachments});
|
{public: public_, tags: tags_, cred: cred_ ? Tinode.credential(cred_) : undefined, attachments: attachments});
|
||||||
}).then(ctrl => {
|
}).then(ctrl => {
|
||||||
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
|
if (ctrl.code >= 300 && ctrl.text == 'validate credentials') {
|
||||||
if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) {
|
if (!TinodeWeb.navigateToCredentialsView(ctrl.params)) {
|
||||||
@@ -1491,7 +1589,15 @@ class TinodeWeb extends React.Component {
|
|||||||
this.handleLoginSuccessful(this);
|
this.handleLoginSuccessful(this);
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
this.handleError(err.message, 'err');
|
// Registration goes over the native WS {acc} protocol, not REST, so im-api.js's
|
||||||
|
// REASON_MESSAGES translation table (REST-only) never sees this error -- it was showing
|
||||||
|
// raw SDK text like "internal error (500)" verbatim (confirmed live: retrying a phone
|
||||||
|
// number that's already registered). CTRL_ERROR_TEXT_CN already translates this same class
|
||||||
|
// of {ctrl} error for login; reused here with a registration-appropriate fallback instead
|
||||||
|
// of loginErrorMessage's own (its 401/403 fallback is "账号或密码错误", which doesn't fit a
|
||||||
|
// failed registration).
|
||||||
|
const known = translateCtrlError(err);
|
||||||
|
this.handleError(known || (err && err.message) || '注册失败,请稍后重试', 'err');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2476,48 +2582,56 @@ class TinodeWeb extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCallRinging(callTopic, callSeq) {
|
// All of the call-signaling senders below (ringing/hang-up/offer/ice-candidate/answer) go through
|
||||||
|
// this one helper rather than calling topic.videoCall() directly. handleCallHangup in particular is
|
||||||
|
// called from several unmount/teardown paths outside the normal call flow (messages-view.jsx's
|
||||||
|
// componentWillUnmount and its topic-switch handler in componentSetup both call it via the
|
||||||
|
// onCallHangup prop, on top of the ring-timeout path and CallInvite's own cleanup here) -- including
|
||||||
|
// ones that fire *after* a logout has already torn down the connection: handleLogout() calls
|
||||||
|
// this.tinode.disconnect() and only afterwards resets state to the blank/logged-out screen, and that
|
||||||
|
// state reset is what unmounts messages-view.jsx and trips its componentWillUnmount -> handleCallHangup
|
||||||
|
// call. Real repro: click a call button (real or scripted handleStartVideoCall/handleStartAudioCall),
|
||||||
|
// then immediately log out -- topic.videoCall() ends up calling the vendored SDK's
|
||||||
|
// Connection.sendText() on an already-closed socket, which throws synchronously ("Websocket is not
|
||||||
|
// connected"). Thrown from componentWillUnmount, React has no error boundary for it and unmounts the
|
||||||
|
// entire app to a blank white page. None of these are anything the peer can still receive over a dead
|
||||||
|
// connection anyway (and for hang-up specifically, the server's own establishment-timeout already
|
||||||
|
// cleans up an abandoned call-in-progress independently), so skip sending entirely once disconnected
|
||||||
|
// instead of letting it throw -- the other four signals are currently unreachable in practice (no real
|
||||||
|
// call can get established while "call invite always 400s" -- see handleCallInvite -- is unfixed) but
|
||||||
|
// share the identical topic.videoCall() shape and would hit the exact same crash the moment that's
|
||||||
|
// fixed and real in-progress calls (offer/ice-candidate/answer exchange, or a mid-call logout) become
|
||||||
|
// possible.
|
||||||
|
sendCallSignal(callTopic, evt, callSeq, payload) {
|
||||||
const topic = this.tinode.getTopic(callTopic);
|
const topic = this.tinode.getTopic(callTopic);
|
||||||
if (!topic) {
|
if (!topic || !this.tinode.isConnected()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
topic.videoCall(evt, callSeq, payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to send call signal '${evt}'`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
topic.videoCall('ringing', callSeq);
|
handleCallRinging(callTopic, callSeq) {
|
||||||
|
this.sendCallSignal(callTopic, 'ringing', callSeq);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCallHangup(callTopic, callSeq) {
|
handleCallHangup(callTopic, callSeq) {
|
||||||
const topic = this.tinode.getTopic(callTopic);
|
this.sendCallSignal(callTopic, 'hang-up', callSeq);
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.videoCall('hang-up', callSeq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCallSendOffer(callTopic, callSeq, sdp) {
|
handleCallSendOffer(callTopic, callSeq, sdp) {
|
||||||
const topic = this.tinode.getTopic(callTopic);
|
this.sendCallSignal(callTopic, 'offer', callSeq, sdp);
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.videoCall('offer', callSeq, sdp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCallIceCandidate(callTopic, callSeq, candidate) {
|
handleCallIceCandidate(callTopic, callSeq, candidate) {
|
||||||
const topic = this.tinode.getTopic(callTopic);
|
this.sendCallSignal(callTopic, 'ice-candidate', callSeq, candidate);
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.videoCall('ice-candidate', callSeq, candidate);
|
|
||||||
}
|
}
|
||||||
handleCallSendAnswer(callTopic, callSeq, sdp) {
|
|
||||||
const topic = this.tinode.getTopic(callTopic);
|
|
||||||
if (!topic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.videoCall('answer', callSeq, sdp);
|
handleCallSendAnswer(callTopic, callSeq, sdp) {
|
||||||
|
this.sendCallSignal(callTopic, 'answer', callSeq, sdp);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCallClose() {
|
handleCallClose() {
|
||||||
@@ -2677,7 +2791,7 @@ class TinodeWeb extends React.Component {
|
|||||||
'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
|
'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
|
||||||
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
|
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
|
||||||
'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
|
'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
|
||||||
'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
|
'change-password',
|
||||||
'app-version', 'privacy-settings',
|
'app-version', 'privacy-settings',
|
||||||
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
|
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
|
||||||
'notification-permission', 'permission-settings-reminder',
|
'notification-permission', 'permission-settings-reminder',
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export default class WallpapersView extends React.PureComponent {
|
|||||||
|
|
||||||
handleTabClick(e) {
|
handleTabClick(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
// Same-page tab switch (pattern / image), not a navigation to a different page -- see the same
|
||||||
|
// fix/comment in new-topic-view.jsx's handleTabClick.
|
||||||
|
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
||||||
this.setState({tab: e.currentTarget.dataset.id});
|
this.setState({tab: e.currentTarget.dataset.id});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ const navigate = (view, params) => {
|
|||||||
HashNavigation.navigateTo(hash);
|
HashNavigation.navigateTo(hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Same bug/fix as content-library-view.jsx's navigateReplace: DailyReportList/MonthlyReportList's
|
||||||
|
// 我的日报|月报 <-> 团队统计 switch is a same-page tab toggle (same component, just a `team` boolean),
|
||||||
|
// not a navigation to a different page -- using navigate() here pushed a history entry per click, so
|
||||||
|
// switching back and forth a few times made the system back button (and this app's own Header back
|
||||||
|
// button, gated on HashNavigation.goBack()) cycle through past toggle states instead of leaving the
|
||||||
|
// report page. replaceState-based navigateReplace keeps the hash in sync without that side effect.
|
||||||
|
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
|
||||||
|
|
||||||
export const WORKPLACE_COLLABORATION_VIEWS = [
|
export const WORKPLACE_COLLABORATION_VIEWS = [
|
||||||
'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail',
|
'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail',
|
||||||
'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail',
|
'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail',
|
||||||
@@ -166,7 +174,7 @@ function Workbench({onToast}) {
|
|||||||
</button>)}</section>
|
</button>)}</section>
|
||||||
</main><nav className="workplace-bottom-nav" aria-label="主导航">
|
</main><nav className="workplace-bottom-nav" aria-label="主导航">
|
||||||
<button type="button" className="active"><Icon>dashboard</Icon><span>工作台</span></button>
|
<button type="button" className="active"><Icon>dashboard</Icon><span>工作台</span></button>
|
||||||
<button type="button" onClick={() => navigate('messages-home')}><Icon>chat_bubble_outline</Icon><span>消息</span></button>
|
<button type="button" onClick={() => navigateReplace('messages-home')}><Icon>chat_bubble_outline</Icon><span>消息</span></button>
|
||||||
</nav></div>;
|
</nav></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,10 +310,21 @@ function MeetingCard({meeting, myUserId, onRespond, onToast}) {
|
|||||||
{!isOrganizer ? (
|
{!isOrganizer ? (
|
||||||
meeting.my_response && meeting.my_response !== 'pending' ?
|
meeting.my_response && meeting.my_response !== 'pending' ?
|
||||||
<footer><button type="button" className="meeting-responded" disabled>{RESPONSE_LABEL[meeting.my_response] || meeting.my_response}</button></footer> :
|
<footer><button type="button" className="meeting-responded" disabled>{RESPONSE_LABEL[meeting.my_response] || meeting.my_response}</button></footer> :
|
||||||
<footer className="meeting-response">
|
// Live-repro'd (round 12): this branch used to render regardless of `permissions.can_respond`,
|
||||||
|
// so a canceled meeting the viewer never responded to (my_response still 'pending', a normal
|
||||||
|
// state for a canceled invite -- canceling doesn't force every invitee to respond first) kept
|
||||||
|
// showing live-looking 拒绝/接受 buttons here. Clicking either got a 403 PERMISSION_DENIED from
|
||||||
|
// PUT .../my-response (confirmed against a real canceled meeting, mtg_0w2TnfQiqaMwy8Bj) --
|
||||||
|
// the request body/version were fine, the backend just correctly refuses to accept a response
|
||||||
|
// once a meeting can no longer be responded to. MeetingDetail already gates its own copy of
|
||||||
|
// these same two buttons on this exact flag (line ~406 below); this list card independently
|
||||||
|
// duplicates the buttons but had never adopted the same guard. Requirements 9.4 also calls for
|
||||||
|
// expired meetings to become read-only history, which this same flag should cover -- can_respond
|
||||||
|
// is a general "still actionable" signal, not canceled-specific.
|
||||||
|
(meeting.permissions && meeting.permissions.can_respond ? <footer className="meeting-response">
|
||||||
<button type="button" disabled={busy} onClick={() => respond('declined')}>拒绝</button>
|
<button type="button" disabled={busy} onClick={() => respond('declined')}>拒绝</button>
|
||||||
<button type="button" className="primary" disabled={busy} onClick={() => respond('accepted')}>接受</button>
|
<button type="button" className="primary" disabled={busy} onClick={() => respond('accepted')}>接受</button>
|
||||||
</footer>
|
</footer> : null)
|
||||||
) : null}
|
) : null}
|
||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
@@ -381,8 +400,15 @@ function MeetingDetail({hashParams, onToast}) {
|
|||||||
};
|
};
|
||||||
const respond = response => {
|
const respond = response => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
// Live-repro'd (round 14): RESPONSE_LABEL's 'accepted'/'declined' entries are already prefixed
|
||||||
|
// ("已接受"/"已拒绝") for use as the disabled-button/participant-badge label elsewhere on this
|
||||||
|
// page -- reusing it here with an extra literal "已" in front produced a toast reading "已已接受"/
|
||||||
|
// "已已拒绝" (and a slightly-off "已待定" for tentative, since that entry has no "已" to double up
|
||||||
|
// with). MeetingCard's own respond() (this same action, reached from the list card instead of this
|
||||||
|
// detail page) already had correct, un-doubled wording for all three -- reuse that instead of
|
||||||
|
// re-deriving new strings from RESPONSE_LABEL.
|
||||||
getMeeting(meeting.meeting_id).then(fresh => respondMeeting(fresh.meeting_id, response, fresh.version))
|
getMeeting(meeting.meeting_id).then(fresh => respondMeeting(fresh.meeting_id, response, fresh.version))
|
||||||
.then(_ => { onToast(RESPONSE_LABEL[response] ? `已${RESPONSE_LABEL[response]}` : '已回应'); load(); })
|
.then(_ => { onToast(response === 'accepted' ? '已接受会议邀请' : response === 'declined' ? '已拒绝会议邀请' : '已标记为待定'); load(); })
|
||||||
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
|
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -412,7 +438,14 @@ function MeetingDetail({hashParams, onToast}) {
|
|||||||
</article>
|
</article>
|
||||||
{isOrganizer ? <section className="meeting-status">
|
{isOrganizer ? <section className="meeting-status">
|
||||||
<h2>参会确认</h2>
|
<h2>参会确认</h2>
|
||||||
<div><span><b>{counts.accepted}</b><small>已接受</small></span><span><b>{counts.pending}</b><small>待响应</small></span><span><b>{counts.declined}</b><small>已拒绝</small></span></div>
|
{/* counts.tentative was already computed above but never rendered here -- live-repro'd
|
||||||
|
(round 14): after a participant responds "待定" via MeetingDetail's own respond('tentative')
|
||||||
|
button (or the equivalent list-card flow), this summary's three numbers (已接受/待响应/
|
||||||
|
已拒绝) silently stopped summing to participant_count with no visible explanation, because
|
||||||
|
the fourth bucket their response actually landed in was computed but dropped on the floor.
|
||||||
|
Appended as a 4th stat (order of the original three left untouched) so every response state
|
||||||
|
has a visible home; grid-template-columns bumped from 3 to 4 to match (see base.css). */}
|
||||||
|
<div><span><b>{counts.accepted}</b><small>已接受</small></span><span><b>{counts.pending}</b><small>待响应</small></span><span><b>{counts.declined}</b><small>已拒绝</small></span><span><b>{counts.tentative}</b><small>待定</small></span></div>
|
||||||
<footer>
|
<footer>
|
||||||
{meeting.permissions && meeting.permissions.can_remind ? <button type="button" disabled={busy} onClick={remind}><Icon>notifications</Icon>提醒未响应</button> : null}
|
{meeting.permissions && meeting.permissions.can_remind ? <button type="button" disabled={busy} onClick={remind}><Icon>notifications</Icon>提醒未响应</button> : null}
|
||||||
{/* 后端常量是 "canceled" 单 l(store/types/meetings.go:7),原来写成 "cancelled" 永远不相等,
|
{/* 后端常量是 "canceled" 单 l(store/types/meetings.go:7),原来写成 "cancelled" 永远不相等,
|
||||||
@@ -695,7 +728,7 @@ function DailyReportCard({report, names, team}) {
|
|||||||
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></header>
|
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></header>
|
||||||
<p>{report.content_short || '(未填写内容)'}</p>
|
<p>{report.content_short || '(未填写内容)'}</p>
|
||||||
<footer><span><Icon>schedule</Icon>{report.report_date}</span>
|
<footer><span><Icon>schedule</Icon>{report.report_date}</span>
|
||||||
<button type="button" onClick={() => navigate('daily-report-detail', {report_id: report.report_id})}>查看详情<Icon>chevron_right</Icon></button></footer>
|
<button type="button" onClick={() => navigate('daily-report-detail', {report_id: report.report_id, team: team ? '1' : ''})}>查看详情<Icon>chevron_right</Icon></button></footer>
|
||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,9 +768,22 @@ function DailyReportList({team, myUserId, onToast}) {
|
|||||||
return hay.includes(query);
|
return hay.includes(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TenantDailyReportRemind (server/db/mysql/tenant_adapter.go) filters *out of a candidate list the
|
||||||
|
// caller supplies* down to whoever hasn't submitted today -- it doesn't compute "everyone" itself,
|
||||||
|
// and rejects an empty user_ids with 400. GET /organization/members with no q/department_id already
|
||||||
|
// returns every member the caller can see (paginated), which is exactly the candidate pool this
|
||||||
|
// button's "提醒未提交成员" wording implies -- so page through it here rather than reporting this as
|
||||||
|
// a backend gap, matching how getOrganizationMembers is already used for the meeting attendee picker.
|
||||||
const remindMissing = () => {
|
const remindMissing = () => {
|
||||||
sendDailyReminders(toDateInputValue(new Date())).then(_ => onToast('已提醒未提交成员'))
|
const collectAllMembers = (cursor, acc) =>
|
||||||
.catch(err => onToast(err.message || '提醒失败'));
|
getOrganizationMembers({cursor, limit: 100}).then(d => {
|
||||||
|
const next = acc.concat((d.items || []).map(m => m.user_id));
|
||||||
|
return d.has_more && d.next_cursor ? collectAllMembers(d.next_cursor, next) : next;
|
||||||
|
});
|
||||||
|
collectAllMembers('', []).then(userIds => {
|
||||||
|
if (!userIds.length) { onToast('组织架构里没有可提醒的成员'); return; }
|
||||||
|
return sendDailyReminders(toDateInputValue(new Date()), userIds).then(_ => onToast('已提醒未提交成员'));
|
||||||
|
}).catch(err => onToast(err.message || '提醒失败'));
|
||||||
};
|
};
|
||||||
|
|
||||||
return <div className="workplace-page report-list-page">
|
return <div className="workplace-page report-list-page">
|
||||||
@@ -745,7 +791,7 @@ function DailyReportList({team, myUserId, onToast}) {
|
|||||||
<main>
|
<main>
|
||||||
{team && stats ? <section className="report-statistics">
|
{team && stats ? <section className="report-statistics">
|
||||||
<header><span><small>{formatMeetingDate(new Date())}</small><strong>全公司</strong></span>
|
<header><span><small>{formatMeetingDate(new Date())}</small><strong>全公司</strong></span>
|
||||||
<button type="button" onClick={() => navigate('daily-report-mine')}>我的日报</button></header>
|
<button type="button" onClick={() => navigateReplace('daily-report-mine')}>我的日报</button></header>
|
||||||
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
|
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
|
||||||
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
|
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
|
||||||
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
|
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
|
||||||
@@ -755,7 +801,7 @@ function DailyReportList({team, myUserId, onToast}) {
|
|||||||
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索日期'} /></label>
|
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索日期'} /></label>
|
||||||
{!team ? <nav className="report-role-switch">
|
{!team ? <nav className="report-role-switch">
|
||||||
<button type="button" className="active">我的日报</button>
|
<button type="button" className="active">我的日报</button>
|
||||||
<button type="button" onClick={() => navigate('daily-report-team')}>团队统计</button>
|
<button type="button" onClick={() => navigateReplace('daily-report-team')}>团队统计</button>
|
||||||
</nav> : null}
|
</nav> : null}
|
||||||
<section className="report-cards">
|
<section className="report-cards">
|
||||||
{reports == null ? <p className="organization-loading">加载中…</p> :
|
{reports == null ? <p className="organization-loading">加载中…</p> :
|
||||||
@@ -768,6 +814,11 @@ function DailyReportList({team, myUserId, onToast}) {
|
|||||||
|
|
||||||
function DailyReportDetail({hashParams, myUserId, onToast}) {
|
function DailyReportDetail({hashParams, myUserId, onToast}) {
|
||||||
const reportId = hashParams && hashParams.report_id;
|
const reportId = hashParams && hashParams.report_id;
|
||||||
|
// DailyReportCard renders identically for DailyReportList's team=true (团队统计) and team=false
|
||||||
|
// (我的日报) modes and always links here -- backTarget hardcoded to daily-report-mine used to send
|
||||||
|
// a manager who'd been browsing 团队统计 back to their own report list instead of the team roster
|
||||||
|
// they'd actually come from.
|
||||||
|
const backTarget = hashParams && hashParams.team ? 'daily-report-team' : 'daily-report-mine';
|
||||||
const [report, setReport] = React.useState(() => (reportId && readCachedData('daily-report:' + reportId)) || null);
|
const [report, setReport] = React.useState(() => (reportId && readCachedData('daily-report:' + reportId)) || null);
|
||||||
const [loadError, setLoadError] = React.useState(false);
|
const [loadError, setLoadError] = React.useState(false);
|
||||||
const names = useUserNames(report ? [report.author_id] : [], myUserId);
|
const names = useUserNames(report ? [report.author_id] : [], myUserId);
|
||||||
@@ -779,9 +830,9 @@ function DailyReportDetail({hashParams, myUserId, onToast}) {
|
|||||||
setReport(readCachedData('daily-report:' + reportId) || null);
|
setReport(readCachedData('daily-report:' + reportId) || null);
|
||||||
getDailyReport(reportId).then(r => { writeCachedData('daily-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '日报加载失败'); setLoadError(true); });
|
getDailyReport(reportId).then(r => { writeCachedData('daily-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '日报加载失败'); setLoadError(true); });
|
||||||
}, [reportId]);
|
}, [reportId]);
|
||||||
if (!report) return <div className="workplace-page"><Header title="查看详情" back="daily-report-mine" /><main><p className="organization-loading">{loadError ? '日报加载失败' : '加载中…'}</p></main></div>;
|
if (!report) return <div className="workplace-page"><Header title="查看详情" back={backTarget} /><main><p className="organization-loading">{loadError ? '日报加载失败' : '加载中…'}</p></main></div>;
|
||||||
|
|
||||||
return <div className="workplace-page"><Header title="查看详情" back="daily-report-mine" />
|
return <div className="workplace-page"><Header title="查看详情" back={backTarget} />
|
||||||
<main className="report-detail">
|
<main className="report-detail">
|
||||||
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
|
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
|
||||||
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
|
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
|
||||||
@@ -938,7 +989,7 @@ function MonthlyReportList({team, myUserId, onToast}) {
|
|||||||
<main>
|
<main>
|
||||||
{team && stats ? <section className="report-statistics">
|
{team && stats ? <section className="report-statistics">
|
||||||
<header><span><small>{formatMonthLabel(toMonthInputValue(new Date()))}</small><strong>全公司</strong></span>
|
<header><span><small>{formatMonthLabel(toMonthInputValue(new Date()))}</small><strong>全公司</strong></span>
|
||||||
<button type="button" onClick={() => navigate('monthly-report-mine')}>我的月报</button></header>
|
<button type="button" onClick={() => navigateReplace('monthly-report-mine')}>我的月报</button></header>
|
||||||
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
|
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
|
||||||
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
|
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
|
||||||
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
|
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
|
||||||
@@ -947,13 +998,13 @@ function MonthlyReportList({team, myUserId, onToast}) {
|
|||||||
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索月份或内容'} /></label>
|
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索月份或内容'} /></label>
|
||||||
{!team ? <nav className="report-role-switch">
|
{!team ? <nav className="report-role-switch">
|
||||||
<button type="button" className="active">我的月报</button>
|
<button type="button" className="active">我的月报</button>
|
||||||
<button type="button" onClick={() => navigate('monthly-report-team')}>团队统计</button>
|
<button type="button" onClick={() => navigateReplace('monthly-report-team')}>团队统计</button>
|
||||||
</nav> : null}
|
</nav> : null}
|
||||||
<section className="report-cards">
|
<section className="report-cards">
|
||||||
{reports == null ? <p className="organization-loading">加载中…</p> :
|
{reports == null ? <p className="organization-loading">加载中…</p> :
|
||||||
filtered.length === 0 ? <p className="organization-loading">{loadError ? '月报列表加载失败' : '暂无月报'}</p> :
|
filtered.length === 0 ? <p className="organization-loading">{loadError ? '月报列表加载失败' : '暂无月报'}</p> :
|
||||||
filtered.map(r => <button type="button" key={r.report_id}
|
filtered.map(r => <button type="button" key={r.report_id}
|
||||||
onClick={() => navigate((r.action || (r.status === 'submitted' ? 'view' : 'edit')) === 'view' ? 'monthly-report-detail' : 'monthly-report-editor', {report_id: r.report_id})}>
|
onClick={() => navigate((r.action || (r.status === 'submitted' ? 'view' : 'edit')) === 'view' ? 'monthly-report-detail' : 'monthly-report-editor', {report_id: r.report_id, team: team ? '1' : ''})}>
|
||||||
<span className="report-month"><Icon>calendar_month</Icon></span>
|
<span className="report-month"><Icon>calendar_month</Icon></span>
|
||||||
<span><strong>{team ? (names[r.author_id] || r.author_id) : formatMonthLabel(r.month)}</strong>
|
<span><strong>{team ? (names[r.author_id] || r.author_id) : formatMonthLabel(r.month)}</strong>
|
||||||
<small>{team ? formatMonthLabel(r.month) : '工作总结与下月计划'}</small></span>
|
<small>{team ? formatMonthLabel(r.month) : '工作总结与下月计划'}</small></span>
|
||||||
@@ -967,6 +1018,10 @@ function MonthlyReportList({team, myUserId, onToast}) {
|
|||||||
|
|
||||||
function MonthlyReportDetail({hashParams, myUserId, onToast}) {
|
function MonthlyReportDetail({hashParams, myUserId, onToast}) {
|
||||||
const reportId = hashParams && hashParams.report_id;
|
const reportId = hashParams && hashParams.report_id;
|
||||||
|
// Same fix as DailyReportDetail's backTarget: the list row that links here renders identically for
|
||||||
|
// team=true (团队统计) and team=false (我的月报), so a hardcoded 'monthly-report-mine' sent a manager
|
||||||
|
// who'd been browsing 团队统计 back to their own list instead of the team roster they came from.
|
||||||
|
const backTarget = hashParams && hashParams.team ? 'monthly-report-team' : 'monthly-report-mine';
|
||||||
const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null);
|
const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null);
|
||||||
const [loadError, setLoadError] = React.useState(false);
|
const [loadError, setLoadError] = React.useState(false);
|
||||||
const [reopening, setReopening] = React.useState(false);
|
const [reopening, setReopening] = React.useState(false);
|
||||||
@@ -977,7 +1032,7 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
|
|||||||
setReport(readCachedData('monthly-report:' + reportId) || null);
|
setReport(readCachedData('monthly-report:' + reportId) || null);
|
||||||
getMonthlyReport(reportId).then(r => { writeCachedData('monthly-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '月报加载失败'); setLoadError(true); });
|
getMonthlyReport(reportId).then(r => { writeCachedData('monthly-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '月报加载失败'); setLoadError(true); });
|
||||||
}, [reportId]);
|
}, [reportId]);
|
||||||
if (!report) return <div className="workplace-page"><Header title="查看详情" back="monthly-report-mine" /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
|
if (!report) return <div className="workplace-page"><Header title="查看详情" back={backTarget} /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
|
||||||
|
|
||||||
// reopenMonthlyReport is self-service only (server keys the UPDATE off the caller's own uid --
|
// reopenMonthlyReport is self-service only (server keys the UPDATE off the caller's own uid --
|
||||||
// see the comment on the im-api.js export), so this button only ever makes sense for the report's
|
// see the comment on the im-api.js export), so this button only ever makes sense for the report's
|
||||||
@@ -991,7 +1046,7 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
|
|||||||
.finally(() => setReopening(false));
|
.finally(() => setReopening(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
return <div className="workplace-page"><Header title="查看详情" back="monthly-report-mine" />
|
return <div className="workplace-page"><Header title="查看详情" back={backTarget} />
|
||||||
<main className="report-detail">
|
<main className="report-detail">
|
||||||
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
|
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
|
||||||
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
|
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
|
||||||
|
|||||||
@@ -240,6 +240,10 @@ class AudioPlayer extends React.PureComponent {
|
|||||||
this.audioPlayer.pause();
|
this.audioPlayer.pause();
|
||||||
this.setState({playing: false});
|
this.setState({playing: false});
|
||||||
} else if (this.audioPlayer.readyState >= 2) {
|
} else if (this.audioPlayer.readyState >= 2) {
|
||||||
|
if (activeAudioPlayer && activeAudioPlayer !== this) {
|
||||||
|
activeAudioPlayer.pauseForOtherPlayer();
|
||||||
|
}
|
||||||
|
activeAudioPlayer = this;
|
||||||
this.audioPlayer.play();
|
this.audioPlayer.play();
|
||||||
this.setState({playing: true}, this.visualize);
|
this.setState({playing: true}, this.visualize);
|
||||||
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
|
// 4.9 语音未读红点:唯一一个知道"用户真的点了播放"这一刻的地方——上层只知道会话/消息本身
|
||||||
@@ -284,7 +288,7 @@ class AudioPlayer extends React.PureComponent {
|
|||||||
:
|
:
|
||||||
<>
|
<>
|
||||||
{play}
|
{play}
|
||||||
<div>
|
<div className="playback-row">
|
||||||
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
<canvas className="playback" ref={this.canvasRef} onClick={this.handleSeek} />
|
||||||
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
|
<div className="timer">{this.state.currentTime}/{this.state.duration}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -424,11 +424,18 @@ class AudioRecorder extends React.PureComponent {
|
|||||||
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
|
// 来驱动"松开发送/上滑取消",不再需要点这几个按钮——holdMode 时换成更贴近参考图的极简样子
|
||||||
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
|
// (波形+计时+提示文案),录音本身(MediaRecorder/波形可视化)完全不动,只是换了一层展示。
|
||||||
if (this.props.holdMode) {
|
if (this.props.holdMode) {
|
||||||
|
// docs/images/内容沟通对话3-语音录音中.png shows a centered floating card (icon + waveform +
|
||||||
|
// hint), not an inline pill docked in the composer row -- reuses .compose-record-overlay,
|
||||||
|
// the position:fixed/centered-card CSS that message-composer-view.jsx's (non-wired, demo-only)
|
||||||
|
// VoiceOverlay already has for this exact look, rather than inventing a second copy of it.
|
||||||
|
// The waveform itself stays the real canvas visualization (real amplitude data via
|
||||||
|
// visualize()), not the demo's static bar list -- only the container and text markup change.
|
||||||
return (
|
return (
|
||||||
<div className={'audio audio-hold' + (this.props.cancelling ? ' cancelling' : '')}>
|
<div className={'compose-record-overlay' + (this.props.cancelling ? ' cancel' : '')} role="status">
|
||||||
|
<i className="material-icons">{this.props.cancelling ? 'delete_outline' : 'mic'}</i>
|
||||||
{this.state.recording ? <canvas ref={this.canvasRef} /> : null}
|
{this.state.recording ? <canvas ref={this.canvasRef} /> : null}
|
||||||
<div className="duration">{this.state.duration}</div>
|
<strong>{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</strong>
|
||||||
<div className="audio-hold-hint">{this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'}</div>
|
<small>{this.state.duration}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default class CallIncoming extends React.Component {
|
|||||||
topic={this.props.topic}
|
topic={this.props.topic}
|
||||||
title={this.state.fullName} />
|
title={this.state.fullName} />
|
||||||
</div>
|
</div>
|
||||||
<h1>{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}
|
<h1><span>{truncateString(this.state.fullName, MAX_PEER_TITLE_LENGTH)}</span>
|
||||||
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></h1>
|
<BadgeList short={true} trustedBadges={this.state.trustedBadges} /></h1>
|
||||||
<p>{isAudioOnly ? '对方邀请你语音通话...' : '对方邀请你视频通话...'}</p>
|
<p>{isAudioOnly ? '对方邀请你语音通话...' : '对方邀请你视频通话...'}</p>
|
||||||
{ringing && <span className="call-ringing-dots"><i /><i /><i /></span>}
|
{ringing && <span className="call-ringing-dots"><i /><i /><i /></span>}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default class MetaMessage extends React.PureComponent {
|
|||||||
// 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上
|
// 没有处理过这个分支,于是整个组件走到下面的 `return null` —— 撤回一条消息之后时间线上
|
||||||
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
|
// 什么都不剩,看不出这里曾经有过一条消息。文案复用已有的 message_deleted("消息已删除")。
|
||||||
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
|
content = <i className="gray">{this.props.deletedText || '消息已删除'}</i>;
|
||||||
bubbleClass += ' date';
|
bubbleClass += ' deleted';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ class ReceivedMarker extends React.PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const icon = deliveryMarker(this.props.received);
|
const icon = deliveryMarker(this.props.received);
|
||||||
const marker = icon ? <i className={'material-icons small ' + icon.color}>{icon.name}</i> : null;
|
// icon.color is only set for the failed/read cases (deliveryMarker's other {name} results omit
|
||||||
|
// the key entirely) -- string-concatenating it unconditionally put the literal text "undefined"
|
||||||
|
// into the className for sending/sent/received messages.
|
||||||
|
const marker = icon ? <i className={'material-icons small ' + (icon.color || '')}>{icon.name}</i> : null;
|
||||||
const edited = this.props.edited ? formatMessage(messages.message_edited_marker) : null;
|
const edited = this.props.edited ? formatMessage(messages.message_edited_marker) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,12 +30,20 @@ const EMOJI_LIST = ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '
|
|||||||
// 互相 import,就近各自维护一份小常量)。收藏摘要要优先读 snapshot.content.txt 这个结构化字段,
|
// 互相 import,就近各自维护一份小常量)。收藏摘要要优先读 snapshot.content.txt 这个结构化字段,
|
||||||
// 不能像最初那版一样直接展示 snapshot.text——那是整条 content 原样 JSON.stringify 的结果
|
// 不能像最初那版一样直接展示 snapshot.text——那是整条 content 原样 JSON.stringify 的结果
|
||||||
// (例如 {"txt":"..."}),不是给人看的,之前联调时真被这个坑绊了一次才发现。
|
// (例如 {"txt":"..."}),不是给人看的,之前联调时真被这个坑绊了一次才发现。
|
||||||
const FAVORITE_TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]'};
|
const FAVORITE_TYPE_LABEL = {image: '[图片]', video: '[视频]', voice: '[语音]', file: '[文件]', link: '[链接]', text: '[文本]'};
|
||||||
function favoriteSummary(item) {
|
function favoriteSummary(item) {
|
||||||
const content = item.snapshot && item.snapshot.content;
|
// image/video/voice favorites' snapshot.content.txt is a placeholder single space in this
|
||||||
if (content && typeof content.txt === 'string' && content.txt) {
|
// project's convention (confirmed via a real GET /favorites response) -- reading it as the
|
||||||
return content.txt;
|
// summary showed a blank line instead of a type label for these three types.
|
||||||
|
if (item.type === 'image' || item.type === 'video' || item.type === 'voice') {
|
||||||
|
return FAVORITE_TYPE_LABEL[item.type];
|
||||||
}
|
}
|
||||||
|
const content = item.snapshot && item.snapshot.content;
|
||||||
|
// A text favorite's snapshot.content is a plain string, not a {txt:...} Drafty object --
|
||||||
|
// content.txt on it is undefined, which used to fall through all the way to the internal
|
||||||
|
// item.type value ("text") being shown to the user verbatim.
|
||||||
|
if (typeof content === 'string' && content.trim()) return content;
|
||||||
|
if (content && typeof content.txt === 'string' && content.txt.trim()) return content.txt;
|
||||||
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
|
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
|
||||||
}
|
}
|
||||||
const RECENT_EMOJI_KEY = 'im-recent-emoji';
|
const RECENT_EMOJI_KEY = 'im-recent-emoji';
|
||||||
@@ -220,9 +228,14 @@ class SendMessage extends React.PureComponent {
|
|||||||
} else if (prevProps.initMessage != this.props.initMessage) {
|
} else if (prevProps.initMessage != this.props.initMessage) {
|
||||||
const msg = this.props.initMessage || '';
|
const msg = this.props.initMessage || '';
|
||||||
this.setState({message: msg}, _ => {
|
this.setState({message: msg}, _ => {
|
||||||
// If there is text, scroll to bottom and set caret to the end.
|
// messageEditArea can be null here: initMessage (draft/edit content) can change while the
|
||||||
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
|
// textarea itself is unmounted, e.g. noInput during an active forward, or disabled while
|
||||||
this.messageEditArea.setSelectionRange(msg.length, msg.length);
|
// ACS is still resolving right after a topic switch.
|
||||||
|
if (this.messageEditArea) {
|
||||||
|
// If there is text, scroll to bottom and set caret to the end.
|
||||||
|
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
|
||||||
|
this.messageEditArea.setSelectionRange(msg.length, msg.length);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (prevProps.reply != this.props.reply) {
|
if (prevProps.reply != this.props.reply) {
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ __webpack_require__.r(__webpack_exports__);
|
|||||||
/* harmony import */ var _widgets_contact_list_jsx__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ../widgets/contact-list.jsx */ "./src/widgets/contact-list.jsx");
|
/* harmony import */ var _widgets_contact_list_jsx__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ../widgets/contact-list.jsx */ "./src/widgets/contact-list.jsx");
|
||||||
/* harmony import */ var _widgets_error_panel_jsx__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ../widgets/error-panel.jsx */ "./src/widgets/error-panel.jsx");
|
/* harmony import */ var _widgets_error_panel_jsx__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ../widgets/error-panel.jsx */ "./src/widgets/error-panel.jsx");
|
||||||
/* harmony import */ var _widgets_group_manager_jsx__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ../widgets/group-manager.jsx */ "./src/widgets/group-manager.jsx");
|
/* harmony import */ var _widgets_group_manager_jsx__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ../widgets/group-manager.jsx */ "./src/widgets/group-manager.jsx");
|
||||||
/* harmony import */ var _widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ../widgets/menu-cancel.jsx */ "./src/widgets/menu-cancel.jsx");
|
/* harmony import */ var _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ../lib/navigation.js */ "./src/lib/navigation.js");
|
||||||
/* harmony import */ var _widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ../widgets/permissions-editor.jsx */ "./src/widgets/permissions-editor.jsx");
|
/* harmony import */ var _widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ../widgets/menu-cancel.jsx */ "./src/widgets/menu-cancel.jsx");
|
||||||
/* harmony import */ var _widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ../widgets/show-qrcode.jsx */ "./src/widgets/show-qrcode.jsx");
|
/* harmony import */ var _widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(/*! ../widgets/permissions-editor.jsx */ "./src/widgets/permissions-editor.jsx");
|
||||||
/* harmony import */ var _topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! ./topic-common-view.jsx */ "./src/views/topic-common-view.jsx");
|
/* harmony import */ var _widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(/*! ../widgets/show-qrcode.jsx */ "./src/widgets/show-qrcode.jsx");
|
||||||
/* harmony import */ var _widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(/*! ../widgets/topic-security.jsx */ "./src/widgets/topic-security.jsx");
|
/* harmony import */ var _topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(/*! ./topic-common-view.jsx */ "./src/views/topic-common-view.jsx");
|
||||||
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
|
/* harmony import */ var _widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(/*! ../widgets/topic-security.jsx */ "./src/widgets/topic-security.jsx");
|
||||||
/* harmony import */ var _lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(/*! ../lib/blob-helpers.js */ "./src/lib/blob-helpers.js");
|
/* harmony import */ var _config_js__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(/*! ../config.js */ "./src/config.js");
|
||||||
/* harmony import */ var _lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(/*! ../lib/strformat.js */ "./src/lib/strformat.js");
|
/* harmony import */ var _lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(/*! ../lib/blob-helpers.js */ "./src/lib/blob-helpers.js");
|
||||||
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
|
/* harmony import */ var _lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(/*! ../lib/strformat.js */ "./src/lib/strformat.js");
|
||||||
|
/* harmony import */ var react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(/*! react/jsx-dev-runtime */ "./node_modules/react/jsx-dev-runtime.js");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -222,8 +224,8 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
newState.modeGiven2 = user2.acs.getGiven();
|
newState.modeGiven2 = user2.acs.getGiven();
|
||||||
newState.modeWant2 = user2.acs.getWant();
|
newState.modeWant2 = user2.acs.getWant();
|
||||||
} else {
|
} else {
|
||||||
newState.modeGiven2 = _config_js__WEBPACK_IMPORTED_MODULE_14__.NO_ACCESS_MODE;
|
newState.modeGiven2 = _config_js__WEBPACK_IMPORTED_MODULE_15__.NO_ACCESS_MODE;
|
||||||
newState.modeWant2 = _config_js__WEBPACK_IMPORTED_MODULE_14__.NO_ACCESS_MODE;
|
newState.modeWant2 = _config_js__WEBPACK_IMPORTED_MODULE_15__.NO_ACCESS_MODE;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
topic.subscribers(sub => {
|
topic.subscribers(sub => {
|
||||||
@@ -250,11 +252,11 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
sharer: acs && acs.isSharer(),
|
sharer: acs && acs.isSharer(),
|
||||||
deleter: acs && acs.isDeleter(),
|
deleter: acs && acs.isDeleter(),
|
||||||
muted: acs && acs.isMuted(),
|
muted: acs && acs.isMuted(),
|
||||||
fullName: isSelf ? props.intl.formatMessage(messages.self_topic_name) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__.truncateString)(topic.public && topic.public.fn, _config_js__WEBPACK_IMPORTED_MODULE_14__.MAX_TITLE_LENGTH),
|
fullName: isSelf ? props.intl.formatMessage(messages.self_topic_name) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__.truncateString)(topic.public && topic.public.fn, _config_js__WEBPACK_IMPORTED_MODULE_15__.MAX_TITLE_LENGTH),
|
||||||
description: isSelf ? props.intl.formatMessage(messages.self_topic_comment) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__.truncateString)(topic.public && topic.public.note, _config_js__WEBPACK_IMPORTED_MODULE_14__.MAX_TOPIC_DESCRIPTION_LENGTH),
|
description: isSelf ? props.intl.formatMessage(messages.self_topic_comment) : (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__.truncateString)(topic.public && topic.public.note, _config_js__WEBPACK_IMPORTED_MODULE_15__.MAX_TOPIC_DESCRIPTION_LENGTH),
|
||||||
avatar: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_15__.makeImageUrl)(topic.public ? topic.public.photo : null),
|
avatar: (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_16__.makeImageUrl)(topic.public ? topic.public.photo : null),
|
||||||
trustedBadges: badges,
|
trustedBadges: badges,
|
||||||
private: (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_16__.truncateString)(topic.private && topic.private.comment, _config_js__WEBPACK_IMPORTED_MODULE_14__.MAX_TITLE_LENGTH),
|
private: (0,_lib_strformat_js__WEBPACK_IMPORTED_MODULE_17__.truncateString)(topic.private && topic.private.comment, _config_js__WEBPACK_IMPORTED_MODULE_15__.MAX_TITLE_LENGTH),
|
||||||
archived: topic.isArchived(),
|
archived: topic.isArchived(),
|
||||||
address: topic.name,
|
address: topic.name,
|
||||||
groupTopic: topic.isGroupType(),
|
groupTopic: topic.isGroupType(),
|
||||||
@@ -425,7 +427,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
this.setState({
|
this.setState({
|
||||||
toast: ''
|
toast: ''
|
||||||
});
|
});
|
||||||
}, _config_js__WEBPACK_IMPORTED_MODULE_14__.TOAST_DURATION);
|
}, _config_js__WEBPACK_IMPORTED_MODULE_15__.TOAST_DURATION);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleShare(e) {
|
handleShare(e) {
|
||||||
@@ -449,6 +451,9 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
handleBackNavigate() {
|
handleBackNavigate() {
|
||||||
|
if (_lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].goBack()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const args = (this.props.panel || 'info').split('/');
|
const args = (this.props.panel || 'info').split('/');
|
||||||
if (args[0] == 'info') {
|
if (args[0] == 'info') {
|
||||||
this.props.onNavigate(null);
|
this.props.onNavigate(null);
|
||||||
@@ -503,28 +508,28 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view]) || messages['info']);
|
const panelTitle = formatMessage((view == 'perm' ? messages['perm_' + args[0]] : messages[view]) || messages['info']);
|
||||||
const topic = this.props.tinode.getTopic(this.state.topic);
|
const topic = this.props.tinode.getTopic(this.state.topic);
|
||||||
const alias = topic && topic.alias();
|
const alias = topic && topic.alias();
|
||||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
id: "info-view",
|
id: "info-view",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: `toast${this.state.toast ? ' show' : ''}`,
|
className: `toast${this.state.toast ? ' show' : ''}`,
|
||||||
children: this.state.toast
|
children: this.state.toast
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "caption-panel",
|
className: "caption-panel",
|
||||||
id: "info-caption-panel",
|
id: "info-caption-panel",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-title",
|
className: "panel-title",
|
||||||
id: "info-title",
|
id: "info-title",
|
||||||
children: panelTitle
|
children: panelTitle
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_9__["default"], {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_menu_cancel_jsx__WEBPACK_IMPORTED_MODULE_10__["default"], {
|
||||||
onCancel: this.handleBackNavigate
|
onCancel: this.handleBackNavigate
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true), this.props.displayMobile ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_error_panel_jsx__WEBPACK_IMPORTED_MODULE_7__["default"], {
|
}, void 0, true), this.props.displayMobile ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_error_panel_jsx__WEBPACK_IMPORTED_MODULE_7__["default"], {
|
||||||
level: this.props.errorLevel,
|
level: this.props.errorLevel,
|
||||||
text: this.props.errorText,
|
text: this.props.errorText,
|
||||||
onClearError: this.props.onError
|
onClearError: this.props.onError
|
||||||
}, void 0, false) : null, view == 'members' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_group_manager_jsx__WEBPACK_IMPORTED_MODULE_8__["default"], {
|
}, void 0, false) : null, view == 'members' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_group_manager_jsx__WEBPACK_IMPORTED_MODULE_8__["default"], {
|
||||||
tinode: this.props.tinode,
|
tinode: this.props.tinode,
|
||||||
members: this.state.contactList,
|
members: this.state.contactList,
|
||||||
requiredMember: this.props.myUserId,
|
requiredMember: this.props.myUserId,
|
||||||
@@ -533,7 +538,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
contacts: this.props.searchableContacts,
|
contacts: this.props.searchableContacts,
|
||||||
onCancel: this.handleBackNavigate,
|
onCancel: this.handleBackNavigate,
|
||||||
onSubmit: this.handleMemberUpdateRequest
|
onSubmit: this.handleMemberUpdateRequest
|
||||||
}, void 0, false) : view == 'perm' && args.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_10__["default"], {
|
}, void 0, false) : view == 'perm' && args.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_permissions_editor_jsx__WEBPACK_IMPORTED_MODULE_11__["default"], {
|
||||||
tinode: this.props.tinode,
|
tinode: this.props.tinode,
|
||||||
mode: this.state.editedPermissions,
|
mode: this.state.editedPermissions,
|
||||||
compare: this.state.immutablePermissions,
|
compare: this.state.immutablePermissions,
|
||||||
@@ -545,7 +550,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
userAvatar: this.state.userPermissionsAvatar,
|
userAvatar: this.state.userPermissionsAvatar,
|
||||||
onSubmit: mode => this.handlePermissionsChanged(args[0], mode),
|
onSubmit: mode => this.handlePermissionsChanged(args[0], mode),
|
||||||
onCancel: this.handleBackNavigate
|
onCancel: this.handleBackNavigate
|
||||||
}, void 0, false) : view == 'general' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_12__["default"], {
|
}, void 0, false) : view == 'general' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_topic_common_view_jsx__WEBPACK_IMPORTED_MODULE_13__["default"], {
|
||||||
tinode: this.props.tinode,
|
tinode: this.props.tinode,
|
||||||
topic: this.props.topic,
|
topic: this.props.topic,
|
||||||
reqCredMethod: this.props.reqCredMethod,
|
reqCredMethod: this.props.reqCredMethod,
|
||||||
@@ -555,7 +560,7 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
onCredDelete: this.props.onCredDelete,
|
onCredDelete: this.props.onCredDelete,
|
||||||
onUpdateTopicDesc: this.props.onTopicDescUpdateRequest,
|
onUpdateTopicDesc: this.props.onTopicDescUpdateRequest,
|
||||||
onError: this.props.onError
|
onError: this.props.onError
|
||||||
}, void 0, false) : view == 'security' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_13__["default"], {
|
}, void 0, false) : view == 'security' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_topic_security_jsx__WEBPACK_IMPORTED_MODULE_14__["default"], {
|
||||||
topic: this.props.topic,
|
topic: this.props.topic,
|
||||||
owner: this.state.owner,
|
owner: this.state.owner,
|
||||||
admin: this.state.admin,
|
admin: this.state.admin,
|
||||||
@@ -578,140 +583,140 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
onReportTopic: this.props.onReportTopic,
|
onReportTopic: this.props.onReportTopic,
|
||||||
onLaunchPermissionsEditor: this.handleLaunchPermissionsEditor,
|
onLaunchPermissionsEditor: this.handleLaunchPermissionsEditor,
|
||||||
onNavigate: this.props.onNavigate
|
onNavigate: this.props.onNavigate
|
||||||
}, void 0, false) : view == 'qrcode' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_11__["default"], {
|
}, void 0, false) : view == 'qrcode' ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_show_qrcode_jsx__WEBPACK_IMPORTED_MODULE_12__["default"], {
|
||||||
uri: tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Tinode.URI_TOPIC_ID_PREFIX + this.props.topic,
|
uri: tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Tinode.URI_TOPIC_ID_PREFIX + this.props.topic,
|
||||||
onCancel: this.handleBackNavigate
|
onCancel: this.handleBackNavigate
|
||||||
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
id: "info-view-content",
|
id: "info-view-content",
|
||||||
className: "scrollable-panel",
|
className: "scrollable-panel",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-column",
|
className: "panel-form-column",
|
||||||
children: [!this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
|
children: [!this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
|
||||||
href: "#",
|
href: "#",
|
||||||
className: "flat-button float-right",
|
className: "flat-button float-right",
|
||||||
onClick: e => {
|
onClick: e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onNavigate('general');
|
this.props.onNavigate('general');
|
||||||
},
|
},
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "edit"
|
children: "edit"
|
||||||
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "button_edit",
|
id: "button_edit",
|
||||||
defaultMessage: "Edit",
|
defaultMessage: "Edit",
|
||||||
description: "Call to action [Edit]"
|
description: "Call to action [Edit]"
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("center", {
|
}, void 0, true) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("center", {
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_avatar_upload_jsx__WEBPACK_IMPORTED_MODULE_3__["default"], {
|
||||||
tinode: this.props.tinode,
|
tinode: this.props.tinode,
|
||||||
avatar: this.state.avatar,
|
avatar: this.state.avatar,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
uid: this.props.topic,
|
uid: this.props.topic,
|
||||||
title: this.state.fullName
|
title: this.state.fullName
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "group",
|
className: "group",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
className: "small",
|
className: "small",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_topic_name",
|
id: "label_topic_name",
|
||||||
defaultMessage: "Name",
|
defaultMessage: "Name",
|
||||||
description: "Label for editing topic name"
|
description: "Label for editing topic name"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "large ellipsized",
|
className: "large ellipsized",
|
||||||
children: [this.state.fullName, "\xA0", this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: [this.state.fullName, "\xA0", this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "podcasts"
|
children: "podcasts"
|
||||||
}, void 0, false) : null]
|
}, void 0, false) : null]
|
||||||
}, void 0, true)]
|
}, void 0, true)]
|
||||||
}, void 0, true), this.state.private ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, true), this.state.private ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "group",
|
className: "group",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
className: "small",
|
className: "small",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_private",
|
id: "label_private",
|
||||||
defaultMessage: "Private comment",
|
defaultMessage: "Private comment",
|
||||||
description: "Label for editing 'private'"
|
description: "Label for editing 'private'"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "large ellipsized",
|
className: "large ellipsized",
|
||||||
children: this.state.private
|
children: this.state.private
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
|
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-row",
|
className: "panel-form-row",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
className: "small",
|
className: "small",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_user_id",
|
id: "label_user_id",
|
||||||
defaultMessage: "ID:",
|
defaultMessage: "ID:",
|
||||||
description: "Label for user address (ID)"
|
description: "Label for user address (ID)"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("tt", {
|
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("tt", {
|
||||||
children: this.state.address
|
children: this.state.address
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
style: {
|
style: {
|
||||||
marginLeft: 'auto'
|
marginLeft: 'auto'
|
||||||
},
|
},
|
||||||
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
|
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
|
||||||
href: "#",
|
href: "#",
|
||||||
onClick: e => {
|
onClick: e => {
|
||||||
this.handleCopyToClipboard(e, this.state.address);
|
this.handleCopyToClipboard(e, this.state.address);
|
||||||
},
|
},
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "content_copy"
|
children: "content_copy"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
|
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
|
||||||
href: "#",
|
href: "#",
|
||||||
onClick: this.handleShowQRCode,
|
onClick: this.handleShowQRCode,
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "qr_code"
|
children: "qr_code"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
|
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
|
||||||
href: "#",
|
href: "#",
|
||||||
onClick: this.handleShare,
|
onClick: this.handleShare,
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "share"
|
children: "share"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), "\xA0"]
|
}, void 0, false), "\xA0"]
|
||||||
}, void 0, true)]
|
}, void 0, true)]
|
||||||
}, void 0, true), alias ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, true), alias ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-row",
|
className: "panel-form-row",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
className: "small",
|
className: "small",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_alias",
|
id: "label_alias",
|
||||||
defaultMessage: "Alias:",
|
defaultMessage: "Alias:",
|
||||||
description: "Label for user or topic alias"
|
description: "Label for user or topic alias"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("tt", {
|
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("tt", {
|
||||||
children: alias
|
children: alias
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
style: {
|
style: {
|
||||||
marginLeft: 'auto'
|
marginLeft: 'auto'
|
||||||
},
|
},
|
||||||
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
|
children: ["\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
|
||||||
href: "#",
|
href: "#",
|
||||||
onClick: e => {
|
onClick: e => {
|
||||||
this.handleCopyToClipboard(e, alias);
|
this.handleCopyToClipboard(e, alias);
|
||||||
},
|
},
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "content_copy"
|
children: "content_copy"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
}, void 0, false), "\xA0 \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
style: {
|
style: {
|
||||||
opacity: 0
|
opacity: 0
|
||||||
@@ -720,125 +725,125 @@ class InfoView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
|||||||
}, void 0, false), "\xA0"]
|
}, void 0, false), "\xA0"]
|
||||||
}, void 0, true)]
|
}, void 0, true)]
|
||||||
}, void 0, true) : null]
|
}, void 0, true) : null]
|
||||||
}, void 0, true) : null, this.state.trustedBadges.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, true) : null, this.state.trustedBadges.length > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "group",
|
className: "group",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_badge_list_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_badge_list_jsx__WEBPACK_IMPORTED_MODULE_4__["default"], {
|
||||||
trustedBadges: this.state.trustedBadges
|
trustedBadges: this.state.trustedBadges
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false) : null, this.state.groupTopic && topic.subcnt > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false) : null, this.state.groupTopic && topic.subcnt > 0 ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "group",
|
className: "group",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
className: "small",
|
className: "small",
|
||||||
children: this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: this.state.channel ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_subscriber_count",
|
id: "label_subscriber_count",
|
||||||
defaultMessage: "Subscribers:",
|
defaultMessage: "Subscribers:",
|
||||||
description: "Label for the count of channel subscribers"
|
description: "Label for the count of channel subscribers"
|
||||||
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
}, void 0, false) : (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_member_count",
|
id: "label_member_count",
|
||||||
defaultMessage: "Members:",
|
defaultMessage: "Members:",
|
||||||
description: "Label for the count of group topic members"
|
description: "Label for the count of group topic members"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedNumber, {
|
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedNumber, {
|
||||||
value: topic.subcnt
|
value: topic.subcnt
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true) : null, this.state.description ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, true) : null, this.state.description ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "group",
|
className: "group",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
className: "small",
|
className: "small",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_description",
|
id: "label_description",
|
||||||
defaultMessage: "Description",
|
defaultMessage: "Description",
|
||||||
description: "Label for description of user or topic"
|
description: "Label for description of user or topic"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
children: this.state.description
|
children: this.state.description
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true) : null]
|
}, void 0, true) : null]
|
||||||
}, void 0, true), !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
|
}, void 0, true), !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "hr"
|
className: "hr"
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-row",
|
className: "panel-form-row",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_muting_topic",
|
id: "label_muting_topic",
|
||||||
defaultMessage: "Muted:",
|
defaultMessage: "Muted:",
|
||||||
description: "Label for Muting/unmuting the topic"
|
description: "Label for Muting/unmuting the topic"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
|
||||||
name: "P",
|
name: "P",
|
||||||
checked: this.state.muted,
|
checked: this.state.muted,
|
||||||
onChange: this.handleMuted
|
onChange: this.handleMuted
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true)]
|
}, void 0, true)]
|
||||||
}, void 0, true) : null, this.state.archived ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, true) : null, this.state.archived ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-row",
|
className: "panel-form-row",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_unarchive_topic",
|
id: "label_unarchive_topic",
|
||||||
defaultMessage: "Archived:",
|
defaultMessage: "Archived:",
|
||||||
description: "Label for unarchiving the topic"
|
description: "Label for unarchiving the topic"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_checkbox_jsx__WEBPACK_IMPORTED_MODULE_5__["default"], {
|
||||||
name: "archived",
|
name: "archived",
|
||||||
checked: true,
|
checked: true,
|
||||||
onChange: this.handleUnarchive
|
onChange: this.handleUnarchive
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
|
}, void 0, true) : null, !this.state.isSelf ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "hr"
|
className: "hr"
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-row",
|
className: "panel-form-row",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
|
||||||
href: "#",
|
href: "#",
|
||||||
className: "flat-button",
|
className: "flat-button",
|
||||||
onClick: e => {
|
onClick: e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onNavigate('security');
|
this.props.onNavigate('security');
|
||||||
},
|
},
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "security"
|
children: "security"
|
||||||
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
}, void 0, false), "\xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "button_security",
|
id: "button_security",
|
||||||
defaultMessage: "Security",
|
defaultMessage: "Security",
|
||||||
description: "Navigaton button for security panel."
|
description: "Navigaton button for security panel."
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true)
|
}, void 0, true)
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true) : null, this.state.groupTopic && this.state.sharer ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.Fragment, {
|
}, void 0, true) : null, this.state.groupTopic && this.state.sharer ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.Fragment, {
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "hr"
|
className: "hr"
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-row",
|
className: "panel-form-row",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("label", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("label", {
|
||||||
className: "small",
|
className: "small",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "label_group_members",
|
id: "label_group_members",
|
||||||
defaultMessage: "Group members:",
|
defaultMessage: "Group members:",
|
||||||
description: "Section title or label"
|
description: "Section title or label"
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("div", {
|
||||||
className: "panel-form-row",
|
className: "panel-form-row",
|
||||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("a", {
|
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("a", {
|
||||||
href: "#",
|
href: "#",
|
||||||
className: "flat-button",
|
className: "flat-button",
|
||||||
onClick: this.handleShowAddMembers,
|
onClick: this.handleShowAddMembers,
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)("i", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)("i", {
|
||||||
className: "material-icons",
|
className: "material-icons",
|
||||||
children: "person_add"
|
children: "person_add"
|
||||||
}, void 0, false), " \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
}, void 0, false), " \xA0", (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "button_add_members",
|
id: "button_add_members",
|
||||||
defaultMessage: "Add members",
|
defaultMessage: "Add members",
|
||||||
description: "Flat button [Add members] (to topic)"
|
description: "Flat button [Add members] (to topic)"
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true)
|
}, void 0, true)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(react_intl__WEBPACK_IMPORTED_MODULE_1__.FormattedMessage, {
|
||||||
id: "group_has_no_members",
|
id: "group_has_no_members",
|
||||||
defaultMessage: "No members",
|
defaultMessage: "No members",
|
||||||
description: "Shown in place of group members",
|
description: "Shown in place of group members",
|
||||||
children: no_members => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_17__.jsxDEV)(_widgets_contact_list_jsx__WEBPACK_IMPORTED_MODULE_6__["default"], {
|
children: no_members => (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_18__.jsxDEV)(_widgets_contact_list_jsx__WEBPACK_IMPORTED_MODULE_6__["default"], {
|
||||||
tinode: this.props.tinode,
|
tinode: this.props.tinode,
|
||||||
contacts: this.state.contactList,
|
contacts: this.state.contactList,
|
||||||
myUserId: this.props.myUserId,
|
myUserId: this.props.myUserId,
|
||||||
|
|||||||
@@ -323,6 +323,24 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
|||||||
}
|
}
|
||||||
this.mountDnDEvents(this.dndRef);
|
this.mountDnDEvents(this.dndRef);
|
||||||
this.componentSetup({}, {});
|
this.componentSetup({}, {});
|
||||||
|
if (this.props.jumpToSeq) {
|
||||||
|
const seq = parseInt(this.props.jumpToSeq, 10);
|
||||||
|
const topicObj = this.props.tinode && this.props.topic ? this.props.tinode.getTopic(this.props.topic) : null;
|
||||||
|
const attemptJump = () => this.jumpToMessage(seq);
|
||||||
|
if (topicObj && !topicObj.isSubscribed()) {
|
||||||
|
let tries = 0;
|
||||||
|
const waitForSubscription = () => {
|
||||||
|
if (topicObj.isSubscribed() || ++tries > 25) {
|
||||||
|
attemptJump();
|
||||||
|
} else {
|
||||||
|
window.setTimeout(waitForSubscription, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waitForSubscription();
|
||||||
|
} else {
|
||||||
|
attemptJump();
|
||||||
|
}
|
||||||
|
}
|
||||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.getBootstrap)().then(data => {
|
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_19__.getBootstrap)().then(data => {
|
||||||
this.setState({
|
this.setState({
|
||||||
messagePolicy: data.message_policy || null
|
messagePolicy: data.message_policy || null
|
||||||
@@ -1016,7 +1034,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
|||||||
menuItems.push('menu_item_copy', 'menu_item_favorite');
|
menuItems.push('menu_item_copy', 'menu_item_favorite');
|
||||||
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
|
const recallWindow = this.state.messagePolicy ? this.state.messagePolicy.recall_window_seconds : 0;
|
||||||
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
|
const canRecallAsAdmin = topic.isGroupType() && topic.getAccessMode() && topic.getAccessMode().isAdmin();
|
||||||
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && recallWindow > 0 && params.timestamp && params.timestamp.getTime() > new Date().getTime() - recallWindow * 1000) {
|
if ((params.userFrom == this.props.myUserId || canRecallAsAdmin) && params.seq > 0 && params.seq < tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.LOCAL_SEQID && recallWindow > 0 && params.timestamp && params.timestamp.getTime() > new Date().getTime() - recallWindow * 1000) {
|
||||||
menuItems.push('menu_item_recall');
|
menuItems.push('menu_item_recall');
|
||||||
}
|
}
|
||||||
if (topic.isSelfType()) {
|
if (topic.isSelfType()) {
|
||||||
@@ -1969,7 +1987,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
|||||||
const messageNodes = [];
|
const messageNodes = [];
|
||||||
let previousFrom = null;
|
let previousFrom = null;
|
||||||
let prevDate = null;
|
let prevDate = null;
|
||||||
let chatBoxClass = null;
|
let chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
|
||||||
topic.messages((msg, prev, next, i) => {
|
topic.messages((msg, prev, next, i) => {
|
||||||
let nextFrom = next ? next.from || 'chan' : null;
|
let nextFrom = next ? next.from || 'chan' : null;
|
||||||
let sequence = 'single';
|
let sequence = 'single';
|
||||||
@@ -1994,7 +2012,6 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
|||||||
userName = user.public.fn;
|
userName = user.public.fn;
|
||||||
userAvatar = (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_17__.makeImageUrl)(user.public.photo);
|
userAvatar = (0,_lib_blob_helpers_js__WEBPACK_IMPORTED_MODULE_17__.makeImageUrl)(user.public.photo);
|
||||||
}
|
}
|
||||||
chatBoxClass = groupTopic ? 'chat-box group' : 'chat-box';
|
|
||||||
const ref = this.getOrCreateMessageRef(msg.seq);
|
const ref = this.getOrCreateMessageRef(msg.seq);
|
||||||
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
|
let replyToSeq = msg.head ? parseInt(msg.head.reply) : null;
|
||||||
if (!replyToSeq || isNaN(replyToSeq)) {
|
if (!replyToSeq || isNaN(replyToSeq)) {
|
||||||
@@ -3175,7 +3192,7 @@ class MetaMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
|||||||
className: "gray",
|
className: "gray",
|
||||||
children: this.props.deletedText || '消息已删除'
|
children: this.props.deletedText || '消息已删除'
|
||||||
}, void 0, false);
|
}, void 0, false);
|
||||||
bubbleClass += ' date';
|
bubbleClass += ' deleted';
|
||||||
}
|
}
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.Fragment, {
|
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxDEV)(react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_1__.Fragment, {
|
||||||
@@ -3449,7 +3466,7 @@ class ReceivedMarker extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureC
|
|||||||
}
|
}
|
||||||
const icon = (0,_lib_utils_js__WEBPACK_IMPORTED_MODULE_3__.deliveryMarker)(this.props.received);
|
const icon = (0,_lib_utils_js__WEBPACK_IMPORTED_MODULE_3__.deliveryMarker)(this.props.received);
|
||||||
const marker = icon ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("i", {
|
const marker = icon ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("i", {
|
||||||
className: 'material-icons small ' + icon.color,
|
className: 'material-icons small ' + (icon.color || ''),
|
||||||
children: icon.name
|
children: icon.name
|
||||||
}, void 0, false) : null;
|
}, void 0, false) : null;
|
||||||
const edited = this.props.edited ? formatMessage(messages.message_edited_marker) : null;
|
const edited = this.props.edited ? formatMessage(messages.message_edited_marker) : null;
|
||||||
@@ -3497,13 +3514,16 @@ const FAVORITE_TYPE_LABEL = {
|
|||||||
video: '[视频]',
|
video: '[视频]',
|
||||||
voice: '[语音]',
|
voice: '[语音]',
|
||||||
file: '[文件]',
|
file: '[文件]',
|
||||||
link: '[链接]'
|
link: '[链接]',
|
||||||
|
text: '[文本]'
|
||||||
};
|
};
|
||||||
function favoriteSummary(item) {
|
function favoriteSummary(item) {
|
||||||
const content = item.snapshot && item.snapshot.content;
|
if (item.type === 'image' || item.type === 'video' || item.type === 'voice') {
|
||||||
if (content && typeof content.txt === 'string' && content.txt) {
|
return FAVORITE_TYPE_LABEL[item.type];
|
||||||
return content.txt;
|
|
||||||
}
|
}
|
||||||
|
const content = item.snapshot && item.snapshot.content;
|
||||||
|
if (typeof content === 'string' && content.trim()) return content;
|
||||||
|
if (content && typeof content.txt === 'string' && content.txt.trim()) return content.txt;
|
||||||
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
|
return FAVORITE_TYPE_LABEL[item.type] || item.type || item.favorite_id;
|
||||||
}
|
}
|
||||||
const RECENT_EMOJI_KEY = 'im-recent-emoji';
|
const RECENT_EMOJI_KEY = 'im-recent-emoji';
|
||||||
@@ -3668,8 +3688,10 @@ class SendMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureComp
|
|||||||
this.setState({
|
this.setState({
|
||||||
message: msg
|
message: msg
|
||||||
}, _ => {
|
}, _ => {
|
||||||
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
|
if (this.messageEditArea) {
|
||||||
this.messageEditArea.setSelectionRange(msg.length, msg.length);
|
this.messageEditArea.scrollTop = this.messageEditArea.scrollHeight;
|
||||||
|
this.messageEditArea.setSelectionRange(msg.length, msg.length);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (prevProps.reply != this.props.reply) {
|
if (prevProps.reply != this.props.reply) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class WallpapersView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureC
|
|||||||
}
|
}
|
||||||
handleTabClick(e) {
|
handleTabClick(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
||||||
this.setState({
|
this.setState({
|
||||||
tab: e.currentTarget.dataset.id
|
tab: e.currentTarget.dataset.id
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -352,15 +352,17 @@ class AudioRecorder extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCo
|
|||||||
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
const resumeClass = 'material-icons ' + (this.state.enabled ? 'red' : 'gray');
|
||||||
if (this.props.holdMode) {
|
if (this.props.holdMode) {
|
||||||
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
return (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
||||||
className: 'audio audio-hold' + (this.props.cancelling ? ' cancelling' : ''),
|
className: 'compose-record-overlay' + (this.props.cancelling ? ' cancel' : ''),
|
||||||
children: [this.state.recording ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("canvas", {
|
role: "status",
|
||||||
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("i", {
|
||||||
|
className: "material-icons",
|
||||||
|
children: this.props.cancelling ? 'delete_outline' : 'mic'
|
||||||
|
}, void 0, false), this.state.recording ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("canvas", {
|
||||||
ref: this.canvasRef
|
ref: this.canvasRef
|
||||||
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("strong", {
|
||||||
className: "duration",
|
|
||||||
children: this.state.duration
|
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("div", {
|
|
||||||
className: "audio-hold-hint",
|
|
||||||
children: this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'
|
children: this.props.cancelling ? '松开手指,取消发送' : '手指上滑,取消发送'
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("small", {
|
||||||
|
children: this.state.duration
|
||||||
}, void 0, false)]
|
}, void 0, false)]
|
||||||
}, void 0, true);
|
}, void 0, true);
|
||||||
}
|
}
|
||||||
|
|||||||